From 36cdc8fba6913558d00ce6eab0813d5f9e4008c7 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Fri, 31 Jul 2026 20:57:05 +0800 Subject: [PATCH] =?UTF-8?q?#10=20=E8=A3=9C=E6=B8=AC=E8=A9=A6=E5=AE=88?= =?UTF-8?q?=E8=A1=9B=EF=BC=8Bconsole=20=E5=85=A9=E9=A0=81=E5=90=8C?= =?UTF-8?q?=E6=97=8F=20fallback=20=E4=B8=80=E4=BD=B5=E6=8B=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ① 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(無迴歸)。 --- .../public/console/dashboard/index.html | 7 +- console-ui/public/console/index.html | 7 +- cypher-executor/tests/portal-admin-ai.test.ts | 141 ++++++++++++++++++ 3 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 cypher-executor/tests/portal-admin-ai.test.ts diff --git a/console-ui/public/console/dashboard/index.html b/console-ui/public/console/dashboard/index.html index 9812592..9c82770 100644 --- a/console-ui/public/console/dashboard/index.html +++ b/console-ui/public/console/dashboard/index.html @@ -92,7 +92,12 @@ .theme-btn { flex: none; margin-left: 12px; width: 34px; height: 34px; border-radius: 50%; border: 1px solid rgba(var(--ink-rgb),.25); background: none; color: rgba(var(--ink-rgb),.65); font-size: 16px; cursor: pointer; line-height: 1; align-self: center; } - +
diff --git a/console-ui/public/console/index.html b/console-ui/public/console/index.html index 4b97e4a..9ddb2f3 100644 --- a/console-ui/public/console/index.html +++ b/console-ui/public/console/index.html @@ -220,7 +220,12 @@ .kvline { display: flex; justify-content: space-between; gap: 12px; font-size: 15px; margin: 5px 0; } - + diff --git a/cypher-executor/tests/portal-admin-ai.test.ts b/cypher-executor/tests/portal-admin-ai.test.ts new file mode 100644 index 0000000..7759d23 --- /dev/null +++ b/cypher-executor/tests/portal-admin-ai.test.ts @@ -0,0 +1,141 @@ +/** + * 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 布林+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; + + 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; + 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; + 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; + expect(d.use_claude_for_extract).toBe(true); + }); +});