Files
Arcrun/cypher-executor/tests/portal-data.test.ts
T
uncle6me-web 35a769fc88 portal-auth P3 測試:21 項(HTML 零租戶字串/enforce 繞不過/越庫 404/graph 粗閘/workflows 唯讀)
- portal-data.test.ts:/portal HTML 殼機械斷言(無 'leo'/X-Arcrun-API-Key//kbdb//Mira);
  search 注入證明(caller 帶 library=hr&owner_id=evil 被靜默覆蓋);entries/:id 越庫/
  跨租戶/不存在同一句 404+NULL→general;graph 403 不打 plugin/放行轉發/disabled
  來源排除;workflows 非 admin 403、admin 唯讀無 webhook_url、workflowsVisible 單元
- portal-auth.test.ts session 測試補 P3 能力欄位(graph_allowed/workflows_visible)
- wrangler.test.toml 補 ANALYTICS_KV mock+KBDB_GRAPH_URL 假 host(絕不外連)
- cypher 154/155(唯一失敗=executor 不存在零件 pre-existing,git stash 複驗);
  kbdb 20/20;兩包 tsc exit 0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:10:19 +08:00

358 lines
18 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 } 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);
});
});