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:
Claude
2026-07-04 23:00:07 +00:00
parent 1db8a13a3a
commit 1d19d46161
9 changed files with 588 additions and 99 deletions
+181
View File
@@ -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();
});
});