diff --git a/src/globals/HomePage.ts b/src/globals/HomePage.ts index 72b65538..b23a2178 100644 --- a/src/globals/HomePage.ts +++ b/src/globals/HomePage.ts @@ -1,3 +1,4 @@ +import { payloadAdminOnly } from '@/utilities/campaignAccess' import { revalidateGlobal } from '@/utilities/globals' import { GlobalConfig } from 'payload' @@ -13,7 +14,8 @@ export const HomePage: GlobalConfig = { }, access: { read: () => true, - update: ({ req: { user } }) => Boolean(user), + // Admin-only like the other site globals (Pass 4) — campaign JWTs reach /api/*. + update: payloadAdminOnly, }, hooks: { afterChange: [revalidate], diff --git a/src/globals/Metadata.ts b/src/globals/Metadata.ts index 8b3802e9..7e96431b 100644 --- a/src/globals/Metadata.ts +++ b/src/globals/Metadata.ts @@ -1,3 +1,4 @@ +import { payloadAdminOnly } from '@/utilities/campaignAccess' import { revalidateGlobal } from '@/utilities/globals' import { GlobalConfig } from 'payload' @@ -13,7 +14,8 @@ export const Metadata: GlobalConfig = { }, access: { read: () => true, - update: ({ req: { user } }) => Boolean(user), + // Admin-only like the other site globals (Pass 4) — campaign JWTs reach /api/*. + update: payloadAdminOnly, }, hooks: { afterChange: [revalidate], diff --git a/src/globals/PrivacyPolicy.ts b/src/globals/PrivacyPolicy.ts index e3ed4749..09c68f89 100644 --- a/src/globals/PrivacyPolicy.ts +++ b/src/globals/PrivacyPolicy.ts @@ -1,3 +1,4 @@ +import { payloadAdminOnly } from '@/utilities/campaignAccess' import { revalidateGlobal } from '@/utilities/globals' import type { GlobalConfig } from 'payload' @@ -18,7 +19,9 @@ export const PrivacyPolicy: GlobalConfig = { }, access: { read: () => true, - update: ({ req: { user } }) => Boolean(user), + // Legal text: admin-only like the other site globals (Pass 4) — campaign + // JWTs reach /api/*. + update: payloadAdminOnly, }, hooks: { afterChange: [revalidate], diff --git a/src/globals/SiteSettings.ts b/src/globals/SiteSettings.ts index 624c255b..d1a09cb5 100644 --- a/src/globals/SiteSettings.ts +++ b/src/globals/SiteSettings.ts @@ -1,3 +1,4 @@ +import { payloadAdminOnly } from '@/utilities/campaignAccess' import { revalidateGlobal } from '@/utilities/globals' import type { GlobalConfig } from 'payload' @@ -13,7 +14,9 @@ export const SiteSettings: GlobalConfig = { }, access: { read: () => true, - update: ({ req: { user } }) => Boolean(user), + // Site-wide config: campaign JWTs authenticate against /api/*, so "any + // authenticated user" here was a write path into the public site (Pass 4). + update: payloadAdminOnly, }, hooks: { afterChange: [revalidate], diff --git a/tests/int/collectionAccessLockdown.int.spec.ts b/tests/int/collectionAccessLockdown.int.spec.ts index 4513b330..df1001f1 100644 --- a/tests/int/collectionAccessLockdown.int.spec.ts +++ b/tests/int/collectionAccessLockdown.int.spec.ts @@ -12,9 +12,13 @@ import { createLeaderSupporterRecord } from '@/app/(campaign)/campanha/actions/l import { Media } from '@/collections/Media' import { Post } from '@/collections/Post' import { Tag } from '@/collections/Tag' +import { HomePage } from '@/globals/HomePage' +import { Metadata } from '@/globals/Metadata' +import { PrivacyPolicy } from '@/globals/PrivacyPolicy' +import { SiteSettings } from '@/globals/SiteSettings' import { relationshipId } from '@/lib/relationship' import config from '@/payload.config' -import { canManagePublishedContent } from '@/utilities/access/shared' +import { canManagePublishedContent, payloadAdminOnly } from '@/utilities/access/shared' import { SUPPORTER_REGISTRATION_CONSENT_KEY } from '@/utilities/campaignConsent' import { installCampaignFixtures } from '../helpers/campaignFixtures' @@ -244,6 +248,75 @@ describe('CMS/PII collection access lockdown', () => { ) }) +/** + * The globals sibling of the collection lockdown above: until Pass 4 the four + * public globals spelled `update` as "any authenticated user", and campaign + * JWTs authenticate against `/api/*` — a campaign session (leader included) + * could PATCH the public site's settings, home, metadata and privacy policy. + * The allow path is asserted on the access function itself because a real + * `updateGlobal` fires the afterChange `revalidateTag` outside Next. + */ +describe('public globals update lockdown', () => { + const publicGlobals = [ + { config: SiteSettings, slug: 'site-settings' }, + { config: HomePage, slug: 'home' }, + { config: Metadata, slug: 'metadata' }, + { config: PrivacyPolicy, slug: 'privacy-policy' }, + ] as const + + beforeAll(async () => { + payload = await getPayload({ config: await config }) + }) + + it('spells update as payloadAdminOnly on every public global', () => { + for (const { config: globalConfig } of publicGlobals) { + expect(globalConfig.access?.update).toBe(payloadAdminOnly) + } + }) + + it.each(publicGlobals)('denies campaign users update on global %s', async ({ slug }) => { + const coordinator = await campaignFixtures().createCampaignUser('coordinator') + + await expect( + payload.updateGlobal({ + slug, + data: {}, + user: coordinator, + overrideAccess: false, + }), + ).rejects.toThrow(/permissão|not allowed/i) + }) + + it.each(publicGlobals)('denies editors update on global %s', async ({ slug }) => { + const editor = await campaignFixtures().createEditorUser() + + await expect( + payload.updateGlobal({ + slug, + data: {}, + user: editor, + overrideAccess: false, + }), + ).rejects.toThrow(/permissão|not allowed/i) + }) + + it('lets payload admins update through the access function', async () => { + const admin = await campaignFixtures().createAdminUser() + + for (const { config: globalConfig } of publicGlobals) { + expect(globalConfig.access?.update?.({ req: stub({ user: admin }) })).toBe( + true, + ) + } + }) + + it.each(publicGlobals)('keeps anonymous read access on global %s', async ({ slug }) => { + // Never-saved globals resolve with field defaults (no id) — the pin is + // that anonymous read is not denied, not the document shape. + await expect(payload.findGlobal({ slug, overrideAccess: false })).resolves.toBeDefined() + }) +}) + describe('leader supporter creation scope', () => { beforeAll(async () => { payload = await getPayload({ config: await config }) diff --git a/tests/unit/globalAccessConventions.unit.spec.ts b/tests/unit/globalAccessConventions.unit.spec.ts new file mode 100644 index 00000000..297c7451 --- /dev/null +++ b/tests/unit/globalAccessConventions.unit.spec.ts @@ -0,0 +1,30 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +// Pass 4 (2026-07-31): the four public globals spelled `update` as +// `({ req: { user } }) => Boolean(user)` — "any authenticated user" — and +// campaign JWTs authenticate against `/api/*`, so a campaign session (leader +// included) could PATCH the public site's settings, home, metadata and privacy +// policy. The lockdown is `payloadAdminOnly`; this guard keeps the +// any-authenticated shape out of global update access for good. Collections +// are covered elsewhere (every collection declares access; lockdown pins live +// in `collectionAccessLockdown.int.spec.ts`). +describe('global update access lockdown (Pass 4)', () => { + const globalsRoot = resolve(process.cwd(), 'src/globals') + + it('never spells global update access as "any authenticated user"', () => { + const offenders: string[] = [] + + for (const entry of readdirSync(globalsRoot, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.ts')) continue + const source = readFileSync(resolve(entry.parentPath, entry.name), 'utf8') + if (/=>\s*Boolean\(user\)/.test(source)) offenders.push(entry.name) + } + + expect( + offenders, + 'global update access must name a policy (payloadAdminOnly, isPayloadAdmin…), not Boolean(user)', + ).toEqual([]) + }) +})