import { getPayload, Payload } from "payload"; import config from "@/payload.config"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { Role, User } from "@/payload-types"; import { UserNotifications } from "@/collections/users/UserNotifications"; import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions"; let payload: Payload; const RUN = `ntf-${Date.now().toString(36)}`; const TIMEOUT = 30_000; describe("User notification read access control", () => { const roleIds: number[] = []; const userIds: number[] = []; let plainUser: User; let adminUser: User; let devUser: User; let leakyUser: User; const makeRole = async (label: string, extra: Partial = {}): Promise => { const role = (await payload.create({ collection: "roles", data: { name: `${RUN}-${label}`, slug: `${RUN}-${label}`, ...extra }, overrideAccess: true, depth: 0, })) as unknown as Role; roleIds.push(role.id); return role; }; const makeUser = async (label: string, roleId: number): Promise => { const user = (await payload.create({ collection: "users", data: { username: `${RUN}-${label}`, discordUsername: `${RUN}-${label}`, displayName: label.toUpperCase(), steamId: `7656119${Math.floor(Math.random() * 1e9)}`, password: "Test123", // Permission resolution reads roleDocs (the dynamic RBAC relationship), not // the legacy `roles` enum — so assign a real role doc to grant permissions. roleDocs: [roleId], }, overrideAccess: true, depth: 0, })) as unknown as User; userIds.push(user.id); return user; }; // Invoke the collection's read access control exactly as Payload would for an // authenticated request. The vitest environment has no Next.js HTTP server, so the // REST endpoint (/api/user-notifications) is not reachable — calling the access // function directly is the standard way to unit-test Payload access control. const readDecision = async (user: User | null): Promise => { const fn = UserNotifications.access?.read; expect(typeof fn).toBe("function"); return await (fn as (args: { req: unknown }) => Promise)({ req: { user, payload } }); }; beforeAll(async () => { const payloadConfig = await config; payload = await getPayload({ config: payloadConfig }); invalidatePermissionCache(); const plainRole = await makeRole("plain", { permissions: [] }); const adminRole = await makeRole("admin", { permissions: ["system:admin-access"] }); const devRole = await makeRole("dev", { isSuperuser: true }); // A role carrying the collection's own read permission. Before the fix, holding // `user-notifications:read` alone granted read-all; after the fix it must stay scoped. const leakyRole = await makeRole("leaky", { permissions: ["user-notifications:read"] }); plainUser = await makeUser("plain", plainRole.id); adminUser = await makeUser("admin", adminRole.id); devUser = await makeUser("dev", devRole.id); leakyUser = await makeUser("leaky", leakyRole.id); }, TIMEOUT); afterAll(async () => { if (!payload) return; for (const id of userIds) { const profiles = await payload .find({ collection: "profiles", where: { user: { equals: id } }, limit: 5, depth: 0, overrideAccess: true }) .catch(() => null); for (const p of profiles?.docs ?? []) { await payload.delete({ collection: "profiles", id: p.id, overrideAccess: true }).catch(() => {}); } await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {}); } for (const id of roleIds) { await payload.delete({ collection: "roles", id, overrideAccess: true }).catch(() => {}); } }); it("scopes a regular user's reads to their own notifications", async () => { const decision = await readDecision(plainUser); // The bug: this returned `true` (read-all), leaking every user's notifications. expect(decision).not.toBe(true); expect(decision).toMatchObject({ user: { equals: plainUser.id } }); }, TIMEOUT); it("grants read-all to admin elevation (system:admin-access)", async () => { expect(await readDecision(adminUser)).toBe(true); }, TIMEOUT); it("grants read-all to superuser roles", async () => { expect(await readDecision(devUser)).toBe(true); }, TIMEOUT); it("denies anonymous (no user) reads", async () => { expect(await readDecision(null)).toBe(false); }, TIMEOUT); it("does not treat the collection's own read permission as read-all", async () => { // Defense in depth: even a role holding `user-notifications:read` must stay scoped, // because read-all is now gated on elevation only. const decision = await readDecision(leakyUser); expect(decision).not.toBe(true); expect(decision).toMatchObject({ user: { equals: leakyUser.id } }); }, TIMEOUT); });