55d6242c7d
Permissions matrix rethink (wiki/decisions/venue-modules.md §"Permissions matrix", open-questions #16) — the grid stays the enforcement layer: - Move 1: each desk's money is guarded by that desk's own permissions. Manifest tillGuards {read, shift, cash}: booth = shift:read / shift:create / drawer:create (unchanged), carwash = carwash:read / carwash:cash (new). Shift + drawer routes resolve the guard FROM THE TILL (requireTill); a wash role holds no shift:* and cannot touch the booth by construction. Replaces the session:read borrowing (tillPermission). /api/shift/tills lists the role's readable tills with canWork; history/movements without a till filter return the union of readable tills. - Move 2: jobs — manifest permission bundles (booth-operator, booth-supervisor, merchant, wash-operator) as one-click chips in Setup → Roles, with "mixes desks" and "partial job" lints (warnings, never blocks). - Move 3: the live WebSocket admits any watch permission (event/session/device read or a module's feedPermission) and filters every push per role; report:read is the reports screen only. Auth: the token's roleId is only a hint — refreshRole() after every jwtVerify resolves the user's CURRENT role (cached, bumped on role/user writes), so reassigning a user's role applies on the next request and a deleted user's session ends with 401. Tests: till guards + look-only role, feed rules, every job's permissions exist, role reassignment without re-login. 353/353. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
206 lines
9.1 KiB
TypeScript
206 lines
9.1 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 { 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<void> {
|
|
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<string, unknown> }>;
|
|
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);
|
|
});
|
|
});
|