Files
Arcrun/cypher-executor/tests/console-auth-legacy.test.ts
T
uncle6me-web c4cee35adb feat(auth): 認證與資料分離——搬動知識資料時登入不再跟著壞掉(D61 / arcrun-rag#55)
leo 2026-08-10 下令:「登入認證資料要分離⋯⋯就算只有我一個人存在單獨的 json 檔也好,
它不能被改資料庫的連結導致無法登入。」「昨天不能登入 portal,今天不能登入 mcp,
這根本就是一個問題。」

病:portal 帳號住 KBDB(owner_id = {CONSOLE_TENANT}::portal),console 管理員帳密住
SESSIONS_KV。兩者都靠 binding 指過去,重裝/遷移一定會被重新指一次 ⇒ 保險箱的鑰匙
放在保險箱裡。2026-08-09 leo 資料一個位元組都沒動,卻被鎖在門外。

修:認證搬到 CF Workers per-script Secrets(掛在 script 上,與 bindings 兩套資源,
重部不會洗掉;journeys/gemini-key-lost-on-reinstall.md 與 installer worker.js:1148 皆有實證)。
- 新增 lib/portal-auth-store.ts:自足的 JSON,讀取零網路呼叫,>4.6KB 自動溢位分片
- portal.ts 的帳號讀寫全部改走它;KBDB 只留為舊實例的回退讀路徑,登入成功順手搬過去
- console-auth.ts 的第二份認證資料同樣搬離 KV
- 不牴觸 D38:KBDB 三張核心表不增不減,本案是把東西搬出去
- 沿用 credentials.ts 既有的 putWorkerSecret/deleteWorkerSecret,不另造第二套寫入路徑(D36)

明顯失敗(把 #10「寧可明顯失敗,不要靜默錯置」套到門鎖上):
- 「這台實例讀不到任何登入資料」回 503 + code=auth_store_empty,且**不計入 5 次鎖定**
  (08-09 leo 就是被系統自己的誤判鎖了 15 分鐘)
- /console/setup 遇既有帳號改說「你剛才輸入的密碼沒有被採用」,不再只說「已設定過」
- /health 與 /console/auth-status 吐 auth_store 狀態(住哪、寫不寫得進去)

stage 實測撞到並修掉的坑:改 secret 會產生 worker 新版本,既有 isolate 讀到的還是舊 env
⇒ 「建好帳號立刻登入」有 15 秒以上 401,還被算進鎖定。加一層短 TTL 的 KV 加速器
(非真相源,只在 secret 查不到/密碼對不上時問一次),換 KV 不影響不變量。

驗收:stage(youlin)把知識資料庫換成另一顆空的 + 換租戶代號 + SESSIONS_KV 換成空的,
三樣一起換之後 portal / MCP /authorize / console 三條登入路徑仍全綠(複跑 3 次)。
對照組(舊版程式碼同樣換庫):登入回「email 或密碼錯誤」,5 次後鎖 15 分鐘。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:13:37 +08:00

102 lines
4.4 KiB
TypeScript

/**
* console-auth.ts —— D61 舊實例相容(帳密只在舊 SESSIONS_KV,尚未搬遷過)
*
* 拆成獨立檔案的理由:portal-auth-store.ts 的 per-isolate overlay 是模組級全域變數,
* 一旦某個測試讓 console 帳密的認證儲存寫入成功,overlay.console 就會在**同一支測試檔案**
* 剩下的測試裡持續存在(不同檔案=不同 worker 執行個體,互不污染,已用小型探針驗證過)。
* tests/console-auth.test.ts 一開始就會走一次「首次設定成功」,之後整支檔案都是「已設定」
* 的世界;「認證儲存還是空的、帳密只活在舊 KV」這個起始狀態只有在全新檔案才測得出來。
*/
import { SELF, env, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
const CF_API = 'https://api.cloudflare.com';
const CREDS_KEY = 'console:credentials';
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),
});
}
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 };
}
/** 複刻 console-auth.ts 內未 export 的私有迭代雜湊(sha256(salt+password) 迭代 3 次),
* 單純為了在測試端準備一筆能通過驗證的 legacy fixture,不是重新實作生產邏輯。 */
async function legacyHash(password: string, salt: string): Promise<string> {
async function sha256Hex(input: string): Promise<string> {
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';
describe('D61 舊實例相容:console 帳密只在舊 KV(尚未搬遷)', () => {
it('GET /console/auth-status:讀到舊 KV 這筆、順手搬進認證儲存', async () => {
const hash = await legacyHash(PASSWORD, SALT);
await env.SESSIONS_KV.put(
CREDS_KEY,
JSON.stringify({ email: EMAIL, salt: SALT, hash, created_at: '2026-01-01T00:00:00.000Z' }),
);
const { puts } = mockAuthStoreWrite();
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: { console_configured: boolean };
};
expect(data.configured).toBe(true);
expect(data.credentials_source).toBe('legacy-kv'); // 這次是靠回退讀到的
// loadCredentials 內的 best-effort 搬遷在回應組出來之前就已 await 完成,
// 故 authStoreStatus 已經反映搬遷後的狀態
expect(data.auth_store.console_configured).toBe(true);
const shards = puts();
expect(shards.length).toBe(1);
const shard = JSON.parse(shards[0].text) as { console: { email: string; hash: string } };
expect(shard.console.email).toBe(EMAIL);
expect(shard.console.hash).toBe(hash); // 原樣搬過去,不重新雜湊
});
it('搬遷後再打一次:新家已經有了,直接命中新家(不用再查舊 KV)', async () => {
const res = await json('GET', '/console/auth-status');
const data = (await res.json()) as { credentials_source: string };
expect(data.credentials_source).toBe('secrets');
});
it('用搬遷過去的帳密登入 → 200(搬遷沒有讓帳密變得登不進去)', async () => {
const res = await json('POST', '/console/login', { email: EMAIL, password: PASSWORD });
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean };
expect(data.success).toBe(true);
});
});