Scrum4Me/__tests__/api/pair-start.test.ts
Madhura68 c48e30df1f fix(M10): bump pending-TTL to 5min + repair MD3 contrast on pair page
TTL: 2 min was te kort voor handmatig curl-paste-confirm-testen — gebruiker
zag 'Pairing verlopen' voor hij kon bevestigen. Bumpt naar 5 min (gelijk aan
approved-TTL): nog steeds tight voor security, ruim voor menselijke reactie.
- app/api/auth/pair/start/route.ts: PENDING_TTL_MS 120s → 300s
- lib/auth/pair-cookie.ts: MAX_AGE_SECONDS 120 → 300
- __tests__/api/pair-start.test.ts: maxAge en expires_at-window meegegroeid

Kleuren: bevestigingspagina gebruikte bg-destructive/10 + text-destructive-
foreground — beide lichte kleuren, te weinig contrast. Vervangen door MD3
container-tokens (zelfde patroon als components/auth/auth-form.tsx):
- error-state: bg-error-container + text-error-container-foreground + border-l-4 border-error
- approved-state: bg-success-container + foreground + accent-border
- cancelled-state: bg-surface-container-high + neutral foreground

Quality gates: lint 0 errors, tsc clean, vitest 139/139.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:15:02 +02:00

110 lines
3.7 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
const { cookieJar } = vi.hoisted(() => ({
cookieJar: { set: vi.fn(), get: vi.fn(), delete: vi.fn() },
}))
vi.mock('@/lib/prisma', () => ({
prisma: {
loginPairing: {
create: vi.fn(),
},
},
}))
vi.mock('next/headers', () => ({
cookies: vi.fn().mockResolvedValue(cookieJar),
}))
import { prisma } from '@/lib/prisma'
import { POST } from '@/app/api/auth/pair/start/route'
const mockPrisma = prisma as unknown as {
loginPairing: { create: ReturnType<typeof vi.fn> }
}
function makePost(opts: { ip?: string; ua?: string } = {}): Request {
const headers = new Headers()
if (opts.ip !== undefined) headers.set('x-forwarded-for', opts.ip)
if (opts.ua !== undefined) headers.set('user-agent', opts.ua)
return new Request('http://localhost:3000/api/auth/pair/start', {
method: 'POST',
headers,
})
}
beforeEach(() => {
vi.clearAllMocks()
mockPrisma.loginPairing.create.mockResolvedValue({
id: 'pair-1',
expires_at: new Date('2026-04-27T20:30:00Z'),
})
})
describe('POST /api/auth/pair/start', () => {
it('200 met body { pairingId, mobileSecret, expiresAt, qrUrl met fragment }', async () => {
const res = await POST(makePost({ ip: '198.51.100.7', ua: 'TestUA/1.0' }))
expect(res.status).toBe(200)
const body = await res.json()
expect(body.pairingId).toBe('pair-1')
expect(body.mobileSecret).toMatch(/^[A-Za-z0-9_-]{43}$/)
expect(body.qrUrl).toBe(
`http://localhost:3000/m/pair#id=pair-1&s=${body.mobileSecret}`,
)
expect(body.expiresAt).toBe('2026-04-27T20:30:00.000Z')
})
it('slaat alleen sha256-hashes op — geen plaintext mobileSecret of desktopToken', async () => {
const res = await POST(makePost({ ip: '198.51.100.8' }))
const body = await res.json()
const arg = mockPrisma.loginPairing.create.mock.calls[0][0].data
expect(arg.secret_hash).toMatch(/^[a-f0-9]{64}$/)
expect(arg.desktop_token_hash).toMatch(/^[a-f0-9]{64}$/)
expect(arg.secret_hash).not.toBe(body.mobileSecret)
expect(arg.status).toBe('pending')
// expires_at ~5 min in toekomst
const dt = new Date(arg.expires_at).getTime() - Date.now()
expect(dt).toBeGreaterThan(295_000)
expect(dt).toBeLessThan(305_000)
})
it('zet HttpOnly Path-scoped s4m_pair cookie met Max-Age 120', async () => {
await POST(makePost({ ip: '198.51.100.9' }))
expect(cookieJar.set).toHaveBeenCalledTimes(1)
const [name, value, opts] = cookieJar.set.mock.calls[0]
expect(name).toBe('s4m_pair')
expect(value).toMatch(/^[A-Za-z0-9_-]{43}$/) // desktopToken
expect(opts).toMatchObject({
httpOnly: true,
sameSite: 'lax',
path: '/api/auth/pair',
maxAge: 300,
})
})
it('slaat user-agent en IP op (afgekapt)', async () => {
const longUa = 'A'.repeat(500)
await POST(makePost({ ip: '198.51.100.10', ua: longUa }))
const arg = mockPrisma.loginPairing.create.mock.calls[0][0].data
expect(arg.desktop_ua).toBe('A'.repeat(255))
expect(arg.desktop_ip).toBe('198.51.100.10')
})
it('desktop_ip = null als x-forwarded-for ontbreekt', async () => {
await POST(makePost({}))
const arg = mockPrisma.loginPairing.create.mock.calls[0][0].data
expect(arg.desktop_ip).toBeNull()
})
it('11e POST binnen window levert 429', async () => {
const ip = '198.51.100.99' // unieke IP zodat andere tests niet in de weg zitten
for (let i = 0; i < 10; i++) {
const ok = await POST(makePost({ ip }))
expect(ok.status).toBe(200)
}
const blocked = await POST(makePost({ ip }))
expect(blocked.status).toBe(429)
const body = await blocked.json()
expect(body.error).toMatch(/Te veel pogingen/i)
})
})