import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createTestDb } from "@parking/db/testing"; import { eq, users, type Db } from "@parking/db"; import type { FastifyInstance } from "fastify"; import { buildServer } from "../server.js"; import { seedUser, login } from "../test-helpers.js"; // Self-service profile (routes/auth.ts): /api/auth/profile + /api/auth/password. These act // ONLY on the signed-in user, need NO `user:*` permission (any role), and the password change // must prove the current password. Distinct from admin user-management (routes/users.ts). 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("PUT /api/auth/profile (self-service)", () => { it("a permission-less user can edit their OWN name + email", async () => { // 'viewer' role with NO user:* permission — profile is not gated on it. const { username, password } = await seedUser(db, { username: "cashier", roleId: "viewer", permissions: [], }); const { cookie, csrf } = await login(app, username, password); const res = await app.inject({ method: "PUT", url: "/api/auth/profile", headers: { cookie, "x-csrf-token": csrf }, payload: { fullName: "Mon Kukaleshi", email: "mon@example.com" }, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.fullName).toBe("Mon Kukaleshi"); expect(body.email).toBe("mon@example.com"); // Persisted to the caller's own row. const row = db.select().from(users).where(eq(users.username, "cashier")).get(); expect(row?.fullName).toBe("Mon Kukaleshi"); expect(row?.email).toBe("mon@example.com"); }); it('clears a field when sent ""', async () => { const { username, password } = await seedUser(db, { username: "u2", roleId: "viewer", permissions: [] }); const { cookie, csrf } = await login(app, username, password); // First set a name… await app.inject({ method: "PUT", url: "/api/auth/profile", headers: { cookie, "x-csrf-token": csrf }, payload: { fullName: "Old Name" }, }); // …then clear it with whitespace (→ null). const res = await app.inject({ method: "PUT", url: "/api/auth/profile", headers: { cookie, "x-csrf-token": csrf }, payload: { fullName: " " }, }); expect(res.statusCode).toBe(200); expect(res.json().fullName).toBeNull(); }); it("rejects an empty patch (nothing to update)", async () => { const { username, password } = await seedUser(db, { username: "u3", roleId: "viewer", permissions: [] }); const { cookie, csrf } = await login(app, username, password); const res = await app.inject({ method: "PUT", url: "/api/auth/profile", headers: { cookie, "x-csrf-token": csrf }, payload: {}, }); expect(res.statusCode).toBe(400); }); it("requires a session (401 without a token)", async () => { const res = await app.inject({ method: "PUT", url: "/api/auth/profile", payload: { fullName: "x" } }); expect(res.statusCode).toBe(401); }); }); describe("PUT /api/auth/password (self-service)", () => { it("changes the password when the current one is correct, and the new one then logs in", async () => { const { username, password } = await seedUser(db, { username: "p1", roleId: "viewer", permissions: [] }); const { cookie, csrf } = await login(app, username, password); const res = await app.inject({ method: "PUT", url: "/api/auth/password", headers: { cookie, "x-csrf-token": csrf }, payload: { currentPassword: password, newPassword: "brand-new-pw-123" }, }); expect(res.statusCode).toBe(200); // Old password no longer works; new one does. const oldTry = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username, password } }); expect(oldTry.statusCode).toBe(401); const newTry = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username, password: "brand-new-pw-123" } }); expect(newTry.statusCode).toBe(200); }); it("refuses when the current password is wrong (403) and leaves the password unchanged", async () => { const { username, password } = await seedUser(db, { username: "p2", roleId: "viewer", permissions: [] }); const { cookie, csrf } = await login(app, username, password); const res = await app.inject({ method: "PUT", url: "/api/auth/password", headers: { cookie, "x-csrf-token": csrf }, payload: { currentPassword: "not-it", newPassword: "brand-new-pw-123" }, }); expect(res.statusCode).toBe(403); // Original password still works. const still = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username, password } }); expect(still.statusCode).toBe(200); }); it("rejects a too-short new password (400)", async () => { const { username, password } = await seedUser(db, { username: "p3", roleId: "viewer", permissions: [] }); const { cookie, csrf } = await login(app, username, password); const res = await app.inject({ method: "PUT", url: "/api/auth/password", headers: { cookie, "x-csrf-token": csrf }, payload: { currentPassword: password, newPassword: "short" }, }); expect(res.statusCode).toBe(400); }); }); describe("PUT /api/auth/font-scale (self-service)", () => { it("persists a valid scale and returns it on the next session", async () => { const { username, password } = await seedUser(db, { username: "f1", roleId: "viewer", permissions: [] }); const { cookie, csrf } = await login(app, username, password); const res = await app.inject({ method: "PUT", url: "/api/auth/font-scale", headers: { cookie, "x-csrf-token": csrf }, payload: { fontScale: 120 }, }); expect(res.statusCode).toBe(200); expect(res.json().fontScale).toBe(120); // Persisted to the caller's row… expect(db.select().from(users).where(eq(users.username, "f1")).get()?.fontScale).toBe(120); // …and surfaced on /me (the session bootstrap). const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } }); expect(me.json().fontScale).toBe(120); }); it("clamps + snaps out-of-band / off-step values", async () => { const { username, password } = await seedUser(db, { username: "f2", roleId: "viewer", permissions: [] }); const { cookie, csrf } = await login(app, username, password); const tooBig = await app.inject({ method: "PUT", url: "/api/auth/font-scale", headers: { cookie, "x-csrf-token": csrf }, payload: { fontScale: 999 }, }); expect(tooBig.json().fontScale).toBe(160); // clamped to max const offStep = await app.inject({ method: "PUT", url: "/api/auth/font-scale", headers: { cookie, "x-csrf-token": csrf }, payload: { fontScale: 113 }, }); expect(offStep.json().fontScale).toBe(110); // snapped to the 10-step }); it("rejects a non-numeric scale (400)", async () => { const { username, password } = await seedUser(db, { username: "f3", roleId: "viewer", permissions: [] }); const { cookie, csrf } = await login(app, username, password); const res = await app.inject({ method: "PUT", url: "/api/auth/font-scale", headers: { cookie, "x-csrf-token": csrf }, payload: { fontScale: "big" }, }); expect(res.statusCode).toBe(400); }); it("defaults to 100 for a fresh user", async () => { const { username, password } = await seedUser(db, { username: "f4", roleId: "viewer", permissions: [] }); const { cookie } = await login(app, username, password); const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } }); expect(me.json().fontScale).toBe(100); }); });