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>
This commit is contained in:
uncle6me-web
2026-07-05 13:44:26 +08:00
parent 55a47d7c18
commit ed2e42e007
7 changed files with 314 additions and 26 deletions
@@ -0,0 +1,105 @@
/**
* 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');
});
});