test(permissions): integration coverage for the client write boundary
This commit is contained in:
parent
857ef1a994
commit
5494f14f60
3 changed files with 347 additions and 0 deletions
92
tests/integration/activity-log.test.ts
Normal file
92
tests/integration/activity-log.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
174
tests/integration/client-permissions.test.ts
Normal file
174
tests/integration/client-permissions.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
81
tests/integration/helpers.ts
Normal file
81
tests/integration/helpers.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue