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 { 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 }[]).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(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(fixed.permissions); expect(jobsBehind(fixed.jobs, (p) => has2.has(p))).toEqual([]); }); });