feat(credentials): T8 回填端點 + T9 治理端點/CLI (credential-store-migration)
- POST /credentials/migrate-to-workers-secrets:舊 KV credential 逐筆解密回填 D1+Workers Secrets,冪等可審,重用 wasi-shim 唯一合法 crypto_decrypt 呼叫點 - GET /credentials 改讀 D1(與既有 /credentials/catalog 共用查詢);DELETE 改為新家優先、 舊 KV fallback,避免孤兒資料 - acr creds list/replace/delete 三支 CLI 薄殼指令;順手修好過期的 acr creds push(舊 client 端加密格式已被 T5 取代) - 新增 cypher-executor/tests/credentials.test.ts + D1 test fixture T6/T7(讀取/注入路徑、雙讀 fallback)需要重新編譯 registry/components/auth_static_key 的 TinyGo WASM,本環境無 tinygo 且 proxy 擋 github.com 下載,卡在工具鏈缺口,詳細分析 記錄在 credential-store-migration.md。 端到端驗證:部署到 leo21c 帳號真實跑過 GET/POST/DELETE 三分支 + migrate 端點(對真實 既存的兩筆 credential 跑,發現 cypher-executor 自己的 ENCRYPTION_KEY secret 疑似為空, 誠實記錄為待 leo/總管裁決的不可逆風險項,未擅自重設)。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,14 +17,21 @@
|
||||
* (2026-07-03 T1.5 spike 定案)對舊格式的刻意取代,SDD §6 Q-b 仍列為需 leo 明確接受的
|
||||
* 誠實 trade-off(本次實作先落地,若 leo 不接受選項甲需回頭改)。
|
||||
*
|
||||
* DELETE /credentials/:name 與 GET /credentials 本次**不動**(T9 治理端點的範圍),
|
||||
* 仍讀寫舊 KV 路徑——這代表新寫入的 credential 目前查不到舊 GET /credentials 列表裡
|
||||
* (誠實缺口,見 credential-store-migration.md T5 完成註記;驗證改用直接查 D1)。
|
||||
* credential-store-migration T8(§4.2 回填)+ T9(§3 治理端點):
|
||||
* - `POST /credentials/migrate-to-workers-secrets`:把呼叫者(X-Arcrun-API-Key)名下的舊
|
||||
* `{api_key}:cred:{name}` KV row 逐一解密(重用 wasi-shim 唯一合法 crypto_decrypt 呼叫點,
|
||||
* 不在本檔重新實作解密)→ PUT 進 Workers Secrets → D1 upsert 目錄。冪等:D1 已有可解析
|
||||
* secret_ref 的 row 就跳過;逐筆誠實回報 ok/skipped/fail(mindset §7 不假綠)。
|
||||
* - `GET /credentials`:改讀 D1(與 `/credentials/catalog` 共用同一份 query,同時保留
|
||||
* `/catalog` 別名,Console 既有呼叫不受影響)。
|
||||
* - `DELETE /credentials/:name`:先查 D1 拿 secret_ref → 有則刪 Workers Secret + D1 row;
|
||||
* 沒有(credential 從未回填過,只存在舊 KV)→ fallback 刪舊 KV key,避免刪不掉的孤兒資料。
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { sha256Prefix } from '../lib/hash';
|
||||
import { createArcrunHostFunctions } from '../lib/wasi-shim';
|
||||
|
||||
export const credentialsRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -82,6 +89,29 @@ async function putWorkerSecret(env: Bindings, secretRef: string, value: string):
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 呼叫 CF Workers Scripts secrets 管理 API 刪除一個 per-script secret(T9 治理端點用)。
|
||||
* 404(本來就不存在)視為成功(冪等刪除,呼叫端可能已被清過)。
|
||||
*/
|
||||
async function deleteWorkerSecret(env: Bindings, secretRef: string): Promise<void> {
|
||||
if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) {
|
||||
throw new Error('此 worker 缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID 設定,刪除路徑未就緒');
|
||||
}
|
||||
const url = `https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/workers/scripts/${CYPHER_SCRIPT_NAME}/secrets/${secretRef}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${env.CF_SECRETS_API_TOKEN}` },
|
||||
});
|
||||
if (res.status === 404) return;
|
||||
const body = (await res.json().catch(() => null)) as
|
||||
| { success?: boolean; errors?: Array<{ message?: string }> }
|
||||
| null;
|
||||
if (!res.ok || !body?.success) {
|
||||
const detail = body?.errors?.map(e => e.message).filter(Boolean).join('; ') || `HTTP ${res.status}`;
|
||||
throw new Error(`CF Workers Secrets 刪除失敗:${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* D1 upsert credential 目錄 row(不含密文)。
|
||||
* created_at 只在首次建立時寫入;覆寫(PUT/重複 POST)保留原 created_at,只更新
|
||||
@@ -110,6 +140,35 @@ async function upsertCredentialRow(
|
||||
.run();
|
||||
}
|
||||
|
||||
interface CredentialRow {
|
||||
name: string;
|
||||
service: string | null;
|
||||
sensitivity: string;
|
||||
created_at: number;
|
||||
last_used_at: number | null;
|
||||
}
|
||||
|
||||
/** D1 目錄 list(不含 secret_ref、不含值)——`GET /credentials` 與 `/credentials/catalog` 共用。 */
|
||||
async function listCredentialRows(db: D1Database, apiKey: string): Promise<CredentialRow[]> {
|
||||
const rows = await db
|
||||
.prepare(
|
||||
`SELECT name, service, sensitivity, created_at, last_used_at
|
||||
FROM credentials WHERE api_key = ? ORDER BY created_at DESC`,
|
||||
)
|
||||
.bind(apiKey)
|
||||
.all<CredentialRow>();
|
||||
return rows.results ?? [];
|
||||
}
|
||||
|
||||
/** 查單一 credential 的 secret_ref(治理端點刪除用;不對外回傳 secret_ref 本身,只內部使用)。 */
|
||||
async function findSecretRef(db: D1Database, apiKey: string, name: string): Promise<string | null> {
|
||||
const row = await db
|
||||
.prepare(`SELECT secret_ref FROM credentials WHERE api_key = ? AND name = ?`)
|
||||
.bind(apiKey, name)
|
||||
.first<{ secret_ref: string }>();
|
||||
return row?.secret_ref ?? null;
|
||||
}
|
||||
|
||||
interface CredentialWriteBody {
|
||||
name?: string;
|
||||
value?: string;
|
||||
@@ -191,7 +250,8 @@ credentialsRouter.put('/credentials/:name', async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /credentials/:name — 刪除 credential(未動:仍是舊 KV 路徑,T9 範圍)
|
||||
// DELETE /credentials/:name — 刪除 credential(T9:新家優先,舊 KV 為回退)
|
||||
// D19 對齊:能刪的只有「目錄 row + Workers Secret 這個密文本體」,本端點從頭到尾不讀值。
|
||||
credentialsRouter.delete('/credentials/:name', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
if (!apiKey) {
|
||||
@@ -199,47 +259,117 @@ credentialsRouter.delete('/credentials/:name', async (c) => {
|
||||
}
|
||||
|
||||
const name = c.req.param('name');
|
||||
const kvKey = `${apiKey}:cred:${name}`;
|
||||
await c.env.CREDENTIALS_KV.delete(kvKey);
|
||||
|
||||
return c.json({ success: true, name });
|
||||
try {
|
||||
const secretRef = await findSecretRef(c.env.CREDENTIALS_DB, apiKey, name);
|
||||
if (secretRef) {
|
||||
await deleteWorkerSecret(c.env, secretRef);
|
||||
await c.env.CREDENTIALS_DB
|
||||
.prepare(`DELETE FROM credentials WHERE api_key = ? AND name = ?`)
|
||||
.bind(apiKey, name)
|
||||
.run();
|
||||
return c.json({ success: true, name, source: 'workers-secrets' });
|
||||
}
|
||||
// D1 沒有 row:這個 credential 可能從未回填過(只存在舊 KV),fallback 刪舊路徑,
|
||||
// 避免「GET 改讀 D1 看不到、DELETE 卻刪不掉」的孤兒資料。
|
||||
await c.env.CREDENTIALS_KV.delete(`${apiKey}:cred:${name}`);
|
||||
return c.json({ success: true, name, source: 'legacy-kv' });
|
||||
} catch (e) {
|
||||
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /credentials/catalog — D1 目錄唯讀 list(Mira Console 完整版,Arcrun#3 console 系)。
|
||||
// 只回 metadata(name/service/sensitivity/created_at/last_used_at),**絕不回密文值**——
|
||||
// 密文在 Workers Secrets,本 worker 自己也讀不回(D19「擁有目錄,不擁有內容物」)。
|
||||
// 與舊 GET /credentials(KV 名稱清單)並存:這裡是新制 D1 目錄;舊 KV 寫入的看不到(T5 已知誠實缺口)。
|
||||
// 註冊須在 GET /credentials/:name 類 route 之前?本檔無 :name GET route,無攔截問題。
|
||||
// 與 GET /credentials(下方,T9 起改讀同一份 D1 查詢)是同一份資料的兩個路徑;
|
||||
// /catalog 保留給既有 Console 呼叫,避免破壞既有前端整合。
|
||||
credentialsRouter.get('/credentials/catalog', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
if (!apiKey) {
|
||||
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
|
||||
}
|
||||
try {
|
||||
const rows = await c.env.CREDENTIALS_DB
|
||||
.prepare(
|
||||
`SELECT name, service, sensitivity, created_at, last_used_at
|
||||
FROM credentials WHERE api_key = ? ORDER BY created_at DESC`,
|
||||
)
|
||||
.bind(apiKey)
|
||||
.all<{ name: string; service: string | null; sensitivity: string; created_at: number; last_used_at: number | null }>();
|
||||
return c.json({ success: true, credentials: rows.results ?? [], total: (rows.results ?? []).length });
|
||||
const rows = await listCredentialRows(c.env.CREDENTIALS_DB, apiKey);
|
||||
return c.json({ success: true, credentials: rows, total: rows.length });
|
||||
} catch (e) {
|
||||
// 誠實回報:D1 未建表 / migration 未跑(不假綠回空陣列裝沒事)
|
||||
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /credentials — 列出 credential 名稱(不含值)(未動:仍是舊 KV 路徑,T9 範圍)
|
||||
// GET /credentials — 列出 credential 目錄(T9:改讀 D1,只回 metadata,絕不含值/secret_ref)
|
||||
credentialsRouter.get('/credentials', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
if (!apiKey) {
|
||||
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
|
||||
}
|
||||
try {
|
||||
const rows = await listCredentialRows(c.env.CREDENTIALS_DB, apiKey);
|
||||
return c.json({ success: true, credentials: rows, total: rows.length });
|
||||
} catch (e) {
|
||||
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502);
|
||||
}
|
||||
});
|
||||
|
||||
interface MigrateResult {
|
||||
name: string;
|
||||
ok: boolean;
|
||||
skipped?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// POST /credentials/migrate-to-workers-secrets — 回填(T8,§4.2):一次性、冪等、可審。
|
||||
// 把呼叫者名下舊 `{api_key}:cred:{name}` KV row({encrypted, iv} AES-GCM 密文)逐一解密
|
||||
// →(重用 wasi-shim 唯一合法 crypto_decrypt 呼叫點,本檔不重新實作解密)→ PUT 進 Workers
|
||||
// Secrets → D1 upsert 目錄。冪等:D1 已有該 (api_key,name) row 且 secret_ref 非空 → 跳過。
|
||||
// 不刪 KV 舊密文(§4.3 回滾錨點——雙讀 fallback、廢除 ENCRYPTION_KEY 前的安全網)。
|
||||
credentialsRouter.post('/credentials/migrate-to-workers-secrets', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
if (!apiKey) {
|
||||
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
|
||||
}
|
||||
|
||||
const cryptoDecrypt = createArcrunHostFunctions(c.env, apiKey).crypto_decrypt;
|
||||
if (!cryptoDecrypt) {
|
||||
return c.json({ success: false, error: 'crypto_decrypt host function 未就緒' }, 500);
|
||||
}
|
||||
|
||||
const prefix = `${apiKey}:cred:`;
|
||||
const list = await c.env.CREDENTIALS_KV.list({ prefix });
|
||||
const names = list.keys.map(k => k.name.slice(prefix.length));
|
||||
const results: MigrateResult[] = [];
|
||||
|
||||
return c.json({ credentials: names });
|
||||
for (const key of list.keys) {
|
||||
const name = key.name.slice(prefix.length);
|
||||
try {
|
||||
const existingRef = await findSecretRef(c.env.CREDENTIALS_DB, apiKey, name);
|
||||
if (existingRef) {
|
||||
results.push({ name, ok: true, skipped: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
const raw = await c.env.CREDENTIALS_KV.get(key.name);
|
||||
if (!raw) {
|
||||
results.push({ name, ok: false, error: 'KV row 讀不到值(可能已被刪除)' });
|
||||
continue;
|
||||
}
|
||||
const { encrypted, iv } = JSON.parse(raw) as { encrypted: string; iv: string };
|
||||
const plaintext = await cryptoDecrypt(encrypted, iv);
|
||||
|
||||
const secretRef = await deriveSecretRef(apiKey, name);
|
||||
await putWorkerSecret(c.env, secretRef, plaintext);
|
||||
await upsertCredentialRow(c.env.CREDENTIALS_DB, apiKey, name, null, 'standard', secretRef);
|
||||
results.push({ name, ok: true });
|
||||
} catch (e) {
|
||||
// 誠實回報逐筆 fail,不假綠(mindset §7)
|
||||
results.push({ name, ok: false, error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
const failed = results.filter(r => !r.ok);
|
||||
return c.json({
|
||||
success: failed.length === 0,
|
||||
total: results.length,
|
||||
migrated: results.filter(r => r.ok && !r.skipped).length,
|
||||
skipped: results.filter(r => r.skipped).length,
|
||||
failed: failed.length,
|
||||
results,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* 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<void> {
|
||||
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<void> {
|
||||
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<Record<string, unknown>> };
|
||||
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();
|
||||
});
|
||||
});
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* vitest-pool-workers 的 `cloudflare:test` `env`/`ProvidedEnv` 型別合併宣告。
|
||||
* 讓 tests/*.test.ts 裡 `import { env } from 'cloudflare:test'` 拿到跟正式 Bindings
|
||||
* 一致的型別(credential-store-migration T8/T9 測試需要 env.CREDENTIALS_DB / CREDENTIALS_KV)。
|
||||
*/
|
||||
import type { Bindings } from '../src/types';
|
||||
|
||||
declare module 'cloudflare:test' {
|
||||
interface ProvidedEnv extends Bindings {}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* vitest-pool-workers 測試環境初始化:credential-store-migration T8/T9 測試用 D1 建表。
|
||||
* 對齊 kbdb/migrations/0002_credentials.sql schema(不接 migrations_dir,單表直接 exec)。
|
||||
*/
|
||||
import { env } from 'cloudflare:test';
|
||||
|
||||
await env.CREDENTIALS_DB.exec(
|
||||
"CREATE TABLE IF NOT EXISTS credentials (api_key TEXT NOT NULL, name TEXT NOT NULL, service TEXT, sensitivity TEXT NOT NULL DEFAULT 'standard', secret_ref TEXT NOT NULL, created_at INTEGER NOT NULL, last_used_at INTEGER, PRIMARY KEY (api_key, name))",
|
||||
);
|
||||
@@ -2,6 +2,7 @@ import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config';
|
||||
|
||||
export default defineWorkersConfig({
|
||||
test: {
|
||||
setupFiles: ['./tests/setup.ts'],
|
||||
poolOptions: {
|
||||
workers: {
|
||||
wrangler: { configPath: './wrangler.test.toml' },
|
||||
|
||||
@@ -16,5 +16,22 @@ id = "test-exec-context"
|
||||
binding = "WEBHOOKS"
|
||||
id = "test-webhooks"
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "CREDENTIALS_KV"
|
||||
id = "test-credentials-kv"
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "RECIPES"
|
||||
id = "test-recipes"
|
||||
|
||||
# credential-store-migration T8/T9 測試用 D1 mock(Miniflare 本地 SQLite,非真實 leo21c D1;
|
||||
# schema 由 tests/setup.ts 在測試啟動時建表,不用 migrations_dir——0002_credentials.sql 就一張表,
|
||||
# 直接 exec 比接 migrations 機制簡單)
|
||||
[[d1_databases]]
|
||||
binding = "CREDENTIALS_DB"
|
||||
database_name = "test-credentials-db"
|
||||
database_id = "test-credentials-db-id"
|
||||
|
||||
[vars]
|
||||
ENVIRONMENT = "test"
|
||||
ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
|
||||
Reference in New Issue
Block a user