Files
Arcrun/cypher-executor/tests/console-auth-legacy.test.ts
uncle6me-web 4b6cc159b8 feat(auth): 認證儲存搬回 D1/KV——落實方案C(leo confirm「走C」,arcrun-rag#99)
實作 pending-changes.md「認證儲存要不要搬回 D1/KV」提案(commit 8286c8a):
D61 的病根「重裝時 binding 被安裝器照名字重新指到新建空資源」已被更通用的
shared/resource-rule(Arcrun#97,2026-08-13)解掉,故不再需要繞開 binding
去躲這個病——而繞開的代價正是這次要收的債:Workers Secrets 寫入需要外部
CF_SECRETS_API_TOKEN,這把 token 從安裝那天起就沒被種過,止血版只解掉
「建第一個帳號」這一格,之後的每一次寫入(換密碼/加帳號/改權限)仍卡死。

改動:
- console 管理員帳密:家改回 SESSIONS_KV(binding,console-auth.ts)
- portal 多人帳號:家改回 KBDB(binding,走 base HTTP API,D38 零 SQL,portal.ts)
- D61 認證儲存(CF Workers Secrets)留為舊實例的唯讀回退路徑:讀取零成本、
  零外部憑證需求(只有寫入才要 token);登入成功即 best-effort 自動搬進新家,
  且**這次登入發出的 session 就直接指向新 record_id**(不必等下一次登入)
- D61 的三項「明顯失敗」語意全部保留:auth_store_empty(讀不到不算密碼錯、
  不計入鎖定)、/console/setup 遇既有帳號說清楚密碼沒被採用、/health 與
  /console/auth-status 吐儲存狀態
- 移除止血版的 x-arcrun-install-token 表頭傳遞機制(installToken 參數)——
  帳號寫入從此不需要任何外部 CF token,這個結構性缺口已從根拔除

測試:cypher-executor 全套 vitest 439/453(14 個既存失敗與本改動無關,已用
git stash 對照 clean checkout 逐一比對檔名確認完全相同);tsc --noEmit
無新增錯誤(3 個既存錯誤同上核實無關)。已跑 build-worker-artifacts.mjs
重打 tier2 bundle,grep 複驗 createKbdbUserRecord/promoteToKbdb 進了成品、
promoteLegacyUser/x-arcrun-install-token 完全從成品消失。

未覆蓋:POST /credentials(一般 workflow API 金鑰儲存)仍依賴
CF_SECRETS_API_TOKEN——這是 01-tech-stack.md 既有的、獨立於 D61 之外的
credential 儲存架構(D19「擁有目錄不擁有內容物」),本提案範圍只涵蓋「認證」
(登入帳密),不涵蓋一般 credential 儲存;07-29 已知缺口仍待另案處理。

不准 merge 進 main(SDD 鐵律③,等總管審過再併);不准部署(D20 出貨閘)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:52:07 +08:00

100 lines
5.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* console-auth.ts —— 舊實例相容(帳密還在 D61 的認證儲存,尚未搬回 SESSIONS_KV
*
* 2026-08-14 起(D61 補充,leo confirm「走C」):console 帳密的家改回 SESSIONS_KVbinding)。
* 已經在跑 D61(帳密住 CF Workers Secrets)的實例不能因為這次改動而登不進去——讀取認證儲存
* 零成本、零外部憑證需求(只有寫入才需要 CF_SECRETS_API_TOKEN),所以永遠讀得到;讀到就
* 順手搬回 SESSIONS_KVbest-effortbinding 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<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';
// 🔴 全部收在**同一個 `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);
});
});