/** * 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 = {}) { 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) { 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 = {}): Record { 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; 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; 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; 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; expect(d.success).toBeUndefined(); }); });