mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
fix(audit-p6-2): perfil page uses requireCampaignPageActor; P3-I guard widened to getCampaignUser* variants
This commit is contained in:
parent
00625631b9
commit
989cf737bf
4 changed files with 62 additions and 14 deletions
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
|
|
|
|||
Loading…
Reference in a new issue