/** * 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(); // 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'); }); });