diff --git a/src/collections/intelligence/Technologies.ts b/src/collections/intelligence/Technologies.ts index 180e65e..e07757a 100644 --- a/src/collections/intelligence/Technologies.ts +++ b/src/collections/intelligence/Technologies.ts @@ -1,6 +1,14 @@ import { CollectionConfig } from "payload"; import { requirePermission } from "@/utils/access-control/hasPermission"; -import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; +import { requireApprovalPermission, requireIntelligencePermission } from "@/utils/access-control/divisionAccess"; + +// Approval is a super-user decision: the field is stripped from create/update +// payloads of everyone below the Admin tier, so the schema default +// ("in_progress") applies and division members can never self-approve. +const approvalFieldAccess = { + create: requireApprovalPermission(), + update: requireApprovalPermission(), +}; export const Technologies: CollectionConfig = { slug: "technologies", @@ -9,9 +17,9 @@ export const Technologies: CollectionConfig = { useAsTitle: "name", }, access: { - create: requirePermission("technologies:create"), - update: requirePermission("technologies:update"), - delete: requirePermission("technologies:delete"), + create: requireIntelligencePermission("technologies:create"), + update: requireIntelligencePermission("technologies:update"), + delete: requireIntelligencePermission("technologies:delete"), read: requirePermission("technologies:read"), }, fields: [ @@ -24,6 +32,7 @@ export const Technologies: CollectionConfig = { name: "approvalStatus", type: "select", label: "Approval Status", + access: approvalFieldAccess, options: [ { label: "In Progress", value: "in_progress" }, { label: "Ready for Review", value: "ready_for_review" }, @@ -32,18 +41,6 @@ export const Technologies: CollectionConfig = { { label: "Revision Requested", value: "revision_requested" }, ], defaultValue: "in_progress", - filterOptions: ({ options, req }) => { - const roles = isPayloadUser(req.user) - ? ((req.user.roles as string[] | undefined) ?? []) - : []; - const canReadAll = roles.includes("admin") || roles.includes("developer"); - if (canReadAll) return options; - return options.filter((option) => - typeof option === "string" - ? options - : ["in_progress", "ready_for_review"].includes(option.value), - ); - }, required: true, admin: { description: diff --git a/src/collections/logistics/Assets.ts b/src/collections/logistics/Assets.ts index 7072ed7..9c92eb8 100644 --- a/src/collections/logistics/Assets.ts +++ b/src/collections/logistics/Assets.ts @@ -1,6 +1,13 @@ import { CollectionConfig } from "payload"; -import { isDeveloper } from "@/utils/access-control/isRole"; -import { requirePermission } from "@/utils/access-control/hasPermission"; +import { + requireApprovalPermission, + requireLogisticsPermission, +} from "@/utils/access-control/divisionAccess"; + +const approvalFieldAccess = { + create: requireApprovalPermission(), + update: requireApprovalPermission(), +}; export const Assets: CollectionConfig = { slug: "assets", @@ -8,6 +15,11 @@ export const Assets: CollectionConfig = { group: "Logistics", useAsTitle: "name", }, + access: { + create: requireLogisticsPermission("assets:create"), + update: requireLogisticsPermission("assets:update"), + delete: requireLogisticsPermission("assets:delete"), + }, fields: [ { name: "name", @@ -18,6 +30,7 @@ export const Assets: CollectionConfig = { name: "approvalStatus", type: "select", label: "Approval Status", + access: approvalFieldAccess, options: [ { label: "In Progress", value: "in_progress" }, { label: "Ready for Review", value: "ready_for_review" }, @@ -26,15 +39,6 @@ export const Assets: CollectionConfig = { { label: "Revision Requested", value: "revision_requested" }, ], defaultValue: "in_progress", - filterOptions: ({ options, req }) => { - return !isDeveloper({ req }) - ? options.filter((option) => - typeof option === "string" - ? options - : ["in_progress", "ready_for_review"].includes(option.value), - ) - : options; - }, required: true, admin: { description: @@ -643,14 +647,15 @@ export const Assets: CollectionConfig = { label: "Auditing", description: "Auditing and moderation for this Asset.", access: { - read: requirePermission("assets:read"), - create: requirePermission("assets:create"), - update: requirePermission("assets:update"), + read: requireLogisticsPermission("assets:read"), + create: requireLogisticsPermission("assets:create"), + update: requireLogisticsPermission("assets:update"), }, fields: [ { name: "isLive", type: "checkbox", + access: approvalFieldAccess, admin: { description: 'If checked, this item is approved and considered "live" (i.e. it will be considered in calculations and made available in-game.)', diff --git a/src/collections/logistics/Resources.ts b/src/collections/logistics/Resources.ts index 35c67b4..04a9b04 100644 --- a/src/collections/logistics/Resources.ts +++ b/src/collections/logistics/Resources.ts @@ -1,5 +1,10 @@ -import { CollectionConfig, PayloadRequest } from "payload"; +import { AccessArgs, AccessResult, CollectionConfig, PayloadRequest } from "payload"; +import type { Permission } from "@/permissions"; import { isDeveloper } from "@/utils/access-control/isRole"; +import { + requireApprovalPermission, + requireLogisticsPermission, +} from "@/utils/access-control/divisionAccess"; const onlyIfNotPrimaryCurrency = async ({ req }: { req: PayloadRequest }) => { if (isDeveloper({ req })) { @@ -31,6 +36,21 @@ const onlyIfNotPrimaryCurrency = async ({ req }: { req: PayloadRequest }) => { return true; }; +// Logistics members may write resources, but the primary-currency guard +// (developer-only, non-primary docs only) is layered on top, not replaced. +const logisticsWriteWithCurrencyGuard = (permission: Permission) => { + const logisticsCheck = requireLogisticsPermission(permission); + return async (args: AccessArgs): Promise => { + if (!(await logisticsCheck(args))) return false; + return onlyIfNotPrimaryCurrency(args); + }; +}; + +const approvalFieldAccess = { + create: requireApprovalPermission(), + update: requireApprovalPermission(), +}; + export const Resources: CollectionConfig = { slug: "resources", admin: { @@ -39,8 +59,9 @@ export const Resources: CollectionConfig = { }, access: { read: ({ req }) => !!req.user, - update: onlyIfNotPrimaryCurrency, - delete: onlyIfNotPrimaryCurrency, + create: requireLogisticsPermission("resources:create"), + update: logisticsWriteWithCurrencyGuard("resources:update"), + delete: logisticsWriteWithCurrencyGuard("resources:delete"), }, fields: [ { @@ -118,6 +139,7 @@ export const Resources: CollectionConfig = { name: "approvalStatus", type: "select", label: "Approval Status", + access: approvalFieldAccess, options: [ { label: "In Progress", value: "in_progress" }, { label: "Ready for Review", value: "ready_for_review" }, @@ -126,15 +148,6 @@ export const Resources: CollectionConfig = { { label: "Revision Requested", value: "revision_requested" }, ], defaultValue: "in_progress", - filterOptions: ({ options, data, req }) => { - return !isDeveloper({ req }) - ? options.filter((option) => - typeof option === "string" - ? options - : ["in_progress", "ready_for_review"].includes(option.value), - ) - : options; - }, required: true, admin: { description: diff --git a/src/collections/logistics/Vehicles.ts b/src/collections/logistics/Vehicles.ts index 851fcc5..23ae52c 100644 --- a/src/collections/logistics/Vehicles.ts +++ b/src/collections/logistics/Vehicles.ts @@ -1,5 +1,13 @@ import { CollectionConfig } from "payload"; -import { isDeveloper } from "@/utils/access-control/isRole"; +import { + requireApprovalPermission, + requireLogisticsPermission, +} from "@/utils/access-control/divisionAccess"; + +const approvalFieldAccess = { + create: requireApprovalPermission(), + update: requireApprovalPermission(), +}; export const Vehicles: CollectionConfig = { slug: "vehicles", @@ -7,6 +15,11 @@ export const Vehicles: CollectionConfig = { useAsTitle: "name", group: "Logistics", }, + access: { + create: requireLogisticsPermission("vehicles:create"), + update: requireLogisticsPermission("vehicles:update"), + delete: requireLogisticsPermission("vehicles:delete"), + }, fields: [ { label: "Identity", @@ -29,6 +42,7 @@ export const Vehicles: CollectionConfig = { name: "approvalStatus", type: "select", label: "Approval Status", + access: approvalFieldAccess, options: [ { label: "In Progress", value: "in_progress" }, { label: "Ready for Review", value: "ready_for_review" }, @@ -37,15 +51,6 @@ export const Vehicles: CollectionConfig = { { label: "Revision Requested", value: "revision_requested" }, ], defaultValue: "in_progress", - filterOptions: ({ options, data, req }) => { - return !isDeveloper({ req }) - ? options.filter((option) => - typeof option === "string" - ? options - : ["in_progress", "ready_for_review"].includes(option.value), - ) - : options; - }, required: true, admin: { description: diff --git a/src/payload.config.ts b/src/payload.config.ts index dc86fd2..2db0408 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -82,7 +82,7 @@ import { CustomMinefieldScores } from "@/collections/minigames/CustomMinefieldSc import { CustomRadioTests } from "@/collections/minigames/CustomRadioTests"; import { CustomRadioTestScores } from "@/collections/minigames/CustomRadioTestScores"; import { RibbonSubmissions } from "@/collections/users/RibbonSubmissions"; -import { requireAdminPageAccess } from "@/utils/access-control/hasPermission"; +import { scopedAdminPageAccess } from "@/utils/access-control/divisionAccess"; import { payloadAiPlugin, PayloadAiPluginLexicalEditorFeature } from "@ai-stack/payloadcms"; import type { SeedPromptFunction } from "@ai-stack/payloadcms/types"; import { mcpPlugin } from "@payloadcms/plugin-mcp"; @@ -331,13 +331,15 @@ const collections = [ ]; // Scoped admin pages: a user needs BOTH `:read` AND `admin::manage` -// to see/interact with a collection in the Payload admin panel. Non-admin +// to see/interact with a collection in the Payload admin panel, except the four +// division-scoped collections (technologies → intelligence; assets/resources/ +// vehicles → logistics), where a division member passes instead. Non-admin // (REST/API) reads keep the collection's original access behavior. const scopedCollections = collections.map((collection) => ({ ...collection, access: { ...collection.access, - read: requireAdminPageAccess(collection.slug, collection.access?.read), + read: scopedAdminPageAccess(collection.slug, collection.access?.read), }, })); diff --git a/src/utils/access-control/divisionAccess.ts b/src/utils/access-control/divisionAccess.ts new file mode 100644 index 0000000..e0de91c --- /dev/null +++ b/src/utils/access-control/divisionAccess.ts @@ -0,0 +1,123 @@ +import type { Access, AccessArgs, AccessResult, Payload, PayloadRequest } from "payload"; +import type { Permission } from "@/permissions"; +import { + canAccessAdminPanel as canAccessAdminPanelByPermissions, + hasPermission, + isAdminPanelRequest, + requireAdminPageAccess, +} from "@/utils/access-control/hasPermission"; +import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification"; +import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification"; + +/** + * Division-scoped access control. + * + * Division membership is established by the two qualification helpers: + * - Intelligence: `hasIntelligenceQualification` (intel-domain RBAC permissions + * or the "intelligence" profile qualification) + * - Logistics: `hasLogisticsQualification` (logistics-domain RBAC permissions + * or a "logistics" profile qualification) + * + * Explicit RBAC permissions always still work on their own - these helpers are + * a union (permission OR qualification OR superuser), never a replacement. + */ + +/** + * Collections whose admin panel is delegated to a division. Division members + * get full admin-panel access to exactly these collections (and nothing else, + * because `requireAdminPageAccess` still gates every other collection's + * admin-panel read behind `admin::manage` permissions). + */ +const DIVISION_ADMIN_QUALIFICATIONS: Record< + string, + (payload: Payload, user: { id: number | string }) => Promise +> = { + technologies: hasIntelligenceQualification, + assets: hasLogisticsQualification, + resources: hasLogisticsQualification, + vehicles: hasLogisticsQualification, +}; + +/** + * Access factory: allows users holding the explicit permission OR members of + * the Intelligence division. + */ +export function requireIntelligencePermission(permission: Permission) { + return async ({ req }: { req: PayloadRequest }): Promise => { + if (await hasPermission(req.payload, req.user, permission)) return true; + if (!req.user) return false; + return hasIntelligenceQualification(req.payload, req.user); + }; +} + +/** + * Access factory: allows users holding the explicit permission OR members of + * the Logistics division. + */ +export function requireLogisticsPermission(permission: Permission) { + return async ({ req }: { req: PayloadRequest }): Promise => { + if (await hasPermission(req.payload, req.user, permission)) return true; + if (!req.user) return false; + return hasLogisticsQualification(req.payload, req.user); + }; +} + +/** + * Field-level access factory for approval-gating fields (approval status, + * is-live flags, ...). Only "super users" may write them: superuser roles or + * holders of the `system:admin-access` permission (the Admin tier). + * + * Used as both `create` and `update` field access: on create the field is + * stripped from non-superuser payloads so the schema default (e.g. + * "in_progress") applies; on update attempts to change the value are ignored. + */ +export function requireApprovalPermission() { + return async ({ req }: { req: PayloadRequest }): Promise => + hasPermission(req.payload, req.user, "system:admin-access"); +} + +/** + * Same contract as `requireAdminPageAccess`, with one addition: for the four + * division-scoped collections an admin-panel request also passes when the user + * qualifies for the owning division. Non-admin (REST/API) requests keep the + * original read access behavior unchanged. + */ +export function scopedAdminPageAccess( + collectionSlug: string, + readAccess?: Access | boolean, +): Access { + const base = requireAdminPageAccess(collectionSlug, readAccess); + const divisionCheck = DIVISION_ADMIN_QUALIFICATIONS[collectionSlug]; + + if (!divisionCheck) return base; + + return async (args: AccessArgs): Promise => { + const { req } = args; + if (req.user && isAdminPanelRequest(req)) { + try { + if (await divisionCheck(req.payload, req.user)) return true; + } catch { + // Qualification lookup failed; fall through to the permission-based + // decision instead of failing the request outright. + } + } + return base(args); + }; +} + +/** + * Division-aware admin panel gate: permission-based access (superuser or any + * `admin::manage` permission) OR membership of a division that owns at + * least one admin panel collection. + */ +export async function canAccessAdminPanel( + payload: Payload, + user: { id: number | string; roleDocs?: unknown } | null | undefined, +): Promise { + if (!user) return false; + if (await canAccessAdminPanelByPermissions(payload, user)) return true; + for (const divisionCheck of Object.values(DIVISION_ADMIN_QUALIFICATIONS)) { + if (await divisionCheck(payload, user)) return true; + } + return false; +} diff --git a/src/utils/access-control/hasPermission.ts b/src/utils/access-control/hasPermission.ts index d7ca099..f283a17 100644 --- a/src/utils/access-control/hasPermission.ts +++ b/src/utils/access-control/hasPermission.ts @@ -90,7 +90,7 @@ export async function hasAllPermissions( * Local API calls (server actions, seeds, MCP, bots) get a non-admin * pathname and therefore keep the original access behavior. */ -function isAdminPanelRequest(req: PayloadRequest): boolean { +export function isAdminPanelRequest(req: PayloadRequest): boolean { const adminRoute = req.payload.config.routes?.admin ?? "/admin"; const pathname = req.pathname; if (!pathname) return false; diff --git a/tests/int/division-admin-access.int.spec.ts b/tests/int/division-admin-access.int.spec.ts new file mode 100644 index 0000000..40ea9f6 --- /dev/null +++ b/tests/int/division-admin-access.int.spec.ts @@ -0,0 +1,451 @@ +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); + }); +});