c4cee35adb
leo 2026-08-10 下令:「登入認證資料要分離⋯⋯就算只有我一個人存在單獨的 json 檔也好,
它不能被改資料庫的連結導致無法登入。」「昨天不能登入 portal,今天不能登入 mcp,
這根本就是一個問題。」
病:portal 帳號住 KBDB(owner_id = {CONSOLE_TENANT}::portal),console 管理員帳密住
SESSIONS_KV。兩者都靠 binding 指過去,重裝/遷移一定會被重新指一次 ⇒ 保險箱的鑰匙
放在保險箱裡。2026-08-09 leo 資料一個位元組都沒動,卻被鎖在門外。
修:認證搬到 CF Workers per-script Secrets(掛在 script 上,與 bindings 兩套資源,
重部不會洗掉;journeys/gemini-key-lost-on-reinstall.md 與 installer worker.js:1148 皆有實證)。
- 新增 lib/portal-auth-store.ts:自足的 JSON,讀取零網路呼叫,>4.6KB 自動溢位分片
- portal.ts 的帳號讀寫全部改走它;KBDB 只留為舊實例的回退讀路徑,登入成功順手搬過去
- console-auth.ts 的第二份認證資料同樣搬離 KV
- 不牴觸 D38:KBDB 三張核心表不增不減,本案是把東西搬出去
- 沿用 credentials.ts 既有的 putWorkerSecret/deleteWorkerSecret,不另造第二套寫入路徑(D36)
明顯失敗(把 #10「寧可明顯失敗,不要靜默錯置」套到門鎖上):
- 「這台實例讀不到任何登入資料」回 503 + code=auth_store_empty,且**不計入 5 次鎖定**
(08-09 leo 就是被系統自己的誤判鎖了 15 分鐘)
- /console/setup 遇既有帳號改說「你剛才輸入的密碼沒有被採用」,不再只說「已設定過」
- /health 與 /console/auth-status 吐 auth_store 狀態(住哪、寫不寫得進去)
stage 實測撞到並修掉的坑:改 secret 會產生 worker 新版本,既有 isolate 讀到的還是舊 env
⇒ 「建好帳號立刻登入」有 15 秒以上 401,還被算進鎖定。加一層短 TTL 的 KV 加速器
(非真相源,只在 secret 查不到/密碼對不上時問一次),換 KV 不影響不變量。
驗收:stage(youlin)把知識資料庫換成另一顆空的 + 換租戶代號 + SESSIONS_KV 換成空的,
三樣一起換之後 portal / MCP /authorize / console 三條登入路徑仍全綠(複跑 3 次)。
對照組(舊版程式碼同樣換庫):登入回「email 或密碼錯誤」,5 次後鎖 15 分鐘。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
751 lines
36 KiB
TypeScript
751 lines
36 KiB
TypeScript
/**
|
||
* 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 只在回應出現一次、明碼不落地
|
||
* (新家只有 pbkdf2 hash,D61 起帳號建立走認證儲存不再落 KBDB);自帶密碼 → 回應無 generated_password。
|
||
* 3. reset-password:回一次性新密碼;PATCH 落地的是 hash 非明碼(目標帳號沿用舊家 fixture,
|
||
* 仍走 KBDB PATCH——見下方 mockPatchPrelude 的說明)。
|
||
* 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 證據表)。
|
||
*
|
||
* D61(ADR D61 / Leo/arcrun-rag#55):本檔測試裡的帳號 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)改成寫進認證儲存(CF Workers
|
||
* Secrets),需要額外攔截 `https://api.cloudflare.com/.../secrets`(PUT)——見 mockAuthStoreWrite。
|
||
*/
|
||
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||
import { hashPassword, PBKDF2_ITERATIONS } from '../src/lib/portal-auth';
|
||
import { AUTH_ID_PREFIX } from '../src/lib/portal-auth-store';
|
||
|
||
const KBDB = 'https://kbdb.test';
|
||
const CF_API = 'https://api.cloudflare.com';
|
||
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),
|
||
});
|
||
}
|
||
|
||
/** D61:認證儲存寫入路徑(同 portal-auth.test.ts 的同名 helper,見那邊檔頭的完整說明)。 */
|
||
function mockAuthStoreWrite(times = 1): { puts: () => Array<{ name: string; text: string }> } {
|
||
const captured: Array<{ name: string; text: string }> = [];
|
||
fetchMock
|
||
.get(CF_API)
|
||
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
|
||
.reply(200, (opts) => {
|
||
const body = JSON.parse(String(opts.body)) as { name: string; text: string };
|
||
captured.push(body);
|
||
return { success: true };
|
||
})
|
||
.times(times);
|
||
return { puts: () => captured };
|
||
}
|
||
|
||
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 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 碼);認證儲存落的是 hash 非明碼(D61)', async () => {
|
||
await seedAdminSession();
|
||
mockGetRecord('rec_admin', adminValues());
|
||
mockHeadLookup('new@example.com', null); // email 未占用(新家找不到 → 回退查舊家)
|
||
const { puts } = mockAuthStoreWrite();
|
||
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.startsWith(AUTH_ID_PREFIX)).toBe(true); // 住新家
|
||
|
||
// 一次性密碼不落地:認證儲存收到的 shard 只有 hash、無明碼
|
||
const shards = puts();
|
||
expect(shards.length).toBe(1);
|
||
expect(shards[0].text).not.toContain(data.generated_password!);
|
||
const shard = JSON.parse(shards[0].text) as { users: Array<{ email: string; password_hash: string }> };
|
||
const stored = shard.users.find((u) => u.email === 'new@example.com');
|
||
expect(stored).toBeDefined();
|
||
expect(stored!.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);
|
||
mockAuthStoreWrite();
|
||
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-password(arcrun-rag#25:admin 忘記 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_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<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 打庫目錄 → 403(role 閘)', async () => {
|
||
await seedAdminSession('tok-user', 'rec_u1');
|
||
mockGetRecord('rec_u1', userValues());
|
||
const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-user' });
|
||
expect(res.status).toBe(403);
|
||
});
|
||
|
||
it('GET auto 庫列表過濾 general(general 是系統桶,不在用戶目錄顯示)', async () => {
|
||
await seedAdminSession();
|
||
mockGetRecord('rec_admin', adminValues());
|
||
mockListByTemplate('portal_library', []);
|
||
// t142:GET /portal/admin/libraries 現在並行呼叫三個 kbdb 端點,三個都要 mock
|
||
fetchMock
|
||
.get(KBDB)
|
||
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
|
||
.reply(200, { libraries: ['kb', 'general', 'notes'] });
|
||
fetchMock
|
||
.get(KBDB)
|
||
.intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' })
|
||
.reply(200, { success: true, stats: [] });
|
||
fetchMock
|
||
.get(KBDB)
|
||
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
|
||
.reply(200, { success: true, stats: [] });
|
||
const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' });
|
||
expect(res.status).toBe(200);
|
||
const data = (await res.json()) as { libraries: { name: string; auto?: boolean }[] };
|
||
const names = data.libraries.map((l) => l.name);
|
||
expect(names).toContain('kb');
|
||
expect(names).toContain('notes');
|
||
expect(names).not.toContain('general');
|
||
});
|
||
});
|
||
|
||
// ═══════════════ t142 庫目錄卡數+三元組數 ═══════════════
|
||
|
||
describe('GET /portal/admin/libraries + stats(t142)', () => {
|
||
it('kbdb 回傳統計 → 已登記庫帶 card_count + triplet_count', async () => {
|
||
await seedAdminSession();
|
||
mockGetRecord('rec_admin', adminValues());
|
||
mockListByTemplate('portal_library', [
|
||
{ record_id: 'rec_lib_kb', values: { name: 'kb', display_name: '知識庫', status: 'active', graph_source: 'false' } },
|
||
]);
|
||
fetchMock
|
||
.get(KBDB)
|
||
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
|
||
.reply(200, { libraries: ['kb'] });
|
||
fetchMock
|
||
.get(KBDB)
|
||
.intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' })
|
||
.reply(200, { success: true, stats: [{ library: 'kb', card_count: 42 }] });
|
||
fetchMock
|
||
.get(KBDB)
|
||
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
|
||
.reply(200, { success: true, stats: [{ library: 'kb', triplet_count: 111 }] });
|
||
const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' });
|
||
expect(res.status).toBe(200);
|
||
const data = (await res.json()) as { libraries: { name: string; card_count?: number; triplet_count?: number }[] };
|
||
const kb = data.libraries.find((l) => l.name === 'kb');
|
||
expect(kb).toBeDefined();
|
||
expect(kb!.card_count).toBe(42);
|
||
expect(kb!.triplet_count).toBe(111);
|
||
});
|
||
|
||
it('auto 庫也帶 card_count + triplet_count', async () => {
|
||
await seedAdminSession();
|
||
mockGetRecord('rec_admin', adminValues());
|
||
mockListByTemplate('portal_library', []);
|
||
fetchMock
|
||
.get(KBDB)
|
||
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
|
||
.reply(200, { libraries: ['notes'] });
|
||
fetchMock
|
||
.get(KBDB)
|
||
.intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' })
|
||
.reply(200, { success: true, stats: [{ library: 'notes', card_count: 7 }] });
|
||
fetchMock
|
||
.get(KBDB)
|
||
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
|
||
.reply(200, { success: true, stats: [{ library: 'notes', triplet_count: 108 }] });
|
||
const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' });
|
||
expect(res.status).toBe(200);
|
||
const data = (await res.json()) as { libraries: { name: string; card_count?: number; triplet_count?: number; auto?: boolean }[] };
|
||
const notes = data.libraries.find((l) => l.name === 'notes');
|
||
expect(notes).toBeDefined();
|
||
expect(notes!.auto).toBe(true);
|
||
expect(notes!.card_count).toBe(7);
|
||
expect(notes!.triplet_count).toBe(108);
|
||
});
|
||
|
||
it('庫無內容時 card_count=0 + triplet_count=0(前端顯示「還沒有內容」)', async () => {
|
||
await seedAdminSession();
|
||
mockGetRecord('rec_admin', adminValues());
|
||
mockListByTemplate('portal_library', [
|
||
{ record_id: 'rec_lib_empty', values: { name: 'empty', display_name: '空庫', status: 'active', graph_source: 'false' } },
|
||
]);
|
||
fetchMock
|
||
.get(KBDB)
|
||
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
|
||
.reply(200, { libraries: [] });
|
||
fetchMock
|
||
.get(KBDB)
|
||
.intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' })
|
||
.reply(200, { success: true, stats: [] });
|
||
fetchMock
|
||
.get(KBDB)
|
||
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
|
||
.reply(200, { success: true, stats: [] });
|
||
const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' });
|
||
expect(res.status).toBe(200);
|
||
const data = (await res.json()) as { libraries: { name: string; card_count: number; triplet_count: number }[] };
|
||
const empty = data.libraries.find((l) => l.name === 'empty');
|
||
expect(empty).toBeDefined();
|
||
expect(empty!.card_count).toBe(0);
|
||
expect(empty!.triplet_count).toBe(0);
|
||
});
|
||
});
|
||
|
||
// ═══════════════ t135 庫目錄移除 ═══════════════
|
||
|
||
describe('DELETE /portal/admin/libraries(t135)', () => {
|
||
it('DELETE /:id — 成功移除已登記庫;KBDB /records/:id DELETE 被呼叫', async () => {
|
||
await seedAdminSession();
|
||
mockGetRecord('rec_admin', adminValues());
|
||
// 成員驗證:list by template 回有該 record
|
||
mockListByTemplate('portal_library', [
|
||
{ record_id: 'rec_lib1', values: { name: 'finance', display_name: '財務庫', status: 'active' } },
|
||
]);
|
||
let deleteCalled = false;
|
||
fetchMock
|
||
.get(KBDB)
|
||
.intercept({ path: '/records/rec_lib1', method: 'DELETE' })
|
||
.reply(200, () => { deleteCalled = true; return { success: true }; });
|
||
const res = await json('DELETE', '/portal/admin/libraries/rec_lib1', undefined, { Authorization: 'Bearer tok-admin' });
|
||
expect(res.status).toBe(200);
|
||
const data = (await res.json()) as { success: boolean; name: string; message: string };
|
||
expect(data.success).toBe(true);
|
||
expect(data.name).toBe('finance');
|
||
expect(deleteCalled).toBe(true);
|
||
});
|
||
|
||
it('DELETE /:id — 庫不在目錄 → 404', async () => {
|
||
await seedAdminSession();
|
||
mockGetRecord('rec_admin', adminValues());
|
||
mockListByTemplate('portal_library', []); // 空目錄
|
||
const res = await json('DELETE', '/portal/admin/libraries/rec_lib_x', undefined, { Authorization: 'Bearer tok-admin' });
|
||
expect(res.status).toBe(404);
|
||
});
|
||
|
||
it('DELETE /:id — 非 admin → 403', async () => {
|
||
await seedAdminSession('tok-user', 'rec_u1');
|
||
mockGetRecord('rec_u1', userValues());
|
||
const res = await json('DELETE', '/portal/admin/libraries/rec_lib1', undefined, { Authorization: 'Bearer tok-user' });
|
||
expect(res.status).toBe(403);
|
||
});
|
||
|
||
it('DELETE /by-name/:name — confirm 符合 → 呼叫 KBDB deprecate-by-library', async () => {
|
||
await seedAdminSession();
|
||
mockGetRecord('rec_admin', adminValues());
|
||
let deprecateCalled = false;
|
||
fetchMock
|
||
.get(KBDB)
|
||
.intercept({ path: '/entries/deprecate-by-library', method: 'PATCH' })
|
||
.reply(200, () => { deprecateCalled = true; return { success: true, deprecated_count: 12 }; });
|
||
const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', { confirm: 'kb' }, { Authorization: 'Bearer tok-admin' });
|
||
expect(res.status).toBe(200);
|
||
const data = (await res.json()) as { success: boolean; deprecated_count: number };
|
||
expect(data.success).toBe(true);
|
||
expect(data.deprecated_count).toBe(12);
|
||
expect(deprecateCalled).toBe(true);
|
||
});
|
||
|
||
it('DELETE /by-name/:name — 無 confirm → 400', async () => {
|
||
await seedAdminSession();
|
||
mockGetRecord('rec_admin', adminValues());
|
||
const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', {}, { Authorization: 'Bearer tok-admin' });
|
||
expect(res.status).toBe(400);
|
||
});
|
||
|
||
it('DELETE /by-name/:name — confirm 不符 → 400', async () => {
|
||
await seedAdminSession();
|
||
mockGetRecord('rec_admin', adminValues());
|
||
const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', { confirm: 'wrong' }, { Authorization: 'Bearer tok-admin' });
|
||
expect(res.status).toBe(400);
|
||
});
|
||
|
||
it('DELETE /by-name/:name — 非 admin → 403', async () => {
|
||
await seedAdminSession('tok-user', 'rec_u1');
|
||
mockGetRecord('rec_u1', userValues());
|
||
const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', { confirm: 'kb' }, { Authorization: 'Bearer tok-user' });
|
||
expect(res.status).toBe(403);
|
||
});
|
||
});
|
||
|
||
// ═══════════════ 6. 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 勾選框又恆 disabled(daemon 從未回報 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 lookup(findUserRecordId 走這個路徑)*/
|
||
function mockEmailLookup(email: string, recordId: string | null) {
|
||
const needle = new URLSearchParams({ page_name: email }).toString();
|
||
fetchMock
|
||
.get(KBDB)
|
||
.intercept({
|
||
path: (p: string) => p.startsWith('/entries?') && p.includes(needle) && p.includes(encodeURIComponent(NS)),
|
||
method: 'GET',
|
||
})
|
||
.reply(200, { success: true, entries: recordId ? [{ content: recordId }] : [], count: recordId ? 1 : 0 });
|
||
}
|
||
|
||
it('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 /portal(P4 admin 頁 HTML 殼)', () => {
|
||
it('admin view 存在;仍零租戶字串、零 /kbdb/、零 X-Arcrun-API-Key、零 Mira;無 kb 種子、無登記到目錄', async () => {
|
||
const res = await SELF.fetch('http://localhost/portal');
|
||
expect(res.status).toBe(200);
|
||
const html = await res.text();
|
||
expect(html).toContain('v-admin'); // P4 管理頁 view
|
||
expect(html).toContain('/portal/admin/users'); // 帳號管理走 admin API
|
||
expect(html).toContain('/portal/admin/libraries'); // 庫目錄管理
|
||
// P3 紅線(design §3.3)在加了 admin UI 後不得回退
|
||
expect(html).not.toMatch(/['"]leo['"]/);
|
||
expect(html).not.toContain('/kbdb/');
|
||
expect(html).not.toContain('X-Arcrun-API-Key');
|
||
expect(html).not.toContain('Mira');
|
||
// t97a:bootstrap 後不再預埋 kb 庫
|
||
expect(html).not.toContain('"name": "kb"');
|
||
expect(html).not.toContain("name: 'kb'");
|
||
// t114:無「登記到目錄」按鈕
|
||
expect(html).not.toContain('lib-adopt');
|
||
expect(html).not.toContain('登記到目錄');
|
||
// t131:合併 AI 設定(舊兩區塊已移除)
|
||
expect(html).toContain('st-ai-panel');
|
||
expect(html).toContain('st-ai-key');
|
||
expect(html).toContain('st-ai-use-claude');
|
||
expect(html).not.toContain('st-extractor-panel');
|
||
expect(html).not.toContain('st-key-save'); // 舊 chat-key 存檔鈕已移除
|
||
});
|
||
});
|
||
|
||
// t131/t122 測試已隨 main 的 t176(刪除雲端下發 LLM 設定)一併移除;
|
||
// 此處只保留 t181(daemon 走 Workers AI)的守衛。
|
||
|
||
describe('POST /portal/daemon/extract(t181:Workers AI 萃卡,免金鑰)', () => {
|
||
// 認證=X-Arcrun-API-Key(=namespace,wrangler.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」,但實測不成立:
|
||
// geek6688:tenant=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');
|
||
});
|
||
});
|