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>
This commit is contained in:
@@ -264,12 +264,17 @@ describe('GET /portal/session', () => {
|
||||
it('有效 session → 回 display_name/role/libraries,無租戶字串', async () => {
|
||||
await seedPortalSession('tok-1', 'rec_1');
|
||||
mockGetRecord('rec_1', activeUserValues());
|
||||
// P3:session 多回 graph_allowed(D-4)——非 ["*"] 用戶要查庫目錄算 graph 來源庫
|
||||
mockListByTemplate('portal_library', []);
|
||||
const res = await json('GET', '/portal/session', undefined, { Authorization: 'Bearer tok-1' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as Record<string, unknown>;
|
||||
expect(data.valid).toBe(true);
|
||||
expect(data.libraries).toEqual(['general', 'finance']);
|
||||
expect('tenant' in data).toBe(false);
|
||||
// P3 能力欄位:來源庫預設 general、本用戶有 general → graph 放行;workflows 預設 admin-only
|
||||
expect(data.graph_allowed).toBe(true);
|
||||
expect(data.workflows_visible).toBe(false);
|
||||
});
|
||||
|
||||
it('帳號被停用 → 既有 session 立即失效(403)且 KV session 被清', async () => {
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* 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 } 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 並回收實際轉發的 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<string, unknown>);
|
||||
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<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('跨租戶 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<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);
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,11 @@ id = "test-recipes"
|
||||
binding = "SESSIONS_KV"
|
||||
id = "test-sessions-kv"
|
||||
|
||||
# portal-auth P3:/portal/data/workflows 讀最近執行(stats:{name}:{ts})
|
||||
[[kv_namespaces]]
|
||||
binding = "ANALYTICS_KV"
|
||||
id = "test-analytics-kv"
|
||||
|
||||
# credential-store-migration T8/T9 測試用 D1 mock(Miniflare 本地 SQLite,非真實 leo21c D1;
|
||||
# schema 由 tests/setup.ts 在測試啟動時建表,不用 migrations_dir——0002_credentials.sql 就一張表,
|
||||
# 直接 exec 比接 migrations 機制簡單)
|
||||
@@ -43,3 +48,5 @@ ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd
|
||||
# 分流台勾掉 route 測試:KBDB 指到假 host(fetchMock 攔截,絕不外連——尤其不打官方 uncle6 fallback)
|
||||
KBDB_BASE_URL = "https://kbdb.test"
|
||||
CONSOLE_TENANT = "leo"
|
||||
# portal-auth P3:graph 粗閘放行後的轉發目標也指假 host(fetchMock 攔截,絕不外連)
|
||||
KBDB_GRAPH_URL = "https://graph.test"
|
||||
|
||||
Reference in New Issue
Block a user