diff --git a/cypher-executor/tests/credentials.test.ts b/cypher-executor/tests/credentials.test.ts index ccc6534..8bcf64b 100644 --- a/cypher-executor/tests/credentials.test.ts +++ b/cypher-executor/tests/credentials.test.ts @@ -1,33 +1,260 @@ /** - * credentials 路由測試 —— 🔴 待重寫(D38 圍牆修復後) + * credentials 路由測試(D38 圍牆修復後補寫,2026-08-08) * - * 【為什麼是紅燈而不是空檔】 - * 2026-08-07 D38 圍牆修復把 credential 目錄從「獨立 credentials 表 + 原生 SQL」 - * 改成「KBDB entries(entry_type='credential')+ HTTP API」。原本 111 行的測試 - * 測的是舊的 SQL 實作,全部不再適用。 + * 前身是刻意留紅的 placeholder(見 git history):2026-08-07 D38 把 credential 目錄從 + * 「獨立 credentials 表 + 原生 SQL」改成「KBDB entries(entry_type='credential')+ + * HTTP API」,舊測試全部作廢,agent 中途被中斷沒補上,故意留一個會失敗的測試佔位、 + * 避免「no tests」被誤讀成「通過」。本檔依 placeholder 頭部列的五項補齊。 * - * 施工的 agent 中途被中斷,留下一行 `// placeholder — see edit below` —— - * 那個 "edit below" 從來沒發生。vitest 對這種檔案回報 `Tests: no tests`, - * **很容易被讀成「沒失敗=通過」**,正是 CP 記過的 - * 「這條 route 曾整條消失過沒人發現」同型。 - * - * ⇒ 這裡刻意留一個**會失敗**的測試:空檔會被誤認為綠,紅燈不會。 - * - * 【重寫時要涵蓋什麼】(照新實作 routes/credentials.ts) - * 1. 寫入走 KBDB HTTP API,且 owner_id = api_key(租戶隔離) - * 2. 讀取查得回 secret_ref,且查不到別的租戶的 - * 3. 刪除是真的刪(不是 deprecated) - * 4. **零原生 SQL**:整支檔案不得出現 .prepare/.exec/.batch - * 5. 密文本體不落 KBDB(只有 secret_ref 指標)—— D19 不變 + * 測試手法比照姊妹模組 execution-logger.test.ts:`vi.stubGlobal('fetch', ...)` 攔截, + * 但這裡的攔截器是**有狀態的假 KBDB**(in-memory entries store),因為 credentials.ts + * 一次操作常涉及多輪 HTTP 呼叫(find → upsert / find → delete),單次回應的 mock 測不出 + * 「查得到剛寫的」「刪掉後真的查不到」這類語意,需要一個會記狀態的假後端。 */ -import { describe, it, expect } from 'vitest'; +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'; -describe('credentials 路由(D38 改走 KBDB API 後)', () => { - it('🔴 測試待重寫 —— 見本檔頭部清單(刻意紅燈,別刪掉改成空檔)', () => { - expect.fail( - 'D38 圍牆修復後 credential 改走 KBDB entries + HTTP API,' + - '舊的 SQL 版測試已作廢、新測試尚未寫。' + - '要補的五項見本檔頭部註解。', - ); +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(); // 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 { + 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; + 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; + 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'); }); }); diff --git a/kbdb/src/actions/credential-legacy-migration.ts b/kbdb/src/actions/credential-legacy-migration.ts new file mode 100644 index 0000000..ea98b47 --- /dev/null +++ b/kbdb/src/actions/credential-legacy-migration.ts @@ -0,0 +1,91 @@ +// credential-legacy-migration.ts — 「新讀取端上線、舊資料還沒搬完」的自癒補丁 +// (D38 圍牆修復收尾,總管交辦,2026-08-08)。 +// +// ── 為什麼這支檔案存在 ──────────────────────────────────────────────────── +// 7ba7855(D38 圍牆修復)把 credential 目錄的讀寫端從舊表 `credentials`(0002,違規多開 +// 的第四張表)改成走 entries 表(entry_type='credential')。0006_drop_credentials_table.sql +// 寫了「把舊表資料搬進 entries 後讓舊表退場」的一次性 migration,但這支 migration **要有人 +// 手動觸發部署才會跑**——2026-08-07 youlin 測試實例的事故就是「code 部署了、migration 沒 +// 跑」造成 20/20 workflow 全部找不到 credential。 +// +// leo 追加的硬要求(2026-08-08):credential 資料住在**用戶自己的 Cloudflare 帳號**, +// 換讀取路徑=每個既有實例的資料都要跟著搬,但**用戶不准做任何手動步驟**——不能要求他 +// 跑指令、改設定、重裝。搬遷必須內建在「用戶本來就會走的路」裡(因此天然無感)。 +// +// ── 解法:把「搬」變成「讀」的副作用,而不是獨立一步 ───────────────────── +// KBDB worker(本檔)是 D38 唯一允許碰 SQL 的地方(牆內)。這裡在**每次查詢某租戶的 +// credential 目錄之前**,先確認舊表資料是否已經搬進 entries——沒有就搬(scoped 到這個 +// owner_id,NOT EXISTS 防重複),有就是零成本的一次 sqlite_master 檢查。 +// +// 呼叫時機只有一個:cypher-executor 的 credentials.ts 熱路徑(getCredentialDirectory / +// findCredentialEntry)本來就會在**每次 workflow 執行**打一次 GET /entries?entry_type= +// credential&owner_id=X(60 秒快取未命中時)。只要 KBDB worker 部署了本檔的邏輯, +// 下一次任何人跑 workflow,那個租戶的資料就自動搬好了——**不需要用戶多做任何事**, +// 也不需要「更新流程」額外呼叫一支新端點:更新 KBDB worker 本身就是唯一需要發生的事, +// 之後的搬遷由使用行為自然觸發。 +// +// ── 三個安全性質(都經得起故意製造壞狀態來驗證,見 tests/credential-legacy-migration.test.ts)── +// 1. 冪等:NOT EXISTS 防止同一筆搬兩次;同一個 owner 呼叫 N 次只搬一次。 +// 2. 對「已經搬過」與「還沒搬」的實例都正確:已搬過 → legacyTableExists 一旦舊表被真的 +// 清空退場(未來清理步驟)就直接短路回 false,query 零成本;還沒搬 → 這次呼叫就地補齊。 +// 3. 不砍表:本檔刻意不執行「讓舊表退場」那句 SQL——多個實例的搬遷時間點不同, +// 表還留著才能讓「還沒搬的」與「已經搬的」實例同時安全運作(leo 08-08: +// 「他們會同時存在一段時間」)。退場是之後所有租戶都確認搬完才做的獨立清理步驟。 + +/** 舊表是否還存在(sqlite_master 查詢,索引命中、幾乎零成本)。 + * 一旦舊表被清理步驟真的清空退場,這裡會回 false,後續呼叫直接短路,不再嘗試搬遷。 */ +async function legacyCredentialsTableExists(db: D1Database): Promise { + const row = await db + .prepare(`SELECT 1 AS x FROM sqlite_master WHERE type = 'table' AND name = 'credentials'`) + .first<{ x: number }>(); + return row !== null; +} + +/** + * 把某個租戶(owner_id=api_key)在舊 `credentials` 表裡、entries 還沒有對應列的 row + * 搬進 entries(entry_type='credential')。scoped 到單一 owner,故查詢便宜,可安全地在 + * 熱路徑(每次 workflow 執行)前呼叫。 + * + * 欄位對應與 0006_drop_credentials_table.sql 逐字一致(page_name=name 冪等鍵, + * metadata_json 打包 service/sensitivity/secret_ref/last_used_at)。 + * + * @returns 實際搬移的筆數(0 = 這個 owner 沒有待搬資料,含「舊表本來就不存在」與 + * 「已經搬過」兩種情況——呼叫端不需要分辨,行為一致)。 + */ +export async function migrateLegacyCredentialsForOwner(db: D1Database, ownerId: string): Promise { + if (!ownerId) return 0; // 沒有 owner_id 的查詢(極少見)不觸發:搬遷是 per-tenant 動作,範圍不明確就不做 + if (!(await legacyCredentialsTableExists(db))) return 0; // 舊表不存在(從未有 / 已清理)→ 零成本短路 + + const before = await db + .prepare(`SELECT COUNT(*) AS n FROM entries WHERE entry_type = 'credential' AND owner_id = ?1`) + .bind(ownerId) + .first<{ n: number }>(); + + await db + .prepare( + `INSERT INTO entries (id, entry_type, owner_id, page_name, metadata_json, created_at, updated_at) + SELECT + 'e_cred_' || lower(hex(randomblob(8))), + 'credential', + c.api_key, + c.name, + json_object('service', c.service, 'sensitivity', c.sensitivity, 'secret_ref', c.secret_ref, 'last_used_at', c.last_used_at), + c.created_at, + unixepoch() + FROM credentials c + WHERE c.api_key = ?1 + AND NOT EXISTS ( + SELECT 1 FROM entries e + WHERE e.entry_type = 'credential' AND e.owner_id = c.api_key AND e.page_name = c.name + )`, + ) + .bind(ownerId) + .run(); + + const after = await db + .prepare(`SELECT COUNT(*) AS n FROM entries WHERE entry_type = 'credential' AND owner_id = ?1`) + .bind(ownerId) + .first<{ n: number }>(); + + return (after?.n ?? 0) - (before?.n ?? 0); +} diff --git a/kbdb/tests/credential-legacy-migration.test.ts b/kbdb/tests/credential-legacy-migration.test.ts new file mode 100644 index 0000000..8afc444 --- /dev/null +++ b/kbdb/tests/credential-legacy-migration.test.ts @@ -0,0 +1,164 @@ +// credential-legacy-migration.test.ts — 「新讀取端上線、舊資料還沒搬完」自癒補丁的迴歸測試 +// (D38 圍牆修復收尾,總管交辦,2026-08-08;youlin 測試實例 2026-08-07 事故的根因修復)。 +// +// 測試策略比照既有 execution-log.test.ts / library-map.test.ts:真 SQLite(node:sqlite) +// 套 migration 原檔,比 mock DB 更硬——驗的是真實 SQL 語意,不是「以為 SQL 長這樣」。 +// 本檔對 D1 介面的直接呼叫全是測試灌資料/驗證用(與上述兩份既有測試同一慣例), +// 不是牆外業務程式碼繞過 API,逐行標 kbdb-sql-ok。 +// +// ── 這份測試在證明什麼(對應 leo 08-08 追加的三個安全性質)───────────────── +// 1. 反向驗證(禁假綠的核心):先重建 2026-08-07 事故的確切狀態——0002 舊表有資料、 +// entries 沒有——直接呼叫 cypher-executor 熱路徑會打的同一個端點(GET /entries? +// entry_type=credential&owner_id=X),**在補丁加入之前這裡本該回空陣列**(就是 +// 事故當天「缺少 credential: kbdb_internal_token」的成因)。本檔驗證補丁讓它改回 +// 找得到,等於把事故重現一次、再證明修好。 +// 2. 冪等:同一個 owner 呼叫兩次、三次,entries 筆數不重複增加。 +// 3. 對「已搬過」與「還沒搬」的實例都正確:不同 owner 各自獨立、互不干擾;已無舊表 +// (模擬清理步驟做完之後)時查詢仍正常運作、不報錯。 +import { describe, it, expect } from 'vitest'; +import { DatabaseSync } from 'node:sqlite'; +import { readFileSync } from 'node:fs'; +import { Hono } from 'hono'; +import { entryRoutes } from '../src/routes/entries'; +import { migrateLegacyCredentialsForOwner } from '../src/actions/credential-legacy-migration'; +import type { Bindings } from '../src/types'; + +// ── node:sqlite → D1 介面最小 adapter(同 execution-log.test.ts / library-map.test.ts 手法)── +function makeSqliteD1(): D1Database { + const raw = new DatabaseSync(':memory:'); + raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 測試 adapter 套 migration 原檔,比照 execution-log.test.ts + raw.exec(readFileSync(new URL('../migrations/0002_credentials.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 測試 adapter 套 migration 原檔 + raw.exec(readFileSync(new URL('../migrations/0005_credential_template.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 測試 adapter 套 migration 原檔 + function stmt(sql: string, params: unknown[]) { + const s = { + bind(...args: unknown[]) { return stmt(sql, args); }, + async all() { return { results: raw.prepare(sql).all(...params) as T[] }; }, // kbdb-sql-ok: 測試 adapter,比照 execution-log.test.ts + async first() { return (raw.prepare(sql).get(...params) ?? null) as T | null; }, // kbdb-sql-ok: 測試 adapter + async run() { raw.prepare(sql).run(...params); return { success: true }; }, // kbdb-sql-ok: 測試 adapter + }; + return s; + } + return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database; // kbdb-sql-ok: 測試 adapter 的 D1 介面實作本身 +} + +function envWith(db: D1Database): Bindings { + return { DB: db, ENVIRONMENT: 'test' } as unknown as Bindings; +} + +function app(db: D1Database) { + const a = new Hono<{ Bindings: Bindings }>(); + a.route('/entries', entryRoutes); + return { fetch: (path: string, init?: RequestInit) => a.request(path, init, envWith(db)) }; +} + +describe('credential-legacy-migration — 反向驗證:重現 2026-08-07 youlin 事故並證明修好', () => { + it('事故前置狀態(舊表有資料、entries 沒有)下,GET /entries 一樣能讀到 credential(自癒生效)', async () => { + const db = makeSqliteD1(); + // 重建事故現場:舊表寫一筆 kbdb_internal_token,entries 完全沒有對應列 + // (新 code 部署了、migration 沒跑——2026-08-07 youlin 的確切狀態)。 + await db + .prepare( // kbdb-sql-ok: 測試重建舊表資料現場,比照 execution-log.test.ts + `INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at) + VALUES (?, ?, ?, ?, ?, ?, NULL)`, + ) + .bind('yuga3bse', 'kbdb_internal_token', 'kbdb', 'high', 'CRED_KBDB_INTERNAL_TOKEN_DEADBEEF', Math.floor(Date.now() / 1000)) + .run(); + + // 事故當天的確切呼叫形狀:cypher-executor credentials.ts 的 findCredentialEntry / + // getCredentialDirectory 都是打這個端點。 + const a = app(db); + const res = await a.fetch('/entries?owner_id=yuga3bse&entry_type=credential&page_name=kbdb_internal_token&limit=1'); + const body = (await res.json()) as { success: boolean; entries: Array<{ page_name: string; metadata_json: string }> }; + + expect(body.success).toBe(true); + expect(body.entries.length).toBe(1); // 補丁加入前這裡是 0——2026-08-07 事故的確切失敗形狀 + expect(body.entries[0].page_name).toBe('kbdb_internal_token'); + const meta = JSON.parse(body.entries[0].metadata_json) as { secret_ref: string; service: string }; + expect(meta.secret_ref).toBe('CRED_KBDB_INTERNAL_TOKEN_DEADBEEF'); + expect(meta.service).toBe('kbdb'); + }); + + it('搬移後 KBDB 核心三表結構不變,舊表刻意保留(本檔不清舊表,交由之後的清理步驟)', async () => { + const db = makeSqliteD1(); + await db + .prepare(`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?, NULL)`) // kbdb-sql-ok: 測試寫入 + .bind('t1', 'x', null, 'standard', 'CRED_X_AAAA', 1) + .run(); + await migrateLegacyCredentialsForOwner(db, 't1'); + const tables = await db + .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`) // kbdb-sql-ok: 測試查詢 + .all<{ name: string }>(); + const names = (tables.results ?? []).map((t) => t.name).sort(); + // entries/templates/entry_values 三張核心表 + credentials(舊表,尚未清理)——沒有第五張表。 + expect(names).toEqual(['credentials', 'entries', 'entry_values', 'templates']); + }); +}); + +describe('credential-legacy-migration — 冪等(同一 owner 呼叫多次不重複搬)', () => { + it('連呼叫三次,entries 裡該租戶的 credential 筆數固定為 1', async () => { + const db = makeSqliteD1(); + await db + .prepare(`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?, NULL)`) // kbdb-sql-ok: 測試寫入 + .bind('owner-idem', 'telegram_bot_token', 'telegram', 'standard', 'CRED_TELEGRAM_BOT_TOKEN_BEEF', 1000) + .run(); + + const n1 = await migrateLegacyCredentialsForOwner(db, 'owner-idem'); + const n2 = await migrateLegacyCredentialsForOwner(db, 'owner-idem'); + const n3 = await migrateLegacyCredentialsForOwner(db, 'owner-idem'); + expect(n1).toBe(1); // 第一次:真的搬了一筆 + expect(n2).toBe(0); // 第二次起:NOT EXISTS 擋下,不重複 + expect(n3).toBe(0); + + const rows = await db + .prepare(`SELECT COUNT(*) AS n FROM entries WHERE entry_type='credential' AND owner_id=?1`) // kbdb-sql-ok: 測試查詢 + .bind('owner-idem') + .first<{ n: number }>(); + expect(rows?.n).toBe(1); + }); +}); + +describe('credential-legacy-migration — 多租戶互不干擾,且對「已搬過」與「還沒搬」同時安全', () => { + it('兩個 owner 各自的 credential 不互相污染;沒有資料的 owner 查詢回空、不報錯', async () => { + const db = makeSqliteD1(); + await db + .prepare(`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?, NULL)`) // kbdb-sql-ok: 測試寫入 + .bind('tenant-a', 'gemini_api_key', 'gemini', 'high', 'CRED_GEMINI_API_KEY_A1', 1) + .run(); + await db + .prepare(`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?, NULL)`) // kbdb-sql-ok: 測試寫入 + .bind('tenant-b', 'gemini_api_key', 'gemini', 'high', 'CRED_GEMINI_API_KEY_B2', 1) + .run(); + + await migrateLegacyCredentialsForOwner(db, 'tenant-a'); + // tenant-b 完全沒觸發過搬遷(模擬「還沒走到這個租戶的下一次 workflow 執行」)。 + + const a = app(db); + const resA = await a.fetch('/entries?owner_id=tenant-a&entry_type=credential&page_name=gemini_api_key&limit=1'); + const bodyA = (await resA.json()) as { entries: Array<{ metadata_json: string }> }; + expect(JSON.parse(bodyA.entries[0].metadata_json).secret_ref).toBe('CRED_GEMINI_API_KEY_A1'); + + // tenant-b 第一次讀取才觸發自己的搬遷(GET /entries 路由本身會呼叫,不需要呼叫端先知道)。 + const resB = await a.fetch('/entries?owner_id=tenant-b&entry_type=credential&page_name=gemini_api_key&limit=1'); + const bodyB = (await resB.json()) as { entries: Array<{ metadata_json: string }> }; + expect(JSON.parse(bodyB.entries[0].metadata_json).secret_ref).toBe('CRED_GEMINI_API_KEY_B2'); + + // 沒有任何資料的第三個 owner:不報錯、乾淨回空。 + const resC = await a.fetch('/entries?owner_id=tenant-c&entry_type=credential&limit=200'); + const bodyC = (await resC.json()) as { success: boolean; entries: unknown[] }; + expect(bodyC.success).toBe(true); + expect(bodyC.entries).toEqual([]); + }); + + it('舊表已被清理(不存在)時查詢照常運作(模擬所有租戶搬完後的最終清理狀態)', async () => { + const db = makeSqliteD1(); + await db.prepare(`DROP TABLE credentials`).run(); // kbdb-sql-ok: 測試模擬「清理步驟已執行」的終態,非牆外存取 + const n = await migrateLegacyCredentialsForOwner(db, 'anyone'); + expect(n).toBe(0); // 短路,不報錯 + + const a = app(db); + const res = await a.fetch('/entries?owner_id=anyone&entry_type=credential&limit=200'); + const body = (await res.json()) as { success: boolean; entries: unknown[] }; + expect(body.success).toBe(true); + expect(body.entries).toEqual([]); + }); +});