88f308642e
根因:D61(認證與資料分離,c4cee35)把 /console/setup、/portal/admin/bootstrap 的寫入全部改走 CF Workers Secrets(env.CF_SECRETS_API_TOKEN),但這把 token 從安裝那天起就沒被種過——07-29 已知缺口(pending-changes.md「credential 走 n8n 模式」),當時只降級某個功能;D61 之後升級成「連第一個帳號都建不起來」 的硬斷點,每台全新安裝必中(leo 本人+封測者實撞:裝得起來但卡在註冊)。 修法:putWorkerSecret/deleteWorkerSecret/authStoreWritable/writeAuthStore/ mutateAuthStore 新增可選的 tokenOverride 參數(呼叫端提供 > worker 自身 env)。 /console/setup、/portal/admin/bootstrap 讀取 x-arcrun-install-token 表頭, 只有安裝精靈(裝機當下手上有一把自己還有效的 OAuth token,workers-scripts.write scope,同一把已用於 putWorkerSecretDirect/seedCredential)會帶這個表頭; 一般使用者自己在瀏覽器操作不受影響。沿用既有「D36 安裝器代寫」precedent, 不是新開一條路;bootstrap 本身已被「已有 admin → 409」擋成只能成功一次, 不會被拿來反覆濫用。 測試:cypher-executor vitest 443/457(14 個既存失敗與本改動無關,已用 git stash 對照確認);新增 2 則直接證明「缺 token→502 auth_store_not_writable/ 帶 token→200」。tsc --noEmit 無新增錯誤。已跑 build-worker-artifacts.mjs 重打 tier2 bundle 供驗證(工作區未 commit 前提下的本地驗證版)。 未完成:安裝器(products/arcrun-rag/installer/oauth-prototype/worker.js) 端的 x-arcrun-install-token 表頭傳遞已另外修好,但兩邊都還沒部署——需要 ①重打正式 worker artifact ②install.arcrun.dev 的安裝器 wrangler deploy ③已卡住的封測者要再走一次安裝精靈讓他的 cypher worker 拿到新 bundle。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
755 lines
40 KiB
TypeScript
755 lines
40 KiB
TypeScript
/**
|
||
* portal-auth P2 測試(design §2/§4/§5,Gitea #24/#25)
|
||
*
|
||
* 覆蓋(=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
|
||
* 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)補的覆蓋(原本沒有,這次變更的重點):
|
||
* 8. 整台實例沒有任何認證資料 → 登入回「讀不到認證資料」(不是密碼錯),且不計入鎖定
|
||
* 9. 舊實例相容:帳號只存在 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 { 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 { portalRouter } from '../src/routes/portal';
|
||
import type { Bindings, ExecutionContext } from '../src/types';
|
||
|
||
const KBDB = 'https://kbdb.test';
|
||
const CF_API = 'https://api.cloudflare.com';
|
||
const NS = 'leo::portal'; // wrangler.test.toml CONSOLE_TENANT=leo → 子 namespace
|
||
const EMAIL = 'user@example.com';
|
||
const PASSWORD = 'correct-horse-9';
|
||
|
||
// 測試用低迭代 hash(verify 從儲存格式解析 iterations → 舊/低參數 hash 也驗得動=漸進遷移特性)
|
||
let storedHash: string;
|
||
|
||
beforeAll(async () => {
|
||
fetchMock.activate();
|
||
fetchMock.disableNetConnect();
|
||
storedHash = await hashPassword(PASSWORD, 10_000);
|
||
});
|
||
afterEach(() => fetchMock.assertNoPendingInterceptors());
|
||
|
||
function json(method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
|
||
return SELF.fetch(`http://localhost${path}`, {
|
||
method,
|
||
headers: { 'Content-Type': 'application/json', ...headers },
|
||
body: body === undefined ? undefined : JSON.stringify(body),
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 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) {
|
||
const needle = new URLSearchParams({ page_name: email }).toString();
|
||
fetchMock
|
||
.get(KBDB)
|
||
.intercept({
|
||
path: (p: string) =>
|
||
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 });
|
||
}
|
||
|
||
function mockGetRecord(recordId: string, values: Record<string, string>) {
|
||
fetchMock
|
||
.get(KBDB)
|
||
.intercept({ path: `/records/${recordId}`, method: 'GET' })
|
||
.reply(200, { success: true, record: { record_id: recordId, template_id: 'tpl_pu', values } });
|
||
}
|
||
|
||
function mockListByTemplate(template: string, records: { record_id: string; values: Record<string, string> }[]) {
|
||
fetchMock
|
||
.get(KBDB)
|
||
.intercept({ path: (p: string) => p.startsWith(`/records/by-template/${template}`), method: 'GET' })
|
||
.reply(200, { success: true, records: records.map((r) => ({ ...r, template_id: 'tpl' })), count: records.length });
|
||
}
|
||
|
||
function mockTemplatesExist() {
|
||
for (const name of ['portal_user', 'portal_library', 'triplet']) {
|
||
fetchMock
|
||
.get(KBDB)
|
||
.intercept({ path: `/templates/${name}`, method: 'GET' })
|
||
.reply(200, { success: true, template: { id: `tpl-${name}`, name } });
|
||
}
|
||
}
|
||
|
||
function activeUserValues(overrides: Record<string, string> = {}): Record<string, string> {
|
||
return {
|
||
email: EMAIL,
|
||
display_name: '測試同仁',
|
||
status: 'active',
|
||
role: 'user',
|
||
password_hash: storedHash,
|
||
libraries: '["general","finance"]',
|
||
created_at: '2026-07-14T00:00:00.000Z',
|
||
updated_at: '2026-07-14T00:00:00.000Z',
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
async function seedPortalSession(token: string, recordId: string) {
|
||
await env.SESSIONS_KV.put(`portal_sess:${token}`, JSON.stringify({ record_id: recordId }));
|
||
}
|
||
|
||
// ═══════════════ 1. KDF 單元 ═══════════════
|
||
|
||
describe('PBKDF2 模組(lib/portal-auth)', () => {
|
||
it('hashPassword 預設格式 = pbkdf2-sha256$100000$salt$hash(CF runtime 上限),且驗證通過', async () => {
|
||
const h = await hashPassword('some-password-123');
|
||
const parts = h.split('$');
|
||
expect(parts.length).toBe(4);
|
||
expect(parts[0]).toBe('pbkdf2-sha256');
|
||
expect(parts[1]).toBe(String(PBKDF2_ITERATIONS));
|
||
// CF Workers 正式 runtime PBKDF2 封頂 100k(600k 在真雲 deriveBits 直接拒絕 → bootstrap 500)
|
||
expect(PBKDF2_ITERATIONS).toBe(100_000);
|
||
expect(PBKDF2_ITERATIONS).toBeLessThanOrEqual(100_000);
|
||
expect(await verifyPassword('some-password-123', h)).toBe(true);
|
||
expect(await verifyPassword('wrong-password-x', h)).toBe(false);
|
||
});
|
||
|
||
it('600k 舊 hash 相容:verify 從儲存值解析迭代數,仍驗得動(miniflare 無平台上限可造)', async () => {
|
||
// 誠實註:此相容性在 miniflare / 未來平台放寬時成立;真 CF runtime 對 deriveBits 一律封頂
|
||
// 100k,600k hash 在真雲仍會被拒——但真雲 bootstrap 本身就 500,雲端不存在 600k hash,無遷移面。
|
||
const legacy = await hashPassword(PASSWORD, 600_000);
|
||
expect(legacy.startsWith('pbkdf2-sha256$600000$')).toBe(true);
|
||
expect(await verifyPassword(PASSWORD, legacy)).toBe(true);
|
||
expect(await verifyPassword('wrong-password-x', legacy)).toBe(false);
|
||
});
|
||
|
||
it('壞格式 / 前綴不認得 / 被竄改 → false(誠實拒絕不拋錯)', async () => {
|
||
expect(await verifyPassword('x', '')).toBe(false);
|
||
expect(await verifyPassword('x', 'bcrypt$10$abc$def')).toBe(false);
|
||
expect(await verifyPassword('x', 'pbkdf2-sha256$notanumber$AA$BB')).toBe(false);
|
||
const tampered = storedHash.slice(0, -4) + 'AAA=';
|
||
expect(await verifyPassword(PASSWORD, tampered)).toBe(false);
|
||
});
|
||
});
|
||
|
||
// ═══════════════ 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 分鐘的事故)', () => {
|
||
it('登入回「讀不到認證資料」而不是「密碼錯誤」,且不計入失敗鎖定', async () => {
|
||
// 新家(overlay/env bag)此刻還是空的(本測試特意排在任何寫入測試之前);
|
||
// 舊家(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 } };
|
||
expect(data.code).toBe('auth_store_empty');
|
||
// 分得出來的錯:這句要誠實講「不是密碼錯」,而且**不能**是密碼錯誤那句通用訊息
|
||
// (文案含混是 leo 被鎖 15 分鐘的根因——他的密碼從頭到尾是對的)。
|
||
expect(data.error).toContain('不是密碼錯');
|
||
expect(data.error).not.toBe('email 或密碼錯誤'); // 不是密碼錯誤路徑用的那句通用訊息
|
||
expect(data.auth_store.users).toBe(0);
|
||
// 不計入鎖定:lockfail 計數器完全沒被寫入
|
||
expect(await env.SESSIONS_KV.get('portal_lockfail:anyone@example.com')).toBeNull();
|
||
});
|
||
});
|
||
|
||
// ═══════════════ 2. bootstrap 閘 ═══════════════
|
||
|
||
describe('POST /portal/admin/bootstrap', () => {
|
||
it('無 console owner session → 401,不碰 KBDB', async () => {
|
||
const res = await json('POST', '/portal/admin/bootstrap', { email: 'a@b.co', password: 'longenough' });
|
||
expect(res.status).toBe(401);
|
||
});
|
||
|
||
it('console session OK → 建第一個 admin:寫進認證儲存(D61,不再落 KBDB)', 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();
|
||
|
||
const res = await json(
|
||
'POST',
|
||
'/portal/admin/bootstrap',
|
||
{ email: 'Admin@Example.com', password: 'bootstrap-pw-1', display_name: '管理員' },
|
||
{ Authorization: 'Bearer owner-token' },
|
||
);
|
||
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.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'); // 明碼絕不落地
|
||
});
|
||
|
||
it('已有 admin → 409 拒絕重複 bootstrap', async () => {
|
||
await env.SESSIONS_KV.put('console_sess:owner-token', JSON.stringify({ created_at: Date.now() }));
|
||
mockTemplatesExist();
|
||
mockListByTemplate('portal_user', [{ record_id: 'rec_a', values: activeUserValues({ role: 'admin' }) }]);
|
||
const res = await json(
|
||
'POST',
|
||
'/portal/admin/bootstrap',
|
||
{ email: 'x@y.co', password: 'whatever-123' },
|
||
{ Authorization: 'Bearer owner-token' },
|
||
);
|
||
expect(res.status).toBe(409);
|
||
});
|
||
});
|
||
|
||
// ═══════════════ 3. 登入對錯 ═══════════════
|
||
|
||
describe('POST /portal/login', () => {
|
||
// 🔴 這一區塊全部共用 EMAIL/'rec_1' 這組舊家 fixture(原本就是),**故意不**在這裡驗證
|
||
// 「登入成功後搬進新家」——promoteLegacyUser 一旦真的寫成功,會把 EMAIL 留進 overlay,
|
||
// 而 overlay 是模組級全域、同檔案後面的測試都讀得到,會讓後面每一則「查 KBDB 的 EMAIL」
|
||
// 全部改成「命中新家」而跳過 KBDB mock,導致假性的 pending-interceptor 骨牌。
|
||
// 搬遷本身的驗證另開一組使用**專屬、不共用**email 的 describe(見檔案最後
|
||
// 「D61:舊實例登入自癒」),避免污染這裡的既有 fixture。
|
||
it('成功:發 session token;回 display_name/role/libraries;**無任何租戶字串欄位**', async () => {
|
||
mockHeadLookup(EMAIL, 'rec_1');
|
||
mockGetRecord('rec_1', activeUserValues());
|
||
const res = await json('POST', '/portal/login', { email: EMAIL, password: PASSWORD });
|
||
expect(res.status).toBe(200);
|
||
const data = (await res.json()) as Record<string, unknown>;
|
||
expect(data.success).toBe(true);
|
||
expect(typeof data.session_token).toBe('string');
|
||
expect(data.display_name).toBe('測試同仁');
|
||
expect(data.role).toBe('user');
|
||
expect(data.libraries).toEqual(['general', 'finance']);
|
||
expect('tenant' in data).toBe(false); // design §3.3:Portal 絕不下發租戶字串
|
||
expect(JSON.stringify(data)).not.toContain('leo'); // 連值都不含租戶字串
|
||
|
||
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 () => {
|
||
mockHeadLookup(EMAIL, 'rec_1');
|
||
mockGetRecord('rec_1', activeUserValues());
|
||
const res = await json('POST', '/portal/login', { email: EMAIL, password: 'wrong-password' });
|
||
expect(res.status).toBe(401);
|
||
const raw = await env.SESSIONS_KV.get(`portal_lockfail:${EMAIL}`);
|
||
expect(raw).toBeTruthy();
|
||
expect((JSON.parse(raw!) as { count: number }).count).toBe(1);
|
||
});
|
||
|
||
it('未知 email → 401 同樣通用訊息(不洩帳號存在性)', async () => {
|
||
mockHeadLookup('ghost@example.com', null);
|
||
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 };
|
||
expect(data.error).toBe('email 或密碼錯誤');
|
||
});
|
||
|
||
it('停用帳號 → 403(正確密碼也拒)', async () => {
|
||
mockHeadLookup(EMAIL, 'rec_1');
|
||
mockGetRecord('rec_1', activeUserValues({ status: 'disabled' }));
|
||
const res = await json('POST', '/portal/login', { email: EMAIL, password: PASSWORD });
|
||
expect(res.status).toBe(403);
|
||
});
|
||
|
||
it('節流:計數達 5 → 429,不碰 KBDB;正確密碼也擋', async () => {
|
||
await env.SESSIONS_KV.put(`portal_lockfail:${EMAIL}`, JSON.stringify({ count: 5 }), { expirationTtl: 900 });
|
||
const res = await json('POST', '/portal/login', { email: EMAIL, password: PASSWORD });
|
||
expect(res.status).toBe(429);
|
||
});
|
||
|
||
it('第 5 次失敗後下一次直接 429(KV 計數累加)', async () => {
|
||
await env.SESSIONS_KV.put(`portal_lockfail:${EMAIL}`, JSON.stringify({ count: 4 }), { expirationTtl: 900 });
|
||
mockHeadLookup(EMAIL, 'rec_1');
|
||
mockGetRecord('rec_1', activeUserValues());
|
||
const res5 = await json('POST', '/portal/login', { email: EMAIL, password: 'wrong-again' });
|
||
expect(res5.status).toBe(401);
|
||
const res6 = await json('POST', '/portal/login', { email: EMAIL, password: PASSWORD });
|
||
expect(res6.status).toBe(429); // 第 6 次不再打 KBDB(無 pending interceptor 可證)
|
||
});
|
||
});
|
||
|
||
// ═══════════════ 4. session 回讀(真相源=record)═══════════════
|
||
|
||
describe('GET /portal/session', () => {
|
||
it('有效 session → 回 display_name/role/libraries,無租戶字串', async () => {
|
||
await seedPortalSession('tok-1', 'rec_1');
|
||
mockGetRecord('rec_1', activeUserValues());
|
||
// P3:session 多回 graph_allowed(D-4)——非 ["*"] 用戶要查庫目錄算 graph 來源庫
|
||
mockListByTemplate('portal_library', []);
|
||
const res = await json('GET', '/portal/session', undefined, { Authorization: 'Bearer tok-1' });
|
||
expect(res.status).toBe(200);
|
||
const data = (await res.json()) as Record<string, unknown>;
|
||
expect(data.valid).toBe(true);
|
||
expect(data.libraries).toEqual(['general', 'finance']);
|
||
expect('tenant' in data).toBe(false);
|
||
// P3 能力欄位:來源庫預設 general、本用戶有 general → graph 放行;workflows 預設 admin-only
|
||
expect(data.graph_allowed).toBe(true);
|
||
expect(data.workflows_visible).toBe(false);
|
||
});
|
||
|
||
it('帳號被停用 → 既有 session 立即失效(403)且 KV session 被清', async () => {
|
||
await seedPortalSession('tok-2', 'rec_1');
|
||
mockGetRecord('rec_1', activeUserValues({ status: 'disabled' }));
|
||
const res = await json('GET', '/portal/session', undefined, { Authorization: 'Bearer tok-2' });
|
||
expect(res.status).toBe(403);
|
||
expect(await env.SESSIONS_KV.get('portal_sess:tok-2')).toBeNull();
|
||
});
|
||
|
||
it('無 token / 壞 token → 401', async () => {
|
||
expect((await json('GET', '/portal/session')).status).toBe(401);
|
||
expect((await json('GET', '/portal/session', undefined, { Authorization: 'Bearer nope' })).status).toBe(401);
|
||
});
|
||
});
|
||
|
||
// ═══════════════ 5. 改自己密碼 ═══════════════
|
||
|
||
describe('POST /portal/me/password', () => {
|
||
it('舊密碼錯 → 401,不發 PATCH', async () => {
|
||
await seedPortalSession('tok-3', 'rec_1');
|
||
mockGetRecord('rec_1', activeUserValues());
|
||
const res = await json(
|
||
'POST',
|
||
'/portal/me/password',
|
||
{ current: 'wrong-old', new: 'new-password-1' },
|
||
{ Authorization: 'Bearer tok-3' },
|
||
);
|
||
expect(res.status).toBe(401);
|
||
});
|
||
|
||
it('舊密碼對 → PATCH 新 hash(現行 100k 格式、非明碼、與舊 hash 不同)', async () => {
|
||
await seedPortalSession('tok-4', 'rec_1');
|
||
mockGetRecord('rec_1', activeUserValues());
|
||
let patched = '';
|
||
fetchMock
|
||
.get(KBDB)
|
||
.intercept({ path: '/records/rec_1', method: 'PATCH' })
|
||
.reply(200, (opts) => {
|
||
patched = String(opts.body);
|
||
return { success: true, record: { record_id: 'rec_1', template_id: 'tpl_pu', values: activeUserValues() } };
|
||
});
|
||
const res = await json(
|
||
'POST',
|
||
'/portal/me/password',
|
||
{ current: PASSWORD, new: 'brand-new-pw-1' },
|
||
{ Authorization: 'Bearer tok-4' },
|
||
);
|
||
expect(res.status).toBe(200);
|
||
const sent = JSON.parse(patched) as { values: Record<string, string> };
|
||
expect(sent.values.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
|
||
expect(sent.values.password_hash).not.toBe(storedHash);
|
||
expect(patched).not.toContain('brand-new-pw-1'); // 明碼不落 KBDB
|
||
});
|
||
});
|
||
|
||
// ═══════════════ 6. role 閘+admin 列表剝敏 ═══════════════
|
||
|
||
describe('admin 端點 role 閘', () => {
|
||
it('一般 user 打 GET /portal/admin/users → 403', async () => {
|
||
await seedPortalSession('tok-5', 'rec_1');
|
||
mockGetRecord('rec_1', activeUserValues({ role: 'user' }));
|
||
const res = await json('GET', '/portal/admin/users', undefined, { Authorization: 'Bearer tok-5' });
|
||
expect(res.status).toBe(403);
|
||
});
|
||
|
||
it('admin 列表 → 200 且**每筆都不含 password_hash**', async () => {
|
||
await seedPortalSession('tok-6', 'rec_admin');
|
||
mockGetRecord('rec_admin', activeUserValues({ role: 'admin', email: 'admin@example.com' }));
|
||
mockListByTemplate('portal_user', [
|
||
{ record_id: 'rec_admin', values: activeUserValues({ role: 'admin', email: 'admin@example.com' }) },
|
||
{ record_id: 'rec_1', values: activeUserValues() },
|
||
]);
|
||
const res = await json('GET', '/portal/admin/users', undefined, { Authorization: 'Bearer tok-6' });
|
||
expect(res.status).toBe(200);
|
||
const data = (await res.json()) as { users: Record<string, unknown>[] };
|
||
expect(data.users.length).toBe(2);
|
||
for (const u of data.users) {
|
||
expect('password_hash' in u).toBe(false);
|
||
expect(Array.isArray(u.libraries)).toBe(true);
|
||
}
|
||
expect(JSON.stringify(data)).not.toContain('pbkdf2-sha256'); // 整包回應無雜湊外洩
|
||
});
|
||
|
||
it('admin 停用同仁:PATCH status=disabled → 經 head entry 成員驗證後改 record', async () => {
|
||
await seedPortalSession('tok-7', 'rec_admin');
|
||
mockGetRecord('rec_admin', activeUserValues({ role: 'admin', email: 'admin@example.com' }));
|
||
mockGetRecord('rec_1', activeUserValues()); // assertPortalUserRecord 回讀目標
|
||
mockHeadLookup(EMAIL, 'rec_1'); // head 指回同 record → 成員資格成立
|
||
let patched = '';
|
||
fetchMock
|
||
.get(KBDB)
|
||
.intercept({ path: '/records/rec_1', method: 'PATCH' })
|
||
.reply(200, (opts) => {
|
||
patched = String(opts.body);
|
||
return {
|
||
success: true,
|
||
record: { record_id: 'rec_1', template_id: 'tpl_pu', values: activeUserValues({ status: 'disabled' }) },
|
||
};
|
||
});
|
||
const res = await json(
|
||
'PATCH',
|
||
'/portal/admin/users/rec_1',
|
||
{ status: 'disabled' },
|
||
{ Authorization: 'Bearer tok-7' },
|
||
);
|
||
expect(res.status).toBe(200);
|
||
const sent = JSON.parse(patched) as { values: Record<string, string> };
|
||
expect(sent.values.status).toBe('disabled');
|
||
const data = (await res.json()) as { user: { status: string } };
|
||
expect(data.user.status).toBe('disabled');
|
||
});
|
||
|
||
it('head entry 指向別的 record(成員資格不符)→ 404 不 PATCH', async () => {
|
||
await seedPortalSession('tok-8', 'rec_admin');
|
||
mockGetRecord('rec_admin', activeUserValues({ role: 'admin', email: 'admin@example.com' }));
|
||
mockGetRecord('rec_evil', activeUserValues({ email: EMAIL }));
|
||
mockHeadLookup(EMAIL, 'rec_1'); // head 指 rec_1 ≠ rec_evil
|
||
const res = await json(
|
||
'PATCH',
|
||
'/portal/admin/users/rec_evil',
|
||
{ status: 'disabled' },
|
||
{ Authorization: 'Bearer tok-8' },
|
||
);
|
||
expect(res.status).toBe(404);
|
||
});
|
||
});
|
||
|
||
// ═══════════════ t130 — triplet template seed ═══════════════
|
||
|
||
describe('t130 — triplet template seed(PORTAL_TEMPLATE_SEEDS 補 triplet,ensurePortalTemplates 冪等)', () => {
|
||
it('PORTAL_TEMPLATE_SEEDS 含 triplet 且必要 slots 齊備(pure data)', () => {
|
||
const seed = PORTAL_TEMPLATE_SEEDS.find((s) => s.name === 'triplet');
|
||
expect(seed).toBeDefined();
|
||
for (const slot of ['subject', 'predicate', 'object', 'source_uri', 'status', 'library']) {
|
||
expect(seed!.slots).toContain(slot);
|
||
}
|
||
});
|
||
|
||
it('POST /init/seed — triplet 已存 → existing(冪等,不重建)', async () => {
|
||
for (const name of ['portal_user', 'portal_library', 'triplet']) {
|
||
fetchMock
|
||
.get(KBDB)
|
||
.intercept({ path: `/templates/${name}`, method: 'GET' })
|
||
.reply(200, { success: true, template: { id: `tpl-${name}`, name } });
|
||
}
|
||
const res = await SELF.fetch('http://localhost/init/seed', { method: 'POST' });
|
||
expect(res.status).toBe(200);
|
||
const data = (await res.json()) as { portal_templates: { created: string[]; existing: string[] } };
|
||
expect(data.portal_templates.existing).toContain('triplet');
|
||
expect(data.portal_templates.created).not.toContain('triplet');
|
||
});
|
||
|
||
it('POST /init/seed — triplet 缺 → 自動補建(新實例首次 seed)', async () => {
|
||
for (const name of ['portal_user', 'portal_library']) {
|
||
fetchMock
|
||
.get(KBDB)
|
||
.intercept({ path: `/templates/${name}`, method: 'GET' })
|
||
.reply(200, { success: true, template: { id: `tpl-${name}`, name } });
|
||
}
|
||
fetchMock
|
||
.get(KBDB)
|
||
.intercept({ path: '/templates/triplet', method: 'GET' })
|
||
.reply(404, { success: false, error: 'template not found: triplet' });
|
||
fetchMock
|
||
.get(KBDB)
|
||
.intercept({ path: '/templates', method: 'POST' })
|
||
.reply(200, { success: true, template: { id: 'tpl-triplet-new', name: 'triplet' } });
|
||
|
||
const res = await SELF.fetch('http://localhost/init/seed', { method: 'POST' });
|
||
expect(res.status).toBe(200);
|
||
const data = (await res.json()) as { portal_templates: { created: string[]; existing: string[] } };
|
||
expect(data.portal_templates.created).toContain('triplet');
|
||
expect(data.portal_templates.existing).not.toContain('triplet');
|
||
});
|
||
});
|
||
|
||
// ═══════════════ D61:舊實例登入自癒(搬進新家)═══════════════
|
||
//
|
||
// 🔴 放在檔案最後、用**專屬 email**(不與上面任何一則共用):portal-auth-store.ts 的
|
||
// per-isolate overlay 是模組級全域變數,寫入一旦成功就會留在同一支測試檔案的後續測試裡
|
||
// (見 mockAuthStoreWrite 檔頭的長註解)。這裡就是要驗證那次「留下」,所以刻意隔離在最後,
|
||
// 不會有更後面的測試共用這個 email 而被污染。
|
||
describe('D61:舊實例登入自癒(帳號只在 KBDB,登入成功後 best-effort 搬進認證儲存)', () => {
|
||
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();
|
||
|
||
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 };
|
||
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); // 原樣搬過去,不重新雜湊
|
||
});
|
||
|
||
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,
|
||
);
|
||
expect(res.status).toBe(200);
|
||
const data = (await res.json()) as { success: boolean };
|
||
expect(data.success).toBe(true);
|
||
// 沒掛 CF API mock:若程式碼真的嘗試網呼叫且被 disableNetConnect 擋下,錯誤仍會被
|
||
// best-effort 吞掉(不影響上面的 200 斷言);若程式碼正確地在 authStoreWritable() 檢查
|
||
// 就提前短路,則根本不會嘗試呼叫——兩種情況這裡都驗不出差異,差異由 afterEach 的
|
||
// 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();
|
||
});
|
||
});
|
||
|
||
// ═══════════════ 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 的完整說明)。下面兩則證明:①這確實是原本會擋死的斷點 ②帶表頭後真的解掉。
|
||
//
|
||
// 🔴 刻意放在檔案最後:這兩則會真的寫入認證儲存(per-isolate overlay 是模組級全域變數,
|
||
// 不隨 test 重置,見 mockAuthStoreWrite 檔頭長註解),排在前面會污染後面測試的「乾淨」假設。
|
||
describe('arcrun-rag#99:全新實例(缺 CF_SECRETS_API_TOKEN)靠安裝表頭補完寫入路徑', () => {
|
||
it('沒帶安裝表頭 → 502 auth_store_not_writable,證明這是真斷點(不是想像出來的假設)', 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(
|
||
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' }),
|
||
}),
|
||
fakeEnv,
|
||
{} as ExecutionContext,
|
||
);
|
||
expect(res.status).toBe(502);
|
||
const data = (await res.json()) as { code: string };
|
||
expect(data.code).toBe('auth_store_not_writable');
|
||
});
|
||
|
||
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();
|
||
|
||
const fakeEnv = { ...env, CF_SECRETS_API_TOKEN: undefined } as unknown as Bindings;
|
||
const res = await portalRouter.fetch(
|
||
new Request('http://localhost/portal/admin/bootstrap', {
|
||
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' }),
|
||
}),
|
||
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'); // 明碼絕不落地
|
||
});
|
||
});
|