teqo-backup/tests/unit/campaignJsonMutationRoute.unit.spec.ts
Francisco Ignácio Fontoura Solla 5f11960c3a B32+: share the campaign JSON route wrapper, fetch helper and cell auto-save.
`campaignJsonMutationRoute` now builds every JSON POST route under /campanha —
`isSameOriginRequest` had been copied into five of them, so the CSRF policy was
failing open by omission; a convention guard sweeps all of `src/app` and names
its three exceptions. `postCampaignJson` is the only place that spells
`credentials: 'same-origin'`, and `useCampaignCellAutosave` is the debounce /
abort / revert / flush-on-close machine the quick-edit cells shipped three
times.

The shell keeps the `aria-live` region outside the Popover/Drawer, since closing
is what commits and it was unmounting the node that would announce the result;
`variant="sheet"` took a `footer` slot, which let the signal control drop the
overlay it only kept for one button.

Two bugs the extraction made visible: a revert issued while a different payload
was in flight was dropped silently, and `change()` never cleared a past failure,
which outranks everything the live region would otherwise say.
2026-07-28 08:07:29 -03:00

121 lines
4.5 KiB
TypeScript

import { NextResponse } from 'next/server'
import { describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
import { CAMPAIGN_SESSION_EXPIRED_MESSAGE } from '@/utilities/campaignFormActionError'
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
/**
* B32+ F2: the same-origin check used to be a line each of the five quick-edit
* routes wrote by hand, and the one that forgot it would have failed OPEN. It
* is now the wrapper's, so these cases guard it once for all of them — plus the
* error contract the routes had before the extraction, which must not have
* moved.
*/
const SAFE_MESSAGE = 'Você não administra este município.'
const GENERIC_MESSAGE = 'Não foi possível salvar. Verifique seu acesso e tente novamente.'
const bodySchema = z.object({ municipalityId: z.number().int().positive() })
const buildRoute = (handler: (body: { municipalityId: number }) => Promise<NextResponse>) =>
campaignJsonMutationRoute(
{ bodySchema, safeMessages: [SAFE_MESSAGE], genericMessage: GENERIC_MESSAGE },
handler,
)
const postRequest = (body: unknown, headers?: HeadersInit) =>
new Request('https://pt.jorgesolla.com.br/campanha/municipios/political-trend', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: typeof body === 'string' ? body : JSON.stringify(body),
})
describe('campaignJsonMutationRoute', () => {
it('answers a cross-origin POST with 403 before parsing or calling the handler', async () => {
const handler = vi.fn()
const response = await buildRoute(handler)(
postRequest({ municipalityId: 1 }, { Origin: 'https://evil.example' }),
)
expect(response.status).toBe(403)
await expect(response.json()).resolves.toEqual({
status: 'error',
message: 'Requisição inválida.',
})
expect(handler).not.toHaveBeenCalled()
})
it('accepts an Origin matching the request URL', async () => {
const route = buildRoute(async ({ municipalityId }) =>
NextResponse.json({ status: 'success', municipalityId }),
)
const response = await route(
postRequest({ municipalityId: 7 }, { Origin: 'https://pt.jorgesolla.com.br' }),
)
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({ status: 'success', municipalityId: 7 })
})
it('answers a malformed body with the shared 400', async () => {
const handler = vi.fn()
const response = await buildRoute(handler)(postRequest('{ not json'))
expect(response.status).toBe(400)
await expect(response.json()).resolves.toEqual({
status: 'error',
message: 'Corpo da requisição inválido.',
})
expect(handler).not.toHaveBeenCalled()
})
it('collapses a schema violation into the route generic message, as before', async () => {
const handler = vi.fn()
const response = await buildRoute(handler)(postRequest({ municipalityId: 'abc' }))
expect(response.status).toBe(400)
await expect(response.json()).resolves.toEqual({
status: 'error',
message: GENERIC_MESSAGE,
})
expect(handler).not.toHaveBeenCalled()
})
it('lets a safe domain message through and collapses anything else', async () => {
const safe = await buildRoute(async () => {
throw new Error(SAFE_MESSAGE)
})(postRequest({ municipalityId: 1 }))
expect(safe.status).toBe(400)
await expect(safe.json()).resolves.toEqual({ status: 'error', message: SAFE_MESSAGE })
const leaky = await buildRoute(async () => {
throw new Error('column "engagement_level" does not exist')
})(postRequest({ municipalityId: 1 }))
expect(leaky.status).toBe(400)
await expect(leaky.json()).resolves.toEqual({ status: 'error', message: GENERIC_MESSAGE })
})
it('answers an expired session with 401', async () => {
const response = await buildRoute(async () => {
throw new Error(CAMPAIGN_SESSION_EXPIRED_MESSAGE)
})(postRequest({ municipalityId: 1 }))
expect(response.status).toBe(401)
await expect(response.json()).resolves.toEqual({
status: 'error',
message: CAMPAIGN_SESSION_EXPIRED_MESSAGE,
})
})
it('returns a handler response untouched, status included', async () => {
const response = await buildRoute(async () =>
NextResponse.json({ status: 'blocked', violations: [] }, { status: 409 }),
)(postRequest({ municipalityId: 1 }))
expect(response.status).toBe(409)
await expect(response.json()).resolves.toEqual({ status: 'blocked', violations: [] })
})
})