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>
This commit is contained in:
@@ -1,17 +1,21 @@
|
||||
/**
|
||||
* console-auth.ts —— D61 舊實例相容(帳密只在舊 SESSIONS_KV,尚未搬遷過)
|
||||
* console-auth.ts —— 舊實例相容(帳密還在 D61 的認證儲存,尚未搬回 SESSIONS_KV)
|
||||
*
|
||||
* 拆成獨立檔案的理由:portal-auth-store.ts 的 per-isolate overlay 是模組級全域變數,
|
||||
* 一旦某個測試讓 console 帳密的認證儲存寫入成功,overlay.console 就會在**同一支測試檔案**
|
||||
* 剩下的測試裡持續存在(不同檔案=不同 worker 執行個體,互不污染,已用小型探針驗證過)。
|
||||
* tests/console-auth.test.ts 一開始就會走一次「首次設定成功」,之後整支檔案都是「已設定」
|
||||
* 的世界;「認證儲存還是空的、帳密只活在舊 KV」這個起始狀態只有在全新檔案才測得出來。
|
||||
* 2026-08-14 起(D61 補充,leo confirm「走C」):console 帳密的家改回 SESSIONS_KV(binding)。
|
||||
* 已經在跑 D61(帳密住 CF Workers Secrets)的實例不能因為這次改動而登不進去——讀取認證儲存
|
||||
* 零成本、零外部憑證需求(只有寫入才需要 CF_SECRETS_API_TOKEN),所以永遠讀得到;讀到就
|
||||
* 順手搬回 SESSIONS_KV(best-effort,binding 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';
|
||||
const CREDS_KEY = 'console:credentials';
|
||||
|
||||
beforeAll(() => {
|
||||
fetchMock.activate();
|
||||
@@ -27,18 +31,12 @@ function json(method: string, path: string, body?: unknown) {
|
||||
});
|
||||
}
|
||||
|
||||
function mockAuthStoreWrite(times = 1): { puts: () => Array<{ name: string; text: string }> } {
|
||||
const captured: Array<{ name: string; text: string }> = [];
|
||||
/** 種一筆進 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, (opts) => {
|
||||
const body = JSON.parse(String(opts.body)) as { name: string; text: string };
|
||||
captured.push(body);
|
||||
return { success: true };
|
||||
})
|
||||
.times(times);
|
||||
return { puts: () => captured };
|
||||
.reply(200, { success: true });
|
||||
}
|
||||
|
||||
/** 複刻 console-auth.ts 內未 export 的私有迭代雜湊(sha256(salt+password) 迭代 3 次),
|
||||
@@ -57,45 +55,45 @@ 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 () => {
|
||||
// 🔴 全部收在**同一個 `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);
|
||||
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();
|
||||
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: { console_configured: boolean };
|
||||
auth_store: { legacy_secrets_present: 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);
|
||||
expect(data.credentials_source).toBe('legacy-secrets'); // 這次是靠回退讀到的
|
||||
// loadCredentials 內的 best-effort 搬遷在回應組出來之前就已 await 完成
|
||||
expect(data.auth_store.legacy_secrets_present).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); // 原樣搬過去,不重新雜湊
|
||||
});
|
||||
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); // 原樣搬過去,不重新雜湊
|
||||
|
||||
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');
|
||||
});
|
||||
// 搬回後再打一次: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');
|
||||
|
||||
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);
|
||||
// 用搬回去的帳密登入 → 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,81 +1,64 @@
|
||||
/**
|
||||
* console-auth.ts 測試(D61:console 管理員帳密搬進認證儲存,ADR D61 / Leo/arcrun-rag#55)
|
||||
* console-auth.ts 測試(2026-08-14 起:console 管理員帳密改回住 SESSIONS_KV,
|
||||
* D61 補充/pending-changes.md「認證儲存要不要搬回 D1/KV」,leo confirm「走C」)
|
||||
*
|
||||
* 這組帳密(/console/setup、/console/login…)原本住 SESSIONS_KV `console:credentials`
|
||||
* (沒有 TTL)——KV 靠 binding 指過去,重裝會被指到新建的空 KV ⇒ 帳密憑空消失
|
||||
* (console-auth.ts 檔頭「KV=暫存、非長期真相源」第三次被違反,這次違反的是大門的鎖)。
|
||||
* D61 起改存進認證儲存(CF Workers Secrets),SESSIONS_KV 只留為回退讀路徑。
|
||||
* 這組帳密(/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。
|
||||
*
|
||||
* 覆蓋(本檔在此之前不存在,D61 交辦要求的新增覆蓋):
|
||||
* 覆蓋:
|
||||
* 1. 全新實例:auth-status 回 configured:false;login 回「讀不到認證資料」(不是密碼錯)。
|
||||
* 2. 首次設定成功:POST /console/setup 寫進認證儲存(CF Workers Secrets),不再寫 KV。
|
||||
* 3. 已設定過 → 409,訊息明講「你剛才輸入的密碼沒有被採用」(D61 明顯失敗,取代舊版
|
||||
* 2. 首次設定成功:POST /console/setup 寫進 SESSIONS_KV。
|
||||
* 3. 已設定過 → 409,訊息明講「你剛才輸入的密碼沒有被採用」(明顯失敗,取代舊版
|
||||
* 只說「已設定過」卻不說清楚剛才那組密碼發生了什麼事的誤導文案)。
|
||||
* 4. 登入對錯:帳密正確 200;密碼錯 401。
|
||||
* 5. /console/setup/reset:舊密碼驗證+新密碼寫進新家;換密碼後舊密碼立即失效。
|
||||
* 5. /console/setup/reset:舊密碼驗證+新密碼寫進 SESSIONS_KV;換密碼後舊密碼立即失效。
|
||||
*
|
||||
* 認證儲存寫入會呼叫 `https://api.cloudflare.com/.../secrets`(PUT),走 fetchMock 假 host
|
||||
* 攔截(同 portal-auth.test.ts 的 mockAuthStoreWrite),不外連;wrangler.test.toml 已預設
|
||||
* CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID 就緒。
|
||||
* 全程不需要任何 CF API mock——SESSIONS_KV 是 binding,wrangler.test.toml 已提供 mock KV。
|
||||
*
|
||||
* ⚠️ 測試順序不可打亂: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 執行個體,狀態不互相污染)。
|
||||
* 🔴 每則測試自帶前置狀態(不依賴前一則測試的寫入殘留):`@cloudflare/vitest-pool-workers`
|
||||
* 的 `isolatedStorage`(預設開)在**每一個 `it()`** 前重置 KV/D1 等 storage bindings——
|
||||
* 這正是本檔要驗證的儲存介質(SESSIONS_KV),故「已設定過」的情境一律在該則測試內先呼叫一次
|
||||
* 真正的 POST /console/setup 自建,不能沿用其他 `it()` 建立的帳密(那套「同檔案內殘留」的
|
||||
* 假設只對 D61 認證儲存的模組級記憶體變數 `overlay` 成立,SESSIONS_KV 不是那個機制)。
|
||||
*/
|
||||
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||
import { SELF, env } from 'cloudflare:test';
|
||||
import { 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<string, string> = {}) {
|
||||
function json(method: string, path: string, body?: unknown) {
|
||||
return SELF.fetch(`http://localhost${path}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
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 };
|
||||
/** 每則測試自建一組帳密(POST /console/setup),回傳供後續斷言使用。 */
|
||||
async function setupOwner(email: string, password: string): Promise<void> {
|
||||
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. 全新實例(尚未設定過,必須排最前面)═══════════════
|
||||
// ═══════════════ 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 } };
|
||||
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「讀不到認證資料」,不是密碼錯(D61 明顯失敗)', async () => {
|
||||
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 };
|
||||
@@ -91,36 +74,31 @@ describe('全新實例(尚未設定過任何管理員帳密)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 2. 首次設定:成功寫進認證儲存(D61 起唯一寫入路徑)═══════════════
|
||||
// ═══════════════ 2. 首次設定:成功寫進 SESSIONS_KV ═══════════════
|
||||
|
||||
describe('POST /console/setup — 首次設定', () => {
|
||||
it('成功:寫進認證儲存(不再寫 SESSIONS_KV),回 session_token', async () => {
|
||||
const { puts } = mockAuthStoreWrite();
|
||||
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');
|
||||
|
||||
// 寫入認證儲存:一片、含小寫 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();
|
||||
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(D61 明顯失敗:說得出「沒有被採用」)═══════════════
|
||||
// ═══════════════ 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 {
|
||||
@@ -130,22 +108,29 @@ describe('POST /console/setup — 已設定過(重複設定)', () => {
|
||||
expect(data.password_applied).toBe(false);
|
||||
expect(data.error).toContain('沒有被採用');
|
||||
expect(data.reset_path).toBe('/console/setup/reset');
|
||||
// 攻擊者填的帳密真的沒有生效:用它登入應該失敗(下一個 describe 也會正面驗證原帳密仍有效)
|
||||
|
||||
// 攻擊者填的帳密真的沒有生效:用它登入應該失敗,原帳密仍有效
|
||||
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:secrets(新家優先命中)', async () => {
|
||||
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: { console_configured: boolean } };
|
||||
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('secrets');
|
||||
expect(data.auth_store.console_configured).toBe(true);
|
||||
expect(data.credentials_source).toBe('kv');
|
||||
expect(data.auth_store.writable).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 4. 登入對錯(用第 2 節設定的帳密)═══════════════
|
||||
// ═══════════════ 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 };
|
||||
@@ -154,30 +139,27 @@ describe('POST /console/login', () => {
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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:換密碼,寫進新家 ═══════════════
|
||||
// ═══════════════ 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 寫進新家;換完後舊密碼立即失效、新密碼生效', async () => {
|
||||
const { puts } = mockAuthStoreWrite();
|
||||
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,
|
||||
});
|
||||
@@ -185,11 +167,10 @@ describe('POST /console/setup/reset', () => {
|
||||
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 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 });
|
||||
|
||||
@@ -16,20 +16,20 @@
|
||||
* KBDB 打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
|
||||
* disableNetConnect——絕不外連。UI 全流程由本機隔離雙 worker 端到端 curl 驗證(PR 證據表)。
|
||||
*
|
||||
* D61(ADR D61 / Leo/arcrun-rag#55):本檔測試裡的帳號 fixture(rec_admin/rec_u1/rec_admin2…)
|
||||
* 全部沿用「record_id 不是 auth: 開頭」這個既有慣例——這正是 portal.ts 的相容分流點
|
||||
* (isAuthStoreId(recordId)),非 auth: 開頭的 id 一律走原本的 KBDB 路徑,行為與 D61 之前
|
||||
* 完全一致,故本檔絕大多數測試不需要改。**只有「新建帳號」這個動作**(POST /portal/admin/users、
|
||||
* POST /portal/admin/bootstrap 走同一支 createPortalUser)改成寫進認證儲存(CF Workers
|
||||
* Secrets),需要額外攔截 `https://api.cloudflare.com/.../secrets`(PUT)——見 mockAuthStoreWrite。
|
||||
* 2026-08-14 起(D61 補充,leo confirm「走C」):帳號改回住 KBDB(binding,見
|
||||
* pending-changes.md「認證儲存要不要搬回 D1/KV」)。本檔測試裡的帳號 fixture
|
||||
* (rec_admin/rec_u1/rec_admin2…)全部沿用「record_id 不是 auth: 開頭」這個既有慣例——
|
||||
* 這正是 portal.ts 的相容分流點(isAuthStoreId(recordId)),非 auth: 開頭的 id 一律走
|
||||
* KBDB 路徑,行為與 D61 之前完全一致,故本檔絕大多數測試不需要改。
|
||||
* **只有「新建帳號」這個動作**(POST /portal/admin/users、POST /portal/admin/bootstrap
|
||||
* 走同一支 createPortalUser)改回直接寫 KBDB(POST /records + POST /entries head),
|
||||
* 不再需要 `https://api.cloudflare.com/.../secrets`。
|
||||
*/
|
||||
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||
import { hashPassword, PBKDF2_ITERATIONS } from '../src/lib/portal-auth';
|
||||
import { AUTH_ID_PREFIX } from '../src/lib/portal-auth-store';
|
||||
|
||||
const KBDB = 'https://kbdb.test';
|
||||
const CF_API = 'https://api.cloudflare.com';
|
||||
const NS = 'leo::portal'; // wrangler.test.toml CONSOLE_TENANT=leo → 子 namespace
|
||||
|
||||
let storedHash: string;
|
||||
@@ -49,19 +49,21 @@ function json(method: string, path: string, body?: unknown, headers: Record<stri
|
||||
});
|
||||
}
|
||||
|
||||
/** D61:認證儲存寫入路徑(同 portal-auth.test.ts 的同名 helper,見那邊檔頭的完整說明)。 */
|
||||
function mockAuthStoreWrite(times = 1): { puts: () => Array<{ name: string; text: string }> } {
|
||||
const captured: Array<{ name: string; text: string }> = [];
|
||||
/** 建帳號的 KBDB 寫入路徑(POST /records + POST /entries head entry)。 */
|
||||
function mockCreateUser(recordId: string): { recordBody: () => string } {
|
||||
let recordBody = '';
|
||||
fetchMock
|
||||
.get(CF_API)
|
||||
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/records', method: 'POST' })
|
||||
.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 };
|
||||
recordBody = String(opts.body);
|
||||
return { success: true, record: { record_id: recordId, template_id: 'tpl_pu', values: {} } };
|
||||
});
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/entries', method: 'POST' })
|
||||
.reply(200, { success: true, entry: { id: `${recordId}_head` } });
|
||||
return { recordBody: () => recordBody };
|
||||
}
|
||||
|
||||
function mockHeadLookup(email: string, recordId: string | null) {
|
||||
@@ -202,11 +204,12 @@ describe('last-admin 鎖死保護(PATCH /portal/admin/users/:id)', () => {
|
||||
// ═══════════════ 2. 一次性密碼(新增帳號)═══════════════
|
||||
|
||||
describe('POST /portal/admin/users(一次性密碼)', () => {
|
||||
it('未帶 password → generated_password 回一次(16 碼);認證儲存落的是 hash 非明碼(D61)', async () => {
|
||||
it('未帶 password → generated_password 回一次(16 碼);KBDB 落的是 hash 非明碼', async () => {
|
||||
await seedAdminSession();
|
||||
mockGetRecord('rec_admin', adminValues());
|
||||
mockHeadLookup('new@example.com', null); // email 未占用(新家找不到 → 回退查舊家)
|
||||
const { puts } = mockAuthStoreWrite();
|
||||
mockHeadLookup('new@example.com', null); // email 未占用
|
||||
const { recordBody } = mockCreateUser('rec_new');
|
||||
mockGetRecord('rec_new', userValues({ email: 'new@example.com' })); // 回應用的回讀
|
||||
const res = await json(
|
||||
'POST',
|
||||
'/portal/admin/users',
|
||||
@@ -218,23 +221,21 @@ describe('POST /portal/admin/users(一次性密碼)', () => {
|
||||
expect(typeof data.generated_password).toBe('string');
|
||||
expect(data.generated_password!.length).toBe(16);
|
||||
expect('password_hash' in data.user).toBe(false);
|
||||
expect((data.user as { record_id: string }).record_id.startsWith(AUTH_ID_PREFIX)).toBe(true); // 住新家
|
||||
expect((data.user as { record_id: string }).record_id).toBe('rec_new'); // 住 KBDB
|
||||
|
||||
// 一次性密碼不落地:認證儲存收到的 shard 只有 hash、無明碼
|
||||
const shards = puts();
|
||||
expect(shards.length).toBe(1);
|
||||
expect(shards[0].text).not.toContain(data.generated_password!);
|
||||
const shard = JSON.parse(shards[0].text) as { users: Array<{ email: string; password_hash: string }> };
|
||||
const stored = shard.users.find((u) => u.email === 'new@example.com');
|
||||
expect(stored).toBeDefined();
|
||||
expect(stored!.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
|
||||
// 一次性密碼不落地:KBDB 收到的 record body 只有 hash、無明碼
|
||||
expect(recordBody()).not.toContain(data.generated_password!);
|
||||
const rec = JSON.parse(recordBody()) as { owner_id: string; values: Record<string, string> };
|
||||
expect(rec.owner_id).toBe(NS);
|
||||
expect(rec.values.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
|
||||
});
|
||||
|
||||
it('自帶 password → 回應**無** generated_password', async () => {
|
||||
await seedAdminSession();
|
||||
mockGetRecord('rec_admin', adminValues());
|
||||
mockHeadLookup('own@example.com', null);
|
||||
mockAuthStoreWrite();
|
||||
mockCreateUser('rec_own');
|
||||
mockGetRecord('rec_own', userValues({ email: 'own@example.com' }));
|
||||
const res = await json(
|
||||
'POST',
|
||||
'/portal/admin/users',
|
||||
|
||||
@@ -4,31 +4,29 @@
|
||||
* 覆蓋(=tasks.md P2 測試項):
|
||||
* 1. KDF:pbkdf2-sha256$100000$… 格式(CF Workers runtime 上限 100k,2026-07-14 真雲實撞)、
|
||||
* 驗證對錯、壞格式誠實 false、600k 舊 hash 相容(迭代數從儲存值解析)
|
||||
* 2. bootstrap 閘:無 console session → 401;建 admin 寫進**認證儲存**(D61);
|
||||
* 已有 admin → 409
|
||||
* 2. bootstrap 閘:無 console session → 401;建 admin 寫進 **KBDB**;已有 admin → 409
|
||||
* 3. 登入對錯:成功發 token(回應**無租戶字串**)、密碼錯 401、停用 403、未知 email 401
|
||||
* 4. 節流:5 次失敗 → 429(KV TTL 計數)
|
||||
* 5. session:每請求回讀 record;停用即拒(既有 session 立即失效)
|
||||
* 6. 改密碼:驗舊密;新 hash 以 100k 格式落 slot
|
||||
* 7. role 閘:非 admin 打 admin 端點 → 403;admin 列表**剝除 password_hash**
|
||||
*
|
||||
* D61(ADR D61 / Leo/arcrun-rag#55)補的覆蓋(原本沒有,這次變更的重點):
|
||||
* 2026-08-14 起(D61 補充,leo confirm「走C」,pending-changes.md「認證儲存要不要搬回
|
||||
* D1/KV」):帳號的家從認證儲存(CF Workers Secrets,D61)改回 **KBDB**(binding,走 base
|
||||
* HTTP API,不需要外部 CF token)。額外覆蓋:
|
||||
* 8. 整台實例沒有任何認證資料 → 登入回「讀不到認證資料」(不是密碼錯),且不計入鎖定
|
||||
* 9. 舊實例相容:帳號只存在 KBDB(舊家)時仍登得進去,登入成功後自動搬進認證儲存
|
||||
* 9. 舊實例相容:帳號只存在認證儲存(D61 舊家)時仍登得進去,登入成功後自動搬進 KBDB,
|
||||
* 且**這次登入發出的 session 就直接指向新 record_id**;搬遷失敗(KBDB 拒寫)不擋登入
|
||||
*
|
||||
* KBDB 打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
|
||||
* disableNetConnect——絕不外連。子 namespace 隔離的「搜 email 搜不到」由本機雙 worker
|
||||
* 端到端 curl 驗證(PR 驗收證據表),這裡驗「寫入時 owner_id=leo::portal」的機械事實。
|
||||
*
|
||||
* D61 起,帳號的家從 KBDB 換成認證儲存(CF Workers Secrets)——寫入會呼叫
|
||||
* `https://api.cloudflare.com/.../secrets`(PUT),同樣走 fetchMock 假 host 攔截,不外連。
|
||||
* wrangler.test.toml 已預設 CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID 就緒(比照真實裝妥的實例)。
|
||||
*/
|
||||
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, beforeEach, afterEach, describe, it, expect } from 'vitest';
|
||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||
import { hashPassword, verifyPassword, PBKDF2_ITERATIONS } from '../src/lib/portal-auth';
|
||||
import { PORTAL_TEMPLATE_SEEDS } from '../src/lib/portal-seeds';
|
||||
import { AUTH_ID_PREFIX } from '../src/lib/portal-auth-store';
|
||||
import { AUTH_ID_PREFIX, isAuthStoreId, mutateAuthStore } from '../src/lib/portal-auth-store';
|
||||
import { portalRouter } from '../src/routes/portal';
|
||||
import type { Bindings, ExecutionContext } from '../src/types';
|
||||
|
||||
@@ -56,38 +54,13 @@ function json(method: string, path: string, body?: unknown, headers: Record<stri
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* D61:認證儲存的寫入路徑(單元測試層級——一次呼叫=一片,測試資料量小不會觸發溢位分片)。
|
||||
* 攔截 CF Workers Scripts secrets 管理 API 的 PUT,捕捉 body 供斷言(片名/內容)。
|
||||
* 用法:每個會觸發寫入的測試呼叫一次,回傳的 `puts()` 拿到依序捕捉到的 {name, text}[]。
|
||||
*
|
||||
* ⚠️ 讀路徑沒有對應的「seed 進 env」捷徑可用:`cloudflare:test` 的 `env` 物件是傳給
|
||||
* `vitest` 主 context 用的,對 `SELF.fetch()` 打的那個 worker isolate **不生效**(實測驗證,
|
||||
* mutate `env.XXX` 後 SELF 端讀到的仍是 wrangler.test.toml 的原值)。因此「舊實例相容」
|
||||
* 一類的讀路徑測試,一律靠**既有的 KBDB fetchMock**(新家預設空,天然等於「帳號只在舊家」);
|
||||
* 要驗證「新家已經有資料」則靠**真的呼叫一次寫入端點**(bootstrap/新增同仁),讓 portal-auth-store
|
||||
* 模組內的 per-isolate overlay 落地——這個 overlay 在同一支測試檔案裡的後續測試\*也讀得到\*
|
||||
* (模組級全域變數不隨 test 重置,只有 KV/D1 等 storage 才有 isolatedStorage 重置),
|
||||
* 這是刻意善用而非意外:想要「乾淨無帳號」的情境,該測試必須排在檔案裡**第一個寫入動作之前**。
|
||||
*/
|
||||
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 };
|
||||
}
|
||||
|
||||
// ── KBDB mock helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/** head entry 查找(GET /entries?page_name=…&entry_type=portal_user&owner_id=ns&limit=1) */
|
||||
function mockHeadLookup(email: string, recordId: string | null) {
|
||||
/**
|
||||
* head entry 查找(GET /entries?page_name=…&entry_type=portal_user&owner_id=ns&limit=1)。
|
||||
* `times`:`findUserRecordId`/`promoteToKbdb` 都可能各查一次同一個 email,需要幾次命中就傳幾。
|
||||
*/
|
||||
function mockHeadLookup(email: string, recordId: string | null, times = 1) {
|
||||
const needle = new URLSearchParams({ page_name: email }).toString();
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
@@ -96,7 +69,61 @@ function mockHeadLookup(email: string, recordId: string | null) {
|
||||
p.startsWith('/entries?') && p.includes(needle) && p.includes(encodeURIComponent(NS)),
|
||||
method: 'GET',
|
||||
})
|
||||
.reply(200, { success: true, entries: recordId ? [{ content: recordId }] : [], count: recordId ? 1 : 0, total: recordId ? 1 : 0 });
|
||||
.reply(200, { success: true, entries: recordId ? [{ content: recordId }] : [], count: recordId ? 1 : 0, total: recordId ? 1 : 0 })
|
||||
.times(times);
|
||||
}
|
||||
|
||||
/** 建 portal_user 的 KBDB 寫入路徑(POST /records + POST /entries head entry)。 */
|
||||
function mockCreateUser(recordId: string): { recordBody: () => string } {
|
||||
let recordBody = '';
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/records', method: 'POST' })
|
||||
.reply(200, (opts) => {
|
||||
recordBody = String(opts.body);
|
||||
return { success: true, record: { record_id: recordId, template_id: 'tpl_pu', values: {} } };
|
||||
});
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/entries', method: 'POST' })
|
||||
.reply(200, { success: true, entry: { id: `${recordId}_head` } });
|
||||
return { recordBody: () => recordBody };
|
||||
}
|
||||
|
||||
/** POST /records 建立失敗(模擬 KBDB 拒寫,用於「搬遷失敗不擋登入」的測試)。 */
|
||||
function mockCreateUserFails(status = 500) {
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/records', method: 'POST' })
|
||||
.reply(status, { success: false, error: 'kbdb write failed (test)' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 種一筆帳號進**認證儲存**(D61 舊家,CF Workers Secrets)——直接呼叫 lib,不經任何 HTTP
|
||||
* route(consoleAuthRouter/portalRouter 現在都不再寫這個家了)。需要攔截一次 CF Secrets PUT
|
||||
* (wrangler.test.toml 已預設 CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID 就緒)。
|
||||
*/
|
||||
async function seedLegacySecretsUser(overrides: Partial<{
|
||||
email: string; display_name: string; status: string; role: string; libraries: string[]; password_hash: string;
|
||||
}> = {}): Promise<void> {
|
||||
fetchMock
|
||||
.get(CF_API)
|
||||
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
|
||||
.reply(200, { success: true });
|
||||
const now = new Date().toISOString();
|
||||
await mutateAuthStore(env as unknown as Bindings, (data) => {
|
||||
data.users.push({
|
||||
id: `auth:${crypto.randomUUID().replace(/-/g, '')}`,
|
||||
email: (overrides.email ?? EMAIL).toLowerCase(),
|
||||
display_name: overrides.display_name ?? '舊實例同仁',
|
||||
status: overrides.status ?? 'active',
|
||||
role: overrides.role ?? 'user',
|
||||
libraries: overrides.libraries ?? ['general'],
|
||||
password_hash: overrides.password_hash ?? storedHash,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function mockGetRecord(recordId: string, values: Record<string, string>) {
|
||||
@@ -176,20 +203,20 @@ describe('PBKDF2 模組(lib/portal-auth)', () => {
|
||||
|
||||
// ═══════════════ 1.5 D61:整台實例沒有任何認證資料 ═══════════════
|
||||
//
|
||||
// 🔴 這個 describe 必須留在檔案裡「第一個會寫入認證儲存的測試」之前(下面 2. bootstrap
|
||||
// 的「console session OK」那則)——見 mockAuthStoreWrite 檔頭註解:portal-auth-store.ts
|
||||
// 的 per-isolate overlay 是模組級全域變數,同一支測試檔案跑起來不會在測試之間重置,
|
||||
// 一旦有測試寫入過,後面的測試都會看到那筆資料,「乾淨無帳號」的前提就不成立了。
|
||||
describe('D61:整台實例沒有任何認證資料(arcrun-rag#55,leo 2026-08-09 被誤鎖 15 分鐘的事故)', () => {
|
||||
// 🔴 這個 describe 必須留在檔案裡「第一個會種入認證儲存(舊家)的測試」之前——見
|
||||
// seedLegacySecretsUser 檔頭註解:portal-auth-store.ts 的 per-isolate overlay 是模組級全域
|
||||
// 變數,同一支測試檔案跑起來不會在測試之間重置,一旦有測試種過舊家資料,後面的測試都會看到
|
||||
// 那筆資料,「乾淨無帳號」的前提就不成立了。
|
||||
describe('整台實例沒有任何認證資料(arcrun-rag#55,leo 2026-08-09 被誤鎖 15 分鐘的事故)', () => {
|
||||
it('登入回「讀不到認證資料」而不是「密碼錯誤」,且不計入失敗鎖定', async () => {
|
||||
// 新家(overlay/env bag)此刻還是空的(本測試特意排在任何寫入測試之前);
|
||||
// 舊家(KBDB)也回空——head lookup 查無此人+by-template 列表也空,兩邊都沒有帳號,
|
||||
// KBDB(新家)此刻回空——head lookup 查無此人+by-template 列表也空;
|
||||
// 認證儲存(舊家)此刻也還是空的(本測試特意排在任何種子測試之前)——兩邊都沒有帳號,
|
||||
// 才是「這台實例真的沒有認證資料」。
|
||||
mockHeadLookup('anyone@example.com', null);
|
||||
mockListByTemplate('portal_user', []);
|
||||
const res = await json('POST', '/portal/login', { email: 'anyone@example.com', password: 'whatever-pw-1' });
|
||||
expect(res.status).toBe(503);
|
||||
const data = (await res.json()) as { error: string; code: string; auth_store: { present: boolean; users: number } };
|
||||
const data = (await res.json()) as { error: string; code: string; auth_store: { users: number } };
|
||||
expect(data.code).toBe('auth_store_empty');
|
||||
// 分得出來的錯:這句要誠實講「不是密碼錯」,而且**不能**是密碼錯誤那句通用訊息
|
||||
// (文案含混是 leo 被鎖 15 分鐘的根因——他的密碼從頭到尾是對的)。
|
||||
@@ -209,12 +236,12 @@ describe('POST /portal/admin/bootstrap', () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('console session OK → 建第一個 admin:寫進認證儲存(D61,不再落 KBDB)', async () => {
|
||||
it('console session OK → 建第一個 admin:寫進 KBDB,不需要任何外部 CF token', async () => {
|
||||
await env.SESSIONS_KV.put('console_sess:owner-token', JSON.stringify({ created_at: Date.now() }));
|
||||
mockTemplatesExist();
|
||||
mockListByTemplate('portal_user', []); // 尚無 admin(新家空,舊家也空)
|
||||
mockHeadLookup('admin@example.com', null); // email 未占用(新家找不到 → 回退查舊家)
|
||||
const { puts } = mockAuthStoreWrite();
|
||||
mockListByTemplate('portal_user', []); // 尚無 admin(KBDB 空,認證儲存舊家也空)
|
||||
mockHeadLookup('admin@example.com', null); // email 未占用
|
||||
const { recordBody } = mockCreateUser('rec_admin_new');
|
||||
|
||||
const res = await json(
|
||||
'POST',
|
||||
@@ -225,25 +252,18 @@ describe('POST /portal/admin/bootstrap', () => {
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as Record<string, unknown>;
|
||||
expect(data.success).toBe(true);
|
||||
expect(typeof data.record_id).toBe('string');
|
||||
expect((data.record_id as string).startsWith(AUTH_ID_PREFIX)).toBe(true); // 住新家(D61)
|
||||
expect(data.record_id).toBe('rec_admin_new'); // 住 KBDB(新 record_id 來自 KBDB 回應)
|
||||
expect(data.email).toBe('admin@example.com'); // 存小寫(design §2.1)
|
||||
|
||||
// D61:一次寫入=一片,落進認證儲存(Workers Secrets),不再有 KBDB record/head entry
|
||||
const shards = puts();
|
||||
expect(shards.length).toBe(1);
|
||||
expect(shards[0].name).toBe('ARCRUN_AUTH_STORE');
|
||||
const shard = JSON.parse(shards[0].text) as {
|
||||
users: Array<{ email: string; role: string; status: string; libraries: string[]; password_hash: string }>;
|
||||
};
|
||||
expect(shard.users.length).toBe(1);
|
||||
const stored = shard.users[0];
|
||||
expect(stored.email).toBe('admin@example.com');
|
||||
expect(stored.role).toBe('admin');
|
||||
expect(stored.status).toBe('active');
|
||||
expect(stored.libraries).toEqual(['*']);
|
||||
expect(stored.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
|
||||
expect(shards[0].text).not.toContain('bootstrap-pw-1'); // 明碼絕不落地
|
||||
// 一次 POST /records=一筆,body 含 owner_id 子 namespace,明碼絕不落地
|
||||
const rec = JSON.parse(recordBody()) as { owner_id: string; values: Record<string, string> };
|
||||
expect(rec.owner_id).toBe(NS);
|
||||
expect(rec.values.email).toBe('admin@example.com');
|
||||
expect(rec.values.role).toBe('admin');
|
||||
expect(rec.values.status).toBe('active');
|
||||
expect(JSON.parse(rec.values.libraries)).toEqual(['*']);
|
||||
expect(rec.values.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
|
||||
expect(recordBody()).not.toContain('bootstrap-pw-1'); // 明碼絕不落地
|
||||
});
|
||||
|
||||
it('已有 admin → 409 拒絕重複 bootstrap', async () => {
|
||||
@@ -263,12 +283,10 @@ describe('POST /portal/admin/bootstrap', () => {
|
||||
// ═══════════════ 3. 登入對錯 ═══════════════
|
||||
|
||||
describe('POST /portal/login', () => {
|
||||
// 🔴 這一區塊全部共用 EMAIL/'rec_1' 這組舊家 fixture(原本就是),**故意不**在這裡驗證
|
||||
// 「登入成功後搬進新家」——promoteLegacyUser 一旦真的寫成功,會把 EMAIL 留進 overlay,
|
||||
// 而 overlay 是模組級全域、同檔案後面的測試都讀得到,會讓後面每一則「查 KBDB 的 EMAIL」
|
||||
// 全部改成「命中新家」而跳過 KBDB mock,導致假性的 pending-interceptor 骨牌。
|
||||
// 搬遷本身的驗證另開一組使用**專屬、不共用**email 的 describe(見檔案最後
|
||||
// 「D61:舊實例登入自癒」),避免污染這裡的既有 fixture。
|
||||
// 🔴 這一區塊全部共用 EMAIL/'rec_1' 這組 **KBDB 原生** fixture(非 auth: 開頭 id)——
|
||||
// 2026-08-14 起 KBDB 是新家,這批帳號本來就住在該住的地方,**不會**觸發任何搬遷嘗試
|
||||
// (isAuthStoreId('rec_1') 為 false)。搬遷本身的驗證另開一組使用**專屬、不共用**email
|
||||
// 的 describe(見檔案最後「舊實例登入自癒」),避免污染這裡的既有 fixture。
|
||||
it('成功:發 session token;回 display_name/role/libraries;**無任何租戶字串欄位**', async () => {
|
||||
mockHeadLookup(EMAIL, 'rec_1');
|
||||
mockGetRecord('rec_1', activeUserValues());
|
||||
@@ -286,11 +304,6 @@ describe('POST /portal/login', () => {
|
||||
const sess = await env.SESSIONS_KV.get(`portal_sess:${data.session_token}`);
|
||||
expect(sess).toBeTruthy();
|
||||
expect((JSON.parse(sess!) as { record_id: string }).record_id).toBe('rec_1'); // 只存 record_id
|
||||
// D61:promoteLegacyUser 的實際寫入嘗試沒有掛 CF API mock,disableNetConnect 之下
|
||||
// 該次 fetch 會失敗,但函式本身 best-effort 吞掉(見 portal.ts promoteLegacyUser 的
|
||||
// try/catch)——這正是要驗的事:搬不動不影響本次登入已經成功這件事實(上面兩個
|
||||
// expect 已經成立)。afterEach 的 assertNoPendingInterceptors 只檢查「有登記但沒用到」
|
||||
// 的 mock,一次沒登記過 mock 的失敗呼叫不算數,故這裡不需要(也不能)額外掛 CF API mock。
|
||||
});
|
||||
|
||||
it('密碼錯 → 401 通用訊息+lockfail 計數 +1', async () => {
|
||||
@@ -305,6 +318,10 @@ describe('POST /portal/login', () => {
|
||||
|
||||
it('未知 email → 401 同樣通用訊息(不洩帳號存在性)', async () => {
|
||||
mockHeadLookup('ghost@example.com', null);
|
||||
// instanceHasNoAuthData 在「查無此帳號」時會確認「是不是整台實例都沒帳號」——
|
||||
// 這裡要證明的是「查無此人」而非「這台實例是空的」,故 by-template 要回非空列表
|
||||
// (這台實例確實有別的帳號,只是不是 ghost@example.com)。
|
||||
mockListByTemplate('portal_user', [{ record_id: 'rec_1', values: activeUserValues() }]);
|
||||
const res = await json('POST', '/portal/login', { email: 'ghost@example.com', password: 'whatever-123' });
|
||||
expect(res.status).toBe(401);
|
||||
const data = (await res.json()) as { error: string };
|
||||
@@ -529,57 +546,52 @@ describe('t130 — triplet template seed(PORTAL_TEMPLATE_SEEDS 補 triplet,e
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ D61:舊實例登入自癒(搬進新家)═══════════════
|
||||
// ═══════════════ 舊實例登入自癒(認證儲存舊家 → 搬進 KBDB 新家)═══════════════
|
||||
//
|
||||
// 🔴 放在檔案最後、用**專屬 email**(不與上面任何一則共用):portal-auth-store.ts 的
|
||||
// per-isolate overlay 是模組級全域變數,寫入一旦成功就會留在同一支測試檔案的後續測試裡
|
||||
// (見 mockAuthStoreWrite 檔頭的長註解)。這裡就是要驗證那次「留下」,所以刻意隔離在最後,
|
||||
// per-isolate overlay 是模組級全域變數,seedLegacySecretsUser 種一次資料就會留在同一支測試
|
||||
// 檔案的後續測試裡(見該函式檔頭的長註解)。這裡就是要驗證那次「留下」,所以刻意隔離在最後,
|
||||
// 不會有更後面的測試共用這個 email 而被污染。
|
||||
describe('D61:舊實例登入自癒(帳號只在 KBDB,登入成功後 best-effort 搬進認證儲存)', () => {
|
||||
describe('舊實例登入自癒(帳號只在認證儲存舊家,登入成功後搬進 KBDB 新家)', () => {
|
||||
const LEGACY_EMAIL = 'legacy-promote@example.com';
|
||||
|
||||
it('登入成功;promoteLegacyUser 把這筆帳號寫進認證儲存(一片、含正確 email/hash)', async () => {
|
||||
mockHeadLookup(LEGACY_EMAIL, 'rec_legacy_1');
|
||||
mockGetRecord('rec_legacy_1', activeUserValues({ email: LEGACY_EMAIL }));
|
||||
const { puts } = mockAuthStoreWrite();
|
||||
it('登入成功;自動把這筆帳號寫進 KBDB(含正確 email/hash),且**這次的 session 就指向新 record_id**', async () => {
|
||||
await seedLegacySecretsUser({ email: LEGACY_EMAIL });
|
||||
// KBDB 查無此人:一次給 findUserRecordId(登入查找)、一次給 promoteToKbdb(搬遷前的
|
||||
// 「已經搬過了嗎」確認)——兩次都要 mock,見 mockHeadLookup 的 times 參數說明。
|
||||
mockHeadLookup(LEGACY_EMAIL, null, 2);
|
||||
const { recordBody } = mockCreateUser('rec_legacy_promoted_1');
|
||||
|
||||
const res = await json('POST', '/portal/login', { email: LEGACY_EMAIL, password: PASSWORD });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean };
|
||||
const data = (await res.json()) as { success: boolean; session_token: string };
|
||||
expect(data.success).toBe(true);
|
||||
|
||||
const shards = puts();
|
||||
expect(shards.length).toBe(1);
|
||||
expect(shards[0].name).toBe('ARCRUN_AUTH_STORE');
|
||||
const shard = JSON.parse(shards[0].text) as { users: Array<{ email: string; password_hash: string }> };
|
||||
const promoted = shard.users.find((u) => u.email === LEGACY_EMAIL);
|
||||
expect(promoted).toBeDefined();
|
||||
expect(promoted!.password_hash).toBe(storedHash); // 原樣搬過去,不重新雜湊
|
||||
const rec = JSON.parse(recordBody()) as { owner_id: string; values: Record<string, string> };
|
||||
expect(rec.owner_id).toBe(NS);
|
||||
expect(rec.values.email).toBe(LEGACY_EMAIL);
|
||||
expect(rec.values.password_hash).toBe(storedHash); // 原樣搬過去,不重新雜湊
|
||||
|
||||
// 這次登入發出的 session 已經指向新 record_id(不必等下一次登入才生效)
|
||||
const sess = await env.SESSIONS_KV.get(`portal_sess:${data.session_token}`);
|
||||
expect((JSON.parse(sess!) as { record_id: string }).record_id).toBe('rec_legacy_promoted_1');
|
||||
});
|
||||
|
||||
it('若新家寫入路徑未就緒(缺 CF_SECRETS_API_TOKEN),照樣登入成功——搬不動不擋門', async () => {
|
||||
// 直接呼叫 router、帶一份缺寫入路徑的 env(health.test.ts 已有的直呼叫慣例),
|
||||
// 證明 promoteLegacyUser 的失敗被 best-effort 吞掉,不影響登入本身。
|
||||
const email = 'legacy-promote-writeless@example.com';
|
||||
mockHeadLookup(email, 'rec_legacy_2');
|
||||
mockGetRecord('rec_legacy_2', activeUserValues({ email }));
|
||||
const fakeEnv = { ...env, CF_SECRETS_API_TOKEN: undefined, CF_ACCOUNT_ID: undefined } as unknown as Bindings;
|
||||
const res = await portalRouter.fetch(
|
||||
new Request('http://localhost/portal/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password: PASSWORD }),
|
||||
}),
|
||||
fakeEnv,
|
||||
{} as ExecutionContext,
|
||||
);
|
||||
it('KBDB 拒寫(搬遷失敗)——照樣登入成功,session 沿用舊家 record_id,搬不動不擋門', async () => {
|
||||
const email = 'legacy-promote-writefail@example.com';
|
||||
await seedLegacySecretsUser({ email });
|
||||
mockHeadLookup(email, null, 2); // 登入查找 + 搬遷前確認,KBDB 兩次都查無此人
|
||||
mockCreateUserFails(500); // 搬遷寫入本身失敗(模擬 KBDB 不可達/拒寫)
|
||||
|
||||
const res = await json('POST', '/portal/login', { email, password: PASSWORD });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean };
|
||||
const data = (await res.json()) as { success: boolean; session_token: string };
|
||||
expect(data.success).toBe(true);
|
||||
// 沒掛 CF API mock:若程式碼真的嘗試網呼叫且被 disableNetConnect 擋下,錯誤仍會被
|
||||
// best-effort 吞掉(不影響上面的 200 斷言);若程式碼正確地在 authStoreWritable() 檢查
|
||||
// 就提前短路,則根本不會嘗試呼叫——兩種情況這裡都驗不出差異,差異由 afterEach 的
|
||||
// assertNoPendingInterceptors 間接把關(沒有殘留 mock 代表沒有意外多打的請求)。
|
||||
|
||||
// 搬不動 → session 沿用舊家(認證儲存)的 record_id("auth:" 開頭),讀路徑仍然通
|
||||
const sess = await env.SESSIONS_KV.get(`portal_sess:${data.session_token}`);
|
||||
const sessRecordId = (JSON.parse(sess!) as { record_id: string }).record_id;
|
||||
expect(isAuthStoreId(sessRecordId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -587,7 +599,7 @@ describe('D61:舊實例登入自癒(帳號只在 KBDB,登入成功後 best
|
||||
//
|
||||
// ⚠️ 順序刻意:這兩個 describe 放在檔案最後,而且「D62」在前、「#66」在後。
|
||||
// 原因=#66 那組會**故意把 per-isolate overlay 灌成一份沒有任何帳號的資料**(模擬傳播空窗),
|
||||
// 而 overlay 是模組級全域變數、不隨 test 重置(見 mockAuthStoreWrite 檔頭長註解)。
|
||||
// 而 overlay 是模組級全域變數、不隨 test 重置(見 seedLegacySecretsUser 檔頭長註解)。
|
||||
// 任何需要「認證儲存裡有帳號」的測試都不能排在它後面。
|
||||
|
||||
describe('D62:改密碼與忘記密碼是同一個機制(同一支端點、同一條寫入路徑)', () => {
|
||||
@@ -686,69 +698,59 @@ describe('arcrun-rag#66:傳播空窗期不可以銷毀 session', () => {
|
||||
// ═══════════════ arcrun-rag#99(2026-08-14):全新安裝從沒種過 CF_SECRETS_API_TOKEN ═══════════════
|
||||
//
|
||||
// 每一台裝好的新實例過去都會在 bootstrap 這裡卡死(leo 本人+封測者都撞到「裝得起來,
|
||||
// 但卡在註冊」——`/console/auth-status` 永遠回 `writable:false`)。安裝精靈裝機當下手上有
|
||||
// 一把自己還有效的 OAuth token,讓它隨 `/portal/admin/bootstrap` 請求帶入
|
||||
// (`x-arcrun-install-token` 表頭),cypher 收到才用這一次、不落地(見 credentials.ts
|
||||
// putWorkerSecret 的完整說明)。下面兩則證明:①這確實是原本會擋死的斷點 ②帶表頭後真的解掉。
|
||||
// 但卡在註冊」——`/console/auth-status` 永遠回 `writable:false`)。止血版(安裝精靈遞一把
|
||||
// 臨時 OAuth token)只解掉「建第一個帳號」這一格;2026-08-14 confirm 走 C 之後,帳號改回
|
||||
// 住 KBDB(binding),**從根拔掉整個問題**:`x-arcrun-install-token` 這條路已經不存在了,
|
||||
// 因為建帳號這件事本來就不再需要任何 CF API 憑證,無論裝機當下還是裝完之後都一樣。
|
||||
//
|
||||
// 🔴 刻意放在檔案最後:這兩則會真的寫入認證儲存(per-isolate overlay 是模組級全域變數,
|
||||
// 不隨 test 重置,見 mockAuthStoreWrite 檔頭長註解),排在前面會污染後面測試的「乾淨」假設。
|
||||
describe('arcrun-rag#99:全新實例(缺 CF_SECRETS_API_TOKEN)靠安裝表頭補完寫入路徑', () => {
|
||||
it('沒帶安裝表頭 → 502 auth_store_not_writable,證明這是真斷點(不是想像出來的假設)', async () => {
|
||||
// 下面這則是回歸守衛:**一台連 `CF_SECRETS_API_TOKEN`/`CF_ACCOUNT_ID` 都沒有的全新實例**,
|
||||
// 從 bootstrap 建第一個帳號、到之後用 /portal/admin/users 加第二個帳號,全程都要成功——
|
||||
// 若哪天有人把帳號寫入路徑又改回去揹 CF Secrets 依賴,這裡會紅。
|
||||
describe('arcrun-rag#99:全新實例(連 CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID 都沒有)不再卡死', () => {
|
||||
it('bootstrap 建第一個 admin、再用 admin session 加第二個 user,全程零 CF token', async () => {
|
||||
await env.SESSIONS_KV.put('console_sess:owner-token-fresh', JSON.stringify({ created_at: Date.now() }));
|
||||
mockTemplatesExist();
|
||||
mockListByTemplate('portal_user', []);
|
||||
mockHeadLookup('fresh-install-noheader@example.com', null);
|
||||
// 刻意不掛 mockAuthStoreWrite:authStoreWritable() 應該在打任何 CF API 之前就短路。
|
||||
// 若程式碼退化成先打了 fetch 才失敗,這裡沒有攔截器會接住它,disableNetConnect 讓那次
|
||||
// 意外的 fetch 直接拋錯,一樣會讓這則測試失敗——兩種退化路徑都攔得住。
|
||||
const fakeEnv = { ...env, CF_SECRETS_API_TOKEN: undefined } as unknown as Bindings;
|
||||
const res = await portalRouter.fetch(
|
||||
mockListByTemplate('portal_user', []); // 尚無 admin
|
||||
mockHeadLookup('fresh-install-admin@example.com', null);
|
||||
const { recordBody: bootstrapBody } = mockCreateUser('rec_fresh_admin');
|
||||
|
||||
const fakeEnv = { ...env, CF_SECRETS_API_TOKEN: undefined, CF_ACCOUNT_ID: undefined } as unknown as Bindings;
|
||||
const bootstrapRes = await portalRouter.fetch(
|
||||
new Request('http://localhost/portal/admin/bootstrap', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer owner-token-fresh' },
|
||||
body: JSON.stringify({ email: 'fresh-install-noheader@example.com', password: 'bootstrap-pw-3' }),
|
||||
body: JSON.stringify({ email: 'fresh-install-admin@example.com', password: 'bootstrap-pw-3' }),
|
||||
}),
|
||||
fakeEnv,
|
||||
{} as ExecutionContext,
|
||||
);
|
||||
expect(res.status).toBe(502);
|
||||
const data = (await res.json()) as { code: string };
|
||||
expect(data.code).toBe('auth_store_not_writable');
|
||||
});
|
||||
expect(bootstrapRes.status).toBe(200);
|
||||
const bootstrapData = (await bootstrapRes.json()) as Record<string, unknown>;
|
||||
expect(bootstrapData.success).toBe(true);
|
||||
expect(bootstrapData.record_id).toBe('rec_fresh_admin');
|
||||
expect(JSON.parse(bootstrapBody()).values.password_hash.startsWith('pbkdf2-sha256$')).toBe(true);
|
||||
expect(bootstrapBody()).not.toContain('bootstrap-pw-3'); // 明碼絕不落地
|
||||
|
||||
it('帶安裝表頭(安裝精靈那條路)→ 仍能建第一個 admin,明碼絕不落地', async () => {
|
||||
await env.SESSIONS_KV.put('console_sess:owner-token-fresh2', JSON.stringify({ created_at: Date.now() }));
|
||||
mockTemplatesExist();
|
||||
mockListByTemplate('portal_user', []);
|
||||
mockHeadLookup('fresh-install-admin@example.com', null);
|
||||
const { puts } = mockAuthStoreWrite();
|
||||
// 安裝精靈離場之後:管理員事後手動加第二個人(POST /portal/admin/users),
|
||||
// 這條路本來就沒帶過任何臨時 token,過去在 D61 底下永遠 502——現在也要成功。
|
||||
await env.SESSIONS_KV.put('portal_sess:tok-fresh-admin', JSON.stringify({ record_id: 'rec_fresh_admin' }));
|
||||
mockGetRecord('rec_fresh_admin', activeUserValues({ email: 'fresh-install-admin@example.com', role: 'admin' }));
|
||||
mockHeadLookup('second-user@example.com', null);
|
||||
const { recordBody: secondBody } = mockCreateUser('rec_fresh_second');
|
||||
mockGetRecord('rec_fresh_second', activeUserValues({ email: 'second-user@example.com' }));
|
||||
|
||||
const fakeEnv = { ...env, CF_SECRETS_API_TOKEN: undefined } as unknown as Bindings;
|
||||
const res = await portalRouter.fetch(
|
||||
new Request('http://localhost/portal/admin/bootstrap', {
|
||||
const addUserRes = await portalRouter.fetch(
|
||||
new Request('http://localhost/portal/admin/users', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer owner-token-fresh2',
|
||||
'x-arcrun-install-token': 'fresh-oauth-token-from-installer',
|
||||
},
|
||||
body: JSON.stringify({ email: 'fresh-install-admin@example.com', password: 'bootstrap-pw-4' }),
|
||||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer tok-fresh-admin' },
|
||||
body: JSON.stringify({ email: 'second-user@example.com', password: 'second-user-pw-1' }),
|
||||
}),
|
||||
fakeEnv,
|
||||
{} as ExecutionContext,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as Record<string, unknown>;
|
||||
expect(data.success).toBe(true);
|
||||
expect((data.record_id as string).startsWith(AUTH_ID_PREFIX)).toBe(true);
|
||||
|
||||
const shards = puts();
|
||||
expect(shards.length).toBe(1);
|
||||
expect(shards[0].name).toBe('ARCRUN_AUTH_STORE');
|
||||
const shard = JSON.parse(shards[0].text) as { users: Array<{ email: string; role: string }> };
|
||||
expect(shard.users[0].email).toBe('fresh-install-admin@example.com');
|
||||
expect(shard.users[0].role).toBe('admin');
|
||||
expect(shards[0].text).not.toContain('bootstrap-pw-4'); // 明碼絕不落地
|
||||
expect(addUserRes.status).toBe(200);
|
||||
const addUserData = (await addUserRes.json()) as { success: boolean };
|
||||
expect(addUserData.success).toBe(true);
|
||||
expect(JSON.parse(secondBody()).owner_id).toBe(NS);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user