import { getPayload, Payload } from "payload"; import type { Access } from "payload"; import config from "@/payload.config"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { Role, User } from "@/payload-types"; import { requireAdminPageAccess } from "@/utils/access-control/hasPermission"; import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions"; let payload: Payload; const RUN = `adm-${Date.now().toString(36)}`; const TIMEOUT = 30_000; describe("Scoped admin page access control", () => { const roleIds: number[] = []; const userIds: number[] = []; let readOnlyUser: User; let manageOnlyUser: User; let bothUser: User; let devUser: User; let neitherUser: 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 admin-page wrapper exactly as Payload would for an admin-panel // request (pathname under /admin). The vitest environment has no Next.js HTTP // server, so calling the access function directly is the standard way to unit-test // Payload access control. const adminDecision = async (user: User | null): Promise => { const fn = requireAdminPageAccess("missions"); return await (fn as (args: { req: unknown }) => Promise)({ req: { user, payload, pathname: "/admin/collections/missions" }, }); }; // Same wrapper, but on a REST API pathname — the original read access must be // preserved unchanged outside the admin panel. const apiDecision = async (user: User | null, readAccess?: Access | boolean): Promise => { const fn = requireAdminPageAccess("missions", readAccess); return await (fn as (args: { req: unknown }) => Promise)({ req: { user, payload, pathname: "/api/missions" }, }); }; beforeAll(async () => { const payloadConfig = await config; payload = await getPayload({ config: payloadConfig }); invalidatePermissionCache(); const readOnlyRole = await makeRole("readonly", { permissions: ["missions:read"] }); const manageOnlyRole = await makeRole("manageonly", { permissions: ["admin:missions:manage"], }); const bothRole = await makeRole("both", { permissions: ["missions:read", "admin:missions:manage"], }); const devRole = await makeRole("dev", { isSuperuser: true }); const neitherRole = await makeRole("neither", { permissions: [] }); readOnlyUser = await makeUser("readonly", readOnlyRole.id); manageOnlyUser = await makeUser("manageonly", manageOnlyRole.id); bothUser = await makeUser("both", bothRole.id); devUser = await makeUser("dev", devRole.id); neitherUser = await makeUser("neither", neitherRole.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 Promise.allSettled([ payload.delete({ collection: "profiles", id: p.id, overrideAccess: true }), ]); } await Promise.allSettled([payload.delete({ collection: "users", id, overrideAccess: true })]); } for (const id of roleIds) { await Promise.allSettled([payload.delete({ collection: "roles", id, overrideAccess: true })]); } }); it("denies admin access with only the collection read permission", async () => { expect(await adminDecision(readOnlyUser)).toBe(false); }, TIMEOUT); it("denies admin access with only the admin page-manage permission", async () => { expect(await adminDecision(manageOnlyUser)).toBe(false); }, TIMEOUT); it("grants admin access with both permissions", async () => { expect(await adminDecision(bothUser)).toBe(true); }, TIMEOUT); it("grants admin access to superuser roles", async () => { expect(await adminDecision(devUser)).toBe(true); }, TIMEOUT); it("denies admin access with neither permission", async () => { expect(await adminDecision(neitherUser)).toBe(false); }, TIMEOUT); it("denies anonymous admin access", async () => { expect(await adminDecision(null)).toBe(false); }, TIMEOUT); it("preserves the default logged-in read on the API path", async () => { expect(await apiDecision(bothUser)).toBe(true); }, TIMEOUT); it("preserves the default anonymous denial on the API path", async () => { expect(await apiDecision(null)).toBe(false); }, TIMEOUT); it("delegates a Where-returning original access on the API path", async () => { const where = { user: { equals: bothUser.id } }; expect(await apiDecision(bothUser, () => where)).toEqual(where); }, TIMEOUT); it("delegates a boolean original access on the API path", async () => { expect(await apiDecision(bothUser, () => false)).toBe(false); expect(await apiDecision(bothUser, () => true)).toBe(true); }, TIMEOUT); });