Files
Arcrun/cypher-executor/tests/portal-admin-ai.test.ts
T
uncle6me-web 36cdc8fba6 #10 補測試守衛+console 兩頁同族 fallback 一併拔
① tests/portal-admin-ai.test.ts(6 項綠)=**route 消失就會紅**的迴歸防線。
   這條 route 以前不存在、前端卻一直在打它 ⇒ key 從來沒存進去(藍字=假綠),
   所以最該守的就是「它還在不在」。覆蓋:未登入 401(**不是 404**=route 真的在)/
   非 admin 403/GET 回 has_key 布林且回應不含任何金鑰欄位(D36)/
   空 body 400 不假裝成功/只改偏好不誤報 has_key/同測試內寫→讀一致。
   註:vitest-pool-workers 預設 isolatedStorage=true ⇒ 跨 it 的 KV 會還原,
   驗來回一致必須在同一個 it 內完成(我第一版寫成跨 it,測試如實抓出來了)。

② console/index.html 與 console/dashboard/index.html 也有同一個寫死
   `|| "https://cypher.arcrun.dev"` fallback(與 portal 同族)⇒ 一併拔掉。
   靜態 config.js 保留無妨:build-ui-bundle.mjs 的 SKIP 會跳過它、改由 worker 動態產生。

驗證:cypher tsc 綠、全套 248 passed(9 既有失敗未變);
UI 產物 grep 寫死 fallback 歸 0;stage verify.sh 仍 11/3(無迴歸)。
2026-07-31 20:57:05 +08:00

142 lines
6.0 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.
/**
* 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(不是 404route 真的在)
* 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 布林+claude 旗標,且回應完全不含金鑰值', 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');
expect(typeof d.claude_available).toBe('boolean');
expect(typeof d.use_claude_for_extract).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');
});
});
describe('POST /portal/admin/ai — 不假裝成功', () => {
it('空 body(沒 key 也沒偏好)→ 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('沒有要變更');
});
it('只改 Claude 偏好(不帶 key)→ 成功並回存後的值', 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(200);
const d = (await res.json()) as Record<string, unknown>;
expect(d.success).toBe(true);
expect(d.use_claude_for_extract).toBe(true);
// 沒送 key ⇒ 不得回報 has_key(避免誤報「已輸入」)
expect(d.has_key).toBeUndefined();
});
// 註:vitest-pool-workers 預設 isolatedStorage=true ⇒ **每個 it 之間 KV 會還原**
// 所以「寫在上一個 it、讀在下一個 it」測不出來(那是測試框架語意,不是程式缺陷)。
// 要驗來回一致,必須在**同一個 it** 內完成寫→讀。
it('偏好可讀回:同一測試內 POST 寫入 → GET 讀得到同一值', async () => {
await seedSession('tok-a4', 'rec_admin');
mockGetRecord('rec_admin', adminValues()); // POST 的 requirePortalAdmin 回讀
mockGetRecord('rec_admin', adminValues()); // GET 的 requirePortalAdmin 回讀
const post = await json('POST', '/portal/admin/ai', { use_claude_for_extract: true }, authHdr('tok-a4'));
expect(post.status).toBe(200);
const get = await json('GET', '/portal/admin/ai', undefined, authHdr('tok-a4'));
const d = (await get.json()) as Record<string, unknown>;
expect(d.use_claude_for_extract).toBe(true);
});
});