/** * 「靜態租戶字串不得用於資料面過濾」這道閘**自己**的測試(Arcrun#108)。 * * 收工標準明列三條,這裡逐條釘: * ① 會擋,不是只提醒 → 壞例子必須產出違規(CLI 端據此 exit 1、hook 據此 exit 2) * ② 判準看「有沒有在做那件事」 → 一整組「長得像但沒在做」的合法寫法必須零誤攔 * ③ 閘自己要能被測試 → 規則是純函式,這裡直接餵字串;不需要跑檔案系統、也擋不到自己 * * 外加一條回歸:現行 src/ 必須是乾淨的(`?raw` 讀真原始碼,不是讀我編的假字串)。 */ import { describe, it, expect } from 'vitest'; // @ts-expect-error -- 純規則模組(.mjs,零 node 相依),型別非本檔關注重點 import { scanSource, TENANT_SOURCE_FILE } from '../scripts/tenant-source-rules.mjs'; // @ts-expect-error -- vite ?raw:build-time 讀檔,runtime 是純字串(Workers 沒有 node:fs) import tenantLibSource from '../src/lib/tenant.ts?raw'; // @ts-expect-error -- 同上 import portalDataSource from '../src/routes/portal-data.ts?raw'; // @ts-expect-error -- 同上 import portalSource from '../src/routes/portal.ts?raw'; // @ts-expect-error -- 同上 import consoleAuthSource from '../src/routes/console-auth.ts?raw'; // @ts-expect-error -- 同上 import consoleDashboardSource from '../src/routes/console-dashboard.ts?raw'; type Violation = { rule: string; line: number; text: string; message: string }; const scan = (path: string, text: string): Violation[] => scanSource(path, text) as Violation[]; const rulesOf = (v: Violation[]): string[] => [...new Set(v.map((x) => x.rule))].sort(); const FILE = 'src/routes/portal-data.ts'; describe('閘會擋:三種「靜態租戶字串進資料面」的真實形狀', () => { it('T1 — 在 tenant.ts 以外讀租戶環境變數(#105/#108 的原句)', () => { const bad = `export function portalTenant(env: Bindings): string {\n return env.CONSOLE_TENANT || 'leo';\n}`; const v = scan('src/routes/portal.ts', bad); expect(rulesOf(v)).toContain('T1'); expect(v[0].message).toContain('knowledgeOwner'); }); it('T1 — `c.env.ARCRUN_NAMESPACE` 也一樣(換一個變數名不是換一個做法)', () => { const v = scan('src/routes/console-dashboard.ts', `const t = c.env.ARCRUN_NAMESPACE || 'leo';`); expect(rulesOf(v)).toEqual(['T1']); }); it('T2 — 繞過唯一產地自己 cast 一個 TenantId', () => { const v = scan(FILE, `const tenant = (c.env.SOMETHING ?? '') as TenantId;`); expect(rulesOf(v)).toContain('T2'); }); it('T2 — 連在 tenant.ts 裡都不准把「字面字串」當成租戶識別(那就是 `|| "leo"` 的原形)', () => { const v = scan(TENANT_SOURCE_FILE, ` return 'leo' as TenantId;`); expect(rulesOf(v)).toEqual(['T2']); expect(v[0].message).toContain('TenantUnresolvedError'); }); it('T3 — 拿帳號層字串去組知識資料面的 owner_id(#108 那一行,逐字)', () => { const bad = " const res = await kbdbFetch(c.env, `/map?owner_id=${encodeURIComponent(portalTenant(c.env))}`);"; const v = scan(FILE, bad); expect(rulesOf(v)).toContain('T3'); expect(v[0].message).toContain('1854'); }); it('T3 — 換成物件屬性寫法一樣擋(`owner_id: portalTenant(c.env)`)', () => { const v = scan(FILE, ` body: JSON.stringify({ values, owner_id: portalTenant(c.env) }),`); expect(rulesOf(v)).toContain('T3'); }); it('T3 — accountTenant() 這個新名字也擋(規則盯的是「這是帳號層的值」,不是某個函式名字的拼法)', () => { const v = scan(FILE, " kbdbFetch(env, `/entries?owner_id=${accountTenant(env)}`);"); expect(rulesOf(v)).toContain('T3'); }); }); describe('閘不誤攔:長得像、但沒有在做那件事的合法寫法', () => { const legit: [string, string, string][] = [ ['讀取別人回傳的 owner_id(不是在組過濾)', FILE, ` if (!isOwnedBy(entry.owner_id, knowledgeOwner(c.env))) return notFound(c);`], ['型別宣告裡的 owner_id 欄位', FILE, ` | { record?: { values?: Record; owner_id?: string | null } }`], ['走唯一入口組過濾', FILE, " const res = await kbdbFetch(c.env, `/map?${ownerQuery(tenant)}`);"], ['走唯一入口填 body', FILE, ` body: JSON.stringify({ template, values, owner_id: ownerField(tenant) }),`], ['帳號子 namespace 的過濾(`{tenant}::portal`,那是 cypher 自己寫的資料)', 'src/routes/portal.ts', ` const res = await kbdbFetch(env, \`/records/by-template/x?owner_id=\${encodeURIComponent(ns)}\`);`], ['請求自帶的租戶(webhooks-named 慣例:呼叫端就是租戶)', 'src/routes/webhooks-named.ts', ` owner_id: apiKey,`], ['註解裡整句在講 CONSOLE_TENANT 與 owner_id(文件不是行為)', FILE, `// 之前的病:owner_id 拿 env.CONSOLE_TENANT,portalTenant(c.env) 那條路整個空掉`], ['JSDoc 區塊裡出現同樣的字', FILE, ` * 舊寫法 owner_id=\${portalTenant(env)} 已廢除,改走 knowledgeOwner。`], ['行末註解裡出現(程式碼本身乾淨)', FILE, ` const tenant = knowledgeOwner(c.env); // 不是 portalTenant(c.env),也不是 owner_id=leo`], ['tenant.ts 自己讀環境變數(它就是唯一產地)', TENANT_SOURCE_FILE, ` const injected = (env.ARCRUN_NAMESPACE ?? '').trim();`], ['types.ts 只宣告型別不取值', 'src/types.ts', ` CONSOLE_TENANT?: string;`], ]; for (const [name, path, line] of legit) { it(`零誤攔:${name}`, () => { expect(scan(path, line)).toEqual([]); }); } }); describe('回歸:現行原始碼是乾淨的(讀真檔,不是讀我編的字串)', () => { const files: [string, string][] = [ [TENANT_SOURCE_FILE, tenantLibSource as string], ['src/routes/portal-data.ts', portalDataSource as string], ['src/routes/portal.ts', portalSource as string], ['src/routes/console-auth.ts', consoleAuthSource as string], ['src/routes/console-dashboard.ts', consoleDashboardSource as string], ]; for (const [path, text] of files) { it(`${path} 零違規`, () => { expect(scan(path, text)).toEqual([]); }); } it('唯一產地本身沒有字面預設值(knowledgeOwner 解析不到要用丟的,不是回 "leo")', () => { const body = (tenantLibSource as string).slice( (tenantLibSource as string).indexOf('export function knowledgeOwner'), (tenantLibSource as string).indexOf('export function tenantFromApiKey'), ); expect(body).toContain('TenantUnresolvedError'); // 解析路徑只准回 env 讀到的值;任何 `|| '...'` / `?? '...'` 形式的字面 fallback 都是本票的病本身 expect(body).not.toMatch(/(\|\||\?\?)\s*['"][^'"]+['"]/); }); });