20c7610371
leo 2026-07-20 明令:「已經改用 cf 自己的 secrets,不要再說它了」 「我希望以後再也看不到這個詞再出現」 背景:credential 早已遷移至 CF Workers per-script Secrets + D1 目錄, 舊的自管金鑰(client 端 AES-GCM + KV 密文 + crypto_decrypt)是遷移期遺留。 本次連根移除,含一併作廢的死 SaaS 碼。 移除: - 舊 KV 密文解密路徑(credential-injector.ts 整檔、dual-read fallback) 前置驗證:leo21c / youlin 兩帳號 CREDENTIALS_KV 實測 *:cred:* 皆 0 筆 - migrate-to-workers-secrets 搬家端點(回填已完成,無可回填) - /register 路由與 generateApiKey(HMAC 產 ak_ key 是 SaaS 遺物; self-hosted 走 namespace 明碼 D21,已無人使用) - platform_crypto component(三帳號實測 404 已退役,無 workflow 引用) 保留(附理由): - crypto_decrypt 保留為永遠回失敗的 stub——現役三個 auth .wasm 仍宣告該 import,缺項會讓 WASM instantiate 直接失敗。待零件重編後可真正刪除。 順帶修復(原不在範圍,但會實際壞事): - /auth/callback 有 `if (!key) redirect(server_error)` 閘,未設該 secret 的 實例會登入直接失敗 → 已移除 - OAuth 兩處把 provider token 寫進舊加密 KV(租戶鍵與實際 api_key 在 rotate 後必然分歧,已失效)→ 改導向 Workers Secrets,包 try/catch 不影響登入 - acr init Standard 模式呼叫已刪除的 /register → 改引導 OAuth 取 key - .claude/rules 與 system-dev/docs 是同一規範的兩份鏡像,先前只改 rules 導致鏡像仍在教舊做法 → 已同步(此類雙檔同步應納入檢查) 新用戶安裝從此零 secret 前置。 測試 187/188(唯一 fail 為 pre-existing,stash 驗證與本次無關); cypher-executor 與 cli typecheck 全綠。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
111 lines
4.4 KiB
TypeScript
111 lines
4.4 KiB
TypeScript
/**
|
||
* credential 治理端點測試。
|
||
*
|
||
* 範圍限制(誠實記錄,非本檔缺陷):`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
|
||
* - 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('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();
|
||
});
|
||
});
|