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:
parent
6169bf6ce6
commit
f90d9348e7
3 changed files with 185 additions and 39 deletions
|
|
@ -162,7 +162,7 @@ export async function setPageLockdown(input: {
|
|||
if (!(await isWikiModerator(payload, user))) {
|
||||
return {
|
||||
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);
|
||||
|
|
@ -195,7 +195,7 @@ export async function deleteWikiPage(input: { id: number }): Promise<ActionResul
|
|||
if (!(await isWikiModerator(payload, user))) {
|
||||
return {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -1,22 +1,22 @@
|
|||
import type { Payload } from "payload";
|
||||
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 = [
|
||||
"intelligence:manage",
|
||||
"missions:read",
|
||||
"missions:create",
|
||||
"missions:update",
|
||||
"missions:delete",
|
||||
"missions:read-sensitive",
|
||||
"campaigns:read",
|
||||
"campaigns:create",
|
||||
"campaigns:update",
|
||||
"campaigns:delete",
|
||||
"factions:read",
|
||||
"factions:create",
|
||||
"factions:update",
|
||||
"factions:delete",
|
||||
"technologies:read",
|
||||
"technologies:create",
|
||||
"technologies:update",
|
||||
"technologies:delete",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import { getPayload, Payload } from "payload";
|
||||
import type { AccessArgs } from "payload";
|
||||
import config from "@/payload.config";
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import type { User, WikiPage, WikiRevision, WikiTemplate } from "@/payload-types";
|
||||
import { WikiPages } from "@/collections/wiki/WikiPages";
|
||||
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
|
||||
import {
|
||||
createPage,
|
||||
deletePage,
|
||||
|
|
@ -21,6 +24,42 @@ let payload: Payload;
|
|||
|
||||
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", () => {
|
||||
let user: User;
|
||||
let userId: number;
|
||||
|
|
@ -73,39 +112,7 @@ describe("Wiki", () => {
|
|||
.delete({ collection: "wiki-templates", id, overrideAccess: true })
|
||||
.catch(() => {});
|
||||
}
|
||||
// User deletion trips FK constraints unless the hook-provisioned
|
||||
// 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(() => {});
|
||||
await deleteUserWithRelations(payload, userId);
|
||||
});
|
||||
|
||||
const fetchRevisions = async (pageId: number): Promise<WikiRevision[]> => {
|
||||
|
|
@ -360,4 +367,143 @@ describe("Wiki", () => {
|
|||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue