Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@siisyphuslabs.ai>
164 lines
5.9 KiB
TypeScript
164 lines
5.9 KiB
TypeScript
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<Role> = {}): Promise<Role> => {
|
|
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<User> => {
|
|
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<unknown> => {
|
|
const fn = requireAdminPageAccess("missions");
|
|
return await (fn as (args: { req: unknown }) => Promise<unknown>)({
|
|
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<unknown> => {
|
|
const fn = requireAdminPageAccess("missions", readAccess);
|
|
return await (fn as (args: { req: unknown }) => Promise<unknown>)({
|
|
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);
|
|
});
|