/** * portal-auth P3 測試(design §1/§3.3/§3.4/§5/§6,Gitea #24/#25) * * 覆蓋(=tasks.md P3 測試項+#24 驗收 3 的 server-side 證明): * 1. /portal HTML 殼:200、brand、**零租戶字串/零 X-Arcrun-API-Key/零 Mira 字樣** * 2. /portal/data/search enforce:server 注入 owner_id+library;caller 自帶 * owner_id/library 參數被靜默覆蓋(filter 繞不過的機械證明);["*"]=不注 library; * 空集合=誠實空結果不打 KBDB * 3. /portal/data/entries/:id 逐筆驗庫:越庫 404、跨租戶 404、不存在 404(同一句, * 不洩存在性)、NULL library→general fallback * 4. graph D-4 粗閘:無來源庫權限 403(不打 plugin);["*"]/有權 → 轉發 * 5. workflows D-8:非 admin 403;admin 唯讀 list+最近執行、回應無 webhook_url; * workflowsVisible 單元(admin/all/off/壞值) * 6. /portal/session 能力欄位:graph_allowed / workflows_visible * * KBDB/graph-plugin 都打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL= * https://kbdb.test、KBDB_GRAPH_URL=https://graph.test)+disableNetConnect——絕不外連。 */ import { SELF, env, fetchMock } from 'cloudflare:test'; import { beforeAll, afterEach, describe, it, expect } from 'vitest'; import { workflowsVisible } from '../src/routes/portal'; import { entryLibrary, sanitizeUploadFilename, filterDeprecatedEntries, mapGraphWorkflowOutput } from '../src/routes/portal-data'; import type { Bindings } from '../src/types'; const KBDB = 'https://kbdb.test'; const GRAPH = 'https://graph.test'; const TENANT = 'leo'; // wrangler.test.toml CONSOLE_TENANT(只在 server 側;下面驗它不出現在前端) beforeAll(() => { fetchMock.activate(); fetchMock.disableNetConnect(); }); afterEach(() => fetchMock.assertNoPendingInterceptors()); function get(path: string, headers: Record = {}) { return SELF.fetch(`http://localhost${path}`, { headers }); } async function seedSession(token: string, recordId: string) { await env.SESSIONS_KV.put(`portal_sess:${token}`, JSON.stringify({ record_id: recordId })); } 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 mockLibraryList(records: { record_id: string; values: Record }[]) { fetchMock .get(KBDB) .intercept({ path: (p: string) => p.startsWith('/records/by-template/portal_library'), method: 'GET' }) .reply(200, { success: true, records: records.map((r) => ({ ...r, template_id: 'tpl_pl' })), count: records.length }); } function userValues(overrides: Record = {}): Record { return { email: 'user@example.com', display_name: '測試同仁', status: 'active', role: 'user', password_hash: 'pbkdf2-sha256$600000$AA$BB', libraries: '["finance"]', created_at: '2026-07-14T00:00:00.000Z', updated_at: '2026-07-14T00:00:00.000Z', ...overrides, }; } /** 攔 KBDB /entries/search 並回收實際轉發的 query(enforce 的機械證據)。 */ function captureSearch(reply: unknown = { success: true, entries: [], count: 0, mode: 'keyword' }): { url: () => string } { let captured = ''; fetchMock .get(KBDB) .intercept({ path: (p: string) => { if (!p.startsWith('/entries/search?')) return false; captured = p; return true; }, method: 'GET', }) .reply(200, reply as Record); return { url: () => captured }; } // ═══════════════ 1. /portal HTML 殼 ═══════════════ describe('GET /portal(HTML 殼)', () => { it('200;brand 出現;**前端零租戶字串、零 X-Arcrun-API-Key、零 Mira**', async () => { const res = await get('/portal'); expect(res.status).toBe(200); const html = await res.text(); expect(html).toContain('Arcrun Portal'); // CONSOLE_BRAND 未設 → Arcrun(引擎共用件不寫死產品名) expect(html).toContain('/portal/data/search'); // 資料只走 enforce 面 // design §3.3 關鍵差異的機械斷言:前端不持租戶字串、不打 /kbdb/* expect(html).not.toContain('X-Arcrun-API-Key'); expect(html).not.toMatch(/['"]leo['"]/); // 租戶字串值不得出現在頁面 expect(html).not.toContain('/kbdb/'); // 不直打 kbdb proxy(那要 API key=租戶字串) expect(html).not.toContain('Mira'); // 零 Mira 字樣(tasks.md P3) expect(html).not.toContain('CONSOLE_TENANT'); }); }); // ═══════════════ 2. /portal/data/search enforce ═══════════════ describe('GET /portal/data/search', () => { it('未登入 → 401,不碰 KBDB', async () => { const res = await get('/portal/data/search?q=hello'); expect(res.status).toBe(401); }); it('server 注入 owner_id+library;caller 自帶 owner_id/library 被靜默覆蓋(繞不過)', async () => { await seedSession('tok-s1', 'rec_1'); mockGetRecord('rec_1', userValues()); // libraries=["finance"] const cap = captureSearch(); // 攻擊嘗試:自帶 library=hr + owner_id=evil → 應完全被 server 值取代 const res = await get('/portal/data/search?q=報告&library=hr&owner_id=evil', { Authorization: 'Bearer tok-s1', }); expect(res.status).toBe(200); const sent = new URLSearchParams(cap.url().split('?')[1]); expect(sent.get('owner_id')).toBe(TENANT); // server 注入的租戶 expect(sent.get('library')).toBe('finance'); // server 注入的用戶庫集合 expect(cap.url()).not.toContain('hr'); // caller 的越權參數完全沒被轉發 expect(cap.url()).not.toContain('evil'); }); it('多庫用戶 → library=逗號集合;mode=semantic 透傳', async () => { await seedSession('tok-s2', 'rec_2'); mockGetRecord('rec_2', userValues({ libraries: '["general","finance"]' })); const cap = captureSearch({ success: true, entries: [], count: 0, mode: 'semantic' }); const res = await get('/portal/data/search?q=q1&mode=semantic', { Authorization: 'Bearer tok-s2' }); expect(res.status).toBe(200); const sent = new URLSearchParams(cap.url().split('?')[1]); expect(sent.get('library')).toBe('general,finance'); expect(sent.get('mode')).toBe('semantic'); }); it('["*"](全庫)→ 只注 owner_id、不注 library(design §3.3)', async () => { await seedSession('tok-s3', 'rec_3'); mockGetRecord('rec_3', userValues({ libraries: '["*"]', role: 'admin' })); const cap = captureSearch(); const res = await get('/portal/data/search?q=q2', { Authorization: 'Bearer tok-s3' }); expect(res.status).toBe(200); const sent = new URLSearchParams(cap.url().split('?')[1]); expect(sent.get('owner_id')).toBe(TENANT); expect(sent.has('library')).toBe(false); }); it('庫集合為空 → 誠實空結果,不打 KBDB search', async () => { await seedSession('tok-s4', 'rec_4'); mockGetRecord('rec_4', userValues({ libraries: '[]' })); const res = await get('/portal/data/search?q=q3', { Authorization: 'Bearer tok-s4' }); expect(res.status).toBe(200); const data = (await res.json()) as { entries: unknown[]; note?: string }; expect(data.entries).toEqual([]); expect(data.note).toContain('尚未被授權'); // 無 pending interceptor=真沒打 KBDB }); }); // ═══════════════ 3. /portal/data/entries/:id 逐筆驗庫 ═══════════════ function mockGetEntry(id: string, entry: Record | null) { fetchMock .get(KBDB) .intercept({ path: `/entries/${id}`, method: 'GET' }) .reply(entry ? 200 : 404, entry ? { success: true, entry } : { success: false, error: 'not found' }); } describe('GET /portal/data/entries/:id(逐筆驗庫)', () => { it('越庫 id 直讀(hr entry、用戶只有 finance)→ 404', async () => { await seedSession('tok-e1', 'rec_1'); mockGetRecord('rec_1', userValues()); mockGetEntry('e_hr', { id: 'e_hr', owner_id: TENANT, metadata_json: '{"library":"hr"}', content: '機密' }); const res = await get('/portal/data/entries/e_hr', { Authorization: 'Bearer tok-e1' }); expect(res.status).toBe(404); const data = (await res.json()) as { error: string }; expect(data.error).toBe('找不到這筆資料'); // 與不存在同一句(不洩存在性) expect(JSON.stringify(data)).not.toContain('hr'); // 不洩庫名 }); it('有權庫(finance)→ 200 回 entry', async () => { await seedSession('tok-e2', 'rec_1'); mockGetRecord('rec_1', userValues()); mockGetEntry('e_fin', { id: 'e_fin', owner_id: TENANT, metadata_json: '{"library":"finance","source":"logseq://x.md"}', content: '財務' }); const res = await get('/portal/data/entries/e_fin', { Authorization: 'Bearer tok-e2' }); expect(res.status).toBe(200); const data = (await res.json()) as { entry: { id: string } }; expect(data.entry.id).toBe('e_fin'); }); it('跨租戶 entry(owner_id 不是本實例租戶)→ 404 同一句', async () => { await seedSession('tok-e3', 'rec_1'); mockGetRecord('rec_1', userValues({ libraries: '["*"]' })); // 就算全庫也擋跨租戶 mockGetEntry('e_other', { id: 'e_other', owner_id: 'other-tenant', metadata_json: '{"library":"finance"}' }); const res = await get('/portal/data/entries/e_other', { Authorization: 'Bearer tok-e3' }); expect(res.status).toBe(404); expect(((await res.json()) as { error: string }).error).toBe('找不到這筆資料'); }); it('不存在的 id → 404 同一句', async () => { await seedSession('tok-e4', 'rec_1'); mockGetRecord('rec_1', userValues()); mockGetEntry('e_ghost', null); const res = await get('/portal/data/entries/e_ghost', { Authorization: 'Bearer tok-e4' }); expect(res.status).toBe(404); expect(((await res.json()) as { error: string }).error).toBe('找不到這筆資料'); }); it('未標記 library(NULL metadata)→ 歸 general:有 general 者 200、無者 404', async () => { await seedSession('tok-e5', 'rec_5'); mockGetRecord('rec_5', userValues({ libraries: '["general"]' })); mockGetEntry('e_old', { id: 'e_old', owner_id: TENANT, metadata_json: null, content: '舊資料' }); const ok = await get('/portal/data/entries/e_old', { Authorization: 'Bearer tok-e5' }); expect(ok.status).toBe(200); await seedSession('tok-e6', 'rec_6'); mockGetRecord('rec_6', userValues({ libraries: '["finance"]' })); // 沒 general mockGetEntry('e_old', { id: 'e_old', owner_id: TENANT, metadata_json: null, content: '舊資料' }); const no = await get('/portal/data/entries/e_old', { Authorization: 'Bearer tok-e6' }); expect(no.status).toBe(404); }); it('entryLibrary 單元:壞 metadata/缺欄位 → general;有 library → 原值', () => { expect(entryLibrary({ metadata_json: null })).toBe('general'); expect(entryLibrary({ metadata_json: 'not-json{{' })).toBe('general'); expect(entryLibrary({ metadata_json: '{"source":"x"}' })).toBe('general'); expect(entryLibrary({ metadata_json: '{"library":""}' })).toBe('general'); expect(entryLibrary({ metadata_json: '{"library":"hr"}' })).toBe('hr'); }); }); // ═══════════════ 4. graph D-4 粗閘 ═══════════════ describe('GET /portal/data/graph/neighbors/:name(D-4 粗閘)', () => { it('無 graph 來源庫權限(來源庫預設 general、用戶只有 finance)→ 403,不打 plugin', async () => { await seedSession('tok-g1', 'rec_1'); mockGetRecord('rec_1', userValues()); // finance only mockLibraryList([]); // 沒有任何庫標 graph_source → 來源預設 ['general'] const res = await get('/portal/data/graph/neighbors/某節點', { Authorization: 'Bearer tok-g1' }); expect(res.status).toBe(403); // 無 pending interceptor(afterEach 驗)=graph plugin 完全沒被打 }); it('["*"] 全庫 → 放行並轉發 plugin(不需查庫目錄)', async () => { await seedSession('tok-g2', 'rec_2'); mockGetRecord('rec_2', userValues({ libraries: '["*"]', role: 'admin' })); fetchMock .get(GRAPH) .intercept({ path: (p: string) => p.startsWith('/graph/neighbors/'), method: 'GET' }) .reply(200, { node: 'n', edges: [], neighbors: [], edgeCount: 0, neighborCount: 0 }); const res = await get('/portal/data/graph/neighbors/n', { Authorization: 'Bearer tok-g2' }); expect(res.status).toBe(200); }); it('庫目錄標 finance 為 graph_source → finance 用戶放行', async () => { await seedSession('tok-g3', 'rec_1'); mockGetRecord('rec_1', userValues()); // finance mockLibraryList([ { record_id: 'lib_fin', values: { name: 'finance', status: 'active', graph_source: 'true' } }, ]); fetchMock .get(GRAPH) .intercept({ path: (p: string) => p.startsWith('/graph/neighbors/'), method: 'GET' }) .reply(200, { node: 'n', edges: [], neighbors: [] }); const res = await get('/portal/data/graph/neighbors/n', { Authorization: 'Bearer tok-g3' }); expect(res.status).toBe(200); }); it('停用的 graph_source 庫不算來源(disabled 排除 → 回到預設 general → finance 用戶 403)', async () => { await seedSession('tok-g4', 'rec_1'); mockGetRecord('rec_1', userValues()); mockLibraryList([ { record_id: 'lib_fin', values: { name: 'finance', status: 'disabled', graph_source: 'true' } }, ]); const res = await get('/portal/data/graph/neighbors/n', { Authorization: 'Bearer tok-g4' }); expect(res.status).toBe(403); }); }); // ═══════════════ 5. workflows D-8 ═══════════════ describe('GET /portal/data/workflows(D-8:admin 唯讀)', () => { it('非 admin(預設 PORTAL_SHOW_WORKFLOWS=admin)→ 403', async () => { await seedSession('tok-w1', 'rec_1'); mockGetRecord('rec_1', userValues({ role: 'user' })); const res = await get('/portal/data/workflows', { Authorization: 'Bearer tok-w1' }); expect(res.status).toBe(403); }); it('admin → 200 唯讀 list+最近執行;**回應無 webhook_url/trigger 把手**', async () => { await seedSession('tok-w2', 'rec_a'); mockGetRecord('rec_a', userValues({ role: 'admin', libraries: '["*"]' })); await env.WEBHOOKS.put( `${TENANT}:wf:daily_report`, JSON.stringify({ description: '每日彙整', created_at: '2026-07-14T00:00:00Z', cron_expr: '0 9 * * *' }), ); await env.ANALYTICS_KV.put('stats:daily_report:1783500000000', JSON.stringify({ verdict: 'success' })); await env.ANALYTICS_KV.put('stats:daily_report:1783400000000', JSON.stringify({ verdict: 'failed' })); const res = await get('/portal/data/workflows', { Authorization: 'Bearer tok-w2' }); expect(res.status).toBe(200); const data = (await res.json()) as { workflows: { name: string; description: string; last_execution: { verdict?: string; timestamp: string } | null }[]; read_only: boolean; }; expect(data.read_only).toBe(true); const wf = data.workflows.find((w) => w.name === 'daily_report'); expect(wf).toBeTruthy(); expect(wf!.description).toBe('每日彙整'); expect(wf!.last_execution?.verdict).toBe('success'); // 取到「最新」那筆(timestamp 較大者) expect(JSON.stringify(data)).not.toContain('webhook_url'); expect(JSON.stringify(data)).not.toContain('/trigger'); // 清場(KV 是 suite 共用實例,避免污染其他測試) await env.WEBHOOKS.delete(`${TENANT}:wf:daily_report`); await env.ANALYTICS_KV.delete('stats:daily_report:1783500000000'); await env.ANALYTICS_KV.delete('stats:daily_report:1783400000000'); }); it('workflowsVisible 單元:admin(預設/壞值)/ all / off', () => { const mk = (v?: string) => ({ PORTAL_SHOW_WORKFLOWS: v }) as unknown as Bindings; expect(workflowsVisible(mk(undefined), 'admin')).toBe(true); expect(workflowsVisible(mk(undefined), 'user')).toBe(false); expect(workflowsVisible(mk('all'), 'user')).toBe(true); expect(workflowsVisible(mk('off'), 'admin')).toBe(false); expect(workflowsVisible(mk('typo!!'), 'user')).toBe(false); // 壞值退回 admin-only,不意外全開 expect(workflowsVisible(mk('typo!!'), 'admin')).toBe(true); }); }); // ═══════════════ 6. /portal/session 能力欄位 ═══════════════ describe('GET /portal/session(P3 能力欄位)', () => { it('一般 user(finance,無 graph 來源權限)→ graph_allowed=false、workflows_visible=false;仍無租戶字串', async () => { await seedSession('tok-p1', 'rec_1'); mockGetRecord('rec_1', userValues()); mockLibraryList([]); const res = await get('/portal/session', { Authorization: 'Bearer tok-p1' }); expect(res.status).toBe(200); const data = (await res.json()) as Record; expect(data.graph_allowed).toBe(false); expect(data.workflows_visible).toBe(false); expect('tenant' in data).toBe(false); expect(JSON.stringify(data)).not.toContain('"leo"'); }); it('admin ["*"] → graph_allowed=true(免查庫目錄)、workflows_visible=true', async () => { await seedSession('tok-p2', 'rec_a'); mockGetRecord('rec_a', userValues({ role: 'admin', libraries: '["*"]' })); const res = await get('/portal/session', { Authorization: 'Bearer tok-p2' }); expect(res.status).toBe(200); const data = (await res.json()) as Record; expect(data.graph_allowed).toBe(true); expect(data.workflows_visible).toBe(true); // portal-demo-suite:測試環境未設 upload bindings → upload_enabled=false(Mira 零影響預設) expect(data.upload_enabled).toBe(false); }); }); // ═══════════════ 7. portal-demo-suite 純函式 ═══════════════ describe('sanitizeUploadFilename(上傳檔名驗證)', () => { it('去路徑分隔(擋穿越)、.txt 改 .md、無副檔名補 .md', () => { expect(sanitizeUploadFilename('notes.md')).toBe('notes.md'); expect(sanitizeUploadFilename('memo.txt')).toBe('memo.md'); expect(sanitizeUploadFilename('README')).toBe('README.md'); expect(sanitizeUploadFilename('../../etc/passwd')).toBe('passwd.md'); // 只取最後一段,穿越失效 expect(sanitizeUploadFilename('a\\b\\c.md')).toBe('c.md'); expect(sanitizeUploadFilename('中文筆記.txt')).toBe('中文筆記.md'); }); it('空名/純路徑/隱藏檔/超過 100 字/非字串 → null', () => { expect(sanitizeUploadFilename('')).toBe(null); expect(sanitizeUploadFilename(' ')).toBe(null); expect(sanitizeUploadFilename('docs/')).toBe(null); expect(sanitizeUploadFilename('.env')).toBe(null); expect(sanitizeUploadFilename('a'.repeat(120) + '.md')).toBe(null); expect(sanitizeUploadFilename(42)).toBe(null); expect(sanitizeUploadFilename(undefined)).toBe(null); }); }); describe('filterDeprecatedEntries(Arcrun#46 搜尋殘影治標)', () => { it('濾 status=deprecated 與「(舊管線產物」開頭;metadata parse 失敗保留', () => { const keepNormal = { metadata_json: '{"library":"general"}', content: '正常內容' }; const keepBadMeta = { metadata_json: 'not-json{{', content: '壞 metadata 不誤殺' }; const keepNullMeta = { metadata_json: null, content: '無 metadata' }; const dropByStatus = { metadata_json: '{"status":"deprecated"}', content: '看起來正常但已標廢' }; const dropByContent = { metadata_json: '{}', content: '(舊管線產物)殘影條目' }; const out = filterDeprecatedEntries([keepNormal, dropByStatus, keepBadMeta, dropByContent, keepNullMeta]); expect(out).toEqual([keepNormal, keepBadMeta, keepNullMeta]); }); it('空陣列 → 空陣列', () => { expect(filterDeprecatedEntries([])).toEqual([]); }); it('濾內部型別 value/workflow(無標題雜項列;leo 2026-07-18 客戶測試回饋);block/wiki 保留', () => { const keepBlock = { entry_type: 'block', metadata_json: '{}', content: '正常 block' }; const keepWiki = { entry_type: 'wiki', metadata_json: '{}', content: '精耕頁' }; const keepNoType = { metadata_json: '{}', content: '無 entry_type 不誤殺' }; const dropValue = { entry_type: 'value', metadata_json: '{}', content: '特休假' }; const dropWorkflow = { entry_type: 'workflow', metadata_json: '{}', content: '同步問答:…' }; const out = filterDeprecatedEntries([keepBlock, dropValue, keepWiki, dropWorkflow, keepNoType]); expect(out).toEqual([keepBlock, keepWiki, keepNoType]); }); }); describe('mapGraphWorkflowOutput(#57 workflow 輸出 → plugin 形狀)', () => { it('本體有 neighbors → 直取;count 重算不信自報', () => { const out = mapGraphWorkflowOutput({ neighbors: ['a', 'b'], edges: [{ subject: 'a', predicate: 'rel', object: 'b' }], count: 99 }); expect(out.neighbors).toEqual(['a', 'b']); expect(out.edges.length).toBe(1); expect(out.count).toBe(2); }); it('包一層 data(http_request 慣例)→ 取內層;非物件/缺欄位 → 誠實空集合', () => { expect(mapGraphWorkflowOutput({ data: { neighbors: ['x'], edges: [] } })).toEqual({ neighbors: ['x'], edges: [], count: 1 }); expect(mapGraphWorkflowOutput(null)).toEqual({ neighbors: [], edges: [], count: 0 }); expect(mapGraphWorkflowOutput('oops')).toEqual({ neighbors: [], edges: [], count: 0 }); }); });