/** * Arcrun#108 — 藏書地圖看得到自己的知識(租戶字串來源收斂)。 * * 釘住的事實: * 1. 資料面 owner_id 來自 `knowledgeOwner(env)`:`ARCRUN_NAMESPACE` 優先、`CONSOLE_TENANT` 回退、 * 兩者皆無 → 丟 TenantUnresolvedError(**沒有 `|| 'leo'` 這種靜默預設值**)。 * 2. `/portal/data/map` 真的拿那個值去打 KBDB(leo 的情境:ARCRUN_NAMESPACE=bfezv28v * → 打 `owner_id=bfezv28v` 拿回 9 個庫,而不是打 `owner_id=leo` 拿回 0 個)。 * 3. **權限沒有被拿掉**:同一份 KBDB 回應,庫權限 ["kb"] 的帳號只看得到 kb。 * 4. 空地圖分得出四種成因(#100 那條「讀不到就說讀不到」延伸到藏書地圖): * no_library_grant / filtered_out / scope_mismatch / confirmed_empty。 * 5. 回應**不含租戶字串**(design §3.3 紅線:前端拿到就能繞過庫過濾直打 /kbdb/*)。 */ import { env, fetchMock } from 'cloudflare:test'; import { beforeAll, afterEach, describe, it, expect } from 'vitest'; import { knowledgeOwner, accountTenant, TenantUnresolvedError, ownerQuery, censusQueryAllTenants } from '../src/lib/tenant'; import { portalDataRouter } from '../src/routes/portal-data'; import type { Bindings } from '../src/types'; const KBDB = 'https://kbdb.test'; /** leo 的真實命名空間(2026-08-11 回灌時定名,見 Leo/mira#8)。 */ const LEO_NS = 'bfezv28v'; beforeAll(() => { fetchMock.activate(); fetchMock.disableNetConnect(); }); afterEach(() => fetchMock.assertNoPendingInterceptors()); /** * 直接餵 router 一份 env(不是 SELF.fetch)——`cloudflare:test` 的 `env` 物件改了不會傳進 * SELF 那個 worker(實測:改 ARCRUN_BUNDLE_VERSION 後 /health 仍回舊值), * 而本票要驗的正是「換一個命名空間,查詢就跟著換」。Hono router 吃 env 參數, * 走的是同一支 handler、同一條 KBDB fetch,只有 env 這一項是測試給的。 */ const ctx = { waitUntil: () => {}, passThroughOnException: () => {} } as unknown as ExecutionContext; async function seedSession(token: string, recordId: string) { await env.SESSIONS_KV.put(`portal_sess:${token}`, JSON.stringify({ record_id: recordId })); } function mockGetRecord(recordId: string, libraries: string) { fetchMock .get(KBDB) .intercept({ path: `/records/${recordId}`, method: 'GET' }) .reply(200, { success: true, record: { record_id: recordId, template_id: 'tpl_pu', values: { email: 'leo@example.com', display_name: 'leo', status: 'active', role: 'admin', password_hash: 'pbkdf2-sha256$600000$AA$BB', libraries, created_at: '2026-08-12T00:00:00.000Z', updated_at: '2026-08-12T00:00:00.000Z', }, }, }); } /** 攔 `/map`,同時把「實際被查詢的 owner_id」記下來給斷言用。 */ function mockMap(libraries: { library: string; triplet_count: number }[], seen: string[]) { fetchMock .get(KBDB) .intercept({ path: (p: string) => { if (!p.startsWith('/map')) return false; seen.push(new URL(p, KBDB).searchParams.get('owner_id') ?? ''); return true; }, method: 'GET', }) .reply(200, { success: true, libraries, count: libraries.length }); } function mockTripletStats(match: (ownerId: string) => boolean, tripletCount: number) { fetchMock .get(KBDB) .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats') && match(new URL(p, KBDB).searchParams.get('owner_id') ?? ''), method: 'GET', }) .reply(200, { success: true, stats: [{ library: 'kb', triplet_count: tripletCount }] }); } async function getMap(token: string, overrides: Partial = {}) { const res = await portalDataRouter.fetch( new Request('http://localhost/portal/data/map', { headers: { authorization: `Bearer ${token}` } }), { ...env, ...overrides } as Bindings, ctx, ); return { status: res.status, body: (await res.json()) as Record }; } /** undici 的 path matcher 可能被呼叫多次 → 比對前先去重(我們在意的是「查了哪些 owner_id」)。 */ const distinct = (xs: string[]): string[] => [...new Set(xs)]; // ── ① 唯一產地的解析順序 ─────────────────────────────────────────────────────── describe('knowledgeOwner:租戶字串只有一個產地,且沒有靜默預設值', () => { it('ARCRUN_NAMESPACE 優先(=acr update 從 ~/.arcrun/config.yaml 的 api_key 注入的那個值)', () => { expect(knowledgeOwner({ ARCRUN_NAMESPACE: LEO_NS, CONSOLE_TENANT: 'leo' } as Bindings)).toBe(LEO_NS); }); it('沒注入 → 回退 CONSOLE_TENANT(官方 prod 與尚未 acr update 的實例,行為一字不變)', () => { expect(knowledgeOwner({ CONSOLE_TENANT: 'leo' } as Bindings)).toBe('leo'); }); it('空字串不算數(部署把 var 設成空字串 ≠ 有設定)', () => { expect(knowledgeOwner({ ARCRUN_NAMESPACE: ' ', CONSOLE_TENANT: 'leo' } as Bindings)).toBe('leo'); }); it('兩個都沒有 → 丟 TenantUnresolvedError,**不回 "leo"**(靜默預設值正是本票的病)', () => { expect(() => knowledgeOwner({} as Bindings)).toThrow(TenantUnresolvedError); }); it('帳號層 accountTenant 不受影響(改它會讓舊實例登不進去,所以刻意不動)', () => { expect(accountTenant({ ARCRUN_NAMESPACE: LEO_NS, CONSOLE_TENANT: 'leo' } as Bindings)).toBe('leo'); expect(accountTenant({} as Bindings)).toBe('leo'); }); it('過濾片段只有兩種形狀:帶租戶的 ownerQuery,與明著喊全庫的普查', () => { expect(ownerQuery(knowledgeOwner({ ARCRUN_NAMESPACE: 'a b' } as Bindings))).toBe('owner_id=a%20b'); expect(censusQueryAllTenants()).toBe('owner_id='); }); }); // ── ② 地圖真的用那個 owner_id 去查 ───────────────────────────────────────────── describe('GET /portal/data/map — leo 的情境(1854 條 → 看得到,不是 0 個庫)', () => { it('注入 ARCRUN_NAMESPACE 後,KBDB 收到的 owner_id 是它,而且庫都回得來', async () => { await seedSession('t-map-1', 'rec_leo'); mockGetRecord('rec_leo', '["*"]'); const seen: string[] = []; mockMap( [ { library: 'kb', triplet_count: 1851 }, { library: 'general', triplet_count: 3 }, ], seen, ); const { status, body } = await getMap('t-map-1', { ARCRUN_NAMESPACE: LEO_NS }); expect(status).toBe(200); expect(distinct(seen)).toEqual([LEO_NS]); // ← 這一行就是本票:以前送出去的是 'leo' expect(body.count).toBe(2); expect((body.libraries as { library: string; triplet_count: number }[]).map((l) => l.triplet_count)) .toEqual([1851, 3]); expect(body.empty_reason).toBeNull(); }); it('回應不含租戶字串(前端拿到就能繞過庫過濾直打 /kbdb/*——design §3.3 紅線)', async () => { await seedSession('t-map-2', 'rec_leo2'); mockGetRecord('rec_leo2', '["*"]'); mockMap([{ library: 'kb', triplet_count: 1851 }], []); const { body } = await getMap('t-map-2', { ARCRUN_NAMESPACE: LEO_NS }); expect(JSON.stringify(body)).not.toContain(LEO_NS); expect(JSON.stringify(body)).not.toContain('ARCRUN_NAMESPACE'); }); it('沒注入時沿用 CONSOLE_TENANT(未跑 acr update 的實例行為不變,這次改動對它是惰性的)', async () => { await seedSession('t-map-3', 'rec_leo3'); mockGetRecord('rec_leo3', '["*"]'); const seen: string[] = []; mockMap([{ library: 'kb', triplet_count: 1 }], seen); await getMap('t-map-3'); expect(distinct(seen)).toEqual(['leo']); // wrangler.test.toml CONSOLE_TENANT }); }); // ── ③ 權限沒有被拿掉(紅線:修這題不准把 owner_id 過濾或庫過濾拆掉)────────────── describe('權限:只被授權部分庫的帳號,只看得到那幾個庫', () => { it('libraries=["kb"] → 同一份 KBDB 回應裡只剩 kb', async () => { await seedSession('t-perm-1', 'rec_partial'); mockGetRecord('rec_partial', '["kb"]'); mockMap( [ { library: 'kb', triplet_count: 1851 }, { library: 'finance', triplet_count: 42 }, { library: 'general', triplet_count: 3 }, ], [], ); const { body } = await getMap('t-perm-1', { ARCRUN_NAMESPACE: LEO_NS }); expect((body.libraries as { library: string }[]).map((l) => l.library)).toEqual(['kb']); expect(body.count).toBe(1); }); it('一個庫都沒被授權 → 不打 KBDB,誠實說是權限問題', async () => { await seedSession('t-perm-2', 'rec_nolib'); mockGetRecord('rec_nolib', '[]'); const { body } = await getMap('t-perm-2'); // 沒有 mockMap:打了就會 assertNoPendingInterceptors 失敗 expect(body.count).toBe(0); expect(body.empty_reason).toBe('no_library_grant'); expect(body.empty_confirmed).toBe(true); }); it('實例有庫但都不在權限內 → filtered_out(是隔離正常,不是資料不見)', async () => { await seedSession('t-perm-3', 'rec_other'); mockGetRecord('rec_other', '["finance"]'); mockMap([{ library: 'kb', triplet_count: 1851 }], []); const { body } = await getMap('t-perm-3', { ARCRUN_NAMESPACE: LEO_NS }); expect(body.empty_reason).toBe('filtered_out'); expect(body.empty_confirmed).toBe(true); }); }); // ── ④ 空地圖的四種成因分得出來(不再把設定錯誤畫成「你沒有資料」)──────────────── describe('空地圖:分得出「讀不到」與「沒有」', () => { it('命名空間對不上(本租戶 0、整台實例有)→ scope_mismatch,並指出該跑 acr update', async () => { await seedSession('t-empty-1', 'rec_e1'); mockGetRecord('rec_e1', '["*"]'); mockMap([], []); mockTripletStats((o) => o === 'wrong-ns', 0); // 本租戶 0 mockTripletStats((o) => o === '', 1854); // 全庫普查:有 1854 條 const { body } = await getMap('t-empty-1', { ARCRUN_NAMESPACE: 'wrong-ns' }); expect(body.empty_reason).toBe('scope_mismatch'); expect(body.empty_confirmed).toBe(false); // 🔴 絕不宣稱「你沒有資料」 expect(body.instance_triplet_count).toBe(1854); expect(String(body.note)).toContain('acr update'); expect(JSON.stringify(body)).not.toContain('wrong-ns'); // 仍不下發租戶字串 }); it('整台實例真的空 → confirmed_empty(此時、也只有此時,才准說「還沒有內容」)', async () => { await seedSession('t-empty-2', 'rec_e2'); mockGetRecord('rec_e2', '["*"]'); mockMap([], []); mockTripletStats((o) => o === 'leo', 0); mockTripletStats((o) => o === '', 0); const { body } = await getMap('t-empty-2'); expect(body.empty_reason).toBe('confirmed_empty'); expect(body.empty_confirmed).toBe(true); }); it('連統計都讀不到 → unreadable(不假裝是空庫)', async () => { await seedSession('t-empty-3', 'rec_e3'); mockGetRecord('rec_e3', '["*"]'); mockMap([], []); fetchMock .get(KBDB) .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' }) .reply(500, { error: 'boom' }); const { body } = await getMap('t-empty-3'); expect(body.empty_reason).toBe('unreadable'); expect(body.empty_confirmed).toBe(false); }); });