Files
Arcrun/cypher-executor/tests/portal-admin.test.ts
T
uncle6me-web d728071a6a t181:新增 POST /portal/daemon/extract——daemon 萃取走 Workers AI(免金鑰)
【leo 08-04 列為最優先】「daemon 的 AI 改用 workers AI」
——「這是我的用戶**最大障礙**,造成首輪測試用戶的**好評或惡評**」。

【舊路徑的三種災難(實測)】要用戶自備 Gemini API Key ⇒
① 完全不知道去哪設定(台大資工碩士都卡住 ⇒ leo:「一般人就完蛋了」)
② 金鑰所屬 Google 帳號被 flag → 403 PERMISSION_DENIED,換專案也無效、申訴有死結
③ 52 檔全滅,還要把金鑰傳給總管實打才查得出真因

【修法】新端點收「已在本機轉成純文字的原稿」,用 env.AI binding 萃卡回傳
⇒ **完全不需要任何金鑰**,用的是用戶自己 CF 帳號內建的 AI,
他的 Google 帳號被封也不受影響。認證沿用 daemon/config 那把(帳密)。

為什麼放雲端:daemon 端沒有 AI binding(binding 是 Worker 專屬),
且模型選型集中在雲端才能統一換。
⚠️ 隱私邊界不變:送上來的是已轉文字的原稿、回傳知識卡,
原始檔案(docx/pdf)仍不出用戶電腦。

提示詞與 daemon 端 gemmaPrompt 同一份契約(第一行必須是「# <頁名>」),
兩邊要一起改。缺 AI binding 時回 501 並指名缺什麼(禁假綠)。

測試 4 則全過(含**回歸守衛:錯誤訊息不得出現 gemini_api_key/credential**
——若有人把它改回打 Google 會立刻紅);全檔 38 passed,
唯一失敗是基準線既有的 GET /portal 靜態資源案,與本次無關。
tsc 對 portal.ts 零錯誤。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 14:10:00 +08:00

868 lines
41 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', 'triplet']) {
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', []);
// t142GET /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 + statst142', () => {
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/librariest135', () => {
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 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('登記到目錄');
// 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-capabilitiest131', () => {
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<string, string> {
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_configextractor_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<string, string> }> }).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=trueextractor 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=trueclaude_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 — 有 claudedaemon_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);
});
});
// ═══════════════ t181:daemon 萃取走 Workers AI(免金鑰)═══════════════
//
// leo 08-04 列為最優先:「daemon 的 AI 改用 workers AI」——
// 「這是我的用戶最大障礙,造成首輪測試用戶的好評或惡評」。
// 舊路徑要用戶自備 Gemini key,實測撞到「不知道去哪設定」「Google 帳號被 flag 403」
// 「52 檔全滅還要把金鑰傳給別人才查得出原因」三種災難。
describe('POST /portal/daemon/extractt181Workers AI 萃卡,免金鑰)', () => {
const USER_EMAIL = 'daemon@example.com';
const USER_PW = 'unit-test-pw-1';
const USER_RECORD = 'rec_daemon_extract';
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('缺 page_name 或 text → 400(不打 AI、不假裝成功)', async () => {
const res = await json('POST', '/portal/daemon/extract', { email: USER_EMAIL, password: USER_PW });
expect(res.status).toBe(400);
const d = (await res.json()) as { error?: string };
expect(String(d.error)).toContain('page_name');
});
it('缺帳密 → 400', async () => {
const res = await json('POST', '/portal/daemon/extract', { page_name: 'x', text: 'y' });
expect(res.status).toBe(400);
});
it('帳密錯 → 401(認證與 daemon/config 同一把)', async () => {
mockEmailLookup(USER_EMAIL, USER_RECORD);
mockGetRecord(USER_RECORD, adminValues({ email: USER_EMAIL, password_hash: storedHash }));
const res = await json('POST', '/portal/daemon/extract', {
email: USER_EMAIL, password: 'wrong-password', page_name: 'x', text: 'y',
});
expect(res.status).toBe(401);
});
// 🔴 回歸守衛:這條路**不得**要求任何 GeminiAPI 金鑰。
// 若哪天有人把它改回打 Google,這則會因為錯誤訊息提到 credential/gemini 而紅。
it('錯誤訊息不得要求任何金鑰(免金鑰是本端點存在的理由)', async () => {
const res = await json('POST', '/portal/daemon/extract', { email: USER_EMAIL, password: USER_PW });
const raw = await res.text();
expect(raw).not.toContain('gemini_api_key');
expect(raw).not.toContain('credential');
});
});