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"; // HTTP integration: boot the REAL Fastify app over a fresh in-memory DB (no listen — // app.inject drives it) and exercise the auth + RBAC guards end to end. The point is the // security seam: no token → 401, wrong permission → 403, CSRF required on mutations, and // a correctly-scoped user passes. (vitest.config sets JWT_SECRET/EVENT_SIGNING_KEY.) let db: Db; let close: () => void; let app: FastifyInstance; beforeEach(async () => { const t = createTestDb(); db = t.db; close = t.close; app = await buildServer({ db }); await app.ready(); }); afterEach(async () => { await app.close(); close(); }); describe("health + login", () => { it("GET /health is open", async () => { const res = await app.inject({ method: "GET", url: "/health" }); expect(res.statusCode).toBe(200); expect(res.json()).toEqual({ status: "ok" }); }); it("login with bad credentials is rejected", async () => { await seedUser(db, { username: "alice", password: "right-password" }); const res = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "alice", password: "wrong" } }); expect(res.statusCode).toBeGreaterThanOrEqual(400); }); it("login with good credentials sets auth + csrf cookies", async () => { await seedUser(db, { username: "alice", password: "right-password" }); const res = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "alice", password: "right-password" } }); expect(res.statusCode).toBe(200); const names = res.cookies.map((c) => c.name); expect(names).toContain("parking_token"); expect(names).toContain("parking_csrf"); }); }); describe("auth guard — no token", () => { it("GET /api/occupancy without a session is 401", async () => { const res = await app.inject({ method: "GET", url: "/api/occupancy" }); expect(res.statusCode).toBe(401); }); }); describe("RBAC permission gate", () => { it("a site:read-only user can GET occupancy but is 403 on PUT site-config", async () => { const { username, password } = await seedUser(db, { username: "viewer", roleId: "viewer", permissions: ["site:read"], }); const { cookie, csrf } = await login(app, username, password); // GET allowed (site:read). const get = await app.inject({ method: "GET", url: "/api/occupancy", headers: { cookie } }); expect(get.statusCode).toBe(200); // PUT requires site:update — which this role lacks → 403 (with valid CSRF, so the // 403 is the PERMISSION check, not CSRF). const put = await app.inject({ method: "PUT", url: "/api/site-config", headers: { cookie, "x-csrf-token": csrf }, payload: { capacity: 50 }, }); expect(put.statusCode).toBe(403); }); it("an admin user passes the same PUT", async () => { const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" }); const { cookie, csrf } = await login(app, username, password); const put = await app.inject({ method: "PUT", url: "/api/site-config", headers: { cookie, "x-csrf-token": csrf }, payload: { capacity: 50 }, }); expect(put.statusCode).toBeLessThan(300); }); }); describe("CSRF double-submit on mutations", () => { it("a mutation with the auth cookie but NO csrf header is 403", async () => { const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" }); const { cookie } = await login(app, username, password); const put = await app.inject({ method: "PUT", url: "/api/site-config", headers: { cookie }, // csrf header deliberately omitted payload: { capacity: 50 }, }); expect(put.statusCode).toBe(403); }); }); describe("drawer balance (the till NOW)", () => { it("shift:read gets the balance; a role without it is 403; no auth 401", async () => { const anon = await app.inject({ method: "GET", url: "/api/drawer/balance" }); expect(anon.statusCode).toBe(401); const viewer = await seedUser(db, { username: "till", roleId: "till", permissions: ["shift:read"] }); const { cookie } = await login(app, viewer.username, viewer.password); const ok = await app.inject({ method: "GET", url: "/api/drawer/balance", headers: { cookie } }); expect(ok.statusCode).toBe(200); expect(ok.json()).toEqual({ balanceMinor: 0, currency: null }); const outsider = await seedUser(db, { username: "noshift", roleId: "noshift", permissions: ["site:read"] }); const other = await login(app, outsider.username, outsider.password); const denied = await app.inject({ method: "GET", url: "/api/drawer/balance", headers: { cookie: other.cookie } }); expect(denied.statusCode).toBe(403); }); });