fix(audit-p6-2): perfil page uses requireCampaignPageActor; P3-I guard widened to getCampaignUser* variants

This commit is contained in:
Francisco Solla 2026-08-25 10:22:06 -03:00
parent 3fb1a141ba
commit b7d0dba650
4 changed files with 62 additions and 14 deletions

View file

@ -1,6 +1,5 @@
import config from '@payload-config'
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignPasskeysCard } from '@/components/campaign/auth/CampaignPasskeysCard'
@ -8,8 +7,8 @@ import { CampaignProfileSettings } from '@/components/campaign/auth/CampaignProf
import { CampaignPushNotificationsCard } from '@/components/campaign/auth/CampaignPushNotificationsCard'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { deviceLabelFromUserAgent } from '@/lib/deviceLabel'
import { getCampaignUserWithAvatar } from '@/utilities/campaignAuth'
import { getCampaignPushConsent } from '@/utilities/campaignConsent'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { campaignUserShellView } from '@/utilities/campaignUserProfile'
import { getCampaignVapidPublicKey } from '@/utilities/notification/sendCampaignPush'
import { loadCampaignPasskeys } from '@/utilities/webauthn/campaignWebAuthnCeremony'
@ -22,8 +21,10 @@ type CampaignProfilePageProps = {
}
export default async function CampaignProfilePage({ searchParams }: CampaignProfilePageProps) {
const [user, params] = await Promise.all([getCampaignUserWithAvatar(), searchParams])
if (!user) redirect('/campanha/login')
const [user, params] = await Promise.all([
requireCampaignPageActor({ withAvatar: true }),
searchParams,
])
const payload = await getPayload({ config })
const [passkeys, relyingParty, requestHeaders, pushConsent] = await Promise.all([

View file

@ -5,11 +5,23 @@ import { redirect } from 'next/navigation'
import { LEADER_CONTACTS_HOME } from '@/lib/campaignPaths'
import { isStaffCampaignRole, isUnrestrictedCampaignRole } from '@/lib/campaignRoles'
import { advisorEditingAccess } from '@/utilities/access/shared'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { getCampaignUser, getCampaignUserWithAvatar } from '@/utilities/campaignAuth'
/** Exactly the non-null actor `getCampaignUser` proves (keeps its `email` refinement). */
export type CampaignPageActor = NonNullable<Awaited<ReturnType<typeof getCampaignUser>>>
export type CampaignPageActorOptions = {
gate?: CampaignPageGate
redirectTo?: string
/**
* Load the actor with `avatar` populated (depth 1). The perfil shell renders
* `avatarUrl`, which requires the `avatar` relationship to be a populated
* media object — depth 0 would resolve it to a number id that the shell
* would treat as `null`.
*/
withAvatar?: boolean
}
export type CampaignPageGate = 'staff' | 'unrestricted' | 'noLeader' | 'writable'
export { LEADER_CONTACTS_HOME }
@ -30,14 +42,19 @@ export const CAMPAIGN_STAFF_QUADRO_PATH = '/campanha/quadro'
* (C142 — write destinations must not be offered to a
* read-only advisor; the server already rejects the write).
*
* A custom `redirectTo` overrides the gate's default target. The convention
* guard in `codebaseConventions.unit.spec.ts` fails the build on a
* `getCampaignUser()` call in a `(app)` route page outside this helper.
* A custom `redirectTo` overrides the gate's default target. `withAvatar`
* loads the actor through `getCampaignUserWithAvatar` (avatar populated at
* depth 1) instead of `getCampaignUser` — both return
* `AuthenticatedCampaignUser | null`; the avatar depth is runtime-only and
* does not change the gates. The convention guard in
* `codebaseConventions.unit.spec.ts` fails the build on a `getCampaignUser*()`
* call in a `(app)` route page outside this helper.
*/
export const requireCampaignPageActor = async (
options: { gate?: CampaignPageGate; redirectTo?: string } = {},
options: CampaignPageActorOptions = {},
): Promise<CampaignPageActor> => {
const user = await getCampaignUser()
const getter = options.withAvatar ? getCampaignUserWithAvatar : getCampaignUser
const user = await getter()
if (!user) redirect('/campanha/login')
const { gate, redirectTo } = options

View file

@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
redirect: vi.fn(),
getCampaignUser: vi.fn(),
getCampaignUserWithAvatar: vi.fn(),
}))
vi.mock('next/navigation', () => ({
@ -13,6 +14,7 @@ vi.mock('next/navigation', () => ({
vi.mock('@/utilities/campaignAuth', () => ({
getCampaignUser: mocks.getCampaignUser,
getCampaignUserWithAvatar: mocks.getCampaignUserWithAvatar,
}))
vi.mock('@/utilities/access/shared', () => ({
@ -79,3 +81,30 @@ describe('requireCampaignPageActor — gate: writable (C142)', () => {
expect(mocks.redirect).toHaveBeenCalled()
})
})
describe('requireCampaignPageActor — withAvatar option', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('loads the actor through the avatar getter (perfil shell needs a populated avatarUrl)', async () => {
mocks.getCampaignUserWithAvatar.mockResolvedValue(actor('coordinator'))
const user = await requireCampaignPageActor({ withAvatar: true })
expect(user.role).toBe('coordinator')
expect(mocks.getCampaignUserWithAvatar).toHaveBeenCalledTimes(1)
expect(mocks.getCampaignUser).not.toHaveBeenCalled()
expect(mocks.redirect).not.toHaveBeenCalled()
})
it('redirects to login when the avatar getter finds no session', async () => {
mocks.getCampaignUserWithAvatar.mockResolvedValue(null)
// Real `next/navigation` redirect never returns (throws NEXT_REDIRECT);
// the helper reads `user.role` right after, so mimic that here.
mocks.redirect.mockImplementationOnce(() => {
throw new Error('NEXT_REDIRECT')
})
await expect(requireCampaignPageActor({ withAvatar: true })).rejects.toThrow('NEXT_REDIRECT')
expect(mocks.redirect).toHaveBeenCalledWith('/campanha/login')
expect(mocks.getCampaignUser).not.toHaveBeenCalled()
})
})

View file

@ -528,11 +528,12 @@ describe('src/utilities top-level is pinned', () => {
describe('campaign route-page actor comes from requireCampaignPageActor', () => {
// P3-I: the staff page prologue was ~110 hand-spelled lines across 30 pages
// with a real divergence (`return null` ×5 — a blank screen for a missing
// session). A page that re-spells `getCampaignUser()` breaks the build here;
// genuinely bespoke prologues are allowlisted with their reason:
// session). A page that re-spells any `getCampaignUser*()` getter variant
// (e.g. `getCampaignUserWithAvatar`) breaks the build here; genuinely
// bespoke prologues are allowlisted with their reason:
const allowlist = new Set<string>([])
it('keeps getCampaignUser() out of (app) route pages', () => {
it('keeps getCampaignUser*() getters out of (app) route pages', () => {
const offenders: string[] = []
for (const file of walkSourceFiles(resolve(repoRoot, 'src/app/(campaign)/campanha/(app)'), [
@ -540,7 +541,7 @@ describe('campaign route-page actor comes from requireCampaignPageActor', () =>
])) {
const path = repoPath(file)
if (basename(file) !== 'page.tsx' || allowlist.has(path)) continue
if (/getCampaignUser\(\)/.test(readFileSync(file, 'utf8'))) offenders.push(path)
if (/getCampaignUser[A-Za-z0-9]*\(/.test(readFileSync(file, 'utf8'))) offenders.push(path)
}
expect(offenders, 'use requireCampaignPageActor from @/utilities/campaignPageActor').toEqual([])