Round-1 review fixes for Task 13: - packages/cli/package.json was missing @wrnexus/authz, and packages/authz/package.json was missing @wrnexus/core despite importing its types in index.ts/middleware.ts/advanced.ts. Both only worked in-repo because bare "@wrnexus/*" specifiers resolve through the root tsconfig.json paths map; a standalone install of @wrnexus/cli or @wrnexus/authz would fail at runtime. - authz.ts's unknown/missing-subcommand and bad --dialect paths now console.error + process.exit(1), matching db.ts's convention, instead of throwing — index.ts's top-level catch previously printed those as a raw stack trace. Added a subprocess-level test that spawns the real CLI and asserts stderr has the usage line with no stack frame. - nextMigrationNumber now extracts the leading-digit run the same way db/migrate.ts's nextNumber does, instead of a fixed slice(0, 4) that would have undercounted once a migration number passed 9999.
256 lines
10 KiB
TypeScript
256 lines
10 KiB
TypeScript
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<void>): Promise<string> {
|
|
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 <fn> (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");
|
|
});
|
|
});
|