import { afterAll, describe, expect, spyOn, test } from "bun:test"; import { mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync, existsSync, rmSync, } from "node:fs"; import { join } from "node:path"; import { loadAuthzCatalog, runAuthzCommand } from "../src/authz.ts"; // Declarations under app/authz/ import "@wrnexus/authz" with a bare specifier, // which Bun resolves via the root tsconfig.json `paths` map by walking up from // the imported file's directory. os.tmpdir() lives outside the repo tree (often // on a different drive on Windows), so that walk never reaches the root // tsconfig.json and the dynamic import fails with "Cannot find module // '@wrnexus/authz'". Scaffolding under this test file's own directory keeps the // walk-up inside the repo, exactly like a real app (which has its own // node_modules/tsconfig with @wrnexus/authz installed). const scratchRoot = join(import.meta.dir, ".tmp-authz-cli"); const createdRoots: string[] = []; function scaffold(): string { mkdirSync(scratchRoot, { recursive: true }); const root = mkdtempSync(join(scratchRoot, "run-")); createdRoots.push(root); mkdirSync(join(root, "app", "authz"), { recursive: true }); mkdirSync(join(root, "app", "pages"), { recursive: true }); writeFileSync( join(root, "app", "authz", "blog.ts"), `import { defineAuthz } from "@wrnexus/authz"; export default defineAuthz({ permissions: { "post:read": { title: "View posts" }, "post:write": {} }, roles: { editor: ["post:*"] }, }); `, "utf8", ); return root; } afterAll(() => { rmSync(scratchRoot, { recursive: true, force: true }); }); /** * authz.ts's `fail()` helper (usage errors, bad --dialect) mirrors db.ts's * convention: console.error + process.exit(1), never throw — so index.ts's * generic `main().catch(err) { console.error(err); process.exit(1); }` (which * prints the raw Error, stack and all) never sees an expected validation * failure. That means a *direct* call to runAuthzCommand() would normally kill * the whole test worker via a real process.exit(); intercept both console.error * and process.exit so the failure path stays testable in-process. */ async function expectCleanFailure(run: () => Promise): Promise { const errors: string[] = []; const originalError = console.error; console.error = (...args: unknown[]) => void errors.push(args.join(" ")); const exitSpy = spyOn(process, "exit").mockImplementation(((code?: number) => { throw new Error(`__process_exit_${code}__`); }) as never); try { await expect(run()).rejects.toThrow(/^__process_exit_1__$/); } finally { console.error = originalError; exitSpy.mockRestore(); } return errors.join("\n"); } describe("wrnexus authz", () => { test("loadAuthzCatalog merges every declaration", async () => { const catalog = await loadAuthzCatalog(join(scaffold(), "app")); expect([...catalog.permissions.keys()].sort()).toEqual(["post:read", "post:write"]); expect([...catalog.roles.keys()]).toEqual(["editor"]); }); test("generate writes the permission types file", async () => { const root = scaffold(); await runAuthzCommand(root, "generate", []); const generated = readFileSync(join(root, "app", "authz", "permissions.gen.ts"), "utf8"); expect(generated).toContain('export type Permission = "post:read" | "post:write";'); }); test("init writes a migration containing both tables", async () => { const root = scaffold(); mkdirSync(join(root, "app", "db", "migrations"), { recursive: true }); await runAuthzCommand(root, "init", []); const dir = join(root, "app", "db", "migrations"); const file = readdirSync(dir).find((name: string) => name.includes("authz")); expect(file).toBeDefined(); const sql = readFileSync(join(dir, file!), "utf8"); expect(sql).toContain("_wrn_authz_assignment"); expect(sql).toContain("_wrn_authz_grant"); expect(sql).toContain("-- +down"); }); test("list prints every permission and role", async () => { const root = scaffold(); const lines: string[] = []; const original = console.log; console.log = (...args: unknown[]) => void lines.push(args.join(" ")); try { await runAuthzCommand(root, "list", []); } finally { console.log = original; } const output = lines.join("\n"); expect(output).toContain("post:read"); expect(output).toContain("editor"); }); test("an unknown subcommand prints usage and exits 1, not a thrown error", async () => { const errorOutput = await expectCleanFailure(() => runAuthzCommand(scaffold(), "bogus", [])); expect(errorOutput).toMatch(/usage/i); }); test("a missing subcommand prints usage and exits 1", async () => { const errorOutput = await expectCleanFailure(() => runAuthzCommand(scaffold(), undefined, [])); expect(errorOutput).toMatch(/usage/i); }); test("CLI subprocess: unknown subcommand prints usage without a stack trace", async () => { const cliEntry = join(import.meta.dir, "..", "src", "index.ts"); const root = scaffold(); const proc = Bun.spawn({ cmd: ["bun", cliEntry, "authz", "bogus"], cwd: root, env: { ...process.env, WRNEXUS_NO_UPDATE_CHECK: "1" }, stdout: "pipe", stderr: "pipe", }); const [stderr] = await Promise.all([ new Response(proc.stderr).text(), new Response(proc.stdout).text(), ]); const exitCode = await proc.exited; expect(exitCode).not.toBe(0); expect(stderr).toMatch(/usage/i); // A raw Error/stack trace looks like "at (file.ts:12:34)"; the clean // console.error(usage) + process.exit(1) path never produces that shape. expect(stderr).not.toMatch(/at .*\.ts:\d+/); }, 15000); test("list does not crash on an app with no app/authz directory", async () => { mkdirSync(scratchRoot, { recursive: true }); const root = mkdtempSync(join(scratchRoot, "empty-")); createdRoots.push(root); mkdirSync(join(root, "app", "pages"), { recursive: true }); const lines: string[] = []; const original = console.log; console.log = (...args: unknown[]) => void lines.push(args.join(" ")); try { await runAuthzCommand(root, "list", []); } finally { console.log = original; } expect(lines.join("\n")).toContain("Permissions (0)"); }); test("loadAuthzCatalog warns and skips a declaration file with no default export", async () => { const root = scaffold(); writeFileSync(join(root, "app", "authz", "empty.ts"), `export const notDefault = 1;\n`, "utf8"); const warnings: unknown[][] = []; const originalWarn = console.warn; console.warn = (...args: unknown[]) => void warnings.push(args); try { const catalog = await loadAuthzCatalog(join(root, "app")); expect([...catalog.permissions.keys()].sort()).toEqual(["post:read", "post:write"]); } finally { console.warn = originalWarn; } expect(warnings.some((args) => String(args.join(" ")).includes("no default export"))).toBe( true, ); }); test("generate is idempotent when run twice", async () => { const root = scaffold(); await runAuthzCommand(root, "generate", []); const first = readFileSync(join(root, "app", "authz", "permissions.gen.ts"), "utf8"); await runAuthzCommand(root, "generate", []); const second = readFileSync(join(root, "app", "authz", "permissions.gen.ts"), "utf8"); expect(second).toBe(first); }); test("init --dialect=postgres emits postgres DDL", async () => { const root = scaffold(); mkdirSync(join(root, "app", "db", "migrations"), { recursive: true }); await runAuthzCommand(root, "init", ["--dialect=postgres"]); const dir = join(root, "app", "db", "migrations"); const file = readdirSync(dir).find((name: string) => name.includes("authz")); const sql = readFileSync(join(dir, file!), "utf8"); expect(sql).toContain("SERIAL PRIMARY KEY"); }); test("init --dialect=mysql emits mysql DDL", async () => { const root = scaffold(); mkdirSync(join(root, "app", "db", "migrations"), { recursive: true }); await runAuthzCommand(root, "init", ["--dialect=mysql"]); const dir = join(root, "app", "db", "migrations"); const file = readdirSync(dir).find((name: string) => name.includes("authz")); const sql = readFileSync(join(dir, file!), "utf8"); expect(sql).toContain("AUTO_INCREMENT PRIMARY KEY"); }); test("init with an unrecognised --dialect= does not silently fall back to sqlite", async () => { const root = scaffold(); mkdirSync(join(root, "app", "db", "migrations"), { recursive: true }); const errorOutput = await expectCleanFailure(() => runAuthzCommand(root, "init", ["--dialect=oracle"]), ); expect(errorOutput).toMatch(/dialect/i); }); test("init writes a migration that the migration runner can parse", async () => { const { loadMigrations } = await import("@wrnexus/db"); const root = scaffold(); const dir = join(root, "app", "db", "migrations"); mkdirSync(dir, { recursive: true }); await runAuthzCommand(root, "init", []); const migrations = loadMigrations(dir); expect(migrations.length).toBe(1); const migration = migrations[0]!; expect(migration.up).toContain("_wrn_authz_assignment"); expect(migration.up).toContain("_wrn_authz_grant"); expect(migration.down).toContain("DROP TABLE IF EXISTS _wrn_authz_grant"); expect(migration.down).toContain("DROP TABLE IF EXISTS _wrn_authz_assignment"); }); test("init numbers the next migration correctly past a 5-digit prefix", async () => { // nextMigrationNumber originally sliced the first 4 characters of the // filename, which would have parsed "10000_big.sql" as "1000" and reused // that number instead of advancing past it. It must match db/migrate.ts's // leading-digit regex instead. const root = scaffold(); const dir = join(root, "app", "db", "migrations"); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, "0001_users.sql"), "-- +up\n\n-- +down\n", "utf8"); writeFileSync(join(dir, "10000_big.sql"), "-- +up\n\n-- +down\n", "utf8"); await runAuthzCommand(root, "init", []); const file = readdirSync(dir).find((name) => name.includes("authz")); expect(file).toBe("10001_authz_tables.sql"); }); test("generate does not clobber a real declaration file", async () => { const root = scaffold(); await runAuthzCommand(root, "generate", []); expect(existsSync(join(root, "app", "authz", "blog.ts"))).toBe(true); const original = readFileSync(join(root, "app", "authz", "blog.ts"), "utf8"); expect(original).toContain("defineAuthz"); }); });