1
0
Fork 0

fix(intel): require write permissions for wiki moderation qualification

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-11 03:25:59 -04:00
parent 6169bf6ce6
commit f90d9348e7
3 changed files with 185 additions and 39 deletions

View file

@ -162,7 +162,7 @@ export async function setPageLockdown(input: {
if (!(await isWikiModerator(payload, user))) { if (!(await isWikiModerator(payload, user))) {
return { return {
success: false, success: false,
error: "You need intelligence division clearance to restore wiki pages.", error: "You need intelligence division clearance to lock wiki pages.",
}; };
} }
const page = await fetchPage(payload, input.id); const page = await fetchPage(payload, input.id);
@ -195,7 +195,7 @@ export async function deleteWikiPage(input: { id: number }): Promise<ActionResul
if (!(await isWikiModerator(payload, user))) { if (!(await isWikiModerator(payload, user))) {
return { return {
success: false, success: false,
error: "You need intelligence division clearance to restore wiki pages.", error: "You need intelligence division clearance to delete wiki pages.",
}; };
} }
const page = await fetchPage(payload, input.id); const page = await fetchPage(payload, input.id);

View file

@ -1,22 +1,22 @@
import type { Payload } from "payload"; import type { Payload } from "payload";
import { hasAnyPermission } from "@/utils/access-control/hasPermission"; import { hasAnyPermission } from "@/utils/access-control/hasPermission";
// Membership signals only. Read permissions (missions:read, campaigns:read,
// factions:read, technologies:read) must NOT appear here: the standard "user"
// role grants all four, so including them would make every regular player pass
// this qualification (and with it, wiki moderation and intel-only UI).
const INTELLIGENCE_PERMISSIONS = [ const INTELLIGENCE_PERMISSIONS = [
"intelligence:manage", "intelligence:manage",
"missions:read",
"missions:create", "missions:create",
"missions:update", "missions:update",
"missions:delete", "missions:delete",
"missions:read-sensitive", "missions:read-sensitive",
"campaigns:read",
"campaigns:create", "campaigns:create",
"campaigns:update", "campaigns:update",
"campaigns:delete", "campaigns:delete",
"factions:read",
"factions:create", "factions:create",
"factions:update", "factions:update",
"factions:delete", "factions:delete",
"technologies:read",
"technologies:create", "technologies:create",
"technologies:update", "technologies:update",
"technologies:delete", "technologies:delete",

View file

@ -1,8 +1,11 @@
import { getPayload, Payload } from "payload"; import { getPayload, Payload } from "payload";
import type { AccessArgs } from "payload";
import config from "@/payload.config"; import config from "@/payload.config";
import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { User, WikiPage, WikiRevision, WikiTemplate } from "@/payload-types"; import type { User, WikiPage, WikiRevision, WikiTemplate } from "@/payload-types";
import { WikiPages } from "@/collections/wiki/WikiPages";
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
import { import {
createPage, createPage,
deletePage, deletePage,
@ -21,6 +24,42 @@ let payload: Payload;
const RUN = `wiki-${Date.now().toString(36)}`; const RUN = `wiki-${Date.now().toString(36)}`;
/**
* User deletion trips FK constraints unless the hook-provisioned personal bank
* account and profile are removed first.
*/
const deleteUserWithRelations = async (pg: Payload, id: number): Promise<void> => {
const accounts = await pg
.find({
collection: "bank-accounts",
where: { ownerUser: { equals: id } },
limit: 5,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
for (const account of accounts?.docs ?? []) {
await pg
.delete({ collection: "bank-accounts", id: account.id, overrideAccess: true })
.catch(() => {});
}
const profiles = await pg
.find({
collection: "profiles",
where: { user: { equals: id } },
limit: 5,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
for (const profile of profiles?.docs ?? []) {
await pg
.delete({ collection: "profiles", id: profile.id, overrideAccess: true })
.catch(() => {});
}
await pg.delete({ collection: "users", id, overrideAccess: true }).catch(() => {});
};
describe("Wiki", () => { describe("Wiki", () => {
let user: User; let user: User;
let userId: number; let userId: number;
@ -73,39 +112,7 @@ describe("Wiki", () => {
.delete({ collection: "wiki-templates", id, overrideAccess: true }) .delete({ collection: "wiki-templates", id, overrideAccess: true })
.catch(() => {}); .catch(() => {});
} }
// User deletion trips FK constraints unless the hook-provisioned await deleteUserWithRelations(payload, userId);
// personal bank account and profile are removed first.
const accounts = await payload
.find({
collection: "bank-accounts",
where: { ownerUser: { equals: userId } },
limit: 5,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
for (const account of accounts?.docs ?? []) {
await payload
.delete({ collection: "bank-accounts", id: account.id, overrideAccess: true })
.catch(() => {});
}
const profiles = await payload
.find({
collection: "profiles",
where: { user: { equals: userId } },
limit: 5,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
for (const profile of profiles?.docs ?? []) {
await payload
.delete({ collection: "profiles", id: profile.id, overrideAccess: true })
.catch(() => {});
}
await payload
.delete({ collection: "users", id: userId, overrideAccess: true })
.catch(() => {});
}); });
const fetchRevisions = async (pageId: number): Promise<WikiRevision[]> => { const fetchRevisions = async (pageId: number): Promise<WikiRevision[]> => {
@ -360,4 +367,143 @@ describe("Wiki", () => {
expect(map[`${RUN}-tpl-b`]).toBe("Template B body"); expect(map[`${RUN}-tpl-b`]).toBe("Template B body");
}); });
}); });
describe("intelligence qualification gating", () => {
let readonlyUser: User;
let qualifiedUser: User;
let superuserUser: User;
const gatingRoleIds: number[] = [];
const gatingUserIds: number[] = [];
let createdQualificationId: number | null = null;
const wikiDeleteAccess = async (user: User | null) => {
const args = { req: { payload, user } } as unknown as AccessArgs;
return (await WikiPages.access?.delete?.(args)) ?? false;
};
const createGatingUser = async (
username: string,
roleDocIds?: number[],
): Promise<User> => {
const u = (await payload.create({
collection: "users",
data: {
username,
discordUsername: username,
displayName: "WIKI GATING TEST",
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
password: "Test123",
...(roleDocIds && roleDocIds.length > 0 ? { roleDocs: roleDocIds } : {}),
},
overrideAccess: true,
depth: 0,
})) as unknown as User;
gatingUserIds.push(u.id);
return u;
};
beforeAll(async () => {
const payloadConfig = await config;
payload = await getPayload({ config: payloadConfig });
// Regression fixture: a role granting ONLY the four broad intelligence
// read permissions that the standard "user" role also grants. Before the
// hasIntelligenceQualification fix, holding these alone made every
// player a wiki moderator.
const readOnlyRole = await payload.create({
collection: "roles",
data: {
name: `${RUN} Intel Read Only`,
slug: `${RUN}-intel-read-only`,
permissions: [
"missions:read",
"campaigns:read",
"factions:read",
"technologies:read",
],
},
overrideAccess: true,
depth: 0,
});
gatingRoleIds.push(readOnlyRole.id);
const superuserRole = await payload.create({
collection: "roles",
data: { name: `${RUN} Superuser`, slug: `${RUN}-superuser`, isSuperuser: true },
overrideAccess: true,
depth: 0,
});
gatingRoleIds.push(superuserRole.id);
readonlyUser = await createGatingUser(`${RUN}-intel-readonly`, [readOnlyRole.id]);
qualifiedUser = await createGatingUser(`${RUN}-intel-qualified`);
superuserUser = await createGatingUser(`${RUN}-intel-super`, [superuserRole.id]);
// Qualification docs have unique names; reuse a shared "Intelligence"
// qualification if one already exists, otherwise create (and clean up) our own.
let qualification = (await payload.find({
collection: "qualifications",
where: { name: { equals: "Intelligence" } },
limit: 1,
depth: 0,
overrideAccess: true,
})) as unknown as { docs: Array<{ id: number }> };
if (qualification.docs.length === 0) {
const created = await payload.create({
collection: "qualifications",
data: { name: "Intelligence" },
overrideAccess: true,
depth: 0,
});
createdQualificationId = created.id;
qualification = { docs: [created] };
}
const profile = (await payload.find({
collection: "profiles",
where: { user: { equals: qualifiedUser.id } },
limit: 1,
depth: 0,
overrideAccess: true,
})) as unknown as { docs: Array<{ id: number }> };
await payload.update({
collection: "profiles",
id: profile.docs[0].id,
data: { progression: { qualifications: [qualification.docs[0].id] } },
overrideAccess: true,
depth: 0,
});
});
afterAll(async () => {
for (const id of gatingUserIds) {
await deleteUserWithRelations(payload, id);
}
for (const id of gatingRoleIds) {
await payload
.delete({ collection: "roles", id, overrideAccess: true })
.catch(() => {});
}
if (createdQualificationId !== null) {
await payload
.delete({ collection: "qualifications", id: createdQualificationId, overrideAccess: true })
.catch(() => {});
}
});
it("a user holding only intelligence read permissions does not qualify as moderator", async () => {
expect(await hasIntelligenceQualification(payload, readonlyUser)).toBe(false);
expect(await wikiDeleteAccess(readonlyUser)).toBe(false);
});
it("a user with the intelligence profile qualification may moderate", async () => {
expect(await hasIntelligenceQualification(payload, qualifiedUser)).toBe(true);
expect(await wikiDeleteAccess(qualifiedUser)).toBe(true);
});
it("a superuser role qualifies regardless of granted permissions", async () => {
expect(await hasIntelligenceQualification(payload, superuserUser)).toBe(true);
expect(await wikiDeleteAccess(superuserUser)).toBe(true);
});
});
}); });