- 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
451 lines
16 KiB
TypeScript
451 lines
16 KiB
TypeScript
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);
|
|
});
|
|
});
|