Files
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

757 lines
39 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.
/**
* portal-auth P2 測試(design §2/§4/§5Gitea #24/#25
*
* 覆蓋(=tasks.md P2 測試項):
* 1. KDFpbkdf2-sha256$100000$… 格式(CF Workers runtime 上限 100k2026-07-14 真雲實撞)、
* 驗證對錯、壞格式誠實 false、600k 舊 hash 相容(迭代數從儲存值解析)
* 2. bootstrap 閘:無 console session → 401;建 admin 寫進 **KBDB**;已有 admin → 409
* 3. 登入對錯:成功發 token(回應**無租戶字串**)、密碼錯 401、停用 403、未知 email 401
* 4. 節流:5 次失敗 → 429KV TTL 計數)
* 5. session:每請求回讀 record;停用即拒(既有 session 立即失效)
* 6. 改密碼:驗舊密;新 hash 以 100k 格式落 slot
* 7. role 閘:非 admin 打 admin 端點 → 403admin 列表**剝除 password_hash**
*
* 2026-08-14 起(D61 補充,leo confirm「走C」,pending-changes.md「認證儲存要不要搬回
* D1/KV」):帳號的家從認證儲存(CF Workers SecretsD61)改回 **KBDB**binding,走 base
* HTTP API,不需要外部 CF token)。額外覆蓋:
* 8. 整台實例沒有任何認證資料 → 登入回「讀不到認證資料」(不是密碼錯),且不計入鎖定
* 9. 舊實例相容:帳號只存在認證儲存(D61 舊家)時仍登得進去,登入成功後自動搬進 KBDB,
* 且**這次登入發出的 session 就直接指向新 record_id**;搬遷失敗(KBDB 拒寫)不擋登入
*
* KBDB 打 fetchMock 假 hostwrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
* disableNetConnect——絕不外連。子 namespace 隔離的「搜 email 搜不到」由本機雙 worker
* 端到端 curl 驗證(PR 驗收證據表),這裡驗「寫入時 owner_id=leo::portal」的機械事實。
*/
import { SELF, env, fetchMock } from 'cloudflare:test';
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, isAuthStoreId, mutateAuthStore } 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';
// 測試用低迭代 hashverify 從儲存格式解析 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),
});
}
// ── KBDB mock helpers ──────────────────────────────────────────────────────
/**
* 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)
.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 })
.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
* routeconsoleAuthRouter/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>) {
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$hashCF 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 封頂 100k600k 在真雲 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 一律封頂
// 100k600k 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 必須留在檔案裡「第一個會種入認證儲存(舊家)的測試」之前——見
// seedLegacySecretsUser 檔頭註解:portal-auth-store.ts 的 per-isolate overlay 是模組級全域
// 變數,同一支測試檔案跑起來不會在測試之間重置,一旦有測試種過舊家資料,後面的測試都會看到
// 那筆資料,「乾淨無帳號」的前提就不成立了。
describe('整台實例沒有任何認證資料(arcrun-rag#55leo 2026-08-09 被誤鎖 15 分鐘的事故)', () => {
it('登入回「讀不到認證資料」而不是「密碼錯誤」,且不計入失敗鎖定', async () => {
// 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: { 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:寫進 KBDB,不需要任何外部 CF token', async () => {
await env.SESSIONS_KV.put('console_sess:owner-token', JSON.stringify({ created_at: Date.now() }));
mockTemplatesExist();
mockListByTemplate('portal_user', []); // 尚無 admin(KBDB 空,認證儲存舊家也空)
mockHeadLookup('admin@example.com', null); // email 未占用
const { recordBody } = mockCreateUser('rec_admin_new');
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(data.record_id).toBe('rec_admin_new'); // 住 KBDB(新 record_id 來自 KBDB 回應)
expect(data.email).toBe('admin@example.com'); // 存小寫(design §2.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 () => {
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' 這組 **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());
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.3Portal 絕不下發租戶字串
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
});
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);
// 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 };
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());
// P3session 多回 graph_allowedD-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 seedPORTAL_TEMPLATE_SEEDS 補 tripletensurePortalTemplates 冪等)', () => {
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');
});
});
// ═══════════════ 舊實例登入自癒(認證儲存舊家 → 搬進 KBDB 新家)═══════════════
//
// 🔴 放在檔案最後、用**專屬 email**(不與上面任何一則共用):portal-auth-store.ts 的
// per-isolate overlay 是模組級全域變數,seedLegacySecretsUser 種一次資料就會留在同一支測試
// 檔案的後續測試裡(見該函式檔頭的長註解)。這裡就是要驗證那次「留下」,所以刻意隔離在最後,
// 不會有更後面的測試共用這個 email 而被污染。
describe('舊實例登入自癒(帳號只在認證儲存舊家,登入成功後搬進 KBDB 新家)', () => {
const LEGACY_EMAIL = 'legacy-promote@example.com';
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; session_token: string };
expect(data.success).toBe(true);
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('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; session_token: string };
expect(data.success).toBe(true);
// 搬不動 → 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);
});
});
// ═══════════════ D62 arcrun-rag#662026-08-10)═══════════════
//
// ⚠️ 順序刻意:這兩個 describe 放在檔案最後,而且「D62」在前、「#66」在後。
// 原因=#66 那組會**故意把 per-isolate overlay 灌成一份沒有任何帳號的資料**(模擬傳播空窗),
// 而 overlay 是模組級全域變數、不隨 test 重置(見 seedLegacySecretsUser 檔頭長註解)。
// 任何需要「認證儲存裡有帳號」的測試都不能排在它後面。
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#992026-08-14):全新安裝從沒種過 CF_SECRETS_API_TOKEN ═══════════════
//
// 每一台裝好的新實例過去都會在 bootstrap 這裡卡死(leo 本人+封測者都撞到「裝得起來,
// 但卡在註冊」——`/console/auth-status` 永遠回 `writable:false`)。止血版(安裝精靈遞一把
// 臨時 OAuth token)只解掉「建第一個帳號」這一格;2026-08-14 confirm 走 C 之後,帳號改回
// 住 KBDBbinding),**從根拔掉整個問題**`x-arcrun-install-token` 這條路已經不存在了,
// 因為建帳號這件事本來就不再需要任何 CF API 憑證,無論裝機當下還是裝完之後都一樣。
//
// 下面這則是回歸守衛:**一台連 `CF_SECRETS_API_TOKEN``CF_ACCOUNT_ID` 都沒有的全新實例**
// 從 bootstrap 建第一個帳號、到之後用 /portal/admin/users 加第二個帳號,全程都要成功——
// 若哪天有人把帳號寫入路徑又改回去揹 CF Secrets 依賴,這裡會紅。
describe('arcrun-rag#99:全新實例(連 CF_SECRETS_API_TOKENCF_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', []); // 尚無 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-admin@example.com', password: 'bootstrap-pw-3' }),
}),
fakeEnv,
{} as ExecutionContext,
);
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'); // 明碼絕不落地
// 安裝精靈離場之後:管理員事後手動加第二個人(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 addUserRes = await portalRouter.fetch(
new Request('http://localhost/portal/admin/users', {
method: 'POST',
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(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);
});
});