50c18405b6
Closes the permissions-matrix loose ends (venue-modules.md §Permissions matrix): - `role_jobs` (migration 0029): a role stores the manifest jobs it was composed from (chips on at save + any bundle fully present). `jobById` / `jobsBehind` in @parking/shared surface a followed job whose bundle grew past the role in a later release; the roles list shows a "behind <job>" badge with a one-click "Update to job" (the union, nothing removed) and the editor lints it. Never a runtime union: the grid stays the explicit enforcement layer and an update never widens a role without a click. - Every role create/update/delete appends a `config_change` (`role.<id>`, prev/value = name + sorted permissions + jobs, operator); a no-op resave signs nothing. roleRoutes now takes the ledger. - booth-supervisor already carries subscription:*; the stale open note is closed. Tests: routes/roles.test.ts. Wiki: venue-modules status, local-jwt-auth, log. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
103 lines
5.0 KiB
TypeScript
103 lines
5.0 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { createTestDb } from "@parking/db/testing";
|
|
import { type Db } from "@parking/db";
|
|
import type { FastifyInstance } from "fastify";
|
|
import { jobsBehind } from "@parking/shared";
|
|
import { buildServer } from "../server.js";
|
|
import { login, seedUser } from "../test-helpers.js";
|
|
|
|
// Roles are data composed from the permission grid (venue-modules.md §Permissions
|
|
// matrix): every edit is SIGNED as a config_change, and a role remembers the manifest
|
|
// JOBS it was built from so a grown job can be surfaced and re-applied.
|
|
|
|
let db: Db;
|
|
let close: () => void;
|
|
let app: FastifyInstance;
|
|
beforeEach(async () => {
|
|
delete process.env.MODULES_ENTITLED;
|
|
const t = createTestDb();
|
|
db = t.db;
|
|
close = t.close;
|
|
app = await buildServer({ db });
|
|
await app.ready();
|
|
});
|
|
afterEach(async () => {
|
|
await app.close();
|
|
close();
|
|
});
|
|
type Auth = { cookie: string; csrf: string };
|
|
const hdrs = (a: Auth) => ({ cookie: a.cookie, "x-csrf-token": a.csrf });
|
|
async function admin(): Promise<Auth> {
|
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
|
return login(app, username, password);
|
|
}
|
|
async function roleChanges(a: Auth) {
|
|
const r = await app.inject({ method: "GET", url: "/api/events?limit=100", headers: { cookie: a.cookie } });
|
|
return (r.json().events as { type: string; payload: Record<string, unknown> }[]).filter(
|
|
(e) => e.type === "config_change" && String(e.payload.setting).startsWith("role."),
|
|
);
|
|
}
|
|
|
|
describe("role edits are signed and jobs are remembered", () => {
|
|
it("create / update / delete each sign one config_change with prev + value + operator; a no-op resave signs nothing", async () => {
|
|
const a = await admin();
|
|
const created = await app.inject({
|
|
method: "POST", url: "/api/roles", headers: hdrs(a),
|
|
payload: { name: "Lavazh", permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"], jobs: ["wash-operator"] },
|
|
});
|
|
expect(created.statusCode).toBe(201);
|
|
const role = created.json();
|
|
expect(role.jobs).toEqual(["wash-operator"]);
|
|
let evs = await roleChanges(a);
|
|
expect(evs).toHaveLength(1);
|
|
expect(evs[0]!.payload).toMatchObject({
|
|
setting: `role.${role.id}`, prev: null, operator: "boss",
|
|
value: { name: "Lavazh", jobs: ["wash-operator"] },
|
|
});
|
|
expect((evs[0]!.payload.value as { permissions: string[] }).permissions).toEqual(["carwash:cash", "carwash:create", "carwash:read", "carwash:update"]);
|
|
|
|
// Same content again → nothing new on the chain.
|
|
const same = await app.inject({
|
|
method: "PUT", url: `/api/roles/${role.id}`, headers: hdrs(a),
|
|
payload: { permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"], jobs: ["wash-operator"] },
|
|
});
|
|
expect(same.statusCode).toBe(200);
|
|
expect(await roleChanges(a)).toHaveLength(1);
|
|
|
|
// A real change: prev is the old shape, value the new.
|
|
const renamed = await app.inject({ method: "PUT", url: `/api/roles/${role.id}`, headers: hdrs(a), payload: { name: "Lavazh NEW" } });
|
|
expect(renamed.statusCode).toBe(200);
|
|
evs = await roleChanges(a);
|
|
expect(evs).toHaveLength(2);
|
|
expect(evs[0]!.payload).toMatchObject({ prev: { name: "Lavazh" }, value: { name: "Lavazh NEW" } });
|
|
|
|
const gone = await app.inject({ method: "DELETE", url: `/api/roles/${role.id}`, headers: hdrs(a) });
|
|
expect(gone.statusCode).toBe(200);
|
|
evs = await roleChanges(a);
|
|
expect(evs).toHaveLength(3);
|
|
expect(evs[0]!.payload).toMatchObject({ prev: { name: "Lavazh NEW" }, value: null });
|
|
});
|
|
|
|
it("unknown jobs are refused; a role built from a job that later grew reports what it is missing", async () => {
|
|
const a = await admin();
|
|
const bad = await app.inject({ method: "POST", url: "/api/roles", headers: hdrs(a), payload: { name: "X", permissions: [], jobs: ["bar-tender"] } });
|
|
expect(bad.statusCode).toBe(400);
|
|
// Compose "behind": the role follows wash-operator but holds only part of today's bundle
|
|
// — exactly what an older release's chip would have left once the job grew.
|
|
const r = (await app.inject({
|
|
method: "POST", url: "/api/roles", headers: hdrs(a),
|
|
payload: { name: "Old wash", permissions: ["carwash:read", "carwash:create"], jobs: ["wash-operator"] },
|
|
})).json();
|
|
const view = (await app.inject({ method: "GET", url: "/api/roles", headers: { cookie: a.cookie } })).json().roles.find((x: { id: string }) => x.id === r.id);
|
|
const has = new Set<string>(view.permissions);
|
|
expect(jobsBehind(view.jobs, (p) => has.has(p))).toEqual([{ job: "wash-operator", missing: ["carwash:update", "carwash:cash"] }]);
|
|
// Re-apply = the union; then nothing is behind.
|
|
const fixed = (await app.inject({
|
|
method: "PUT", url: `/api/roles/${r.id}`, headers: hdrs(a),
|
|
payload: { permissions: [...has, "carwash:update", "carwash:cash"] },
|
|
})).json();
|
|
const has2 = new Set<string>(fixed.permissions);
|
|
expect(jobsBehind(fixed.jobs, (p) => has2.has(p))).toEqual([]);
|
|
});
|
|
});
|