import { describe, it, expect, beforeAll, afterAll } 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 projectsRoutes from "../routes/admin/projects"; // --------------------------------------------------------------------------- // Project notes: author-only editing with a visible edited_at stamp, and // admin-only deletion (a projects.edit holder may add + edit their own notes // but never delete — user decision 2026-06-12). // --------------------------------------------------------------------------- const N = "projnotes_test_"; let app: Awaited>; let adminToken: string; let userAId: number; let userAToken: string; let userBToken: string; let roleId: number; let projectId: number; let noteId: 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(projectsRoutes, { prefix: "/api/admin/projects" }); return a; } 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" }, ); } function inject( method: "POST" | "PUT" | "DELETE", path: string, token: string, body?: unknown, ) { return app.inject({ method, url: path, headers: { Authorization: `Bearer ${token}`, // Content-Type only with a body — Fastify 400s an empty JSON body. ...(body !== undefined ? { "Content-Type": "application/json" } : {}), }, ...(body !== undefined ? { payload: body as object } : {}), }); } beforeAll(async () => { app = await buildApp(); // Defensive pre-clean of a previously failed run. await prisma.project_notes.deleteMany({ where: { content: { contains: N } }, }); await prisma.projects.deleteMany({ where: { name: { startsWith: N } } }); await prisma.users.deleteMany({ where: { username: { startsWith: N } } }); await prisma.roles.deleteMany({ where: { name: `${N}role` } }); const admin = await prisma.users.findFirst({ where: { roles: { name: "admin" } }, include: { roles: true }, }); if (!admin) throw new Error("Test setup: admin user not found"); adminToken = generateToken({ id: admin.id, username: admin.username, roleName: admin.roles?.name ?? null, }); // Non-admin role with projects.view + projects.edit. const role = await prisma.roles.create({ data: { name: `${N}role`, display_name: "ProjNotes test role" }, }); roleId = role.id; const perms = await prisma.permissions.findMany({ where: { name: { in: ["projects.view", "projects.edit"] } }, select: { id: true }, }); if (perms.length !== 2) { throw new Error("Test setup: projects.view/projects.edit missing"); } await prisma.role_permissions.createMany({ data: perms.map((p) => ({ role_id: roleId, permission_id: p.id })), }); const mkUser = async (suffix: string) => prisma.users.create({ data: { username: `${N}${suffix}`, email: `${N}${suffix}@test.local`, password_hash: "x", first_name: "Projnotes", last_name: suffix.toUpperCase(), is_active: true, role_id: roleId, }, }); const userA = await mkUser("a"); const userB = await mkUser("b"); userAId = userA.id; userAToken = generateToken({ id: userA.id, username: userA.username, roleName: `${N}role`, }); userBToken = generateToken({ id: userB.id, username: userB.username, roleName: `${N}role`, }); const project = await prisma.projects.create({ data: { name: `${N}project`, status: "aktivni" }, }); projectId = project.id; // Note authored by user A (through the route, like the UI does). const res = await inject( "POST", `/api/admin/projects/${projectId}/notes`, userAToken, { content: `${N}original` }, ); expect(res.statusCode).toBe(201); noteId = res.json().data.note.id; }); afterAll(async () => { await prisma.project_notes.deleteMany({ where: { project_id: projectId } }); await prisma.projects.deleteMany({ where: { id: projectId } }); await prisma.users.deleteMany({ where: { username: { startsWith: N } } }); await prisma.roles.deleteMany({ where: { id: roleId } }); await app.close(); }); describe("project notes — author-only edit with edited_at stamp", () => { it("the author edits their own note and the edit is stamped", async () => { const res = await inject( "PUT", `/api/admin/projects/${projectId}/notes/${noteId}`, userAToken, { content: `${N}edited` }, ); expect(res.statusCode).toBe(200); const row = await prisma.project_notes.findUnique({ where: { id: noteId }, }); expect(row?.content).toBe(`${N}edited`); expect(row?.user_id).toBe(userAId); expect(row?.edited_at).not.toBeNull(); }); it("another user cannot edit someone else's note", async () => { const res = await inject( "PUT", `/api/admin/projects/${projectId}/notes/${noteId}`, userBToken, { content: `${N}hijack` }, ); expect(res.statusCode).toBe(403); const row = await prisma.project_notes.findUnique({ where: { id: noteId }, }); expect(row?.content).toBe(`${N}edited`); // unchanged }); it("rejects empty content and unknown note ids", async () => { const empty = await inject( "PUT", `/api/admin/projects/${projectId}/notes/${noteId}`, userAToken, { content: " " }, ); expect(empty.statusCode).toBe(400); const missing = await inject( "PUT", `/api/admin/projects/${projectId}/notes/999999999`, userAToken, { content: "x" }, ); expect(missing.statusCode).toBe(404); }); }); describe("project notes — admin-only deletion", () => { it("a projects.edit holder cannot delete (even their own note)", async () => { const res = await inject( "DELETE", `/api/admin/projects/${projectId}/notes/${noteId}`, userAToken, ); expect(res.statusCode).toBe(403); }); it("an admin deletes anybody's note", async () => { const res = await inject( "DELETE", `/api/admin/projects/${projectId}/notes/${noteId}`, adminToken, ); expect(res.statusCode).toBe(200); const row = await prisma.project_notes.findUnique({ where: { id: noteId }, }); expect(row).toBeNull(); }); });