diff --git a/tests/integration/activity-log.test.ts b/tests/integration/activity-log.test.ts new file mode 100644 index 0000000..c19da2d --- /dev/null +++ b/tests/integration/activity-log.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import PocketBase from "pocketbase"; + +import { APP_URL, DEMO, adminPb, requireServices, signIn } from "./helpers"; + +let admin: PocketBase; +let cookie: string; +let cardId: string; + +beforeAll(async () => { + await requireServices(); + admin = await adminPb(); + cookie = await signIn(DEMO.managerEmail, DEMO.managerPassword); + + const project = await admin + .collection("projects") + .getFirstListItem(`slug = "${DEMO.projectSlug}"`); + + cardId = ( + await admin.collection("cards").getFirstListItem(`project = "${project.id}"`) + ).id; +}); + +async function patch(body: Record) { + return fetch(`${APP_URL}/api/client/cards/${cardId}`, { + method: "PATCH", + headers: { "content-type": "application/json", cookie }, + body: JSON.stringify(body), + }); +} + +async function activityCount(): Promise { + const rows = await admin + .collection("activity") + .getFullList({ filter: `card = "${cardId}"` }); + return rows.length; +} + +/** Spec §9 test 5 */ +describe("every client-originated change is audited", () => { + it("writes one row with the correct old and new values", async () => { + await admin.collection("cards").update(cardId, { priority: "medium" }); + const before = await activityCount(); + + const response = await patch({ priority: "high" }); + expect(response.status).toBe(200); + + // Exactly one new row overall — not "at least one", which would pass even + // if the route wrote a row per field it merely looked at. + expect(await activityCount()).toBe(before + 1); + + const rows = await admin.collection("activity").getFullList({ + filter: `card = "${cardId}" && field = "priority"`, + sort: "-created", + }); + + expect(rows[0].action).toBe("priority_changed"); + expect(rows[0].old_value).toBe("medium"); + expect(rows[0].new_value).toBe("high"); + expect(rows[0].actor).not.toBe(""); + }); + + it("writes no row when the value is unchanged", async () => { + await admin.collection("cards").update(cardId, { priority: "low" }); + const before = await activityCount(); + + const response = await patch({ priority: "low" }); + expect(response.status).toBe(200); + + expect(await activityCount()).toBe(before); + }); + + it("writes one row per field when two change at once", async () => { + await admin + .collection("cards") + .update(cardId, { priority: "medium", target_date: "2026-10-01" }); + const before = await activityCount(); + + const response = await patch({ + priority: "critical", + target_date: "2026-12-24", + }); + expect(response.status).toBe(200); + + expect(await activityCount()).toBe(before + 2); + }); + + it("returns 400 when the body contains no editable fields", async () => { + const response = await patch({ title: "nope", column: "whatever" }); + expect(response.status).toBe(400); + }); +}); \ No newline at end of file diff --git a/tests/integration/client-permissions.test.ts b/tests/integration/client-permissions.test.ts new file mode 100644 index 0000000..6ac4345 --- /dev/null +++ b/tests/integration/client-permissions.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import PocketBase from "pocketbase"; + +import { + APP_URL, + DEMO, + adminPb, + requireServices, + signIn, + userPb, +} from "./helpers"; + +let admin: PocketBase; +let cookie: string; +let projectId: string; +let cardId: string; +let inboxColumnId: string; +let doneColumnId: string; + +beforeAll(async () => { + await requireServices(); + admin = await adminPb(); + cookie = await signIn(DEMO.managerEmail, DEMO.managerPassword); + + const project = await admin + .collection("projects") + .getFirstListItem(`slug = "${DEMO.projectSlug}"`); + projectId = project.id; + + const card = await admin + .collection("cards") + .getFirstListItem(`project = "${projectId}"`); + cardId = card.id; + + inboxColumnId = ( + await admin + .collection("columns") + .getFirstListItem(`project = "${projectId}" && is_inbox = true`) + ).id; + + doneColumnId = ( + await admin + .collection("columns") + .getFirstListItem(`project = "${projectId}" && is_done = true`) + ).id; +}); + +/** Spec §9 test 1 */ +describe("a client manager cannot write non-whitelisted card fields", () => { + it("leaves column, title and accepted untouched", async () => { + const before = await admin.collection("cards").getOne(cardId); + + const response = await fetch(`${APP_URL}/api/client/cards/${cardId}`, { + method: "PATCH", + headers: { "content-type": "application/json", cookie }, + body: JSON.stringify({ + priority: "critical", + column: doneColumnId, + title: "hacked", + accepted: false, + archived: true, + scope_rule: "stock", + }), + }); + + expect(response.status).toBe(200); + + const after = await admin.collection("cards").getOne(cardId); + expect(after.priority).toBe("critical"); // whitelisted, applied + expect(after.column).toBe(before.column); // not whitelisted, unchanged + expect(after.title).toBe(before.title); + expect(after.accepted).toBe(before.accepted); + expect(after.archived).toBe(before.archived); + expect(after.scope_rule).toBe(before.scope_rule); + }); + + it("refuses a direct PocketBase update with the manager's own token", async () => { + const pb = await userPb(DEMO.managerEmail, DEMO.managerPassword); + + await expect( + pb.collection("cards").update(cardId, { title: "hacked directly" }) + ).rejects.toThrow(); + + const after = await admin.collection("cards").getOne(cardId); + expect(after.title).not.toBe("hacked directly"); + }); +}); + +/** Spec §9 test 2 */ +describe("a client manager cannot reach another client's project", () => { + it("returns no records for a foreign project", async () => { + const otherClient = await admin.collection("clients").create({ + name: "Mega Infrastructure", + slug: `mega-${Date.now()}`, + active: true, + }); + + const otherProject = await admin.collection("projects").create({ + client: otherClient.id, + name: "Mega Plant", + slug: `mega-plant-${Date.now()}`, + status: "active", + }); + + const pb = await userPb(DEMO.managerEmail, DEMO.managerPassword); + + await expect( + pb.collection("projects").getOne(otherProject.id) + ).rejects.toThrow(); + + const visible = await pb.collection("projects").getFullList(); + expect(visible.map((p) => p.id)).not.toContain(otherProject.id); + + const response = await fetch(`${APP_URL}/api/client/cards`, { + method: "POST", + headers: { "content-type": "application/json", cookie }, + body: JSON.stringify({ project: otherProject.id, title: "sneaky" }), + }); + expect(response.status).toBe(404); + + await admin.collection("projects").delete(otherProject.id); + await admin.collection("clients").delete(otherClient.id); + }); +}); + +/** Spec §9 test 3 */ +describe("client-created cards land in the Inbox", () => { + it("ignores the requested column, priority and accepted flag", async () => { + const response = await fetch(`${APP_URL}/api/client/cards`, { + method: "POST", + headers: { "content-type": "application/json", cookie }, + body: JSON.stringify({ + project: projectId, + title: "Add a POS system", + description: "Card payments at the counter.", + column: doneColumnId, + priority: "critical", + accepted: true, + }), + }); + + expect(response.status).toBe(201); + const { card } = await response.json(); + + const created = await admin.collection("cards").getOne(card.id); + expect(created.column).toBe(inboxColumnId); + expect(created.priority).toBe("medium"); + expect(created.accepted).toBe(false); + expect(created.created_by_role).toBe("client"); + expect(created.title).toBe("Add a POS system"); + + await admin.collection("cards").delete(card.id); + }); + + it("rejects a create with no title", async () => { + const response = await fetch(`${APP_URL}/api/client/cards`, { + method: "POST", + headers: { "content-type": "application/json", cookie }, + body: JSON.stringify({ project: projectId, description: "no title" }), + }); + expect(response.status).toBe(400); + }); +}); + +describe("unauthenticated requests", () => { + it("are refused without a session cookie", async () => { + const response = await fetch(`${APP_URL}/api/client/cards/${cardId}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ priority: "low" }), + }); + expect(response.status).toBe(401); + }); +}); \ No newline at end of file diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts new file mode 100644 index 0000000..292b3d0 --- /dev/null +++ b/tests/integration/helpers.ts @@ -0,0 +1,81 @@ +import PocketBase from "pocketbase"; + +export const PB_URL = process.env.NEXT_PUBLIC_PB_URL ?? "http://127.0.0.1:8090"; +export const APP_URL = process.env.APP_URL ?? "http://localhost:3000"; + +export const DEMO = { + clientSlug: "wf", + projectSlug: "wfdash-webapp", + managerEmail: "manager@wf.example.com", + managerPassword: "manager-password-123", +}; + +/** + * Fail loudly rather than skipping. A permission test that silently skips is + * worse than no test: the suite goes green and nobody checks the boundary. + */ +export async function requireServices(): Promise { + const pbUp = await fetch(`${PB_URL}/api/health`) + .then((r) => r.ok) + .catch(() => false); + + if (!pbUp) { + throw new Error( + `PocketBase is not reachable at ${PB_URL}. Check NEXT_PUBLIC_PB_URL.` + ); + } + + const appUp = await fetch(`${APP_URL}/login`) + .then((r) => r.ok) + .catch(() => false); + + if (!appUp) { + throw new Error( + `The app is not reachable at ${APP_URL}. Run \`npm run dev\` first.` + ); + } +} + +export async function adminPb(): Promise { + const pb = new PocketBase(PB_URL); + pb.autoCancellation(false); + await pb + .collection("_superusers") + .authWithPassword( + process.env.PB_SUPERUSER_EMAIL!, + process.env.PB_SUPERUSER_PASSWORD! + ); + return pb; +} + +/** Sign in through the app and return the Set-Cookie value for later requests. */ +export async function signIn( + email: string, + password: string +): Promise { + const response = await fetch(`${APP_URL}/api/auth/login`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + + if (!response.ok) { + throw new Error(`Sign in failed for ${email}: ${response.status}`); + } + + const setCookie = response.headers.get("set-cookie"); + if (!setCookie) throw new Error("No auth cookie returned"); + + return setCookie.split(";")[0]; +} + +/** A PocketBase client authenticated as a normal user, for read-rule tests. */ +export async function userPb( + email: string, + password: string +): Promise { + const pb = new PocketBase(PB_URL); + pb.autoCancellation(false); + await pb.collection("users").authWithPassword(email, password); + return pb; +} \ No newline at end of file