From c76e10d314ee686aed0fd6a6a3f1e5fd9320f0f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 13:55:53 +0000 Subject: [PATCH] =?UTF-8?q?test(portal):=20=E8=A3=9C=20#66=20=E8=88=87=20D?= =?UTF-8?q?62=20=E7=9A=84=E5=9B=9E=E6=AD=B8=E6=B8=AC=E8=A9=A6=EF=BC=888=20?= =?UTF-8?q?=E6=A2=9D=EF=BC=8C35/35=20=E7=B6=A0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #66(三條,斷言的是「session 有沒有被刪掉」而不只是狀態碼) - 傳播空窗(加速器 key 還在)+讀不到 record → 503 auth_store_propagating,**KV 那筆還在** - 非空窗+讀不到 record → 401 擋下,**KV 那筆仍然還在**(刪是清潔工,清掉的卻是唯一憑據) - session 內容本身壞掉 → 401 且**該刪**(那是確定的事實,不是暫時讀不到) ⇒ 前兩條在舊碼上必紅:舊碼在這兩個情況都會 SESSIONS_KV.delete() D62(五條) - 帶 reset_token 呼叫 /portal/password/change:**不必登入、不必現有密碼**; 且「先刪再回」=同一條連結第二次必定 400 reset_token_invalid,KV 裡也真的沒了 - 先「看一眼」票(GET /portal/password/reset)不會消耗它 - 亂猜 / 格式不對的 token → 400 - 沒 reset_token 又沒登入 → 401(修改密碼那一格仍要身分) - 沒設代寄服務 → /portal/password/forgot 誠實 503 mail_relay_not_configured(不假裝寄出去了) 擺放順序有註解說明:#66 那組會故意把 per-isolate overlay 灌成空的(模擬空窗), 而 overlay 是模組級全域變數不隨 test 重置,故必須排在檔案最後。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01R8vF2zS2XpaZjzkC75Fjss --- cypher-executor/tests/portal-auth.test.ts | 100 ++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/cypher-executor/tests/portal-auth.test.ts b/cypher-executor/tests/portal-auth.test.ts index f7f973b..448a525 100644 --- a/cypher-executor/tests/portal-auth.test.ts +++ b/cypher-executor/tests/portal-auth.test.ts @@ -582,3 +582,103 @@ describe('D61:舊實例登入自癒(帳號只在 KBDB,登入成功後 best // assertNoPendingInterceptors 間接把關(沒有殘留 mock 代表沒有意外多打的請求)。 }); }); + +// ═══════════════ D62 + arcrun-rag#66(2026-08-10)═══════════════ +// +// ⚠️ 順序刻意:這兩個 describe 放在檔案最後,而且「D62」在前、「#66」在後。 +// 原因=#66 那組會**故意把 per-isolate overlay 灌成一份沒有任何帳號的資料**(模擬傳播空窗), +// 而 overlay 是模組級全域變數、不隨 test 重置(見 mockAuthStoreWrite 檔頭長註解)。 +// 任何需要「認證儲存裡有帳號」的測試都不能排在它後面。 + +describe('D62:改密碼與忘記密碼是同一個機制(同一支端點、同一條寫入路徑)', () => { + const D62_EMAIL = 'd62-reset@example.com'; + + it('/portal/password/change 帶 reset_token:**不需要登入、不需要現有密碼**,且票用完即失效', async () => { + // 直接把一張票種進 KV(等同 /portal/password/forgot 發出來的那張), + // 存的是 token 的 sha256——KV 裡看不到可用的連結。 + const { sha256Hex } = await import('../src/lib/portal-auth'); + const token = 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90'; + const recordId = `${AUTH_ID_PREFIX}d62test000000000000000`; + await env.SESSIONS_KV.put( + `portal_pwreset:${await sha256Hex(token)}`, + JSON.stringify({ record_id: recordId, email: D62_EMAIL, created_at: new Date().toISOString() }), + ); + + // 票有效時,先「看一眼」不會消耗它 + const peek = await json('GET', `/portal/password/reset?token=${token}`); + expect(peek.status).toBe(200); + expect((await peek.json() as { valid: boolean; email: string }).email).toBe(D62_EMAIL); + + // 認證儲存裡沒有這個 record_id → 覆蓋密碼會失敗,但**票必須已經被消耗**(先刪再回) + const used = await json('POST', '/portal/password/change', { reset_token: token, new: 'brand-new-pw-1' }); + expect(used.status).not.toBe(200); // 這個 record 不存在,寫入失敗是預期的 + // 關鍵斷言:同一條連結**不能再用第二次** + const again = await json('POST', '/portal/password/change', { reset_token: token, new: 'second-try-pw-1' }); + expect(again.status).toBe(400); + expect((await again.json() as { code: string }).code).toBe('reset_token_invalid'); + // 而且票在 KV 裡真的沒了 + expect(await env.SESSIONS_KV.get(`portal_pwreset:${await sha256Hex(token)}`)).toBeNull(); + }); + + it('亂猜的 token / 格式不對的 token → 400,不洩漏任何東西', async () => { + for (const t of ['deadbeef'.repeat(8), 'not-hex-at-all', '']) { + const res = await json('GET', `/portal/password/reset?token=${t}`); + expect(res.status).toBe(400); + expect((await res.json() as { valid: boolean }).valid).toBe(false); + } + }); + + it('沒帶 reset_token 又沒登入 → 401(修改密碼那一格仍然要身分)', async () => { + const res = await json('POST', '/portal/password/change', { current: 'x', new: 'brand-new-pw-1' }); + expect(res.status).toBe(401); + }); + + it('新密碼太短 → 400(兩條路共用同一組驗證)', async () => { + const res = await json('POST', '/portal/password/change', { reset_token: 'a'.repeat(64), new: 'short' }); + expect(res.status).toBe(400); + }); + + it('/portal/password/forgot:沒設代寄服務 → 誠實回 503,不假裝信寄出去了', async () => { + const res = await json('POST', '/portal/password/forgot', { email: D62_EMAIL }); + expect(res.status).toBe(503); + expect((await res.json() as { code: string }).code).toBe('mail_relay_not_configured'); + }); +}); + +describe('arcrun-rag#66:傳播空窗期不可以銷毀 session', () => { + const TOKEN_A = 'sess-token-66-propagating'; + const TOKEN_B = 'sess-token-66-really-gone'; + const MISSING = `${AUTH_ID_PREFIX}notinstore0000000000000`; + + it('正在傳播(加速器 key 還在)+讀不到 record → 503 auth_store_propagating,且 **session 沒被刪**', async () => { + await seedPortalSession(TOKEN_A, MISSING); + // 加速器 key 存在=「剛剛有人動過認證儲存」=現在是傳播空窗 + await env.SESSIONS_KV.put( + 'auth_store_recent', + JSON.stringify({ written_at: Date.now() + 10_000_000, data: { version: 1, console: null, users: [] } }), + ); + + const res = await json('GET', '/portal/session', undefined, { Authorization: `Bearer ${TOKEN_A}` }); + expect(res.status).toBe(503); + expect((await res.json() as { code: string }).code).toBe('auth_store_propagating'); + // 🔴 這是整張票的重點:舊碼會在這裡把 KV 那筆刪掉,等 secret 鋪開也回不來 + expect(await env.SESSIONS_KV.get(`portal_sess:${TOKEN_A}`)).not.toBeNull(); + }); + + it('不在傳播空窗(加速器 key 不存在)+讀不到 record → 401 擋下,但**仍然不刪 session**', async () => { + await seedPortalSession(TOKEN_B, MISSING); + await env.SESSIONS_KV.delete('auth_store_recent'); + + const res = await json('GET', '/portal/session', undefined, { Authorization: `Bearer ${TOKEN_B}` }); + expect(res.status).toBe(401); + // 刪 session 是 best-effort 清潔工,而它清掉的是使用者唯一的憑據;KV 的 TTL 本來就會回收 + expect(await env.SESSIONS_KV.get(`portal_sess:${TOKEN_B}`)).not.toBeNull(); + }); + + it('session 內容本身壞掉(不是讀不到)→ 401 且**該刪**(確定的事實,不是暫時性)', async () => { + await env.SESSIONS_KV.put('portal_sess:broken-66', 'not-json-at-all'); + const res = await json('GET', '/portal/session', undefined, { Authorization: 'Bearer broken-66' }); + expect(res.status).toBe(401); + expect(await env.SESSIONS_KV.get('portal_sess:broken-66')).toBeNull(); + }); +});