/** * portal-auth P4 測試(design §5/§6,Gitea #24/#25) * * 覆蓋(=tasks.md P4+總管派工驗收重點): * 1. **最後一個 active admin 鎖死保護**:停用 → 409;降級 role=user → 409; * 「還有另一個 active admin」才放行;另一個 admin 是 disabled 不算數。 * 2. 一次性密碼:新增未帶密碼 → generated_password 只在回應出現一次、明碼不落 KBDB * (庫裡只有 pbkdf2 hash);自帶密碼 → 回應無 generated_password。 * 3. reset-password:回一次性新密碼;PATCH 進 KBDB 的是 hash 非明碼。 * 4. 庫權限:PATCH libraries=["*"](全庫)合法;空陣列/壞庫名 → 400。 * 5. 庫目錄:POST 建庫寫 {tenant}::portal 子 namespace;PATCH graph_source boolean。 * 6. /portal HTML 殼(P4 admin 頁):admin view 存在;**仍零租戶字串、零 /kbdb/、 * 零 X-Arcrun-API-Key**(P3 紅線在新增 admin UI 後不得回退)。 * * KBDB 打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+ * disableNetConnect——絕不外連。UI 全流程由本機隔離雙 worker 端到端 curl 驗證(PR 證據表)。 */ import { SELF, env, fetchMock } from 'cloudflare:test'; import { beforeAll, afterEach, describe, it, expect } from 'vitest'; import { hashPassword, PBKDF2_ITERATIONS } from '../src/lib/portal-auth'; const KBDB = 'https://kbdb.test'; const NS = 'leo::portal'; // wrangler.test.toml CONSOLE_TENANT=leo → 子 namespace let storedHash: string; beforeAll(async () => { fetchMock.activate(); fetchMock.disableNetConnect(); storedHash = await hashPassword('unit-test-pw-1', 10_000); }); afterEach(() => fetchMock.assertNoPendingInterceptors()); function json(method: string, path: string, body?: unknown, headers: Record = {}) { return SELF.fetch(`http://localhost${path}`, { method, headers: { 'Content-Type': 'application/json', ...headers }, body: body === undefined ? undefined : JSON.stringify(body), }); } 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 }); } function mockGetRecord(recordId: string, values: Record) { 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 }[]) { 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 adminValues(overrides: Record = {}): Record { return { email: 'admin@example.com', display_name: '管理員', status: 'active', role: 'admin', password_hash: storedHash, libraries: '["*"]', created_at: '2026-07-14T00:00:00.000Z', updated_at: '2026-07-14T00:00:00.000Z', ...overrides, }; } function userValues(overrides: Record = {}): Record { return adminValues({ email: 'user@example.com', display_name: '同仁', role: 'user', libraries: '["general"]', ...overrides }); } async function seedAdminSession(token = 'tok-admin', recordId = 'rec_admin') { await env.SESSIONS_KV.put(`portal_sess:${token}`, JSON.stringify({ record_id: recordId })); } /** PATCH /portal/admin/users/:id 的共通 mock 前奏:admin session 回讀+目標 record 成員驗證。 */ function mockPatchPrelude(targetId: string, targetValues: Record) { mockGetRecord('rec_admin', adminValues()); // requirePortalAdmin 回讀 mockGetRecord(targetId, targetValues); // assertPortalUserRecord 回讀目標 mockHeadLookup(targetValues.email, targetId); // head 指回同 record → 成員資格成立 } // ═══════════════ 1. 最後一個 active admin 鎖死保護 ═══════════════ describe('last-admin 鎖死保護(PATCH /portal/admin/users/:id)', () => { it('停用最後一個 active admin → 409,不發 PATCH', async () => { await seedAdminSession(); mockPatchPrelude('rec_admin2', adminValues({ email: 'admin2@example.com' })); // guard 查全列表:只有目標自己是 active admin(另一人是一般 user) mockListByTemplate('portal_user', [ { record_id: 'rec_admin2', values: adminValues({ email: 'admin2@example.com' }) }, { record_id: 'rec_u1', values: userValues() }, ]); const res = await json('PATCH', '/portal/admin/users/rec_admin2', { status: 'disabled' }, { Authorization: 'Bearer tok-admin' }); expect(res.status).toBe(409); const data = (await res.json()) as { error: string }; expect(data.error).toContain('最後一個管理員'); }); it('降級最後一個 active admin(role=user)→ 409', async () => { await seedAdminSession(); mockPatchPrelude('rec_admin2', adminValues({ email: 'admin2@example.com' })); mockListByTemplate('portal_user', [ { record_id: 'rec_admin2', values: adminValues({ email: 'admin2@example.com' }) }, ]); const res = await json('PATCH', '/portal/admin/users/rec_admin2', { role: 'user' }, { Authorization: 'Bearer tok-admin' }); expect(res.status).toBe(409); }); it('「另一個 admin 是 disabled」不算數 → 仍 409', async () => { await seedAdminSession(); mockPatchPrelude('rec_admin2', adminValues({ email: 'admin2@example.com' })); mockListByTemplate('portal_user', [ { record_id: 'rec_admin2', values: adminValues({ email: 'admin2@example.com' }) }, { record_id: 'rec_admin3', values: adminValues({ email: 'admin3@example.com', status: 'disabled' }) }, ]); const res = await json('PATCH', '/portal/admin/users/rec_admin2', { status: 'disabled' }, { Authorization: 'Bearer tok-admin' }); expect(res.status).toBe(409); }); it('還有另一個 active admin → 停用放行(200)', async () => { await seedAdminSession(); mockPatchPrelude('rec_admin2', adminValues({ email: 'admin2@example.com' })); mockListByTemplate('portal_user', [ { record_id: 'rec_admin2', values: adminValues({ email: 'admin2@example.com' }) }, { record_id: 'rec_admin', values: adminValues() }, // 操作者自己也是 active admin ]); fetchMock .get(KBDB) .intercept({ path: '/records/rec_admin2', method: 'PATCH' }) .reply(200, { success: true, record: { record_id: 'rec_admin2', template_id: 'tpl_pu', values: adminValues({ email: 'admin2@example.com', status: 'disabled' }) }, }); const res = await json('PATCH', '/portal/admin/users/rec_admin2', { status: 'disabled' }, { Authorization: 'Bearer tok-admin' }); expect(res.status).toBe(200); }); it('停用一般 user 不觸發 admin 列表檢查(無 by-template mock 也過=機械證明沒多打)', async () => { await seedAdminSession(); mockPatchPrelude('rec_u1', userValues()); fetchMock .get(KBDB) .intercept({ path: '/records/rec_u1', method: 'PATCH' }) .reply(200, { success: true, record: { record_id: 'rec_u1', template_id: 'tpl_pu', values: userValues({ status: 'disabled' }) }, }); const res = await json('PATCH', '/portal/admin/users/rec_u1', { status: 'disabled' }, { Authorization: 'Bearer tok-admin' }); expect(res.status).toBe(200); // afterEach assertNoPendingInterceptors = 沒有殘留 list mock }); }); // ═══════════════ 2. 一次性密碼(新增帳號)═══════════════ describe('POST /portal/admin/users(一次性密碼)', () => { it('未帶 password → generated_password 回一次(16 碼);KBDB 落的是 hash 非明碼', async () => { await seedAdminSession(); mockGetRecord('rec_admin', adminValues()); mockHeadLookup('new@example.com', null); // email 未占用 let recordBody = ''; fetchMock .get(KBDB) .intercept({ path: '/records', method: 'POST' }) .reply(200, (opts) => { recordBody = String(opts.body); return { success: true, record: { record_id: 'rec_new', template_id: 'tpl_pu', values: {} } }; }); fetchMock .get(KBDB) .intercept({ path: '/entries', method: 'POST' }) .reply(200, { success: true, entry: { id: 'e_head' } }); mockGetRecord('rec_new', userValues({ email: 'new@example.com' })); // 回應用的回讀 const res = await json( 'POST', '/portal/admin/users', { email: 'new@example.com', display_name: '新同仁', libraries: ['general'] }, { Authorization: 'Bearer tok-admin' }, ); expect(res.status).toBe(200); const data = (await res.json()) as { generated_password?: string; user: Record }; expect(typeof data.generated_password).toBe('string'); expect(data.generated_password!.length).toBe(16); expect('password_hash' in data.user).toBe(false); // 一次性密碼不落庫:KBDB 收到的 record body 只有 hash、無明碼 expect(recordBody).not.toContain(data.generated_password!); const rec = JSON.parse(recordBody) as { owner_id: string; values: Record }; 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); fetchMock .get(KBDB) .intercept({ path: '/records', method: 'POST' }) .reply(200, { success: true, record: { record_id: 'rec_own', template_id: 'tpl_pu', values: {} } }); fetchMock .get(KBDB) .intercept({ path: '/entries', method: 'POST' }) .reply(200, { success: true, entry: { id: 'e_head2' } }); mockGetRecord('rec_own', userValues({ email: 'own@example.com' })); const res = await json( 'POST', '/portal/admin/users', { email: 'own@example.com', password: 'self-chosen-pw-1' }, { Authorization: 'Bearer tok-admin' }, ); expect(res.status).toBe(200); const data = (await res.json()) as Record; expect('generated_password' in data).toBe(false); }); }); // ═══════════════ 3. reset-password ═══════════════ describe('POST /portal/admin/users/:id/reset-password', () => { it('回一次性新密碼;PATCH 落 KBDB 的是新 hash 非明碼', async () => { await seedAdminSession(); mockPatchPrelude('rec_u1', userValues()); let patched = ''; fetchMock .get(KBDB) .intercept({ path: '/records/rec_u1', method: 'PATCH' }) .reply(200, (opts) => { patched = String(opts.body); return { success: true, record: { record_id: 'rec_u1', template_id: 'tpl_pu', values: userValues() } }; }); const res = await json('POST', '/portal/admin/users/rec_u1/reset-password', undefined, { Authorization: 'Bearer tok-admin' }); expect(res.status).toBe(200); const data = (await res.json()) as { password: string }; expect(typeof data.password).toBe('string'); expect(data.password.length).toBe(16); expect(patched).not.toContain(data.password); // 明碼不落 KBDB const sent = JSON.parse(patched) as { values: Record }; expect(sent.values.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true); expect(sent.values.password_hash).not.toBe(storedHash); // 真的換了 }); }); // ═══════════════ 4. 庫權限勾選(libraries PATCH)═══════════════ describe('PATCH libraries(每帳號可查庫)', () => { it('["*"](全庫選項)合法,存成 JSON 字串', async () => { await seedAdminSession(); mockPatchPrelude('rec_u1', userValues()); let patched = ''; fetchMock .get(KBDB) .intercept({ path: '/records/rec_u1', method: 'PATCH' }) .reply(200, (opts) => { patched = String(opts.body); return { success: true, record: { record_id: 'rec_u1', template_id: 'tpl_pu', values: userValues({ libraries: '["*"]' }) } }; }); const res = await json('PATCH', '/portal/admin/users/rec_u1', { libraries: ['*'] }, { Authorization: 'Bearer tok-admin' }); expect(res.status).toBe(200); expect((JSON.parse(patched) as { values: Record }).values.libraries).toBe('["*"]'); }); it('空陣列 / 壞庫名(含逗號)→ 400 不 PATCH', async () => { await seedAdminSession(); mockPatchPrelude('rec_u1', userValues()); const res = await json('PATCH', '/portal/admin/users/rec_u1', { libraries: [] }, { Authorization: 'Bearer tok-admin' }); expect(res.status).toBe(400); await seedAdminSession('tok-admin2'); mockGetRecord('rec_admin', adminValues()); mockGetRecord('rec_u1', userValues()); mockHeadLookup('user@example.com', 'rec_u1'); const res2 = await json('PATCH', '/portal/admin/users/rec_u1', { libraries: ['a,b'] }, { Authorization: 'Bearer tok-admin2' }); expect(res2.status).toBe(400); }); }); // ═══════════════ 5. 庫目錄管理 ═══════════════ describe('/portal/admin/libraries', () => { it('POST 建庫:寫 {tenant}::portal 子 namespace;重複登記 → 409', async () => { await seedAdminSession(); mockGetRecord('rec_admin', adminValues()); mockTemplatesExist(); mockListByTemplate('portal_library', []); let recordBody = ''; fetchMock .get(KBDB) .intercept({ path: '/records', method: 'POST' }) .reply(200, (opts) => { recordBody = String(opts.body); return { success: true, record: { record_id: 'rec_lib1', template_id: 'tpl_pl', values: { name: 'finance', display_name: '財務庫', status: 'active' } }, }; }); const res = await json( 'POST', '/portal/admin/libraries', { name: 'finance', display_name: '財務庫' }, { Authorization: 'Bearer tok-admin' }, ); expect(res.status).toBe(200); const rec = JSON.parse(recordBody) as { owner_id: string; template: string }; expect(rec.owner_id).toBe(NS); expect(rec.template).toBe('portal_library'); // 重複登記 await seedAdminSession('tok-admin3'); mockGetRecord('rec_admin', adminValues()); mockTemplatesExist(); mockListByTemplate('portal_library', [ { record_id: 'rec_lib1', values: { name: 'finance', display_name: '財務庫', status: 'active' } }, ]); const dup = await json('POST', '/portal/admin/libraries', { name: 'finance' }, { Authorization: 'Bearer tok-admin3' }); expect(dup.status).toBe(409); }); it('PATCH graph_source:boolean 進、slot 存字串;非 boolean → 400', async () => { await seedAdminSession(); mockGetRecord('rec_admin', adminValues()); mockListByTemplate('portal_library', [ { record_id: 'rec_lib1', values: { name: 'finance', display_name: '財務庫', status: 'active' } }, ]); let patched = ''; fetchMock .get(KBDB) .intercept({ path: '/records/rec_lib1', method: 'PATCH' }) .reply(200, (opts) => { patched = String(opts.body); return { success: true, record: { record_id: 'rec_lib1', template_id: 'tpl_pl', values: { name: 'finance', status: 'active', graph_source: 'true' } }, }; }); const res = await json('PATCH', '/portal/admin/libraries/rec_lib1', { graph_source: true }, { Authorization: 'Bearer tok-admin' }); expect(res.status).toBe(200); expect((JSON.parse(patched) as { values: Record }).values.graph_source).toBe('true'); const data = (await res.json()) as { library: { graph_source: boolean } }; expect(data.library.graph_source).toBe(true); await seedAdminSession('tok-admin4'); mockGetRecord('rec_admin', adminValues()); mockListByTemplate('portal_library', [ { record_id: 'rec_lib1', values: { name: 'finance', status: 'active' } }, ]); const bad = await json('PATCH', '/portal/admin/libraries/rec_lib1', { graph_source: 'yes' }, { Authorization: 'Bearer tok-admin4' }); expect(bad.status).toBe(400); }); it('一般 user 打庫目錄 → 403(role 閘)', async () => { await seedAdminSession('tok-user', 'rec_u1'); mockGetRecord('rec_u1', userValues()); const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-user' }); expect(res.status).toBe(403); }); it('GET auto 庫列表過濾 general(general 是系統桶,不在用戶目錄顯示)', async () => { await seedAdminSession(); mockGetRecord('rec_admin', adminValues()); mockListByTemplate('portal_library', []); // t142:GET /portal/admin/libraries 現在並行呼叫三個 kbdb 端點,三個都要 mock fetchMock .get(KBDB) .intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' }) .reply(200, { libraries: ['kb', 'general', 'notes'] }); fetchMock .get(KBDB) .intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' }) .reply(200, { success: true, stats: [] }); fetchMock .get(KBDB) .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' }) .reply(200, { success: true, stats: [] }); const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' }); expect(res.status).toBe(200); const data = (await res.json()) as { libraries: { name: string; auto?: boolean }[] }; const names = data.libraries.map((l) => l.name); expect(names).toContain('kb'); expect(names).toContain('notes'); expect(names).not.toContain('general'); }); }); // ═══════════════ t142 庫目錄卡數+三元組數 ═══════════════ describe('GET /portal/admin/libraries + stats(t142)', () => { it('kbdb 回傳統計 → 已登記庫帶 card_count + triplet_count', async () => { await seedAdminSession(); mockGetRecord('rec_admin', adminValues()); mockListByTemplate('portal_library', [ { record_id: 'rec_lib_kb', values: { name: 'kb', display_name: '知識庫', status: 'active', graph_source: 'false' } }, ]); fetchMock .get(KBDB) .intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' }) .reply(200, { libraries: ['kb'] }); fetchMock .get(KBDB) .intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' }) .reply(200, { success: true, stats: [{ library: 'kb', card_count: 42 }] }); fetchMock .get(KBDB) .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' }) .reply(200, { success: true, stats: [{ library: 'kb', triplet_count: 111 }] }); const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' }); expect(res.status).toBe(200); const data = (await res.json()) as { libraries: { name: string; card_count?: number; triplet_count?: number }[] }; const kb = data.libraries.find((l) => l.name === 'kb'); expect(kb).toBeDefined(); expect(kb!.card_count).toBe(42); expect(kb!.triplet_count).toBe(111); }); it('auto 庫也帶 card_count + triplet_count', async () => { await seedAdminSession(); mockGetRecord('rec_admin', adminValues()); mockListByTemplate('portal_library', []); fetchMock .get(KBDB) .intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' }) .reply(200, { libraries: ['notes'] }); fetchMock .get(KBDB) .intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' }) .reply(200, { success: true, stats: [{ library: 'notes', card_count: 7 }] }); fetchMock .get(KBDB) .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' }) .reply(200, { success: true, stats: [{ library: 'notes', triplet_count: 108 }] }); const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' }); expect(res.status).toBe(200); const data = (await res.json()) as { libraries: { name: string; card_count?: number; triplet_count?: number; auto?: boolean }[] }; const notes = data.libraries.find((l) => l.name === 'notes'); expect(notes).toBeDefined(); expect(notes!.auto).toBe(true); expect(notes!.card_count).toBe(7); expect(notes!.triplet_count).toBe(108); }); it('庫無內容時 card_count=0 + triplet_count=0(前端顯示「還沒有內容」)', async () => { await seedAdminSession(); mockGetRecord('rec_admin', adminValues()); mockListByTemplate('portal_library', [ { record_id: 'rec_lib_empty', values: { name: 'empty', display_name: '空庫', status: 'active', graph_source: 'false' } }, ]); fetchMock .get(KBDB) .intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' }) .reply(200, { libraries: [] }); fetchMock .get(KBDB) .intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' }) .reply(200, { success: true, stats: [] }); fetchMock .get(KBDB) .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' }) .reply(200, { success: true, stats: [] }); const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' }); expect(res.status).toBe(200); const data = (await res.json()) as { libraries: { name: string; card_count: number; triplet_count: number }[] }; const empty = data.libraries.find((l) => l.name === 'empty'); expect(empty).toBeDefined(); expect(empty!.card_count).toBe(0); expect(empty!.triplet_count).toBe(0); }); }); // ═══════════════ t135 庫目錄移除 ═══════════════ describe('DELETE /portal/admin/libraries(t135)', () => { it('DELETE /:id — 成功移除已登記庫;KBDB /records/:id DELETE 被呼叫', async () => { await seedAdminSession(); mockGetRecord('rec_admin', adminValues()); // 成員驗證:list by template 回有該 record mockListByTemplate('portal_library', [ { record_id: 'rec_lib1', values: { name: 'finance', display_name: '財務庫', status: 'active' } }, ]); let deleteCalled = false; fetchMock .get(KBDB) .intercept({ path: '/records/rec_lib1', method: 'DELETE' }) .reply(200, () => { deleteCalled = true; return { success: true }; }); const res = await json('DELETE', '/portal/admin/libraries/rec_lib1', undefined, { Authorization: 'Bearer tok-admin' }); expect(res.status).toBe(200); const data = (await res.json()) as { success: boolean; name: string; message: string }; expect(data.success).toBe(true); expect(data.name).toBe('finance'); expect(deleteCalled).toBe(true); }); it('DELETE /:id — 庫不在目錄 → 404', async () => { await seedAdminSession(); mockGetRecord('rec_admin', adminValues()); mockListByTemplate('portal_library', []); // 空目錄 const res = await json('DELETE', '/portal/admin/libraries/rec_lib_x', undefined, { Authorization: 'Bearer tok-admin' }); expect(res.status).toBe(404); }); it('DELETE /:id — 非 admin → 403', async () => { await seedAdminSession('tok-user', 'rec_u1'); mockGetRecord('rec_u1', userValues()); const res = await json('DELETE', '/portal/admin/libraries/rec_lib1', undefined, { Authorization: 'Bearer tok-user' }); expect(res.status).toBe(403); }); it('DELETE /by-name/:name — confirm 符合 → 呼叫 KBDB deprecate-by-library', async () => { await seedAdminSession(); mockGetRecord('rec_admin', adminValues()); let deprecateCalled = false; fetchMock .get(KBDB) .intercept({ path: '/entries/deprecate-by-library', method: 'PATCH' }) .reply(200, () => { deprecateCalled = true; return { success: true, deprecated_count: 12 }; }); const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', { confirm: 'kb' }, { Authorization: 'Bearer tok-admin' }); expect(res.status).toBe(200); const data = (await res.json()) as { success: boolean; deprecated_count: number }; expect(data.success).toBe(true); expect(data.deprecated_count).toBe(12); expect(deprecateCalled).toBe(true); }); it('DELETE /by-name/:name — 無 confirm → 400', async () => { await seedAdminSession(); mockGetRecord('rec_admin', adminValues()); const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', {}, { Authorization: 'Bearer tok-admin' }); expect(res.status).toBe(400); }); it('DELETE /by-name/:name — confirm 不符 → 400', async () => { await seedAdminSession(); mockGetRecord('rec_admin', adminValues()); const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', { confirm: 'wrong' }, { Authorization: 'Bearer tok-admin' }); expect(res.status).toBe(400); }); it('DELETE /by-name/:name — 非 admin → 403', async () => { await seedAdminSession('tok-user', 'rec_u1'); mockGetRecord('rec_u1', userValues()); const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', { confirm: 'kb' }, { Authorization: 'Bearer tok-user' }); expect(res.status).toBe(403); }); }); // ═══════════════ 6. t122 萃取引擎金鑰雲端下發 ═══════════════ describe('/portal/admin/extractor + /portal/daemon/config 萃取引擎(t122)', () => { const USER_EMAIL = 'daemon@example.com'; const USER_PW = 'unit-test-pw-1'; // 與 storedHash 配對(beforeAll 計算) const USER_RECORD = 'rec_daemon_user'; const EXTRACTOR_KV_KEY = 'leo:portal:extractor_config'; // wrangler.test.toml CONSOLE_TENANT=leo /** mock email head lookup(findUserRecordId 走這個路徑)*/ function mockEmailLookup(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 }); } it('未設定 → daemon/config 下發 extractor=gemma,無 gemini_api_key', async () => { // 確保 KV 沒有 extractor config await env.WEBHOOKS.delete(EXTRACTOR_KV_KEY); mockEmailLookup(USER_EMAIL, USER_RECORD); mockGetRecord(USER_RECORD, adminValues({ email: USER_EMAIL, password_hash: storedHash })); const res = await json('POST', '/portal/daemon/config', { email: USER_EMAIL, password: USER_PW }); expect(res.status).toBe(200); const data = (await res.json()) as { success: boolean; config: Record }; expect(data.success).toBe(true); expect(data.config.extractor).toBe('gemma'); expect('gemini_api_key' in data.config).toBe(false); }); it('設定 gemma+金鑰後 → daemon/config 下發含 gemini_api_key', async () => { await env.WEBHOOKS.put(EXTRACTOR_KV_KEY, JSON.stringify({ engine: 'gemma', gemini_api_key: 'AIza-test-key-999' })); mockEmailLookup(USER_EMAIL, USER_RECORD); mockGetRecord(USER_RECORD, adminValues({ email: USER_EMAIL, password_hash: storedHash })); const res = await json('POST', '/portal/daemon/config', { email: USER_EMAIL, password: USER_PW }); expect(res.status).toBe(200); const data = (await res.json()) as { success: boolean; config: Record }; expect(data.config.extractor).toBe('gemma'); expect(data.config.gemini_api_key).toBe('AIza-test-key-999'); // cleanup await env.WEBHOOKS.delete(EXTRACTOR_KV_KEY); }); it('GET /portal/admin/extractor → has_key=true,回應不含金鑰明文', async () => { await env.WEBHOOKS.put(EXTRACTOR_KV_KEY, JSON.stringify({ engine: 'gemma', gemini_api_key: 'AIza-secret-key' })); await seedAdminSession(); mockGetRecord('rec_admin', adminValues()); const res = await json('GET', '/portal/admin/extractor', undefined, { Authorization: 'Bearer tok-admin' }); expect(res.status).toBe(200); const data = (await res.json()) as { success: boolean; engine: string; has_key: boolean }; expect(data.engine).toBe('gemma'); expect(data.has_key).toBe(true); // 回應主體不含金鑰明文 const raw = JSON.stringify(data); expect(raw).not.toContain('AIza-secret-key'); expect(raw).not.toContain('gemini_api_key'); // cleanup await env.WEBHOOKS.delete(EXTRACTOR_KV_KEY); }); }); // ═══════════════ 7. /portal HTML 殼(P4 admin 頁後紅線不回退)═══════════════ describe('GET /portal(P4 admin 頁 HTML 殼)', () => { it('admin view 存在;仍零租戶字串、零 /kbdb/、零 X-Arcrun-API-Key、零 Mira;無 kb 種子、無登記到目錄', async () => { const res = await SELF.fetch('http://localhost/portal'); expect(res.status).toBe(200); const html = await res.text(); expect(html).toContain('v-admin'); // P4 管理頁 view expect(html).toContain('/portal/admin/users'); // 帳號管理走 admin API expect(html).toContain('/portal/admin/libraries'); // 庫目錄管理 // P3 紅線(design §3.3)在加了 admin UI 後不得回退 expect(html).not.toMatch(/['"]leo['"]/); expect(html).not.toContain('/kbdb/'); expect(html).not.toContain('X-Arcrun-API-Key'); expect(html).not.toContain('Mira'); // t97a:bootstrap 後不再預埋 kb 庫 expect(html).not.toContain('"name": "kb"'); expect(html).not.toContain("name: 'kb'"); // t114:無「登記到目錄」按鈕 expect(html).not.toContain('lib-adopt'); expect(html).not.toContain('登記到目錄'); // t131:合併 AI 設定(舊兩區塊已移除) expect(html).toContain('st-ai-panel'); expect(html).toContain('st-ai-key'); expect(html).toContain('st-ai-use-claude'); expect(html).not.toContain('st-extractor-panel'); expect(html).not.toContain('st-key-save'); // 舊 chat-key 存檔鈕已移除 }); }); // ═══════════════ 8. t131 合併 AI 設定 ═══════════════ describe('/portal/admin/ai + /portal/daemon/report-capabilities(t131)', () => { const USER_EMAIL = 'ai-test@example.com'; const USER_PW = 'unit-test-pw-1'; const USER_RECORD = 'rec_ai_user'; const AI_CONFIG_KEY = 'leo:portal:ai_config'; const EXTRACTOR_KV_KEY = 'leo:portal:extractor_config'; const DAEMON_CAPS_KEY = 'leo:portal:daemon_caps'; function aiAdminVals(): Record { return { email: USER_EMAIL, display_name: 'AI 測試 admin', status: 'active', role: 'admin', password_hash: storedHash }; } // 與全域 seedAdminSession 相同格式(JSON.stringify({record_id})),fetchMock 由各測試自行 mock async function seedAiSession(token = 'tok-ai-admin', recordId = USER_RECORD) { await env.SESSIONS_KV.put(`portal_sess:${token}`, JSON.stringify({ record_id: recordId })); } function mockAiRecord(recordId = USER_RECORD) { fetchMock.get(KBDB).intercept({ path: `/records/${recordId}`, method: 'GET' }).reply(200, { success: true, record: { record_id: recordId, template_id: 'tpl_pu', values: aiAdminVals() }, }); } function mockEmailLookup(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 }); } afterEach(async () => { await env.WEBHOOKS.delete(AI_CONFIG_KEY); await env.WEBHOOKS.delete(EXTRACTOR_KV_KEY); await env.WEBHOOKS.delete(DAEMON_CAPS_KEY); }); it('POST /ai — 首次設定:同時寫 ai_config+extractor_config+更新 rag_chat workflow', async () => { const ragChatKey = 'leo:wf:rag_chat'; const workflow = { graph: { nodes: [{ config: { 'x-goog-api-key': '{{credential.gemini}}' } }] }, config: {} }; await env.WEBHOOKS.put(ragChatKey, JSON.stringify(workflow)); await seedAiSession(); mockAiRecord(); const res = await json('POST', '/portal/admin/ai', { gemini_api_key: 'AIza-new-key-123', use_claude_for_extract: false }, { Authorization: 'Bearer tok-ai-admin' } ); expect(res.status).toBe(200); const data = (await res.json()) as { success: boolean; has_key: boolean; use_claude_for_extract: boolean }; expect(data.success).toBe(true); expect(data.has_key).toBe(true); expect(data.use_claude_for_extract).toBe(false); const stored = JSON.parse((await env.WEBHOOKS.get(AI_CONFIG_KEY, 'text')) ?? '{}'); expect(stored.gemini_api_key).toBe('AIza-new-key-123'); expect(stored.use_claude_for_extract).toBe(false); const exCfg = JSON.parse((await env.WEBHOOKS.get(EXTRACTOR_KV_KEY, 'text')) ?? '{}'); expect(exCfg.engine).toBe('gemma'); expect(exCfg.gemini_api_key).toBe('AIza-new-key-123'); const updated = JSON.parse((await env.WEBHOOKS.get(ragChatKey, 'text')) ?? '{}') as typeof workflow; expect((updated.graph as { nodes: Array<{ config: Record }> }).nodes[0].config['x-goog-api-key']).toBe('AIza-new-key-123'); await env.WEBHOOKS.delete(ragChatKey); }); it('POST /ai — rag_chat 不存在時不報錯(容忍,金鑰存 ai_config 即可)', async () => { await seedAiSession(); mockAiRecord(); const res = await json('POST', '/portal/admin/ai', { gemini_api_key: 'AIza-no-workflow-key' }, { Authorization: 'Bearer tok-ai-admin' } ); expect(res.status).toBe(200); const data = (await res.json()) as { success: boolean; has_key: boolean }; expect(data.success).toBe(true); expect(data.has_key).toBe(true); const stored = JSON.parse((await env.WEBHOOKS.get(AI_CONFIG_KEY, 'text')) ?? '{}'); expect(stored.gemini_api_key).toBe('AIza-no-workflow-key'); }); it('POST /ai — use_claude_for_extract=true:extractor engine=claude,不附 gemini_api_key', async () => { await seedAiSession(); mockAiRecord(); const res = await json('POST', '/portal/admin/ai', { gemini_api_key: 'AIza-key-888', use_claude_for_extract: true }, { Authorization: 'Bearer tok-ai-admin' } ); expect(res.status).toBe(200); const data = (await res.json()) as { success: boolean; use_claude_for_extract: boolean }; expect(data.use_claude_for_extract).toBe(true); const exCfg = JSON.parse((await env.WEBHOOKS.get(EXTRACTOR_KV_KEY, 'text')) ?? '{}'); expect(exCfg.engine).toBe('claude'); expect('gemini_api_key' in exCfg).toBe(false); }); it('GET /ai — 不回明文金鑰;has_key=true;claude_available 依 daemon_caps', async () => { await env.WEBHOOKS.put(AI_CONFIG_KEY, JSON.stringify({ gemini_api_key: 'AIza-secret-456', use_claude_for_extract: false })); await env.WEBHOOKS.put(DAEMON_CAPS_KEY, JSON.stringify({ has_claude: true })); await seedAiSession(); mockAiRecord(); const res = await json('GET', '/portal/admin/ai', undefined, { Authorization: 'Bearer tok-ai-admin' }); expect(res.status).toBe(200); const data = (await res.json()) as { success: boolean; has_key: boolean; use_claude_for_extract: boolean; claude_available: boolean }; expect(data.has_key).toBe(true); expect(data.use_claude_for_extract).toBe(false); expect(data.claude_available).toBe(true); const raw = JSON.stringify(data); expect(raw).not.toContain('AIza-secret-456'); expect(raw).not.toContain('gemini_api_key'); }); it('GET /ai — 沒有 daemon_caps → claude_available=false', async () => { await env.WEBHOOKS.put(AI_CONFIG_KEY, JSON.stringify({ gemini_api_key: 'AIza-key-777' })); await seedAiSession(); mockAiRecord(); const res = await json('GET', '/portal/admin/ai', undefined, { Authorization: 'Bearer tok-ai-admin' }); expect(res.status).toBe(200); const data = (await res.json()) as { claude_available: boolean }; expect(data.claude_available).toBe(false); }); it('POST /portal/daemon/report-capabilities — 有 claude:daemon_caps 寫入 has_claude=true', async () => { mockEmailLookup(USER_EMAIL, USER_RECORD); mockAiRecord(); const res = await json('POST', '/portal/daemon/report-capabilities', { email: USER_EMAIL, password: USER_PW, has_claude: true, daemon_version: '1.2.0', os: 'darwin', }); expect(res.status).toBe(200); const data = (await res.json()) as { success: boolean }; expect(data.success).toBe(true); const caps = JSON.parse((await env.WEBHOOKS.get(DAEMON_CAPS_KEY, 'text')) ?? '{}'); expect(caps.has_claude).toBe(true); expect(caps.daemon_version).toBe('1.2.0'); }); it('舊端點 /portal/admin/chat-key 仍可用(相容)', async () => { const ragChatKey = 'leo:wf:rag_chat'; const workflow = { graph: { nodes: [{ config: { 'x-goog-api-key': 'old' } }] }, config: {} }; await env.WEBHOOKS.put(ragChatKey, JSON.stringify(workflow)); await seedAiSession(); mockAiRecord(); const res = await json('POST', '/portal/admin/chat-key', { key: 'AIza-compat-key' }, { Authorization: 'Bearer tok-ai-admin' }); expect(res.status).toBe(200); const data = (await res.json()) as { success: boolean; replaced: number }; expect(data.success).toBe(true); expect(data.replaced).toBeGreaterThan(0); await env.WEBHOOKS.delete(ragChatKey); }); });