- 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>
228 lines
6.7 KiB
TypeScript
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();
|
|
});
|
|
});
|