feat(cli): add wrnexus authz list/generate/init
Introspects the merged authz catalog, emits app/authz/permissions.gen.ts type unions, and scaffolds the assignment-table migration. init validates --dialect explicitly (unrecognised values reject rather than silently falling back to sqlite) and joins authzMigrationSql's up/down statement lists with terminators instead of interpolating the arrays. Test scaffolding for dynamically-imported app/authz declarations must live inside the repo tree (not os.tmpdir()) for the "@wrnexus/*" bare specifier to resolve via tsconfig paths; .gitignore excludes the scratch dirs this produces.
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user