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))) {
|
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);
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Loading…
Reference in a new issue