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 { 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user