Files
app/src/__tests__/project-notes.test.ts
BOHA 64bb7f9c37 feat(projects): editable notes with edit stamp + NoteCard bubble component
- author-only note editing (PUT /projects/:id/notes/:noteId, strict schema,
  audit with old/new content); edits stamped via new edited_at column
  (migration 20260612160000) and shown as '· upraveno <datum>'
- note deletion tightened to admin-only server-side (was any projects.edit
  holder via direct API)
- new NoteCard component: chat-bubble note row (initials avatar, wrapping
  header, inline editor, actions in the bubble corner so they don't squeeze
  content on mobile), table-matching edit/delete icons
- Odin project detail carries the edited_at stamp; route-level tests cover
  author-edit, foreign-edit 403, validation, admin-only delete

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:26:38 +02:00

228 lines
6.7 KiB
TypeScript

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<ReturnType<typeof buildApp>>;
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();
});
});