merge main:把 t176(拔雲端 LLM 下發)與 workers_ai_chat 種子併進 CIS 分支
出貨前發現兩條分支各有一半: main → t176 拔掉雲端下發 extractor/刪 admin/extractor/workers_ai_chat 種子 fix/cis-round3-portal → t181 新萃取端點 /portal/daemon/extract、CIS 視覺 **要合起來才是完整的出貨內容**(實測:合併前 bundle 仍含 admin/extractor ×2)。 原始碼三檔全自動合併;只有 portal-admin.test.ts 衝突—— HEAD 側是**過時的 t131/t122 測試**(測 main 已刪的端點,留著必紅), main 側是刪除。解法:接受刪除、保留我這側的 t181 守衛。 測試 31 passed,唯一 failed 是基準線既有的 GET /portal 靜態資源案。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -300,42 +300,16 @@ describe('PATCH libraries(每帳號可查庫)', () => {
|
||||
// ═══════════════ 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' } },
|
||||
};
|
||||
});
|
||||
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(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);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('PATCH graph_source:boolean 進、slot 存字串;非 boolean → 400', async () => {
|
||||
@@ -564,13 +538,20 @@ describe('DELETE /portal/admin/libraries(t135)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 6. t122 萃取引擎金鑰雲端下發 ═══════════════
|
||||
// ═══════════════ 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('/portal/admin/extractor + /portal/daemon/config 萃取引擎(t122)', () => {
|
||||
describe('t176:雲端不再下發/設定地端 LLM', () => {
|
||||
const USER_EMAIL = 'daemon@example.com';
|
||||
const USER_PW = 'unit-test-pw-1'; // 與 storedHash 配對(beforeAll 計算)
|
||||
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 lookup(findUserRecordId 走這個路徑)*/
|
||||
function mockEmailLookup(email: string, recordId: string | null) {
|
||||
@@ -584,47 +565,37 @@ describe('/portal/admin/extractor + /portal/daemon/config 萃取引擎(t122)
|
||||
.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);
|
||||
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 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);
|
||||
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');
|
||||
});
|
||||
|
||||
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);
|
||||
// 註: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('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);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -658,166 +629,8 @@ describe('GET /portal(P4 admin 頁 HTML 殼)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 8. t131 合併 AI 設定 ═══════════════
|
||||
|
||||
describe('/portal/admin/ai + /portal/daemon/report-capabilities(t131)', () => {
|
||||
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_config+extractor_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=true:extractor 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=true;claude_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 — 有 claude:daemon_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 檔全滅還要把金鑰傳給別人才查得出原因」三種災難。
|
||||
// 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),
|
||||
|
||||
Reference in New Issue
Block a user