import { getPayload, Payload } from "payload"; import config from "@/payload.config"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { Role, TechCategory, Technology, User } from "@/payload-types"; import { scopedAdminPageAccess } from "@/utils/access-control/divisionAccess"; import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions"; let payload: Payload; const RUN = `techcat-${Date.now().toString(36)}`; const TIMEOUT = 30_000; describe("Tech Categories (collection + RBAC + tech tree data model)", () => { const roleIds: number[] = []; const userIds: number[] = []; const categoryIds: number[] = []; const technologyIds: number[] = []; const createdQualificationIds: number[] = []; let plainUser: User; let intelUser: User; let superUser: User; let categoryManagerUser: 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, }); }; 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 }); const managerRole = await makeRole("techcat-manager", { permissions: [ "tech-categories:create", "tech-categories:read", "tech-categories:update", "tech-categories:delete", ], }); plainUser = await makeUser("plain", bareRole.id); intelUser = await makeUser("intel", bareRole.id); superUser = await makeUser("super", superRole.id); categoryManagerUser = await makeUser("manager", managerRole.id); const intelligenceId = await findOrCreateQualification("Intelligence"); await grantQualification(intelUser, intelligenceId); }, TIMEOUT); afterAll(async () => { if (!payload) return; for (const id of technologyIds) { await payload.delete({ collection: "technologies", id, overrideAccess: true }).catch(() => {}); } for (const id of categoryIds) { await payload .delete({ collection: "tech-categories", 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("CRUD (overrideAccess sanity)", () => { it("creates a category with name, position, and optional hex color", async () => { const category = (await payload.create({ collection: "tech-categories", data: { name: `${RUN}-Tier 1`, position: 1, color: "#22d3ee" }, overrideAccess: true, depth: 0, })) as unknown as TechCategory; expect(category.id).toBeGreaterThan(0); expect(category.position).toBe(1); expect(category.color).toBe("#22d3ee"); categoryIds.push(category.id); }, TIMEOUT); it("rejects duplicate names (unique)", async () => { await expect( payload.create({ collection: "tech-categories", data: { name: `${RUN}-Tier 1`, position: 2 }, overrideAccess: true, depth: 0, }), ).rejects.toThrow(); }, TIMEOUT); it("rejects non-hex color values", async () => { await expect( payload.create({ collection: "tech-categories", data: { name: `${RUN}-bad-color`, position: 3, color: "not-a-color" }, overrideAccess: true, depth: 0, }), ).rejects.toThrow(); }, TIMEOUT); }); describe("permission gating", () => { it("plain users cannot create or read categories (no permission, no qualification)", async () => { await expectAccessDenied(() => payload.create({ collection: "tech-categories", data: { name: `${RUN}-plain`, position: 1 }, user: plainUser, overrideAccess: false, depth: 0, }), ); await expectAccessDenied(() => payload.find({ collection: "tech-categories", user: plainUser, overrideAccess: false, depth: 0, }), ); }, TIMEOUT); it("intelligence-qualified users create and read via the division bypass", async () => { const created = (await payload.create({ collection: "tech-categories", data: { name: `${RUN}-intel`, position: 2 }, user: intelUser, overrideAccess: false, depth: 0, })) as unknown as TechCategory; categoryIds.push(created.id); expect(created.id).toBeGreaterThan(0); const found = (await payload.find({ collection: "tech-categories", user: intelUser, overrideAccess: false, depth: 0, })) as unknown as { docs: Array<{ id: number }> }; expect(found.docs.some((d) => d.id === created.id)).toBe(true); }, TIMEOUT); it("users holding explicit tech-categories permissions pass without a qualification", async () => { const created = (await payload.create({ collection: "tech-categories", data: { name: `${RUN}-manager`, position: 3 }, user: categoryManagerUser, overrideAccess: false, depth: 0, })) as unknown as TechCategory; categoryIds.push(created.id); expect(created.id).toBeGreaterThan(0); }, TIMEOUT); it("superusers always pass", async () => { const created = (await payload.create({ collection: "tech-categories", data: { name: `${RUN}-super`, position: 4 }, user: superUser, overrideAccess: false, depth: 0, })) as unknown as TechCategory; categoryIds.push(created.id); expect(created.id).toBeGreaterThan(0); }, TIMEOUT); it("the tech-categories admin page follows the intelligence division like technologies", async () => { // scopedAdminPageAccess consults DIVISION_ADMIN_QUALIFICATIONS; assert // the BEHAVIOR: an intelligence-qualified user passes an admin-panel // request for tech-categories, a plain user does not, superusers pass. const decision = async (user: User | null): Promise => { const fn = scopedAdminPageAccess("tech-categories"); return Boolean( await (fn as (args: { req: unknown }) => Promise)({ req: { user, payload, pathname: "/admin/collections/tech-categories" }, }), ); }; expect(await decision(intelUser)).toBe(true); expect(await decision(plainUser)).toBe(false); expect(await decision(superUser)).toBe(true); expect(await decision(null)).toBe(false); }, TIMEOUT); }); describe("technologies category relationship", () => { it("assigns a category to a technology and reads it back", async () => { const category = (await payload.create({ collection: "tech-categories", data: { name: `${RUN}-Tier 2`, position: 2 }, overrideAccess: true, depth: 0, })) as unknown as TechCategory; categoryIds.push(category.id); const tech = (await payload.create({ collection: "technologies", data: { name: `${RUN}-tech`, type: "upgrade", summary: "Test technology for the tree.", category: category.id, approvalStatus: "in_progress", researchCosts: { minimumResearchDuration: 1, maximumResearchDuration: 10 }, }, overrideAccess: true, depth: 0, })) as unknown as Technology; technologyIds.push(tech.id); const fetched = (await payload.findByID({ collection: "technologies", id: tech.id, depth: 0, overrideAccess: true, })) as unknown as Technology; expect(fetched.category).toBe(category.id); }, TIMEOUT); it("deleting a category leaves the technology with no category (FK set null)", async () => { const category = (await payload.create({ collection: "tech-categories", data: { name: `${RUN}-Tier 9`, position: 9 }, overrideAccess: true, depth: 0, })) as unknown as TechCategory; const tech = (await payload.create({ collection: "technologies", data: { name: `${RUN}-tech-orphan`, type: "asset", summary: "Orphaned on category delete.", category: category.id, approvalStatus: "in_progress", researchCosts: { minimumResearchDuration: 1, maximumResearchDuration: 10 }, }, overrideAccess: true, depth: 0, })) as unknown as Technology; technologyIds.push(tech.id); await payload.delete({ collection: "tech-categories", id: category.id, overrideAccess: true, }); const fetched = (await payload.findByID({ collection: "technologies", id: tech.id, depth: 0, overrideAccess: true, })) as unknown as Technology; expect(fetched.category === null || fetched.category === undefined).toBe(true); }, TIMEOUT); }); describe("tech tree manual positions", () => { it("stores a dragged tree position and clears it again", async () => { const tech = (await payload.create({ collection: "technologies", data: { name: `${RUN}-positioned`, type: "upgrade", summary: "Manual canvas position round-trip.", approvalStatus: "in_progress", researchCosts: { minimumResearchDuration: 1, maximumResearchDuration: 10 }, treePosition: { x: 420, y: 260 }, }, overrideAccess: true, depth: 0, })) as unknown as Technology; technologyIds.push(tech.id); const fetched = (await payload.findByID({ collection: "technologies", id: tech.id, depth: 0, overrideAccess: true, })) as unknown as Technology; expect(fetched.treePosition?.x).toBe(420); expect(fetched.treePosition?.y).toBe(260); const cleared = (await payload.update({ collection: "technologies", id: tech.id, data: { treePosition: { x: null, y: null } }, overrideAccess: true, depth: 0, })) as unknown as Technology; expect(cleared.treePosition?.x ?? null).toBeNull(); expect(cleared.treePosition?.y ?? null).toBeNull(); }, TIMEOUT); }); });