66f1b59f7f
leo 回報四件事:①雲端 Gemini 無法用 ②搞不清楚設定 AI 是設雲端還是地端 ③地端不會萃、每個人都死掉因為都去抓 Claude ④雲端還要設置明明就有 Workers AI。 目標:「雲端裝好就能用,地端輸一個 Gemini API Key,然後就開始用。」 根因:extractor_config 的 KV key 由 portalTenant() 組出,而 portalTenant 是 **worker 層級**環境變數(portal.ts:43)⇒ **全租戶共用一把**。任一處設了 claude, 所有人的 daemon 都收到 claude;沒裝 Claude Code 的機器萃取全滅,而 portal 的 Claude 勾選框又恆 disabled(daemon 從未實作 report-capabilities ⇒ daemon_caps 永遠空) ⇒ 用戶自己解不開(awindhon 08-03 實證:雲端同步成功、金鑰有效、零張卡)。 本次(雲端側): - POST /portal/daemon/config **不再下發** extractor/gemini_api_key/llm_model, 只回連線欄位。⚠️ route 本身與 daemon/libraries 資料夾管理完全不動(leo 明確劃界: 「雲端拉地端檔案夾部分不要刪,刪掉從雲端設置地端 LLM 選項部分」)。 - 移除 POST|GET /portal/admin/extractor(t122)——這是「雲端指定地端引擎」的入口, 且無任何伺服器端驗證,打一下就能把全租戶設成 claude。 - 移除 POST /portal/daemon/report-capabilities(t131)——daemon 端從未實作該呼叫。 - 移除 t131 那組重複註冊的 /portal/admin/ai。**它是重複 route**:後段(arcrun-rag#10) 另有同路徑一組,Hono 先到先比 ⇒ 舊的一直贏,後段修好的「金鑰真的寫進 credentials」 形同死碼。保留後段那組(只管 Gemini 金鑰、走 storeCredential),並拿掉 Claude 偏好欄。 - portal 設定頁「AI 設定」整塊移除,改成一句話說明:雲端不需設定(Workers AI 免金鑰), 地端請在同步小幫手的「AI 設定…」填 Gemini Key。順手清掉 main 上既有的 conflict 標記。 - 清掉隨之孤兒化的 helper(ExtractorConfig/AiConfig/DaemonCapabilities/ syncExtractorFromAiConfig/readAiPref 等)。 測試:**相對 merge 前 main 基準線,新增失敗 = 0**(基準線本就 9 紅:console 藏書地圖 6/ portal HTML 殼 2/POST execute 1,皆與本次無關)。t122/t131 兩組測試改寫成 **回歸守衛**(斷言那些端點/欄位確實 404、確實不存在),不是刪掉充綠。 順手修 health bundle_version 測試與實作對齊(實作刻意省略該欄,測試卻期待空字串)。 未送達:本 commit 只到 code,尚未部署上線。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
134 lines
5.6 KiB
TypeScript
134 lines
5.6 KiB
TypeScript
/**
|
||
* GET|POST /portal/admin/ai —— arcrun-rag#10 迴歸守衛
|
||
*
|
||
* 🔴 為什麼有這支測試(別刪):
|
||
* 這條 route **以前根本不存在**,但前端設定頁一直在打它 ⇒ 用戶填 Gemini key → 404
|
||
* ⇒ **key 從來沒被存進任何地方**,畫面卻像存好了(藍字=假綠)。
|
||
* leo 實撞成「重裝後 key 不見」,真相是「從來沒存進去,所以重填也沒用」。
|
||
* 產物層鐵證(修復前):bundle tier2/ui grep 'portal/admin/ai'=1、tier2/cypher=**0**。
|
||
* ⇒ 這支測試的存在本身就是防線:**route 消失=測試紅**。
|
||
*
|
||
* 覆蓋:
|
||
* 1. 未登入 → 401;非 admin → 403(不是 404=route 真的在)
|
||
* 2. GET 回 has_key 布林,**永不回傳 key 本身**(D36)
|
||
* 3. POST 空 body → 400(不假裝成功)
|
||
* 4. POST 只改 Claude 偏好(不帶 key)→ 成功,且不碰 credential
|
||
*/
|
||
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||
import { hashPassword } from '../src/lib/portal-auth';
|
||
|
||
const KBDB = 'https://kbdb.test';
|
||
|
||
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 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 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,
|
||
};
|
||
}
|
||
|
||
async function seedSession(token: string, recordId: string) {
|
||
await env.SESSIONS_KV.put(`portal_sess:${token}`, JSON.stringify({ record_id: recordId }));
|
||
}
|
||
|
||
const authHdr = (t: string) => ({ Authorization: `Bearer ${t}` });
|
||
|
||
describe('GET /portal/admin/ai — 認證閘(route 存在的證明)', () => {
|
||
it('未登入 → 401(不是 404 ⇒ route 真的在)', async () => {
|
||
const res = await json('GET', '/portal/admin/ai');
|
||
expect(res.status).toBe(401);
|
||
expect(res.status).not.toBe(404);
|
||
});
|
||
|
||
it('非 admin → 403', async () => {
|
||
await seedSession('tok-user', 'rec_user');
|
||
mockGetRecord('rec_user', adminValues({ role: 'user', email: 'u@example.com' }));
|
||
const res = await json('GET', '/portal/admin/ai', undefined, authHdr('tok-user'));
|
||
expect(res.status).toBe(403);
|
||
});
|
||
});
|
||
|
||
describe('GET /portal/admin/ai — 回應形狀(D36:永不回傳 key)', () => {
|
||
it('回 has_key 布林,且回應完全不含金鑰值', async () => {
|
||
await seedSession('tok-a1', 'rec_admin');
|
||
mockGetRecord('rec_admin', adminValues());
|
||
const res = await json('GET', '/portal/admin/ai', undefined, authHdr('tok-a1'));
|
||
expect(res.status).toBe(200);
|
||
const raw = await res.text();
|
||
const d = JSON.parse(raw) as Record<string, unknown>;
|
||
|
||
expect(typeof d.has_key).toBe('boolean');
|
||
|
||
// D36:回應裡不得出現任何疑似金鑰的欄位
|
||
expect(raw).not.toContain('gemini_api_key_value');
|
||
expect(d).not.toHaveProperty('key');
|
||
expect(d).not.toHaveProperty('value');
|
||
expect(d).not.toHaveProperty('secret_ref');
|
||
});
|
||
|
||
// t176 回歸守衛(leo 08-03):雲端不再有「地端用哪個模型」的概念。
|
||
// 這兩個欄位若復活,代表又走回「雲端控制地端」的老路——那正是 08-03 事故根因
|
||
//(extractor_config 全租戶共用一把,任一處設 claude 就讓所有人萃取全滅)。
|
||
it('不再回 claude_available/use_claude_for_extract(地端模型改由小幫手自己設)', async () => {
|
||
await seedSession('tok-a1b', 'rec_admin');
|
||
mockGetRecord('rec_admin', adminValues());
|
||
const res = await json('GET', '/portal/admin/ai', undefined, authHdr('tok-a1b'));
|
||
const d = (await res.json()) as Record<string, unknown>;
|
||
expect(d).not.toHaveProperty('claude_available');
|
||
expect(d).not.toHaveProperty('use_claude_for_extract');
|
||
});
|
||
});
|
||
|
||
describe('POST /portal/admin/ai — 不假裝成功', () => {
|
||
it('空 body(沒帶金鑰)→ 400,不回 success', async () => {
|
||
await seedSession('tok-a2', 'rec_admin');
|
||
mockGetRecord('rec_admin', adminValues());
|
||
const res = await json('POST', '/portal/admin/ai', {}, authHdr('tok-a2'));
|
||
expect(res.status).toBe(400);
|
||
const d = (await res.json()) as Record<string, unknown>;
|
||
expect(d.success).toBeUndefined();
|
||
expect(String(d.error)).toContain('沒有要變更');
|
||
});
|
||
|
||
// t176 回歸守衛:只送 Claude 偏好=沒有要變更的項目 → 400(該欄位已不存在)。
|
||
it('只送 use_claude_for_extract(已廢欄位)→ 400,不得假裝成功', async () => {
|
||
await seedSession('tok-a3', 'rec_admin');
|
||
mockGetRecord('rec_admin', adminValues());
|
||
const res = await json('POST', '/portal/admin/ai', { use_claude_for_extract: true }, authHdr('tok-a3'));
|
||
expect(res.status).toBe(400);
|
||
const d = (await res.json()) as Record<string, unknown>;
|
||
expect(d.success).toBeUndefined();
|
||
});
|
||
});
|