Files
uncle6me-web ed2e42e007 feat(credentials): T6 讀取/注入接新家 + T7 雙讀 fallback (credential-store-migration 方案 A)
D19「擁有目錄不擁有內容物」遷移的讀取斷點。密文住 cypher per-script secrets、
D1 只存 secret_ref、auth worker 讀不到 cypher 的 secrets → cypher 先取值塞 payload。

WASM 端(T7 fallback 骨架,零行為改變可獨立部署驗):
- auth_static_key/main.go + auth_service_account/main.go:Input 加 resolved_secrets,
  解密處改「有 resolved 就用、沒有才 fallback 舊 kv_get+crypto_decrypt」。default 等於舊碼。
- tinygo build 兩支通過,copy 到 .component-builds/*/component.wasm。

TS 端(T6 主路徑):
- auth-dispatcher.ts 新增 resolveSecretsFromNewHome:查 D1 拿 secret_ref →
  secret_get(env[ref], T4) 取明文 → 組 map(取不到的 name 缺席,不放空字串) →
  更新 last_used_at。tryAuthDispatch + resolveCredentialRefs 都塞 resolved_secrets。
- rule 02 §2.2 對齊:只查 ref/取值/塞字串,不解密不展開模板不組 JWT。

驗證:cypher-executor + cli tsc exit 0;vitest 41/42(新增 auth-dispatcher.test.ts 6 案例全過,
剩 1 pre-existing 無關失敗)。待 leo21c 部署驗 WASM 端到端(不由本任務部署,acr update
硬綁 GitHub codeload=mistakes #23)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 13:44:26 +08:00

106 lines
4.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* credential-store-migration T6/T7(方案 AD19)測試:auth-dispatcher 的
* `resolveSecretsFromNewHome`——「查 D1 拿 secret_ref → secret_get(env[ref]) 取值 → 組 map」。
*
* 覆蓋 T6 主路徑 + T7 雙讀分流的判準:
* - D1 有 ref 且 secret 存在(新家有值)→ name 進 mappayload 會含 resolved_secrets[name]
* - D1 無 ref(從未回填)→ name 缺席 map(讓 WASM fallback 舊 KVT7
* - D1 有 ref 但 secret 不存在(新家還沒寫值)→ secret_get 回 null → name 缺席(不放空字串!)
* - 取到值的 name 更新 last_used_at
*
* secret_get 的實作是 host function `env[ref]`wasi-shim),故測試把 CRED_* 值放進傳入的
* env 物件模擬「per-script secret 已注入」,用真實 D1 binding 存目錄(同 credentials.test.ts)。
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { env } from 'cloudflare:test';
import type { Bindings } from '../src/types';
import { resolveSecretsFromNewHome } from '../src/actions/auth-dispatcher';
const API_KEY = 'test-tenant-t67';
async function insertRefRow(name: string, secretRef: string): Promise<void> {
await env.CREDENTIALS_DB
.prepare(
`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at)
VALUES (?, ?, ?, ?, ?, ?, NULL)`,
)
.bind(API_KEY, name, null, 'standard', secretRef, Math.floor(Date.now() / 1000))
.run();
}
async function clearRows(): Promise<void> {
await env.CREDENTIALS_DB.prepare(`DELETE FROM credentials WHERE api_key = ?`).bind(API_KEY).run();
}
/** 把 CRED_* secret 值疊到真實 env 上(模擬 per-script secret 已注入 runtime)。 */
function envWithSecrets(secrets: Record<string, string>): Bindings {
return { ...(env as unknown as Record<string, unknown>), ...secrets } as unknown as Bindings;
}
describe('resolveSecretsFromNewHome (T6/T7 方案 A)', () => {
beforeEach(clearRows);
it('D1 有 ref 且新家有值 → name 進 mapT6 命中新家)', async () => {
await insertRefRow('openai_key', 'CRED_OPENAI_KEY_ABCDEF01');
const testEnv = envWithSecrets({ CRED_OPENAI_KEY_ABCDEF01: 'sk-live-123' });
const resolved = await resolveSecretsFromNewHome(testEnv, API_KEY, ['openai_key']);
expect(resolved).toEqual({ openai_key: 'sk-live-123' });
});
it('D1 無 ref(從未回填)→ name 缺席 map(走 WASM fallback 舊 KVT7', async () => {
// D1 沒有這個 name 的 row
const testEnv = envWithSecrets({ CRED_ANYTHING: 'unused' });
const resolved = await resolveSecretsFromNewHome(testEnv, API_KEY, ['never_migrated']);
expect(resolved).not.toHaveProperty('never_migrated');
expect(resolved).toEqual({});
});
it('D1 有 ref 但新家沒值 → secret_get 回 null → name 缺席(不放空字串)', async () => {
await insertRefRow('notion_token', 'CRED_NOTION_TOKEN_ABCDEF01');
// env 裡沒有 CRED_NOTION_TOKEN_ABCDEF01 → env[ref] 為 undefined → secret_get 回 null
const testEnv = envWithSecrets({});
const resolved = await resolveSecretsFromNewHome(testEnv, API_KEY, ['notion_token']);
// 關鍵:缺席,不是 { notion_token: '' }(空字串會讓 WASM 誤判命中用空值)
expect(resolved).not.toHaveProperty('notion_token');
expect(resolved).toEqual({});
});
it('混合:一把有值一把沒 ref → 只有有值的進 map', async () => {
await insertRefRow('has_value', 'CRED_HAS_VALUE_ABCDEF01');
const testEnv = envWithSecrets({ CRED_HAS_VALUE_ABCDEF01: 'the-value' });
const resolved = await resolveSecretsFromNewHome(testEnv, API_KEY, ['has_value', 'no_ref']);
expect(resolved).toEqual({ has_value: 'the-value' });
});
it('空 names → 空 map(零開銷)', async () => {
const resolved = await resolveSecretsFromNewHome(envWithSecrets({}), API_KEY, []);
expect(resolved).toEqual({});
});
it('取到值後更新 last_used_at(治理面)', async () => {
await insertRefRow('used_key', 'CRED_USED_KEY_ABCDEF01');
const before = await env.CREDENTIALS_DB
.prepare(`SELECT last_used_at FROM credentials WHERE api_key = ? AND name = ?`)
.bind(API_KEY, 'used_key')
.first<{ last_used_at: number | null }>();
expect(before?.last_used_at).toBeNull();
const testEnv = envWithSecrets({ CRED_USED_KEY_ABCDEF01: 'v' });
await resolveSecretsFromNewHome(testEnv, API_KEY, ['used_key']);
const after = await env.CREDENTIALS_DB
.prepare(`SELECT last_used_at FROM credentials WHERE api_key = ? AND name = ?`)
.bind(API_KEY, 'used_key')
.first<{ last_used_at: number | null }>();
expect(typeof after?.last_used_at).toBe('number');
});
});