1
0
Fork 0

feat(access): division-scoped admin page access

- scopedAdminPageAccess replaces requireAdminPageAccess: technologies pass for
  intelligence division members, assets/resources/vehicles for logistics;
  everyone else still needs admin:<slug>:manage
- technology approval is stripped from create/update below admin so division
  members can never self-approve; technologies access moves to the
  intelligence permission
- assets/resources/vehicles move to logistics division permissions
This commit is contained in:
Jason Fraley 2026-09-21 20:48:08 -04:00
parent c177f2486f
commit 4dde790aa8
8 changed files with 652 additions and 56 deletions

View file

@ -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:

View file

@ -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.)',

View file

@ -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<AccessResult> => {
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:

View file

@ -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:

View file

@ -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 `<slug>:read` AND `admin:<slug>: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),
},
}));

View file

@ -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:<slug>:manage` permissions).
*/
const DIVISION_ADMIN_QUALIFICATIONS: Record<
string,
(payload: Payload, user: { id: number | string }) => Promise<boolean>
> = {
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<boolean> => {
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<boolean> => {
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<boolean> =>
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<AccessResult> => {
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:<slug>: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<boolean> {
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;
}

View file

@ -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;

View file

@ -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<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",
roleDocs: [roleId],
},
overrideAccess: true,
depth: 0,
})) as unknown as User;
userIds.push(user.id);
return user;
};
const findOrCreateQualification = async (name: string): Promise<number> => {
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<boolean> => {
const fn = scopedAdminPageAccess(slug);
return Boolean(
await (fn as (args: { req: unknown }) => Promise<unknown>)({
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<unknown>)({
req: { user, payload, pathname: `/api/${slug}` },
}),
);
};
const accessFnDecision = async (fn: (args: AccessArgs) => Promise<boolean>, user: User) =>
Boolean(await fn({ req: { payload, user } } as unknown as AccessArgs));
const expectAccessDenied = async (fn: () => Promise<unknown>) => {
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);
});
});