test(plan): HTTP route tests with auth, scoping, and force flag
This commit is contained in:
@@ -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<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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user