/** * credential-store-migration T8(回填端點)+ T9(治理端點)測試。 * * 範圍限制(誠實記錄,非本檔缺陷):`putWorkerSecret` / `deleteWorkerSecret` 呼叫真實 * Cloudflare API(`fetch` 到 api.cloudflare.com)。測試環境(wrangler.test.toml)刻意不設 * CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID,所以本檔只覆蓋「不需要真的打 CF API」的路徑: * - D1-only 的 GET /credentials、/credentials/catalog * - migrate 端點的冪等 skip 分支(D1 已有 row 就不會走到 putWorkerSecret) * - migrate 端點在缺 CF token 時對「真的需要新建」的 row 誠實回報 fail(不假綠) * - DELETE 在 D1 無 row 時 fallback 刪舊 KV(不會走到 deleteWorkerSecret) * 真正打 CF Workers Secrets API 成功寫入/刪除的路徑,由部署到 leo21c 帳號後的端到端 * curl 驗證覆蓋(見 credential-store-migration.md T8/T9 完成記錄)。 */ import { describe, it, expect, beforeEach } from 'vitest'; import { env, SELF } from 'cloudflare:test'; const API_KEY = 'test-tenant-t89'; async function insertCredentialRow( name: string, secretRef: string, extra: Partial<{ service: string | null; sensitivity: string; last_used_at: number | null }> = {}, ): Promise { await env.CREDENTIALS_DB .prepare( `INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, ) .bind( API_KEY, name, extra.service ?? null, extra.sensitivity ?? 'standard', secretRef, Math.floor(Date.now() / 1000), extra.last_used_at ?? null, ) .run(); } async function clearTenantRows(): Promise { await env.CREDENTIALS_DB.prepare(`DELETE FROM credentials WHERE api_key = ?`).bind(API_KEY).run(); } describe('GET /credentials (D1, T9)', () => { beforeEach(clearTenantRows); it('缺 X-Arcrun-API-Key → 401', async () => { const res = await SELF.fetch('https://cypher.test/credentials'); expect(res.status).toBe(401); }); it('無資料 → 空陣列(非拋錯)', async () => { const res = await SELF.fetch('https://cypher.test/credentials', { headers: { 'X-Arcrun-API-Key': API_KEY }, }); expect(res.status).toBe(200); const body = await res.json() as { success: boolean; credentials: unknown[]; total: number }; expect(body.success).toBe(true); expect(body.credentials).toEqual([]); expect(body.total).toBe(0); }); it('回傳 metadata,絕不含 secret_ref 或值', async () => { await insertCredentialRow('telegram_bot_token', 'CRED_TELEGRAM_BOT_TOKEN_ABCDEF01', { service: 'telegram' }); const res = await SELF.fetch('https://cypher.test/credentials', { headers: { 'X-Arcrun-API-Key': API_KEY }, }); const body = await res.json() as { success: boolean; credentials: Array> }; expect(body.success).toBe(true); expect(body.credentials).toHaveLength(1); const row = body.credentials[0]; expect(row.name).toBe('telegram_bot_token'); expect(row.service).toBe('telegram'); expect(row).not.toHaveProperty('secret_ref'); expect(row).not.toHaveProperty('value'); expect(JSON.stringify(row)).not.toMatch(/CRED_/); }); it('/credentials/catalog 回同一份資料(Console 相容別名)', async () => { await insertCredentialRow('notion_token', 'CRED_NOTION_TOKEN_ABCDEF01'); const [listRes, catalogRes] = await Promise.all([ SELF.fetch('https://cypher.test/credentials', { headers: { 'X-Arcrun-API-Key': API_KEY } }), SELF.fetch('https://cypher.test/credentials/catalog', { headers: { 'X-Arcrun-API-Key': API_KEY } }), ]); const [listBody, catalogBody] = await Promise.all([listRes.json(), catalogRes.json()]) as Array<{ credentials: Array<{ name: string }>; }>; expect(listBody.credentials.map(r => r.name)).toEqual(catalogBody.credentials.map(r => r.name)); }); }); describe('POST /credentials/migrate-to-workers-secrets (T8)', () => { beforeEach(async () => { await clearTenantRows(); await env.CREDENTIALS_KV.list({ prefix: `${API_KEY}:cred:` }).then(async (list) => { for (const k of list.keys) await env.CREDENTIALS_KV.delete(k.name); }); }); it('缺 X-Arcrun-API-Key → 401', async () => { const res = await SELF.fetch('https://cypher.test/credentials/migrate-to-workers-secrets', { method: 'POST' }); expect(res.status).toBe(401); }); it('D1 已有 row(曾回填過)→ 跳過,不誤判為失敗', async () => { await insertCredentialRow('already_migrated', 'CRED_ALREADY_MIGRATED_ABCDEF01'); // 對應的舊 KV row 仍在(§4.3 回滾錨點:回填後不刪 KV),驗證「有 D1 row 就跳過」而非重打 CF API await env.CREDENTIALS_KV.put( `${API_KEY}:cred:already_migrated`, JSON.stringify({ encrypted: 'irrelevant', iv: 'irrelevant' }), ); const res = await SELF.fetch('https://cypher.test/credentials/migrate-to-workers-secrets', { method: 'POST', headers: { 'X-Arcrun-API-Key': API_KEY }, }); const body = await res.json() as { success: boolean; total: number; migrated: number; skipped: number; failed: number; results: Array<{ name: string; ok: boolean; skipped?: boolean }>; }; expect(body.success).toBe(true); expect(body.total).toBe(1); expect(body.skipped).toBe(1); expect(body.migrated).toBe(0); expect(body.failed).toBe(0); expect(body.results[0]).toMatchObject({ name: 'already_migrated', ok: true, skipped: true }); }); it('無任何舊 KV row → 空結果,success:true(沒東西可回填不是失敗)', async () => { const res = await SELF.fetch('https://cypher.test/credentials/migrate-to-workers-secrets', { method: 'POST', headers: { 'X-Arcrun-API-Key': API_KEY }, }); const body = await res.json() as { success: boolean; total: number }; expect(body.success).toBe(true); expect(body.total).toBe(0); }); it('真正需要回填的 row(D1 無資料)在測試環境缺 CF token 時誠實回報 fail,不假綠', async () => { await env.CREDENTIALS_KV.put( `${API_KEY}:cred:needs_migration`, JSON.stringify({ encrypted: 'ZmFrZQ==', iv: 'ZmFrZQ==' }), ); const res = await SELF.fetch('https://cypher.test/credentials/migrate-to-workers-secrets', { method: 'POST', headers: { 'X-Arcrun-API-Key': API_KEY }, }); const body = await res.json() as { success: boolean; failed: number; results: Array<{ name: string; ok: boolean; error?: string }>; }; // 解密本身可能因假造的 base64 密文而失敗,或走到 putWorkerSecret 因缺 CF_SECRETS_API_TOKEN 失敗—— // 兩者都應該落在「誠實回報 fail」而非靜默假裝成功 expect(body.success).toBe(false); expect(body.failed).toBe(1); const row = body.results.find(r => r.name === 'needs_migration'); expect(row?.ok).toBe(false); expect(row?.error).toBeTruthy(); }); }); describe('DELETE /credentials/:name (T9)', () => { beforeEach(clearTenantRows); it('D1 無 row(從未回填)→ fallback 刪舊 KV,不誤報找不到', async () => { await env.CREDENTIALS_KV.put( `${API_KEY}:cred:legacy_only`, JSON.stringify({ encrypted: 'x', iv: 'y' }), ); const res = await SELF.fetch('https://cypher.test/credentials/legacy_only', { method: 'DELETE', headers: { 'X-Arcrun-API-Key': API_KEY }, }); const body = await res.json() as { success: boolean; source: string }; expect(res.status).toBe(200); expect(body.success).toBe(true); expect(body.source).toBe('legacy-kv'); const raw = await env.CREDENTIALS_KV.get(`${API_KEY}:cred:legacy_only`); expect(raw).toBeNull(); }); });