From 9c10ff9f221e7b023a2e8a06cf8a0c742b16b53f Mon Sep 17 00:00:00 2001 From: BOHA Date: Fri, 5 Jun 2026 12:51:20 +0200 Subject: [PATCH] test(plan): HTTP route tests with auth, scoping, and force flag --- src/__tests__/plan.test.ts | 235 +++++++++++++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) diff --git a/src/__tests__/plan.test.ts b/src/__tests__/plan.test.ts index 104eefc..a284756 100644 --- a/src/__tests__/plan.test.ts +++ b/src/__tests__/plan.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import Fastify from "fastify"; +import cookie from "@fastify/cookie"; +import rateLimit from "@fastify/rate-limit"; +import jwt from "jsonwebtoken"; import prisma from "../config/database"; +import { config } from "../config/env"; +import { securityHeaders } from "../middleware/security"; +import planRoutes from "../routes/admin/plan"; import { resolveCell, resolveGrid, @@ -566,3 +573,231 @@ describe("plan.service.listOverrides (employee scoping)", () => { expect(rows).toEqual([]); }); }); + +// --------------------------------------------------------------------------- +// HTTP route tests (Fastify inject) +// --------------------------------------------------------------------------- + +// Build a Fastify app with only the plan routes registered under +// /api/admin/plan. rateLimit max=1000 keeps the 7 tests from tripping it. +let app: Awaited>; +let adminToken: string; +let noPermToken: string; +let noPermRoleId: number; + +async function buildApp() { + const a = Fastify({ logger: false }); + await a.register(cookie); + await a.register(rateLimit, { max: 1000, timeWindow: "1 minute" }); + a.addHook("onRequest", securityHeaders); + await a.register(planRoutes, { prefix: "/api/admin/plan" }); + return a; +} + +/** + * Generate a JWT access token. `requireAuth` re-loads auth data from the + * DB via `loadAuthData(payload.sub)`, so the `role` claim is ignored — + * only the `sub` (user id) matters for these tests. + */ +function generateToken(user: { + id: number; + username: string; + roleName: string | null; +}): string { + return jwt.sign( + { sub: user.id, username: user.username, role: user.roleName }, + config.jwt.secret, + { expiresIn: "15m" }, + ); +} + +async function authGet(path: string, token: string) { + return app.inject({ + method: "GET", + url: path, + headers: { Authorization: `Bearer ${token}` }, + }); +} + +async function authPost(path: string, token: string, body: unknown) { + return app.inject({ + method: "POST", + url: path, + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + payload: body as object, + }); +} + +async function authPatch(path: string, token: string, body: unknown) { + return app.inject({ + method: "PATCH", + url: path, + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + payload: body as object, + }); +} + +async function authDelete(path: string, token: string) { + return app.inject({ + method: "DELETE", + url: path, + headers: { Authorization: `Bearer ${token}` }, + }); +} + +beforeAll(async () => { + app = await buildApp(); + + // Re-fetch the admin user with the roles relation so we can build a token. + const admin = await prisma.users.findFirst({ + where: { roles: { name: "admin" } }, + include: { roles: true }, + }); + if (!admin) throw new Error("admin user not found in test setup"); + adminUserId = admin.id; + adminToken = generateToken({ + id: admin.id, + username: admin.username, + roleName: admin.roles?.name ?? null, + }); + + // Create a no-permission role + user; the role has zero permissions so + // any permission check returns 403. + const stamp = Date.now(); + const noPermRole = await prisma.roles.create({ + data: { + name: `noperm_plan_${stamp}`, + display_name: "No Perm Plan Test", + }, + }); + noPermRoleId = noPermRole.id; + const noPermUser = await prisma.users.create({ + data: { + username: `noperm_plan_${stamp}`, + first_name: "No", + last_name: "Perm", + email: `noperm_plan_${stamp}@test.local`, + password_hash: + "$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali", + role_id: noPermRole.id, + is_active: true, + }, + }); + noPermUserId = noPermUser.id; + noPermToken = generateToken({ + id: noPermUser.id, + username: noPermUser.username, + roleName: noPermRole.name, + }); +}); + +afterAll(async () => { + if (app) await app.close(); + if (noPermUserId) { + await prisma.users + .deleteMany({ where: { id: noPermUserId } }) + .catch(() => {}); + } + if (noPermRoleId) { + await prisma.roles + .deleteMany({ where: { id: noPermRoleId } }) + .catch(() => {}); + } +}); + +describe("HTTP /api/admin/plan", () => { + it("GET /users requires auth", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/admin/plan/users", + }); + expect(res.statusCode).toBe(401); + }); + + it("GET /users returns plan users for an admin", async () => { + const res = await authGet("/api/admin/plan/users", adminToken); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.success).toBe(true); + expect(Array.isArray(body.data)).toBe(true); + }); + + it("GET /grid returns days, users, cells", async () => { + // Seed an entry so the cell on 2097-06-01 has a known note. + await createEntry( + { + user_id: adminUserId, + date_from: "2097-06-01", + date_to: "2097-06-05", + category: "work", + note: `${N}grid test`, + }, + adminUserId, + false, + ); + const res = await authGet( + "/api/admin/plan/grid?date_from=2097-06-01&date_to=2097-06-05", + adminToken, + ); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.data.users).toBeDefined(); + expect(body.data.cells[adminUserId]).toBeDefined(); + expect(body.data.cells[adminUserId]["2097-06-01"].note).toBe( + `${N}grid test`, + ); + }); + + it("GET /grid rejects ranges > 92 days", async () => { + const res = await authGet( + "/api/admin/plan/grid?date_from=2097-01-01&date_to=2097-12-31", + adminToken, + ); + expect(res.statusCode).toBe(400); + }); + + it("POST /entries requires attendance.manage", async () => { + const res = await authPost("/api/admin/plan/entries", noPermToken, { + user_id: adminUserId, + date_from: "2097-07-01", + date_to: "2097-07-01", + category: "work", + note: `${N}forbidden`, + }); + expect(res.statusCode).toBe(403); + }); + + it("POST /entries works for admin", async () => { + const res = await authPost("/api/admin/plan/entries", adminToken, { + user_id: adminUserId, + date_from: "2097-08-01", + date_to: "2097-08-01", + category: "work", + note: `${N}http create`, + }); + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.success).toBe(true); + expect(body.data.note).toBe(`${N}http create`); + }); + + it("POST /overrides with force=1 allows past date", async () => { + const res = await authPost( + "/api/admin/plan/overrides?force=1", + adminToken, + { + user_id: adminUserId, + shift_date: "2000-01-15", + category: "leave", + note: `${N}forced past`, + }, + ); + expect(res.statusCode).toBe(201); + }); +});