import { describe, expect, test } from "bun:test"; import { pathToFileURL } from "node:url"; import { join } from "node:path"; import { defineAuthz, getAuthzCatalog, mergeCatalogs, setAuthzCatalog } from "@wrnexus/authz"; import { applyAuthzManifestEarly, createProductionHandlers, type ProdManifest, } from "../src/prod.ts"; const EMPTY_MANIFEST: ProdManifest = { pages: [], api: [], realtime: [], middleware: [], components: [], layouts: [], }; const PROD_URL = pathToFileURL(join(import.meta.dir, "..", "src", "prod.ts")).href; describe("createProductionHandlers authorization wiring (the idempotent second pass)", () => { test("an empty/absent authz array never throws, whatever the ambient catalog state", () => { createProductionHandlers(EMPTY_MANIFEST, { authz: [] }); createProductionHandlers(EMPTY_MANIFEST, {}); }); test("starting from a genuinely unset catalog, an empty/absent authz array yields an empty catalog", async () => { // bun test does NOT isolate module instances between test files run in // the same invocation (see client.test.ts's comment on the same trap), // so "no catalog set yet" cannot be observed reliably in-process — some // other file's test may already have called setAuthzCatalog. A fresh // subprocess is the only way to guarantee that. const proc = Bun.spawn({ cmd: [ "bun", "-e", `const mod = await import(${JSON.stringify(PROD_URL)}); const manifest = { pages: [], api: [], realtime: [], middleware: [], components: [], layouts: [] }; mod.createProductionHandlers(manifest, { authz: [] }); const { getAuthzCatalog } = await import("@wrnexus/authz"); console.log("SIZE:" + getAuthzCatalog().permissions.size);`, ], stdout: "pipe", stderr: "pipe", cwd: join(import.meta.dir, ".."), }); const [stdout, stderr, exitCode] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited, ]); expect(stderr).toBe(""); expect(exitCode).toBe(0); expect(stdout).toContain("SIZE:0"); }); test("a declaration with no default export warns and is skipped, not fatal", () => { const originalWarn = console.warn; const warnings: unknown[][] = []; console.warn = (...args: unknown[]) => { warnings.push(args); }; try { createProductionHandlers(EMPTY_MANIFEST, { authz: [ { source: "broken.ts", module: undefined }, { source: "ok.ts", module: defineAuthz({ permissions: { "post:read": {} } }), }, ], }); } finally { console.warn = originalWarn; } expect(getAuthzCatalog().permissions.has("post:read")).toBe(true); expect(getAuthzCatalog().permissions.size).toBe(1); expect(warnings.some((args) => args.some((arg) => String(arg).includes("broken.ts")))).toBe( true, ); }); test("a conflicting pair of declarations throws, naming both source files", () => { expect(() => createProductionHandlers(EMPTY_MANIFEST, { authz: [ { source: "a.ts", module: defineAuthz({ permissions: { "post:read": { risk: "low" } } }), }, { source: "b.ts", module: defineAuthz({ permissions: { "post:read": { risk: "high" } } }), }, ], }), ).toThrow(/WRN-AUTHZ-CONFLICT/); let thrown: unknown; try { createProductionHandlers(EMPTY_MANIFEST, { authz: [ { source: "a.ts", module: defineAuthz({ permissions: { "post:read": { risk: "low" } } }), }, { source: "b.ts", module: defineAuthz({ permissions: { "post:read": { risk: "high" } } }), }, ], }); } catch (error) { thrown = error; } expect(thrown).toBeInstanceOf(Error); const message = (thrown as Error).message; expect(message).toContain("a.ts"); expect(message).toContain("b.ts"); }); test("calling createProductionHandlers a second time with different declarations re-validates, not skips", () => { // Regression guard for the "skip merging if a catalog is already set" // trap: since setAuthzCatalog is a process-wide singleton, an earlier // test (or an earlier createProductionHandlers call in the same process) // can leave hasAuthzCatalog() true. This call must still independently // merge+validate its OWN opts.authz, not silently trust a stale catalog // left over from something else. createProductionHandlers(EMPTY_MANIFEST, { authz: [{ source: "first.ts", module: defineAuthz({ permissions: { "a:read": {} } }) }], }); expect(getAuthzCatalog().permissions.has("a:read")).toBe(true); createProductionHandlers(EMPTY_MANIFEST, { authz: [{ source: "second.ts", module: defineAuthz({ permissions: { "b:read": {} } }) }], }); expect(getAuthzCatalog().permissions.has("a:read")).toBe(false); expect(getAuthzCatalog().permissions.has("b:read")).toBe(true); }); test("a caller-set catalog survives when opts.authz is omitted (the client.ts escape hatch)", () => { // client.ts documents that a direct caller of createProductionHandlers may // call setAuthzCatalog(catalog) itself, before importing anything that // reads it, when it bypasses the generated `.authz-setup.ts` entry. That // catalog must not be wiped just because this call's own opts.authz is // empty/absent. const preset = mergeCatalogs([ { source: "preset.ts", module: defineAuthz({ permissions: { "preset:read": {}, "preset:write": {}, "preset:delete": {} }, }), }, ]); setAuthzCatalog(preset); expect(getAuthzCatalog().permissions.size).toBe(3); createProductionHandlers(EMPTY_MANIFEST, {}); expect(getAuthzCatalog()).toBe(preset); expect(getAuthzCatalog().permissions.size).toBe(3); expect(getAuthzCatalog().permissions.has("preset:read")).toBe(true); }); test("a non-empty opts.authz still sets (and still throws on a conflict), even over a pre-set catalog", () => { const preset = mergeCatalogs([ { source: "preset.ts", module: defineAuthz({ permissions: { "preset:read": {} } }) }, ]); setAuthzCatalog(preset); // A non-empty authz array must still replace the pre-set catalog with the // merged result of ITS OWN declarations, not defer to the pre-set one. createProductionHandlers(EMPTY_MANIFEST, { authz: [{ source: "own.ts", module: defineAuthz({ permissions: { "own:read": {} } }) }], }); expect(getAuthzCatalog()).not.toBe(preset); expect(getAuthzCatalog().permissions.has("own:read")).toBe(true); expect(getAuthzCatalog().permissions.has("preset:read")).toBe(false); // And a genuine conflict inside that non-empty array still throws, exactly // as it did before this pass became conditional. setAuthzCatalog(preset); expect(() => createProductionHandlers(EMPTY_MANIFEST, { authz: [ { source: "a.ts", module: defineAuthz({ permissions: { "post:read": { risk: "low" } } }), }, { source: "b.ts", module: defineAuthz({ permissions: { "post:read": { risk: "high" } } }), }, ], }), ).toThrow(/WRN-AUTHZ-CONFLICT/); }); }); describe("applyAuthzManifestEarly (the eager, silent pass called only by the generated .authz-setup.ts)", () => { test("sets the catalog from valid declarations", () => { applyAuthzManifestEarly([ { source: "early.ts", module: defineAuthz({ permissions: { "early:read": {} } }) }, ]); expect(getAuthzCatalog().permissions.has("early:read")).toBe(true); }); test("silently skips a missing default export — no warning, no throw", () => { const originalWarn = console.warn; let warnCalls = 0; console.warn = () => { warnCalls++; }; try { expect(() => applyAuthzManifestEarly([{ source: "broken.ts", module: undefined }]), ).not.toThrow(); } finally { console.warn = originalWarn; } expect(warnCalls).toBe(0); expect(getAuthzCatalog().permissions.size).toBe(0); }); test("still throws on a genuine conflict (fatal either way, just earlier)", () => { expect(() => applyAuthzManifestEarly([ { source: "a.ts", module: defineAuthz({ permissions: { "post:read": { risk: "low" } } }) }, { source: "b.ts", module: defineAuthz({ permissions: { "post:read": { risk: "high" } } }) }, ]), ).toThrow(/WRN-AUTHZ-CONFLICT/); }); });