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 } } 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) }) })