/** * console-auth.ts 測試(D61:console 管理員帳密搬進認證儲存,ADR D61 / Leo/arcrun-rag#55) * * 這組帳密(/console/setup、/console/login…)原本住 SESSIONS_KV `console:credentials` * (沒有 TTL)——KV 靠 binding 指過去,重裝會被指到新建的空 KV ⇒ 帳密憑空消失 * (console-auth.ts 檔頭「KV=暫存、非長期真相源」第三次被違反,這次違反的是大門的鎖)。 * D61 起改存進認證儲存(CF Workers Secrets),SESSIONS_KV 只留為回退讀路徑。 * * 覆蓋(本檔在此之前不存在,D61 交辦要求的新增覆蓋): * 1. 全新實例:auth-status 回 configured:false;login 回「讀不到認證資料」(不是密碼錯)。 * 2. 首次設定成功:POST /console/setup 寫進認證儲存(CF Workers Secrets),不再寫 KV。 * 3. 已設定過 → 409,訊息明講「你剛才輸入的密碼沒有被採用」(D61 明顯失敗,取代舊版 * 只說「已設定過」卻不說清楚剛才那組密碼發生了什麼事的誤導文案)。 * 4. 登入對錯:帳密正確 200;密碼錯 401。 * 5. /console/setup/reset:舊密碼驗證+新密碼寫進新家;換密碼後舊密碼立即失效。 * * 認證儲存寫入會呼叫 `https://api.cloudflare.com/.../secrets`(PUT),走 fetchMock 假 host * 攔截(同 portal-auth.test.ts 的 mockAuthStoreWrite),不外連;wrangler.test.toml 已預設 * CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID 就緒。 * * ⚠️ 測試順序不可打亂:portal-auth-store.ts 的 per-isolate overlay 是模組級全域變數, * 一旦某則測試讓 /console/setup 或 reset 真的寫成功,overlay.console 就會在**這支檔案** * 剩下的測試裡持續存在(同檔案不會在測試之間重置模組全域,只有 KV/D1 等 storage 才有 * isolatedStorage 重置)。因此本檔刻意排成一條線性故事:先驗證「全新、尚未設定」的分支, * 再做一次成功的 /console/setup(之後永久變成「已設定」),後面的測試都建立在這個已設定 * 的基礎上。「帳密只存在舊 KV(尚未搬遷過)」這個分支需要 overlay 是空的,因此另開一支 * 檔案 tests/console-auth-legacy.test.ts(不同檔案=不同 worker 執行個體,狀態不互相污染)。 */ import { SELF, env, fetchMock } from 'cloudflare:test'; import { beforeAll, afterEach, describe, it, expect } from 'vitest'; const CF_API = 'https://api.cloudflare.com'; beforeAll(() => { fetchMock.activate(); fetchMock.disableNetConnect(); }); afterEach(() => fetchMock.assertNoPendingInterceptors()); function json(method: string, path: string, body?: unknown, headers: Record = {}) { return SELF.fetch(`http://localhost${path}`, { method, headers: { 'Content-Type': 'application/json', ...headers }, body: body === undefined ? undefined : JSON.stringify(body), }); } /** D61:認證儲存寫入路徑(同 portal-auth.test.ts 的同名 helper,那邊有完整說明)。 */ function mockAuthStoreWrite(times = 1): { puts: () => Array<{ name: string; text: string }> } { const captured: Array<{ name: string; text: string }> = []; fetchMock .get(CF_API) .intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' }) .reply(200, (opts) => { const body = JSON.parse(String(opts.body)) as { name: string; text: string }; captured.push(body); return { success: true }; }) .times(times); return { puts: () => captured }; } const OWNER_EMAIL = 'owner@example.com'; const OWNER_PW = 'owner-first-pw-1'; // ═══════════════ 1. 全新實例(尚未設定過,必須排最前面)═══════════════ describe('全新實例(尚未設定過任何管理員帳密)', () => { it('GET /console/auth-status → configured:false,不洩漏 email', async () => { 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: { present: boolean } }; expect(data.configured).toBe(false); expect(data.credentials_source).toBe('none'); expect(JSON.stringify(data)).not.toContain('@'); // 不洩漏 email }); it('POST /console/login → 400「讀不到認證資料」,不是密碼錯(D61 明顯失敗)', async () => { const res = await json('POST', '/console/login', { email: 'anyone@example.com', password: 'whatever-pw-1' }); expect(res.status).toBe(400); const data = (await res.json()) as { code: string; error: string }; expect(data.code).toBe('auth_store_empty'); expect(data.error).not.toBe('email 或密碼錯誤'); // 不是密碼錯誤路徑用的那句通用訊息 }); it('POST /console/setup/reset(還沒設定過就想換密碼)→ 400,叫去用 /console/setup', async () => { const res = await json('POST', '/console/setup/reset', { current_password: 'whatever', email: 'x@y.co', password: 'newpassword1', }); expect(res.status).toBe(400); }); }); // ═══════════════ 2. 首次設定:成功寫進認證儲存(D61 起唯一寫入路徑)═══════════════ describe('POST /console/setup — 首次設定', () => { it('成功:寫進認證儲存(不再寫 SESSIONS_KV),回 session_token', async () => { const { puts } = mockAuthStoreWrite(); const res = await json('POST', '/console/setup', { email: OWNER_EMAIL.toUpperCase(), password: OWNER_PW }); expect(res.status).toBe(200); const data = (await res.json()) as { success: boolean; session_token: string; tenant: string }; expect(data.success).toBe(true); expect(typeof data.session_token).toBe('string'); // 寫入認證儲存:一片、含小寫 email,明碼密碼絕不落地 const shards = puts(); expect(shards.length).toBe(1); expect(shards[0].name).toBe('ARCRUN_AUTH_STORE'); expect(shards[0].text).not.toContain(OWNER_PW); const shard = JSON.parse(shards[0].text) as { console: { email: string; salt: string; hash: string } }; expect(shard.console.email).toBe(OWNER_EMAIL); // 存小寫 expect(typeof shard.console.salt).toBe('string'); expect(typeof shard.console.hash).toBe('string'); // D61:不再寫舊 KV——這是本次變更的核心(舊版寫 SESSIONS_KV,重裝就蒸發) expect(await env.SESSIONS_KV.get('console:credentials')).toBeNull(); }); }); // ═══════════════ 3. 已設定過 → 409(D61 明顯失敗:說得出「沒有被採用」)═══════════════ describe('POST /console/setup — 已設定過(重複設定)', () => { it('409,訊息明講「你剛才輸入的密碼沒有被採用」,不誤導成「設定成功」', async () => { const res = await json('POST', '/console/setup', { email: 'attacker@example.com', password: 'trying-to-hijack-1' }); expect(res.status).toBe(409); const data = (await res.json()) as { error: string; code: string; password_applied: boolean; reset_path: string; }; expect(data.code).toBe('already_configured'); expect(data.password_applied).toBe(false); expect(data.error).toContain('沒有被採用'); expect(data.reset_path).toBe('/console/setup/reset'); // 攻擊者填的帳密真的沒有生效:用它登入應該失敗(下一個 describe 也會正面驗證原帳密仍有效) }); it('GET /console/auth-status → configured:true,credentials_source:secrets(新家優先命中)', async () => { const res = await json('GET', '/console/auth-status'); const data = (await res.json()) as { configured: boolean; credentials_source: string; auth_store: { console_configured: boolean } }; expect(data.configured).toBe(true); expect(data.credentials_source).toBe('secrets'); expect(data.auth_store.console_configured).toBe(true); }); }); // ═══════════════ 4. 登入對錯(用第 2 節設定的帳密)═══════════════ describe('POST /console/login', () => { it('帳密正確 → 200,發 session token', async () => { const res = await json('POST', '/console/login', { email: OWNER_EMAIL, password: OWNER_PW }); expect(res.status).toBe(200); const data = (await res.json()) as { success: boolean; session_token: string }; expect(data.success).toBe(true); expect(typeof data.session_token).toBe('string'); }); it('密碼錯 → 401', async () => { const res = await json('POST', '/console/login', { email: OWNER_EMAIL, password: 'wrong-password-x' }); expect(res.status).toBe(401); }); it('攻擊者在第 3 節試圖搶注的帳密登不進來(證明真的「沒有被採用」)', async () => { const res = await json('POST', '/console/login', { email: 'attacker@example.com', password: 'trying-to-hijack-1' }); expect(res.status).toBe(401); }); }); // ═══════════════ 5. /console/setup/reset:換密碼,寫進新家 ═══════════════ describe('POST /console/setup/reset', () => { const NEW_PW = 'brand-new-owner-pw-1'; it('舊密碼錯 → 401,不寫入', async () => { const res = await json('POST', '/console/setup/reset', { current_password: 'still-wrong', email: OWNER_EMAIL, password: NEW_PW, }); expect(res.status).toBe(401); }); it('舊密碼對 → 200,新 hash 寫進新家;換完後舊密碼立即失效、新密碼生效', async () => { const { puts } = mockAuthStoreWrite(); const res = await json('POST', '/console/setup/reset', { current_password: OWNER_PW, email: OWNER_EMAIL, password: NEW_PW, }); expect(res.status).toBe(200); const data = (await res.json()) as { success: boolean }; expect(data.success).toBe(true); const shards = puts(); expect(shards.length).toBe(1); expect(shards[0].text).not.toContain(NEW_PW); // 明碼不落地 const shard = JSON.parse(shards[0].text) as { console: { email: string } }; expect(shard.console.email).toBe(OWNER_EMAIL); // 舊密碼立即失效 const oldLogin = await json('POST', '/console/login', { email: OWNER_EMAIL, password: OWNER_PW }); expect(oldLogin.status).toBe(401); // 新密碼生效 const newLogin = await json('POST', '/console/login', { email: OWNER_EMAIL, password: NEW_PW }); expect(newLogin.status).toBe(200); }); });