import { getPayload, Payload } from "payload"; import type { Access, AccessArgs } from "payload"; import config from "@/payload.config"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { Role, User } from "@/payload-types"; import { canAccessAdminPanel, requireApprovalPermission, requireIntelligencePermission, requireLogisticsPermission, scopedAdminPageAccess, } from "@/utils/access-control/divisionAccess"; import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions"; let payload: Payload; const RUN = `div-${Date.now().toString(36)}`; const TIMEOUT = 30_000; describe("Division-scoped admin access (intelligence / logistics)", () => { const roleIds: number[] = []; const userIds: number[] = []; const assetIds: number[] = []; const resourceIds: number[] = []; const vehicleIds: number[] = []; const technologyIds: number[] = []; const createdQualificationIds: number[] = []; let intelUser: User; let logiUser: User; let plainUser: User; let superUser: 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", roleDocs: [roleId], }, overrideAccess: true, depth: 0, })) as unknown as User; userIds.push(user.id); return user; }; const findOrCreateQualification = async (name: string): Promise => { const found = (await payload.find({ collection: "qualifications", where: { name: { equals: name } }, limit: 1, depth: 0, overrideAccess: true, })) as unknown as { docs: Array<{ id: number }> }; if (found.docs.length > 0) return found.docs[0].id; const created = (await payload.create({ collection: "qualifications", data: { name }, overrideAccess: true, depth: 0, })) as unknown as { id: number }; createdQualificationIds.push(created.id); return created.id; }; const grantQualification = async (user: User, qualificationId: number) => { const profile = (await payload.find({ collection: "profiles", where: { user: { equals: user.id } }, limit: 1, depth: 0, overrideAccess: true, })) as unknown as { docs: Array<{ id: number }> }; expect(profile.docs.length).toBeGreaterThan(0); await payload.update({ collection: "profiles", id: profile.docs[0].id, data: { progression: { qualifications: [qualificationId] } }, overrideAccess: true, depth: 0, }); }; // Invoke the scoped admin-page wrapper exactly as Payload would for an // admin-panel request (pathname under /admin). const adminDecision = async (slug: string, user: User | null): Promise => { const fn = scopedAdminPageAccess(slug); return Boolean( await (fn as (args: { req: unknown }) => Promise)({ req: { user, payload, pathname: `/admin/collections/${slug}` }, }), ); }; const apiDecision = async (slug: string, user: User | null, readAccess?: Access | boolean) => { const fn = scopedAdminPageAccess(slug, readAccess); return Boolean( await (fn as (args: { req: unknown }) => Promise)({ req: { user, payload, pathname: `/api/${slug}` }, }), ); }; const accessFnDecision = async (fn: (args: AccessArgs) => Promise, user: User) => Boolean(await fn({ req: { payload, user } } as unknown as AccessArgs)); const expectAccessDenied = async (fn: () => Promise) => { try { await fn(); } catch (e) { const name = (e as { name?: string })?.name ?? ""; const message = e instanceof Error ? e.message : ""; expect( name === "AccessError" || name === "Forbidden" || /not permitted|not allowed|access denied/i.test(message), ).toBe(true); return; } throw new Error("Expected operation to be denied"); }; beforeAll(async () => { const payloadConfig = await config; payload = await getPayload({ config: payloadConfig }); invalidatePermissionCache(); const bareRole = await makeRole("bare", { permissions: [] }); const superRole = await makeRole("super", { isSuperuser: true }); intelUser = await makeUser("intel", bareRole.id); logiUser = await makeUser("logi", bareRole.id); plainUser = await makeUser("plain", bareRole.id); superUser = await makeUser("super", superRole.id); const intelligenceId = await findOrCreateQualification("Intelligence"); const logisticsId = await findOrCreateQualification("Logistics"); await grantQualification(intelUser, intelligenceId); await grantQualification(logiUser, logisticsId); }, TIMEOUT); afterAll(async () => { if (!payload) return; type TestSlug = "assets" | "resources" | "vehicles" | "technologies"; const docs: Array<[TestSlug, number]> = [ ...assetIds.map((id) => ["assets", id] as [TestSlug, number]), ...resourceIds.map((id) => ["resources", id] as [TestSlug, number]), ...vehicleIds.map((id) => ["vehicles", id] as [TestSlug, number]), ...technologyIds.map((id) => ["technologies", id] as [TestSlug, number]), ]; for (const [collection, id] of docs) { await payload.delete({ collection, id, overrideAccess: true }).catch(() => {}); } 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(() => {}); } for (const id of createdQualificationIds) { await payload .delete({ collection: "qualifications", id, overrideAccess: true }) .catch(() => {}); } }); describe("admin panel visibility (scopedAdminPageAccess)", () => { it("intelligence qualification grants technologies only", async () => { expect(await adminDecision("technologies", intelUser)).toBe(true); expect(await adminDecision("assets", intelUser)).toBe(false); expect(await adminDecision("resources", intelUser)).toBe(false); expect(await adminDecision("vehicles", intelUser)).toBe(false); expect(await adminDecision("missions", intelUser)).toBe(false); }, TIMEOUT); it("logistics qualification grants assets, resources, and vehicles only", async () => { expect(await adminDecision("assets", logiUser)).toBe(true); expect(await adminDecision("resources", logiUser)).toBe(true); expect(await adminDecision("vehicles", logiUser)).toBe(true); expect(await adminDecision("technologies", logiUser)).toBe(false); expect(await adminDecision("missions", logiUser)).toBe(false); expect(await adminDecision("structures", logiUser)).toBe(false); }, TIMEOUT); it("plain users and anonymous users see none of the four", async () => { for (const slug of ["technologies", "assets", "resources", "vehicles"]) { expect(await adminDecision(slug, plainUser)).toBe(false); expect(await adminDecision(slug, null)).toBe(false); } }, TIMEOUT); it("superusers keep full admin panel access", async () => { for (const slug of ["technologies", "assets", "resources", "vehicles"]) { expect(await adminDecision(slug, superUser)).toBe(true); } }, TIMEOUT); it("preserves the original read behavior on non-admin (REST) paths", async () => { expect(await apiDecision("assets", logiUser)).toBe(true); expect(await apiDecision("assets", null)).toBe(false); expect(await apiDecision("technologies", logiUser, () => false)).toBe(false); }, TIMEOUT); }); describe("admin panel gate (canAccessAdminPanel)", () => { it("division members pass; plain users and anonymous users do not", async () => { expect(await canAccessAdminPanel(payload, intelUser)).toBe(true); expect(await canAccessAdminPanel(payload, logiUser)).toBe(true); expect(await canAccessAdminPanel(payload, superUser)).toBe(true); expect(await canAccessAdminPanel(payload, plainUser)).toBe(false); expect(await canAccessAdminPanel(payload, null)).toBe(false); }, TIMEOUT); }); describe("collection write access", () => { it("logistics members fully manage assets but cannot self-approve", async () => { const asset = (await payload.create({ collection: "assets", data: { name: `${RUN} Asset`, className: "test-asset", assetType: "weapon", approvalStatus: "approved", crafting: { craftingData: { craftingTimePerUnit: 1, batchSize: 1 } }, storageDimensions: { gridWidth: 1, gridHeight: 1 }, }, user: logiUser, overrideAccess: false, })) as unknown as { id: number; approvalStatus: string }; assetIds.push(asset.id); expect(asset.approvalStatus).toBe("in_progress"); const renamed = (await payload.update({ collection: "assets", id: asset.id, data: { name: `${RUN} Asset v2`, approvalStatus: "rejected" }, user: logiUser, overrideAccess: false, })) as unknown as { name: string; approvalStatus: string }; expect(renamed.name).toBe(`${RUN} Asset v2`); expect(renamed.approvalStatus).toBe("in_progress"); const approved = (await payload.update({ collection: "assets", id: asset.id, data: { approvalStatus: "approved", isLive: true }, user: superUser, overrideAccess: false, })) as unknown as { approvalStatus: string; isLive: boolean }; expect(approved.approvalStatus).toBe("approved"); expect(approved.isLive).toBe(true); const demoted = (await payload.update({ collection: "assets", id: asset.id, data: { isLive: false }, user: logiUser, overrideAccess: false, })) as unknown as { isLive: boolean }; expect(demoted.isLive).toBe(true); }, TIMEOUT); it("intelligence members fully manage technologies but cannot self-approve", async () => { const tech = (await payload.create({ collection: "technologies", data: { name: `${RUN} Tech`, summary: "Division test tech", type: "upgrade", approvalStatus: "approved", researchCosts: { minimumResearchDuration: 1 }, }, user: intelUser, overrideAccess: false, })) as unknown as { id: number; approvalStatus: string }; technologyIds.push(tech.id); expect(tech.approvalStatus).toBe("in_progress"); const renamed = (await payload.update({ collection: "technologies", id: tech.id, data: { name: `${RUN} Tech v2`, approvalStatus: "approved" }, user: intelUser, overrideAccess: false, })) as unknown as { name: string; approvalStatus: string }; expect(renamed.name).toBe(`${RUN} Tech v2`); expect(renamed.approvalStatus).toBe("in_progress"); await expectAccessDenied(() => payload.create({ collection: "technologies", data: { name: `${RUN} Tech Denied`, summary: "no", type: "upgrade", approvalStatus: "in_progress", researchCosts: { minimumResearchDuration: 1 }, }, user: logiUser, overrideAccess: false, }), ); await expectAccessDenied(() => payload.create({ collection: "technologies", data: { name: `${RUN} Tech Denied 2`, summary: "no", type: "upgrade", approvalStatus: "in_progress", researchCosts: { minimumResearchDuration: 1 }, }, user: plainUser, overrideAccess: false, }), ); }, TIMEOUT); it("logistics members manage resources and vehicles; plain users are denied", async () => { const resource = (await payload.create({ collection: "resources", data: { name: `${RUN} Fuel`, codeName: `res_fuel_${RUN}`, unitOfMeasure: "liter", massPerUnit: 0, gridWidth: 1, gridHeight: 1, type: "fluid", baseValue: 1, rarity: "common", approvalStatus: "approved", }, user: logiUser, overrideAccess: false, })) as unknown as { id: number; approvalStatus: string }; resourceIds.push(resource.id); expect(resource.approvalStatus).toBe("in_progress"); await expectAccessDenied(() => payload.create({ collection: "resources", data: { name: `${RUN} Denied`, codeName: `res_denied_${RUN}`, unitOfMeasure: "unit", massPerUnit: 0, gridWidth: 1, gridHeight: 1, type: "physical", baseValue: 1, rarity: "common", approvalStatus: "in_progress", }, user: plainUser, overrideAccess: false, }), ); const vehicle = (await payload.create({ collection: "vehicles", data: { name: `${RUN} Truck`, transportMode: "ground", approvalStatus: "approved", fuel: { fuelType: resource.id, fuelCapacity: 100, fuelConsumptionRate: 1 }, }, user: logiUser, overrideAccess: false, })) as unknown as { id: number; approvalStatus: string }; vehicleIds.push(vehicle.id); expect(vehicle.approvalStatus).toBe("in_progress"); await expectAccessDenied(() => payload.create({ collection: "vehicles", data: { name: `${RUN} Denied Truck`, transportMode: "ground", approvalStatus: "in_progress", fuel: { fuelType: resource.id, fuelCapacity: 10, fuelConsumptionRate: 1 }, }, user: intelUser, overrideAccess: false, }), ); }, TIMEOUT); it("superusers create pre-approved documents directly", async () => { const asset = (await payload.create({ collection: "assets", data: { name: `${RUN} Super Asset`, className: "test-asset", assetType: "weapon", approvalStatus: "approved", crafting: { craftingData: { craftingTimePerUnit: 1, batchSize: 1 } }, storageDimensions: { gridWidth: 1, gridHeight: 1 }, }, user: superUser, overrideAccess: false, })) as unknown as { id: number; approvalStatus: string }; assetIds.push(asset.id); expect(asset.approvalStatus).toBe("approved"); }, TIMEOUT); it("permission holders bypass the qualification requirement", async () => { const assetsCreate = requireLogisticsPermission("assets:create"); expect(await accessFnDecision(assetsCreate, logiUser)).toBe(true); const technologiesCreate = requireIntelligencePermission("technologies:create"); expect(await accessFnDecision(technologiesCreate, intelUser)).toBe(true); }, TIMEOUT); it("approval fields reject writes from everyone below the super-user tier", async () => { const approvalUpdate = requireApprovalPermission(); expect(await accessFnDecision(approvalUpdate, superUser)).toBe(true); expect(await accessFnDecision(approvalUpdate, logiUser)).toBe(false); expect(await accessFnDecision(approvalUpdate, intelUser)).toBe(false); expect(await accessFnDecision(approvalUpdate, plainUser)).toBe(false); }, TIMEOUT); }); });