Files
Arcrun/cypher-executor/tests/portal-admin.test.ts
T
uncle6me-web eb9f2db513 feat(t122 🔴🔴): 萃取引擎雲端設定+隨連線下發——封測者終於萃得出東西
真兇(總管查證):daemon/config 寫死 extractor='claude' 且不下發金鑰 ⇒ 封測者 100% 萃取失敗
(leo:「地端沒有 AI 根本不能萃,那它就不能玩」)。
- POST/GET /portal/admin/extractor(admin 閘;GET 只回 has_key 不回明文)
- daemon/config 改讀設定:未設定→gemma 無金鑰;設定後→含 gemini_api_key
- portal 設定頁加「萃取引擎」區(Gemini 推薦+aistudio 連結,存後提示「小幫手點連上知識庫重連即可」)
測試 3 條新綠(vitest 17 passed;1 紅=console HTML 搬遷陳舊測試,非本案)。
(實作=子 CC;驗證+commit=總管)
2026-07-29 13:21:26 +08:00

485 lines
22 KiB
TypeScript
Raw 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 P4 測試(design §5/§6Gitea #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 子 namespacePATCH graph_source boolean。
* 6. /portal HTML 殼(P4 admin 頁):admin view 存在;**仍零租戶字串、零 /kbdb/、
* 零 X-Arcrun-API-Key**P3 紅線在新增 admin UI 後不得回退)。
*
* KBDB 打 fetchMock 假 hostwrangler.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<string, string> = {}) {
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<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']) {
fetchMock
.get(KBDB)
.intercept({ path: `/templates/${name}`, method: 'GET' })
.reply(200, { success: true, template: { id: `tpl-${name}`, name } });
}
}
function adminValues(overrides: Record<string, string> = {}): Record<string, string> {
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<string, string> = {}): Record<string, string> {
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<string, string>) {
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 adminrole=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<string, unknown> };
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<string, string> };
expect(rec.owner_id).toBe(NS);
expect(rec.values.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
});
it('自帶 password → 回應**無** generated_password', async () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
mockHeadLookup('own@example.com', null);
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<string, unknown>;
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<string, string> };
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<string, string> }).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_sourceboolean 進、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<string, string> }).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 打庫目錄 → 403role 閘)', 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 庫列表過濾 generalgeneral 是系統桶,不在用戶目錄顯示)', 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: ['kb', 'general', 'notes'] });
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');
});
});
// ═══════════════ 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 lookupfindUserRecordId 走這個路徑)*/
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<string, string> };
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<string, string> };
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 /portalP4 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');
// t97abootstrap 後不再預埋 kb 庫
expect(html).not.toContain('"name": "kb"');
expect(html).not.toContain("name: 'kb'");
// t114:無「登記到目錄」按鈕
expect(html).not.toContain('lib-adopt');
expect(html).not.toContain('登記到目錄');
});
});