test(plan): HTTP route tests with auth, scoping, and force flag

This commit is contained in:
BOHA
2026-06-05 12:51:20 +02:00
parent 38a58b55c0
commit 9c10ff9f22

View File

@@ -1,5 +1,12 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; 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 prisma from "../config/database";
import { config } from "../config/env";
import { securityHeaders } from "../middleware/security";
import planRoutes from "../routes/admin/plan";
import { import {
resolveCell, resolveCell,
resolveGrid, resolveGrid,
@@ -566,3 +573,231 @@ describe("plan.service.listOverrides (employee scoping)", () => {
expect(rows).toEqual([]); 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<ReturnType<typeof buildApp>>;
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);
});
});