import { afterAll, describe, expect, 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 }); }); 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 throws with usage", async () => { await expect(runAuthzCommand(scaffold(), "bogus", [])).rejects.toThrow(/usage/i); }); test("a missing subcommand throws with usage", async () => { await expect(runAuthzCommand(scaffold(), undefined, [])).rejects.toThrow(/usage/i); }); 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 }); await expect(runAuthzCommand(root, "init", ["--dialect=oracle"])).rejects.toThrow(/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("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"); }); });