test(permissions): integration coverage for the client write boundary

This commit is contained in:
Dave 2026-09-16 09:46:58 +02:00
parent 857ef1a994
commit 5494f14f60
3 changed files with 347 additions and 0 deletions

View file

@ -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<string, unknown>) {
return fetch(`${APP_URL}/api/client/cards/${cardId}`, {
method: "PATCH",
headers: { "content-type": "application/json", cookie },
body: JSON.stringify(body),
});
}
async function activityCount(): Promise<number> {
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);
});
});

View file

@ -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);
});
});

View file

@ -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<void> {
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<PocketBase> {
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<string> {
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<PocketBase> {
const pb = new PocketBase(PB_URL);
pb.autoCancellation(false);
await pb.collection("users").authWithPassword(email, password);
return pb;
}