046ceba29c
根因(2026-08-07 youlin 測試實例):7ba7855 把 credential 讀寫端從舊表
credentials(0002)改走 KBDB entries(entry_type='credential'),但舊表
資料的搬遷 migration(0006)要人手動觸發部署才會跑。實查 youlin 的 D1:
credentials 表有 1 筆(yuga3bse/kbdb_internal_token),entries 對應筆數
為 0——新讀取端上線、舊資料還沒搬,20 次 workflow 全部找不到 credential。
leo 追加硬要求:credential 資料住在用戶自己的 CF 帳號,換讀取路徑=每個
既有實例都要遷移,但用戶不准做任何手動步驟——搬遷必須內建在既有更新流程
裡、天然無感。
解法(kbdb/src/actions/credential-legacy-migration.ts):把「搬」變成
「讀」的副作用而非獨立步驟。KBDB worker(D38 唯一允許碰 SQL 的牆內)在
每次查詢某租戶的 credential 目錄前,先確認舊表資料是否已搬進 entries
——沒有就搬(per-owner scoped、NOT EXISTS 冪等),有就是零成本的
sqlite_master 短路檢查。呼叫時機掛在 GET /entries?entry_type=credential
(cypher-executor 熱路徑本來就會打的端點),故只要更新 KBDB worker,
下一次任何人跑 workflow 該租戶就自動搬好,不需要用戶或安裝器多做任何事。
刻意不執行退場(DROP TABLE)——多個實例搬遷時間點不同,舊表留著才能讓
「已搬」與「還沒搬」的實例同時安全運作;退場留給之後獨立的清理步驟。
kbdb/tests/credential-legacy-migration.test.ts:反向驗證重建 2026-08-07
事故的確切前置狀態(真 SQLite + 0001/0002/0005 migration 原檔),證明補丁
加入前 entries.length 回 0(事故重現),加入後回 1(修好);另驗冪等
(連呼叫三次不重複搬)、多租戶互不干擾、舊表已清理時的終態安全。
cypher-executor/tests/credentials.test.ts:補齊 7ba7855 留下的刻意紅燈
(原 placeholder 五項清單),涵蓋租戶隔離的讀寫、真刪除(非 deprecated
標記)、零原生 SQL 原始碼掃描、密文本體不落 KBDB。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
261 lines
12 KiB
TypeScript
261 lines
12 KiB
TypeScript
/**
|
||
* credentials 路由測試(D38 圍牆修復後補寫,2026-08-08)
|
||
*
|
||
* 前身是刻意留紅的 placeholder(見 git history):2026-08-07 D38 把 credential 目錄從
|
||
* 「獨立 credentials 表 + 原生 SQL」改成「KBDB entries(entry_type='credential')+
|
||
* HTTP API」,舊測試全部作廢,agent 中途被中斷沒補上,故意留一個會失敗的測試佔位、
|
||
* 避免「no tests」被誤讀成「通過」。本檔依 placeholder 頭部列的五項補齊。
|
||
*
|
||
* 測試手法比照姊妹模組 execution-logger.test.ts:`vi.stubGlobal('fetch', ...)` 攔截,
|
||
* 但這裡的攔截器是**有狀態的假 KBDB**(in-memory entries store),因為 credentials.ts
|
||
* 一次操作常涉及多輪 HTTP 呼叫(find → upsert / find → delete),單次回應的 mock 測不出
|
||
* 「查得到剛寫的」「刪掉後真的查不到」這類語意,需要一個會記狀態的假後端。
|
||
*/
|
||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||
import { Hono } from 'hono';
|
||
import { credentialsRouter, getCredentialSecretRefs, hasCredential, invalidateCredentialCache } from '../src/routes/credentials';
|
||
import type { Bindings } from '../src/types';
|
||
// Workers runtime(@cloudflare/vitest-pool-workers)沒有 node:fs——原始碼掃描改用 Vite 的
|
||
// `?raw` import 取字串內容(build-time 讀檔,runtime 是純字串,不受 Workers 限制)。
|
||
// @ts-expect-error -- vite ?raw 型別由 tsconfig 的 vite/client 提供,非本檔關注重點
|
||
import credentialsSource from '../src/routes/credentials.ts?raw';
|
||
|
||
afterEach(() => vi.unstubAllGlobals());
|
||
|
||
// ── 有狀態假 KBDB:只實作 credentials.ts 實際會打的四個操作(GET list/find, POST, PATCH, DELETE)──
|
||
interface FakeEntry {
|
||
id: string;
|
||
entry_type: string;
|
||
owner_id: string;
|
||
page_name: string;
|
||
metadata_json: string;
|
||
created_at: number;
|
||
}
|
||
|
||
function makeFakeKbdb() {
|
||
const entries: FakeEntry[] = [];
|
||
let idSeq = 0;
|
||
const secretsStore = new Map<string, string>(); // secretRef -> plaintext(模擬 CF Workers Secrets,唯寫,測試用來斷言「有沒有被塞值」)
|
||
const secretPuts: Array<{ name: string; text: string }> = [];
|
||
const secretDeletes: string[] = [];
|
||
const kbdbRequests: Array<{ method: string; url: string; body: unknown }> = [];
|
||
|
||
async function handle(url: string, init: RequestInit = {}): Promise<Response> {
|
||
const method = (init.method ?? 'GET').toUpperCase();
|
||
const u = new URL(url);
|
||
|
||
// CF Workers Scripts secrets 管理 API(唯寫,讀不回值)
|
||
if (u.hostname === 'api.cloudflare.com') {
|
||
if (method === 'PUT' && u.pathname.endsWith('/secrets')) {
|
||
const body = JSON.parse(String(init.body)) as { name: string; text: string };
|
||
secretsStore.set(body.name, body.text);
|
||
secretPuts.push(body);
|
||
return new Response(JSON.stringify({ success: true }), { status: 200 });
|
||
}
|
||
if (method === 'DELETE' && u.pathname.includes('/secrets/')) {
|
||
const name = u.pathname.split('/secrets/')[1];
|
||
secretsStore.delete(name);
|
||
secretDeletes.push(name);
|
||
return new Response(JSON.stringify({ success: true }), { status: 200 });
|
||
}
|
||
throw new Error(`unhandled CF API call: ${method} ${url}`);
|
||
}
|
||
|
||
// KBDB entries API
|
||
kbdbRequests.push({ method, url, body: init.body ? JSON.parse(String(init.body)) : undefined });
|
||
|
||
if (method === 'POST' && u.pathname === '/entries') {
|
||
const body = JSON.parse(String(init.body)) as Partial<FakeEntry>;
|
||
const entry: FakeEntry = {
|
||
id: `e_${++idSeq}`,
|
||
entry_type: body.entry_type!,
|
||
owner_id: body.owner_id!,
|
||
page_name: body.page_name!,
|
||
metadata_json: body.metadata_json!,
|
||
created_at: Math.floor(Date.now() / 1000),
|
||
};
|
||
entries.push(entry);
|
||
return new Response(JSON.stringify({ success: true, entry }), { status: 200 });
|
||
}
|
||
|
||
if (method === 'GET' && u.pathname === '/entries') {
|
||
const ownerId = u.searchParams.get('owner_id');
|
||
const entryType = u.searchParams.get('entry_type');
|
||
const pageName = u.searchParams.get('page_name');
|
||
let rows = entries.filter((e) => e.entry_type === entryType && e.owner_id === ownerId);
|
||
if (pageName) rows = rows.filter((e) => e.page_name === pageName);
|
||
return new Response(JSON.stringify({ success: true, entries: rows, count: rows.length }), { status: 200 });
|
||
}
|
||
|
||
if (method === 'PATCH' && u.pathname.startsWith('/entries/')) {
|
||
const id = decodeURIComponent(u.pathname.slice('/entries/'.length));
|
||
const body = JSON.parse(String(init.body)) as Partial<FakeEntry>;
|
||
const entry = entries.find((e) => e.id === id);
|
||
if (!entry) return new Response(JSON.stringify({ success: false }), { status: 404 });
|
||
if (body.metadata_json !== undefined) entry.metadata_json = body.metadata_json;
|
||
return new Response(JSON.stringify({ success: true, entry }), { status: 200 });
|
||
}
|
||
|
||
if (method === 'DELETE' && u.pathname.startsWith('/entries/')) {
|
||
const id = decodeURIComponent(u.pathname.slice('/entries/'.length));
|
||
const idx = entries.findIndex((e) => e.id === id);
|
||
if (idx === -1) return new Response(JSON.stringify({ success: false }), { status: 404 });
|
||
entries.splice(idx, 1); // 真的從陣列移除,不是標記
|
||
return new Response(JSON.stringify({ success: true }), { status: 200 });
|
||
}
|
||
|
||
throw new Error(`unhandled KBDB call: ${method} ${url}`);
|
||
}
|
||
|
||
vi.stubGlobal('fetch', vi.fn((url: string, init?: RequestInit) => handle(url, init)));
|
||
|
||
return { entries, secretsStore, secretPuts, secretDeletes, kbdbRequests };
|
||
}
|
||
|
||
function fakeEnv(): Bindings {
|
||
return {
|
||
KBDB_BASE_URL: 'https://kbdb.test',
|
||
CF_SECRETS_API_TOKEN: 'fake-cf-token',
|
||
CF_ACCOUNT_ID: 'fake-account',
|
||
ENVIRONMENT: 'test',
|
||
CREDENTIALS_KV: { delete: vi.fn(async () => {}) } as unknown as KVNamespace,
|
||
} as unknown as Bindings;
|
||
}
|
||
|
||
function app() {
|
||
const a = new Hono<{ Bindings: Bindings }>();
|
||
a.route('/', credentialsRouter);
|
||
return a;
|
||
}
|
||
|
||
beforeEach(() => {
|
||
invalidateCredentialCache('tenant-a');
|
||
invalidateCredentialCache('tenant-b');
|
||
});
|
||
|
||
describe('1. 寫入走 KBDB HTTP API,且 owner_id = api_key(租戶隔離)', () => {
|
||
it('POST /credentials 寫入後,entries 裡的 owner_id 就是呼叫者的 api_key', async () => {
|
||
const fake = makeFakeKbdb();
|
||
const env = fakeEnv();
|
||
const a = app();
|
||
const res = await a.request('/credentials', {
|
||
method: 'POST',
|
||
headers: { 'X-Arcrun-API-Key': 'tenant-a', 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: 'telegram_bot_token', value: 'secret-plaintext-value', service: 'telegram' }),
|
||
}, env);
|
||
expect(res.status).toBe(200);
|
||
const body = (await res.json()) as { success: boolean };
|
||
expect(body.success).toBe(true);
|
||
expect(fake.entries).toHaveLength(1);
|
||
expect(fake.entries[0].owner_id).toBe('tenant-a');
|
||
expect(fake.entries[0].page_name).toBe('telegram_bot_token');
|
||
});
|
||
|
||
it('兩個不同 api_key 各自建立的同名 credential 落在不同 owner_id、互不覆蓋', async () => {
|
||
const fake = makeFakeKbdb();
|
||
const env = fakeEnv();
|
||
const a = app();
|
||
await a.request('/credentials', {
|
||
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-a', 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: 'gemini_api_key', value: 'value-a' }),
|
||
}, env);
|
||
await a.request('/credentials', {
|
||
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-b', 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: 'gemini_api_key', value: 'value-b' }),
|
||
}, env);
|
||
expect(fake.entries).toHaveLength(2);
|
||
const owners = fake.entries.map((e) => e.owner_id).sort();
|
||
expect(owners).toEqual(['tenant-a', 'tenant-b']);
|
||
});
|
||
});
|
||
|
||
describe('2. 讀取查得回 secret_ref,且查不到別的租戶的', () => {
|
||
it('getCredentialSecretRefs 回該租戶的 name→secret_ref 對照,不含其他租戶的', async () => {
|
||
makeFakeKbdb();
|
||
const env = fakeEnv();
|
||
const a = app();
|
||
await a.request('/credentials', {
|
||
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-a', 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: 'gemini_api_key', value: 'value-a' }),
|
||
}, env);
|
||
await a.request('/credentials', {
|
||
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-b', 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: 'other_key', value: 'value-b' }),
|
||
}, env);
|
||
|
||
const refsA = await getCredentialSecretRefs(env, 'tenant-a');
|
||
expect(Object.keys(refsA)).toEqual(['gemini_api_key']);
|
||
expect(refsA.gemini_api_key).toMatch(/^CRED_GEMINI_API_KEY_/);
|
||
expect(refsA.other_key).toBeUndefined(); // 查不到別租戶的
|
||
|
||
const refsB = await getCredentialSecretRefs(env, 'tenant-b');
|
||
expect(Object.keys(refsB)).toEqual(['other_key']);
|
||
});
|
||
|
||
it('hasCredential:查得到自己的,查不到別租戶的同名 credential', async () => {
|
||
makeFakeKbdb();
|
||
const env = fakeEnv();
|
||
const a = app();
|
||
await a.request('/credentials', {
|
||
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-a', 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: 'kbdb_internal_token', value: 'v' }),
|
||
}, env);
|
||
expect(await hasCredential(env, 'tenant-a', 'kbdb_internal_token')).toBe(true);
|
||
expect(await hasCredential(env, 'tenant-b', 'kbdb_internal_token')).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('3. 刪除是真的刪(不是 deprecated 標記)', () => {
|
||
it('DELETE /credentials/:name 後,該筆 entries row 從 KBDB 消失(不是 metadata 打 deprecated 標記)', async () => {
|
||
const fake = makeFakeKbdb();
|
||
const env = fakeEnv();
|
||
const a = app();
|
||
await a.request('/credentials', {
|
||
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-a', 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: 'to_delete', value: 'v' }),
|
||
}, env);
|
||
expect(fake.entries).toHaveLength(1);
|
||
|
||
const res = await a.request('/credentials/to_delete', {
|
||
method: 'DELETE', headers: { 'X-Arcrun-API-Key': 'tenant-a' },
|
||
}, env);
|
||
expect(res.status).toBe(200);
|
||
const body = (await res.json()) as { success: boolean; source: string };
|
||
expect(body.success).toBe(true);
|
||
expect(body.source).toBe('workers-secrets');
|
||
|
||
// 真的從陣列移除,不是留著、metadata 打上 status:deprecated
|
||
expect(fake.entries).toHaveLength(0);
|
||
// Workers Secret 本體也真的被刪(DELETE 呼叫過),不是只刪目錄留孤兒密文
|
||
expect(fake.secretDeletes.length).toBe(1);
|
||
});
|
||
});
|
||
|
||
describe('4. 零原生 SQL:整支檔案不得出現 .prepare/.exec/.batch', () => {
|
||
it('routes/credentials.ts 原始碼掃描:沒有任何 D1 原生呼叫語法', () => {
|
||
expect(/\.\s*(prepare|exec|batch)\s*\(/.test(credentialsSource)).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('5. 密文本體不落 KBDB(只有 secret_ref 指標)—— D19 不變', () => {
|
||
it('送去 KBDB 的 body 裡從頭到尾沒有明文 credential value,只有 secret_ref', async () => {
|
||
const fake = makeFakeKbdb();
|
||
const env = fakeEnv();
|
||
const a = app();
|
||
const plaintext = 'super-secret-plaintext-should-never-leave-workers-secrets';
|
||
await a.request('/credentials', {
|
||
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-a', 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: 'sensitive_key', value: plaintext }),
|
||
}, env);
|
||
|
||
// 明文只出現在 CF Workers Secrets 的 PUT(唯寫 API),不出現在任何打去 KBDB 的請求 body 裡
|
||
expect(fake.secretPuts.some((p) => p.text === plaintext)).toBe(true);
|
||
for (const req of fake.kbdbRequests) {
|
||
expect(JSON.stringify(req.body ?? '')).not.toContain(plaintext);
|
||
}
|
||
// entries 裡存的是 secret_ref 指標,不是值
|
||
expect(fake.entries[0].metadata_json).not.toContain(plaintext);
|
||
expect(fake.entries[0].metadata_json).toContain('secret_ref');
|
||
});
|
||
});
|