/** * console-auth.ts 測試(2026-08-14 起:console 管理員帳密改回住 SESSIONS_KV, * D61 補充/pending-changes.md「認證儲存要不要搬回 D1/KV」,leo confirm「走C」) * * 這組帳密(/console/setup、/console/login…)曾在 D61(ADR D61 / Leo/arcrun-rag#55)搬進 * 認證儲存(CF Workers Secrets)以躲開「重裝時 binding 被重新指到新建空資源」的病根——但 * Workers Secrets 的寫入需要外部 `CF_SECRETS_API_TOKEN`,這把 token 從安裝那天起就沒被種過, * 於是每一台全新實例永遠建不出第一個帳號(arcrun-rag#99)。病根本身已被更通用的 * `shared/resource-rule`(Arcrun#97)解掉,故改回 SESSIONS_KV(binding,永不需要外部 token)。 * 認證儲存留為舊實例的回退讀路徑,見 tests/console-auth-legacy.test.ts。 * * 覆蓋: * 1. 全新實例:auth-status 回 configured:false;login 回「讀不到認證資料」(不是密碼錯)。 * 2. 首次設定成功:POST /console/setup 寫進 SESSIONS_KV。 * 3. 已設定過 → 409,訊息明講「你剛才輸入的密碼沒有被採用」(明顯失敗,取代舊版 * 只說「已設定過」卻不說清楚剛才那組密碼發生了什麼事的誤導文案)。 * 4. 登入對錯:帳密正確 200;密碼錯 401。 * 5. /console/setup/reset:舊密碼驗證+新密碼寫進 SESSIONS_KV;換密碼後舊密碼立即失效。 * * 全程不需要任何 CF API mock——SESSIONS_KV 是 binding,wrangler.test.toml 已提供 mock KV。 * * 🔴 每則測試自帶前置狀態(不依賴前一則測試的寫入殘留):`@cloudflare/vitest-pool-workers` * 的 `isolatedStorage`(預設開)在**每一個 `it()`** 前重置 KV/D1 等 storage bindings—— * 這正是本檔要驗證的儲存介質(SESSIONS_KV),故「已設定過」的情境一律在該則測試內先呼叫一次 * 真正的 POST /console/setup 自建,不能沿用其他 `it()` 建立的帳密(那套「同檔案內殘留」的 * 假設只對 D61 認證儲存的模組級記憶體變數 `overlay` 成立,SESSIONS_KV 不是那個機制)。 */ import { SELF, env } from 'cloudflare:test'; import { describe, it, expect } from 'vitest'; 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), }); } /** 每則測試自建一組帳密(POST /console/setup),回傳供後續斷言使用。 */ async function setupOwner(email: string, password: string): Promise { const res = await json('POST', '/console/setup', { email, password }); expect(res.status).toBe(200); } 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: { home: string } }; expect(data.configured).toBe(false); expect(data.credentials_source).toBe('none'); expect(data.auth_store.home).toBe('sessions-kv'); expect(JSON.stringify(data)).not.toContain('@'); // 不洩漏 email }); it('POST /console/login → 400「讀不到認證資料」,不是密碼錯(明顯失敗)', 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. 首次設定:成功寫進 SESSIONS_KV ═══════════════ describe('POST /console/setup — 首次設定', () => { it('成功:寫進 SESSIONS_KV(binding,不需要任何外部 CF token),回 session_token', async () => { 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'); const raw = await env.SESSIONS_KV.get('console:credentials'); expect(raw).toBeTruthy(); expect(raw).not.toContain(OWNER_PW); // 明碼絕不落地 const stored = JSON.parse(raw!) as { email: string; salt: string; hash: string }; expect(stored.email).toBe(OWNER_EMAIL); // 存小寫 expect(typeof stored.salt).toBe('string'); expect(typeof stored.hash).toBe('string'); }); }); // ═══════════════ 3. 已設定過 → 409(明顯失敗:說得出「沒有被採用」)═══════════════ describe('POST /console/setup — 已設定過(重複設定)', () => { it('409,訊息明講「你剛才輸入的密碼沒有被採用」,不誤導成「設定成功」', async () => { await setupOwner(OWNER_EMAIL, OWNER_PW); 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'); // 攻擊者填的帳密真的沒有生效:用它登入應該失敗,原帳密仍有效 const attackerLogin = await json('POST', '/console/login', { email: 'attacker@example.com', password: 'trying-to-hijack-1' }); expect(attackerLogin.status).toBe(401); const ownerLogin = await json('POST', '/console/login', { email: OWNER_EMAIL, password: OWNER_PW }); expect(ownerLogin.status).toBe(200); }); it('GET /console/auth-status → configured:true,credentials_source:kv', async () => { await setupOwner(OWNER_EMAIL, OWNER_PW); const res = await json('GET', '/console/auth-status'); const data = (await res.json()) as { configured: boolean; credentials_source: string; auth_store: { writable: boolean } }; expect(data.configured).toBe(true); expect(data.credentials_source).toBe('kv'); expect(data.auth_store.writable).toBe(true); }); }); // ═══════════════ 4. 登入對錯 ═══════════════ describe('POST /console/login', () => { it('帳密正確 → 200,發 session token', async () => { await setupOwner(OWNER_EMAIL, OWNER_PW); 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 () => { await setupOwner(OWNER_EMAIL, OWNER_PW); const res = await json('POST', '/console/login', { email: OWNER_EMAIL, password: 'wrong-password-x' }); expect(res.status).toBe(401); }); }); // ═══════════════ 5. /console/setup/reset:換密碼,寫進 SESSIONS_KV ═══════════════ describe('POST /console/setup/reset', () => { const NEW_PW = 'brand-new-owner-pw-1'; it('舊密碼錯 → 401,不寫入', async () => { await setupOwner(OWNER_EMAIL, OWNER_PW); 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 寫進 SESSIONS_KV;換完後舊密碼立即失效、新密碼生效', async () => { await setupOwner(OWNER_EMAIL, OWNER_PW); 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 raw = await env.SESSIONS_KV.get('console:credentials'); expect(raw).not.toContain(NEW_PW); // 明碼不落地 const stored = JSON.parse(raw!) as { email: string }; expect(stored.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); }); });