/** * console-auth.ts —— 舊實例相容(帳密還在 D61 的認證儲存,尚未搬回 SESSIONS_KV) * * 2026-08-14 起(D61 補充,leo confirm「走C」):console 帳密的家改回 SESSIONS_KV(binding)。 * 已經在跑 D61(帳密住 CF Workers Secrets)的實例不能因為這次改動而登不進去——讀取認證儲存 * 零成本、零外部憑證需求(只有寫入才需要 CF_SECRETS_API_TOKEN),所以永遠讀得到;讀到就 * 順手搬回 SESSIONS_KV(best-effort,binding put 幾乎不會失敗)。 * * 拆成獨立檔案的理由:portal-auth-store.ts 內部沒有跨測試檔案共享的可變狀態問題(每個測試 * 檔案是獨立 worker 執行個體),但為了跟 console-auth.test.ts(一開始就會把帳密設進 * SESSIONS_KV)的「全新、尚未設定」起始狀態互不干擾,仍分開一支檔案,語意更清楚。 */ import { SELF, env, fetchMock } from 'cloudflare:test'; import { beforeAll, afterEach, describe, it, expect } from 'vitest'; import { mutateAuthStore } from '../src/lib/portal-auth-store'; import type { Bindings } from '../src/types'; const CF_API = 'https://api.cloudflare.com'; beforeAll(() => { fetchMock.activate(); fetchMock.disableNetConnect(); }); afterEach(() => fetchMock.assertNoPendingInterceptors()); function json(method: string, path: string, body?: unknown) { return SELF.fetch(`http://localhost${path}`, { method, headers: { 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), }); } /** 種一筆進 D61 認證儲存(CF Workers Secrets)——直接呼叫 lib,不經任何 HTTP route。 */ function mockLegacySecretsWrite(): void { fetchMock .get(CF_API) .intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' }) .reply(200, { success: true }); } /** 複刻 console-auth.ts 內未 export 的私有迭代雜湊(sha256(salt+password) 迭代 3 次), * 單純為了在測試端準備一筆能通過驗證的 legacy fixture,不是重新實作生產邏輯。 */ async function legacyHash(password: string, salt: string): Promise { async function sha256Hex(input: string): Promise { const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input)); return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, '0')).join(''); } let h = `${salt}:${password}`; for (let i = 0; i < 3; i++) h = await sha256Hex(h); return h; } const EMAIL = 'legacy-owner@example.com'; const PASSWORD = 'legacy-owner-pw-1'; const SALT = 'deadbeef00112233'; // 🔴 全部收在**同一個 `it()`** 裡(不拆成多則):`isolatedStorage`(vitest-pool-workers 預設開) // 在每一個 `it()` 前重置 SESSIONS_KV,但 D61 認證儲存的模組級記憶體變數 `overlay` 不受影響 // (見 lib/portal-auth-store.ts 檔頭)。若拆成多則,「搬回 SESSIONS_KV 後再打一次直接命中 // 新家」這一步在下一個 `it()` 會因為 KV 被重置而又落回 legacy-secrets 分支,驗不出「新家優先」 // 這件事——所以要在同一次測試、同一份 KV 狀態內連續打兩次才驗得出來。 describe('舊實例相容:console 帳密只在認證儲存(尚未搬回 SESSIONS_KV)', () => { it('GET /console/auth-status 讀到認證儲存這筆、順手搬回 SESSIONS_KV;再打一次直接命中新家;帳密登得進去', async () => { const hash = await legacyHash(PASSWORD, SALT); mockLegacySecretsWrite(); await mutateAuthStore(env as unknown as Bindings, (data) => { data.console = { email: EMAIL, salt: SALT, hash, created_at: '2026-01-01T00:00:00.000Z' }; }); const res = await json('GET', '/console/auth-status'); expect(res.status).toBe(200); const data = (await res.json()) as { configured: boolean; credentials_source: string; auth_store: { legacy_secrets_present: boolean }; }; expect(data.configured).toBe(true); expect(data.credentials_source).toBe('legacy-secrets'); // 這次是靠回退讀到的 // loadCredentials 內的 best-effort 搬遷在回應組出來之前就已 await 完成 expect(data.auth_store.legacy_secrets_present).toBe(true); const kvRaw = await env.SESSIONS_KV.get('console:credentials'); expect(kvRaw).toBeTruthy(); const stored = JSON.parse(kvRaw!) as { email: string; hash: string }; expect(stored.email).toBe(EMAIL); expect(stored.hash).toBe(hash); // 原樣搬過去,不重新雜湊 // 搬回後再打一次:SESSIONS_KV 已經有了,直接命中新家(不用再查認證儲存) const res2 = await json('GET', '/console/auth-status'); const data2 = (await res2.json()) as { credentials_source: string }; expect(data2.credentials_source).toBe('kv'); // 用搬回去的帳密登入 → 200(搬遷沒有讓帳密變得登不進去) const login = await json('POST', '/console/login', { email: EMAIL, password: PASSWORD }); expect(login.status).toBe(200); expect((await login.json() as { success: boolean }).success).toBe(true); }); });