Files
Arcrun/cypher-executor/tests/portal-data.test.ts
T
Claude 8accdacc3c portal 搜尋濾殘影(#46 治標)+四件套單測
/portal/data/search 拿到 KBDB 回應後 server-side 過濾 metadata_json.status=
'deprecated' 與 content 以「(舊管線產物」開頭的 entries(metadata parse 失敗
視為保留,不誤殺),count 重算。這是 Arcrun#46 上游修好前的 portal 端治標,
上游清完資料後整段(含 filterDeprecatedEntries)可拔。
單測:sanitizeUploadFilename(路徑穿越/副檔名/長度)、filterDeprecatedEntries
(濾殘影/壞 metadata 保留)、mapGraphWorkflowOutput(#57 輸出映射);
/portal/session 補 upload_enabled=false 斷言。另修 portal-ui 一處註解字樣
(誤用產品名,撞零 Mira 斷言)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BH7LdhuCdVUHfHXbM7N8r5
2026-07-17 06:28:04 +00:00

411 lines
21 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.
/**
* portal-auth P3 測試(design §1/§3.3/§3.4/§5/§6Gitea #24/#25
*
* 覆蓋(=tasks.md P3 測試項+#24 驗收 3 的 server-side 證明):
* 1. /portal HTML 殼:200、brand、**零租戶字串/零 X-Arcrun-API-Key/零 Mira 字樣**
* 2. /portal/data/search enforceserver 注入 owner_idlibrarycaller 自帶
* 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 403admin 唯讀 list+最近執行、回應無 webhook_url
* workflowsVisible 單元(admin/all/off/壞值)
* 6. /portal/session 能力欄位:graph_allowed / workflows_visible
*
* KBDBgraph-plugin 都打 fetchMock 假 hostwrangler.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<string, string> = {}) {
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<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 mockLibraryList(records: { record_id: string; values: Record<string, string> }[]) {
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<string, string> = {}): Record<string, string> {
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 並回收實際轉發的 queryenforce 的機械證據)。 */
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<string, unknown>);
return { url: () => captured };
}
// ═══════════════ 1. /portal HTML 殼 ═══════════════
describe('GET /portalHTML 殼)', () => {
it('200brand 出現;**前端零租戶字串、零 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_idlibrarycaller 自帶 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、不注 librarydesign §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<string, unknown> | 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('跨租戶 entryowner_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('未標記 libraryNULL 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/:nameD-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 interceptorafterEach 驗)=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/workflowsD-8admin 唯讀)', () => {
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_urltrigger 把手**', 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/sessionP3 能力欄位)', () => {
it('一般 userfinance,無 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<string, unknown>;
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<string, unknown>;
expect(data.graph_allowed).toBe(true);
expect(data.workflows_visible).toBe(true);
// portal-demo-suite:測試環境未設 upload bindings → upload_enabled=falseMira 零影響預設)
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('filterDeprecatedEntriesArcrun#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([]);
});
});
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('包一層 datahttp_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 });
});
});