Files
Arcrun/cypher-executor/tests/portal-admin.test.ts
T
uncle6me-web 4b6cc159b8 feat(auth): 認證儲存搬回 D1/KV——落實方案C(leo confirm「走C」,arcrun-rag#99)
實作 pending-changes.md「認證儲存要不要搬回 D1/KV」提案(commit 8286c8a):
D61 的病根「重裝時 binding 被安裝器照名字重新指到新建空資源」已被更通用的
shared/resource-rule(Arcrun#97,2026-08-13)解掉,故不再需要繞開 binding
去躲這個病——而繞開的代價正是這次要收的債:Workers Secrets 寫入需要外部
CF_SECRETS_API_TOKEN,這把 token 從安裝那天起就沒被種過,止血版只解掉
「建第一個帳號」這一格,之後的每一次寫入(換密碼/加帳號/改權限)仍卡死。

改動:
- console 管理員帳密:家改回 SESSIONS_KV(binding,console-auth.ts)
- portal 多人帳號:家改回 KBDB(binding,走 base HTTP API,D38 零 SQL,portal.ts)
- D61 認證儲存(CF Workers Secrets)留為舊實例的唯讀回退路徑:讀取零成本、
  零外部憑證需求(只有寫入才要 token);登入成功即 best-effort 自動搬進新家,
  且**這次登入發出的 session 就直接指向新 record_id**(不必等下一次登入)
- D61 的三項「明顯失敗」語意全部保留:auth_store_empty(讀不到不算密碼錯、
  不計入鎖定)、/console/setup 遇既有帳號說清楚密碼沒被採用、/health 與
  /console/auth-status 吐儲存狀態
- 移除止血版的 x-arcrun-install-token 表頭傳遞機制(installToken 參數)——
  帳號寫入從此不需要任何外部 CF token,這個結構性缺口已從根拔除

測試:cypher-executor 全套 vitest 439/453(14 個既存失敗與本改動無關,已用
git stash 對照 clean checkout 逐一比對檔名確認完全相同);tsc --noEmit
無新增錯誤(3 個既存錯誤同上核實無關)。已跑 build-worker-artifacts.mjs
重打 tier2 bundle,grep 複驗 createKbdbUserRecord/promoteToKbdb 進了成品、
promoteLegacyUser/x-arcrun-install-token 完全從成品消失。

未覆蓋:POST /credentials(一般 workflow API 金鑰儲存)仍依賴
CF_SECRETS_API_TOKEN——這是 01-tech-stack.md 既有的、獨立於 D61 之外的
credential 儲存架構(D19「擁有目錄不擁有內容物」),本提案範圍只涵蓋「認證」
(登入帳密),不涵蓋一般 credential 儲存;07-29 已知缺口仍待另案處理。

不准 merge 進 main(SDD 鐵律③,等總管審過再併);不准部署(D20 出貨閘)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:52:07 +08:00

752 lines
36 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 只在回應出現一次、明碼不落地
* (新家只有 pbkdf2 hash,D61 起帳號建立走認證儲存不再落 KBDB);自帶密碼 → 回應無 generated_password。
* 3. reset-password:回一次性新密碼;PATCH 落地的是 hash 非明碼(目標帳號沿用舊家 fixture,
* 仍走 KBDB PATCH——見下方 mockPatchPrelude 的說明)。
* 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 證據表)。
*
* 2026-08-14 起(D61 補充,leo confirm「走C」):帳號改回住 KBDBbinding,見
* pending-changes.md「認證儲存要不要搬回 D1/KV」)。本檔測試裡的帳號 fixture
* rec_admin/rec_u1/rec_admin2…)全部沿用「record_id 不是 auth: 開頭」這個既有慣例——
* 這正是 portal.ts 的相容分流點(isAuthStoreId(recordId)),非 auth: 開頭的 id 一律走
* KBDB 路徑,行為與 D61 之前完全一致,故本檔絕大多數測試不需要改。
* **只有「新建帳號」這個動作**POST /portal/admin/users、POST /portal/admin/bootstrap
* 走同一支 createPortalUser)改回直接寫 KBDBPOST /records + POST /entries head),
* 不再需要 `https://api.cloudflare.com/.../secrets`。
*/
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),
});
}
/** 建帳號的 KBDB 寫入路徑(POST /records + POST /entries head entry)。 */
function mockCreateUser(recordId: string): { recordBody: () => string } {
let recordBody = '';
fetchMock
.get(KBDB)
.intercept({ path: '/records', method: 'POST' })
.reply(200, (opts) => {
recordBody = String(opts.body);
return { success: true, record: { record_id: recordId, template_id: 'tpl_pu', values: {} } };
});
fetchMock
.get(KBDB)
.intercept({ path: '/entries', method: 'POST' })
.reply(200, { success: true, entry: { id: `${recordId}_head` } });
return { recordBody: () => recordBody };
}
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 未占用
const { recordBody } = mockCreateUser('rec_new');
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);
expect((data.user as { record_id: string }).record_id).toBe('rec_new'); // 住 KBDB
// 一次性密碼不落地: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);
mockCreateUser('rec_own');
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); // 真的換了
});
});
// ═══════════════ 3.5 recover-passwordarcrun-rag#25admin 忘記 portal 密碼自救)═══════════════
describe('POST /portal/admin/recover-password', () => {
it('無 console owner session → 401,不碰 KBDB', async () => {
const res = await json('POST', '/portal/admin/recover-password', { email: 'admin@example.com' });
expect(res.status).toBe(401);
});
it('有 console session 但 email 格式不對 → 400,不碰 KBDB', async () => {
await env.SESSIONS_KV.put('console_sess:owner-token', JSON.stringify({ created_at: Date.now() }));
const res = await json(
'POST',
'/portal/admin/recover-password',
{ email: 'not-an-email' },
{ Authorization: 'Bearer owner-token' },
);
expect(res.status).toBe(400);
});
it('查無此 email 的 portal 帳號 → 404,不誤導成別種錯誤', async () => {
await env.SESSIONS_KV.put('console_sess:owner-token', JSON.stringify({ created_at: Date.now() }));
mockHeadLookup('ghost@example.com', null);
const res = await json(
'POST',
'/portal/admin/recover-password',
{ email: 'ghost@example.com' },
{ Authorization: 'Bearer owner-token' },
);
expect(res.status).toBe(404);
});
it('console session 有效+帳號存在 → 回一次性新密碼;PATCH 落 KBDB 的是新 hash 非明碼;**不需要任何 portal session**', async () => {
await env.SESSIONS_KV.put('console_sess:owner-token', JSON.stringify({ created_at: Date.now() }));
// 刻意不 seedAdminSession():這條路唯一該吃的是 console session,機械證明繞得過
// 「忘記 portal 密碼 ⇒ 沒有 portal_sess ⇒ 打不進其他 admin 端點」這個死結。
mockHeadLookup('admin@example.com', 'rec_admin');
mockGetRecord('rec_admin', adminValues());
let patched = '';
fetchMock
.get(KBDB)
.intercept({ path: '/records/rec_admin', method: 'PATCH' })
.reply(200, (opts) => {
patched = String(opts.body);
return { success: true, record: { record_id: 'rec_admin', template_id: 'tpl_pu', values: adminValues() } };
});
const res = await json(
'POST',
'/portal/admin/recover-password',
{ email: 'Admin@Example.com' }, // 混寫大小寫,驗證正規化成小寫再查
{ Authorization: 'Bearer owner-token' },
);
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; email: string; password: string };
expect(data.success).toBe(true);
expect(data.email).toBe('admin@example.com');
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('t160:人工建庫端點已刪(leo「沒有登記這回事」)——POST → 404;庫只從 daemon 同步來', async () => {
// 舊測試驗「POST 建庫 200+重複 409」——t160 拔掉人工建庫(e744ad1)後規格為:
// 庫由 /portal/daemon/libraries(連線精靈自動登記,t159)產生,admin 只能 GET/PATCH。
const res = await json(
'POST',
'/portal/admin/libraries',
{ name: 'finance', display_name: '財務庫' },
{ Authorization: 'Bearer tok-admin' },
);
expect(res.status).toBe(404);
});
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. t176:雲端不再管地端 LLM 設定(取代原 t122/t131 兩組測試)═══════════════
//
// leo 2026-08-03 架構翻案:「地端要用什麼模型就在 daemon 上輸入 API Key 設置,
// 而不是雲端設置後控制地端」。原因是 extractor_config 的 KV key 由 portalTenant() 組出,
// 而 portalTenant 是 **worker 層級**環境變數 ⇒ **全租戶共用一把**:任一處設了 claude,
// 所有人的 daemon 都收到 claude,沒裝 Claude Code 的機器萃取全滅,
// 而 portal 的 Claude 勾選框又恆 disableddaemon 從未回報 has_claude)⇒ 用戶自己解不開。
//
// 以下是**回歸守衛**:這些端點/欄位若復活,代表又走回「雲端控制地端」的老路。
describe('t176:雲端不再下發/設定地端 LLM', () => {
const USER_EMAIL = 'daemon@example.com';
const USER_PW = 'unit-test-pw-1'; // 與 storedHash 配對(外層 beforeAll 計算)
const USER_RECORD = 'rec_daemon_user';
/** 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('POST /portal/daemon/config 只回連線欄位,**不含任何 LLM 欄位**', async () => {
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 d = (await res.json()) as { config: Record<string, unknown> };
// 連線欄位照舊(daemon 靠它上線)
expect(d.config.cypher_url).toBeTruthy();
expect(d.config.namespace).toBeTruthy();
expect(d.config.library).toBe('kb');
// LLM 欄位一律不下發(t176 核心)
expect(d.config).not.toHaveProperty('extractor');
expect(d.config).not.toHaveProperty('gemini_api_key');
expect(d.config).not.toHaveProperty('llm_model');
});
// 註:route 不存在 ⇒ 在認證之前就 404,因此不需要(也不能)預先掛 record mock
// 否則 afterEach 的 assertNoPendingInterceptors 會因「mock 沒被用到」而失敗。
it('POST /portal/admin/extractor 已移除(雲端不再有指定地端引擎的入口)', async () => {
const res = await json('POST', '/portal/admin/extractor', { engine: 'claude' }, { Authorization: 'Bearer tok-ex' });
expect(res.status).toBe(404);
});
it('POST /portal/daemon/report-capabilities 已移除(has_claude 回報鏈整條退役)', async () => {
const res = await json('POST', '/portal/daemon/report-capabilities', {
email: USER_EMAIL, password: USER_PW, has_claude: true,
});
expect(res.status).toBe(404);
});
});
// ═══════════════ 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 存檔鈕已移除
});
});
// t131/t122 測試已隨 main 的 t176(刪除雲端下發 LLM 設定)一併移除;
// 此處只保留 t181daemon 走 Workers AI)的守衛。
describe('POST /portal/daemon/extractt181Workers AI 萃卡,免金鑰)', () => {
// 認證=X-Arcrun-API-Key(=namespacewrangler.test.toml CONSOLE_TENANT=leo),
// **不是帳密**:daemon 密碼不落地(連線精靈用完即丟),背景萃取拿不到密碼。
const KEY = { 'X-Arcrun-API-Key': 'leo' };
it('沒帶 API Key → 401', async () => {
const res = await json('POST', '/portal/daemon/extract', { page_name: 'x', text: 'y' });
expect(res.status).toBe(401);
});
// 🔴 t189:這則原本是「API Key 錯 → 401(租戶隔離)」,**是錯的,而且害我看到假綠**。
//
// 它假設「daemon 的 api_key 實例的 CONSOLE_TENANT」,但實測不成立:
// geek6688tenant=ckxt8yr9、daemon api_key=yuga3bse ⇒ 真用戶**永遠 401**、萃不了
// youlin :兩者碰巧相同 ⇒ 我這邊測起來都對
// 舊測試只證明「符合我的假設」,不證明「假設是對的」——
// **把錯誤假設寫成測試,就是把假綠焊死。**
//
// 翻轉成守衛:**key 與 tenant 不同也要能萃**(這正是 leo 撞到的情境)。
// 若哪天有人又加回等值比對,這則會紅。
it('key 與實例 tenant 不同也要能用(t189:多帳號 daemon 的常態)', async () => {
const res = await json('POST', '/portal/daemon/extract',
{ page_name: 'x', text: 'y' }, { 'X-Arcrun-API-Key': 'another-tenant-key' });
expect(res.status).not.toBe(401);
});
it('缺 page_name 或 text → 400(不打 AI、不假裝成功)', async () => {
const res = await json('POST', '/portal/daemon/extract', {}, KEY);
expect(res.status).toBe(400);
const d = (await res.json()) as { error?: string };
expect(String(d.error)).toContain('page_name');
});
// 🔴 回歸守衛:這條路**不得**要求任何 Gemini/API 金鑰——免金鑰正是它存在的理由。
// 若哪天有人把它改回打 Google,錯誤訊息會出現 credential/gemini_api_key ⇒ 這則會紅。
it('錯誤訊息不得要求任何金鑰(免金鑰是本端點存在的理由)', async () => {
const res = await json('POST', '/portal/daemon/extract', {}, KEY);
const raw = await res.text();
expect(raw).not.toContain('gemini_api_key');
expect(raw).not.toContain('credential');
});
});