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 { buildServer } from "./server.js"; import { seedUser, login } from "./test-helpers.js"; // Venue modules — entitled ∩ activated, enforced server-side (wiki/decisions/ // venue-modules.md). Boots the real app over an in-memory DB and drives it with // app.inject, like routes.test.ts. let db: Db; let close: () => void; let app: FastifyInstance; const savedEnv = process.env.MODULES_ENTITLED; async function boot(): Promise { const t = createTestDb(); db = t.db; close = t.close; app = await buildServer({ db }); await app.ready(); } beforeEach(async () => { delete process.env.MODULES_ENTITLED; await boot(); }); afterEach(async () => { await app.close(); close(); if (savedEnv === undefined) delete process.env.MODULES_ENTITLED; else process.env.MODULES_ENTITLED = savedEnv; }); async function admin() { const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" }); return login(app, username, password); } describe("defaults (no env, nothing activated)", () => { it("every registered module is entitled, activated and effective; /me carries the set", async () => { const { cookie } = await admin(); const cfg = await app.inject({ method: "GET", url: "/api/site-config", headers: { cookie } }); expect(cfg.statusCode).toBe(200); const body = cfg.json(); expect(body.modulesEntitled).toEqual(["parking", "validation", "carwash"]); expect(body.modulesActivated).toEqual(["parking", "validation", "carwash"]); expect(body.modules).toEqual(["parking", "validation", "carwash"]); const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } }); expect(me.json().modules).toEqual(["parking", "validation", "carwash"]); // A module route answers normally while the module is on. const programs = await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } }); expect(programs.statusCode).toBe(200); }); }); describe("activation (site admin)", () => { it("deactivating validation 403s its routes with module_disabled, signs a config_change, and is reversible", async () => { const { cookie, csrf } = await admin(); const put = await app.inject({ method: "PUT", url: "/api/site-config", headers: { cookie, "x-csrf-token": csrf }, payload: { modules: ["parking"] }, }); expect(put.statusCode).toBe(200); expect(put.json().modules).toEqual(["parking"]); expect(put.json().modulesActivated).toEqual(["parking"]); const off = await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } }); expect(off.statusCode).toBe(403); expect(off.json().code).toBe("module_disabled"); const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } }); expect(me.json().modules).toEqual(["parking"]); // The flip is on the signed ledger, attributed. const events = await app.inject({ method: "GET", url: "/api/events?limit=50", headers: { cookie } }); expect(events.statusCode).toBe(200); const list = (events.json().events ?? events.json()) as Array<{ type: string; payload: Record }>; const flip = list.find((e) => e.type === "config_change" && e.payload?.setting === "modules.validation"); expect(flip).toBeTruthy(); expect(flip!.payload).toMatchObject({ value: false, prev: true, operator: "boss" }); // Nothing was deleted: re-enable and the route is back. const back = await app.inject({ method: "PUT", url: "/api/site-config", headers: { cookie, "x-csrf-token": csrf }, payload: { modules: ["parking", "validation"] }, }); expect(back.json().modules).toEqual(["parking", "validation"]); const on = await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } }); expect(on.statusCode).toBe(200); }); it("required modules cannot be deactivated (parking is always included)", async () => { const { cookie, csrf } = await admin(); const put = await app.inject({ method: "PUT", url: "/api/site-config", headers: { cookie, "x-csrf-token": csrf }, payload: { modules: [] }, }); expect(put.statusCode).toBe(200); expect(put.json().modules).toEqual(["parking"]); }); it("rejects unknown ids with 400", async () => { const { cookie, csrf } = await admin(); const put = await app.inject({ method: "PUT", url: "/api/site-config", headers: { cookie, "x-csrf-token": csrf }, payload: { modules: ["parking", "bar"] }, }); expect(put.statusCode).toBe(400); }); it("dependency rule: carwash cannot be on while validation is off", async () => { const { cookie, csrf } = await admin(); const put = await app.inject({ method: "PUT", url: "/api/site-config", headers: { cookie, "x-csrf-token": csrf }, payload: { modules: ["parking", "carwash"] }, }); expect(put.statusCode).toBe(400); expect(put.json().error).toMatch(/requires "validation"/); }); it("a no-op resave signs nothing", async () => { const { cookie, csrf } = await admin(); const before = await app.inject({ method: "GET", url: "/api/events?limit=50", headers: { cookie } }); const countBefore = ((before.json().events ?? before.json()) as unknown[]).length; await app.inject({ method: "PUT", url: "/api/site-config", headers: { cookie, "x-csrf-token": csrf }, payload: { modules: ["parking", "validation", "carwash"] }, }); const after = await app.inject({ method: "GET", url: "/api/events?limit=50", headers: { cookie } }); expect(((after.json().events ?? after.json()) as unknown[]).length).toBe(countBefore); }); }); describe("entitlement (vendor env)", () => { it("MODULES_ENTITLED=parking: validation is neither offered nor activatable, and its routes 403", async () => { await app.close(); close(); process.env.MODULES_ENTITLED = "parking"; await boot(); const { cookie, csrf } = await admin(); const cfg = await app.inject({ method: "GET", url: "/api/site-config", headers: { cookie } }); expect(cfg.json().modulesEntitled).toEqual(["parking"]); expect(cfg.json().modules).toEqual(["parking"]); const put = await app.inject({ method: "PUT", url: "/api/site-config", headers: { cookie, "x-csrf-token": csrf }, payload: { modules: ["parking", "validation"] }, }); expect(put.statusCode).toBe(400); expect(put.json().error).toMatch(/not entitled/); const off = await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } }); expect(off.statusCode).toBe(403); }); it("required modules are entitled even when the env omits them; unknown ids are ignored", async () => { await app.close(); close(); process.env.MODULES_ENTITLED = "validation,bogus"; await boot(); const { cookie } = await admin(); const cfg = await app.inject({ method: "GET", url: "/api/site-config", headers: { cookie } }); expect(cfg.json().modulesEntitled).toEqual(["parking", "validation"]); expect(cfg.json().modules).toEqual(["parking", "validation"]); }); }); describe("permissions matrix helpers (venue-modules.md §Permissions matrix)", async () => { const shared = await import("@parking/shared"); it("each till is guarded by its own module's permissions", () => { expect(shared.tillGuards("booth")).toEqual({ read: "shift:read", shift: "shift:create", cash: "drawer:create" }); expect(shared.tillGuards("carwash")).toEqual({ read: "carwash:read", shift: "carwash:cash", cash: "carwash:cash" }); const wash = new Set(["carwash:read", "carwash:cash"]); expect(shared.tillsFor(["parking", "validation", "carwash"], (p) => wash.has(p))).toEqual(["carwash"]); expect(shared.tillsFor(["parking", "validation", "carwash"], (p) => wash.has(p), "shift")).toEqual(["carwash"]); expect(shared.tillsFor(["parking", "validation", "carwash"], (p) => p === "carwash:read", "shift")).toEqual([]); // Module off → its till is not even addressable. expect(shared.tillsFor(["parking"], () => true)).toEqual(["booth"]); }); it("the live feed admits by watch permission and filters ledger events by their module", () => { expect(shared.watchPermissions(["parking", "validation", "carwash"])).toEqual( expect.arrayContaining(["event:read", "session:read", "device:read", "carwash:read"]), ); expect(shared.watchPermissions(["parking", "validation", "carwash"])).not.toContain("report:read"); expect(shared.watchPermissions(["parking"])).not.toContain("carwash:read"); expect(shared.feedPermissionFor("carwash_payment")).toBe("carwash:read"); expect(shared.feedPermissionFor("payment")).toBe("event:read"); expect(shared.feedPermissionFor("validation")).toBe("event:read"); }); it("every job's permissions exist in the grid", () => { for (const m of shared.MODULES) for (const j of m.jobs) for (const p of j.permissions) expect(shared.PERMISSIONS).toContain(p); }); });