diff --git a/src/app/(frontend)/wiki/actions.ts b/src/app/(frontend)/wiki/actions.ts index aedce90..31d3483 100644 --- a/src/app/(frontend)/wiki/actions.ts +++ b/src/app/(frontend)/wiki/actions.ts @@ -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 => { + 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 => { @@ -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 => { + 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); + }); + }); }); \ No newline at end of file