From 605204511fb9c4e17e4f93dca78289cb7db910e0 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Tue, 14 Jul 2026 15:32:42 +0800 Subject: [PATCH] =?UTF-8?q?portal-auth=20P4=EF=BC=9Aadmin=20=E6=B8=AC?= =?UTF-8?q?=E8=A9=A6=2014=20=E9=A0=85=EF=BC=88=E9=8E=96=E6=AD=BB=E4=BF=9D?= =?UTF-8?q?=E8=AD=B7/=E4=B8=80=E6=AC=A1=E6=80=A7=E5=AF=86=E7=A2=BC?= =?UTF-8?q?=E4=B8=8D=E8=90=BD=E5=BA=AB/=E5=BA=AB=E7=9B=AE=E9=8C=84/HTML=20?= =?UTF-8?q?=E7=B4=85=E7=B7=9A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit last-admin 409(含另一 admin 是 disabled 不算數、停用一般 user 不多打 list); generated_password/reset-password 一次性回傳且明碼不進 KBDB body;libraries ["*"] 與壞庫名 400;庫目錄子 namespace+graph_source;/portal HTML 殼加 admin 頁後仍零租戶字串/零 /kbdb//零 X-Arcrun-API-Key/零 Mira。 Co-Authored-By: Claude Fable 5 --- cypher-executor/tests/portal-admin.test.ts | 397 +++++++++++++++++++++ 1 file changed, 397 insertions(+) create mode 100644 cypher-executor/tests/portal-admin.test.ts diff --git a/cypher-executor/tests/portal-admin.test.ts b/cypher-executor/tests/portal-admin.test.ts new file mode 100644 index 0000000..37da814 --- /dev/null +++ b/cypher-executor/tests/portal-admin.test.ts @@ -0,0 +1,397 @@ +/** + * 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']) { + 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); + }); +}); + +// ═══════════════ 6. /portal HTML 殼(P4 admin 頁後紅線不回退)═══════════════ + +describe('GET /portal(P4 admin 頁 HTML 殼)', () => { + it('admin view 存在;仍零租戶字串、零 /kbdb/、零 X-Arcrun-API-Key、零 Mira', 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'); + }); +});