Scrum4Me/__tests__/api/pair-claim.test.ts
Madhura68 5c4ee150ea feat(ST-1006): add /api/auth/pair/claim with atomic consume + iron-session
POST /api/auth/pair/claim (cookie-auth, runtime: 'nodejs'):
- Auth via s4m_pair HttpOnly cookie alleen — body bevat enkel pairingId, geen
  secret. Het cookie-token is het bewijs.
- Atomic state-transitie via prisma.loginPairing.updateMany met composite
  WHERE (id + status='approved' + desktop_token_hash + expires_at > now);
  PostgreSQL row-locking garandeert dat concurrent dubbele claims slechts één
  count=1 zien — de rest 410.
- Bij geen rij geüpdate: tweede findFirst om te disambigueren tussen 401
  (cookie matcht geen pairing) en 410 (al consumed/cancelled). Cookie altijd
  gecleared bij faalpaden om herhaalde verwerking te voorkomen.
- Bij succes: getIronSession schrijft scrum4me-session-cookie met userId +
  isDemo (uit user-record als vangnet) + paired=true + pairedExpiresAt = now+8h
  (kortere TTL voor publieke desktops). s4m_pair wordt gecleared.
- Logging onder NODE_ENV !== 'production' alleen pairingId, nooit cookie of
  mobileSecret.

Tests __tests__/api/pair-claim.test.ts (7 cases):
- 200 happy: updateMany met juiste WHERE, iron-session payload (userId, isDemo,
  paired, pairedExpiresAt ~8h), save() called, s4m_pair cleared
- demo-vangnet: isDemo=true wordt doorgezet
- 401 zonder cookie (geen DB-call)
- 400 op malformed body
- 400 zonder pairingId
- 410 op tweede claim (al consumed, cookie cleared, geen session.save)
- 401 op cookie/hash-mismatch (cookie cleared)

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 22:58:17 +02:00

158 lines
5.1 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockReadPairCookie, mockClearPairCookie, mockSession, mockGetIronSession } = vi.hoisted(
() => ({
mockReadPairCookie: vi.fn(),
mockClearPairCookie: vi.fn(),
mockSession: { userId: '', isDemo: false, paired: false, pairedExpiresAt: 0, save: vi.fn() },
mockGetIronSession: vi.fn(),
}),
)
vi.mock('@/lib/auth/pair-cookie', () => ({
readPairCookie: mockReadPairCookie,
clearPairCookie: mockClearPairCookie,
setPairCookie: vi.fn(),
}))
vi.mock('iron-session', () => ({
getIronSession: mockGetIronSession,
}))
vi.mock('next/headers', () => ({
cookies: vi.fn().mockResolvedValue({}),
}))
vi.mock('@/lib/prisma', () => ({
prisma: {
loginPairing: {
updateMany: vi.fn(),
findFirst: vi.fn(),
findUnique: vi.fn(),
},
},
}))
import { prisma } from '@/lib/prisma'
import { hashToken } from '@/lib/auth/pairing'
import { POST } from '@/app/api/auth/pair/claim/route'
const mockPrisma = prisma as unknown as {
loginPairing: {
updateMany: ReturnType<typeof vi.fn>
findFirst: ReturnType<typeof vi.fn>
findUnique: ReturnType<typeof vi.fn>
}
}
const COOKIE_TOKEN = 'desktop-token-abc'
const COOKIE_HASH = hashToken(COOKIE_TOKEN)
const PAIRING_ID = 'cmohmk0qpair006c001'
function makePost(body: unknown): Request {
return new Request('http://localhost:3000/api/auth/pair/claim', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: typeof body === 'string' ? body : JSON.stringify(body),
})
}
beforeEach(() => {
vi.clearAllMocks()
// Reset session-mock voor elke test
mockSession.userId = ''
mockSession.isDemo = false
mockSession.paired = false
mockSession.pairedExpiresAt = 0
mockSession.save = vi.fn().mockResolvedValue(undefined)
mockGetIronSession.mockResolvedValue(mockSession)
})
describe('POST /api/auth/pair/claim', () => {
it('200: schrijft iron-session, clear s4m_pair, retourneert {ok:true}', async () => {
mockReadPairCookie.mockResolvedValue(COOKIE_TOKEN)
mockPrisma.loginPairing.updateMany.mockResolvedValue({ count: 1 })
mockPrisma.loginPairing.findUnique.mockResolvedValue({
user_id: 'user-42',
user: { is_demo: false },
})
const res = await POST(makePost({ pairingId: PAIRING_ID }))
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ ok: true })
// Atomic update aangeroepen met juiste WHERE
expect(mockPrisma.loginPairing.updateMany).toHaveBeenCalledTimes(1)
const where = mockPrisma.loginPairing.updateMany.mock.calls[0][0].where
expect(where).toMatchObject({
id: PAIRING_ID,
status: 'approved',
desktop_token_hash: COOKIE_HASH,
})
expect(where.expires_at).toMatchObject({ gt: expect.any(Date) })
// Iron-session payload
expect(mockSession.userId).toBe('user-42')
expect(mockSession.isDemo).toBe(false)
expect(mockSession.paired).toBe(true)
const dt = mockSession.pairedExpiresAt - Date.now()
expect(dt).toBeGreaterThan(8 * 60 * 60 * 1000 - 5_000)
expect(dt).toBeLessThan(8 * 60 * 60 * 1000 + 5_000)
expect(mockSession.save).toHaveBeenCalledTimes(1)
expect(mockClearPairCookie).toHaveBeenCalledTimes(1)
})
it('demo-user: isDemo doorgezet als vangnet', async () => {
mockReadPairCookie.mockResolvedValue(COOKIE_TOKEN)
mockPrisma.loginPairing.updateMany.mockResolvedValue({ count: 1 })
mockPrisma.loginPairing.findUnique.mockResolvedValue({
user_id: 'demo-1',
user: { is_demo: true },
})
const res = await POST(makePost({ pairingId: PAIRING_ID }))
expect(res.status).toBe(200)
expect(mockSession.isDemo).toBe(true)
})
it('401 zonder s4m_pair-cookie', async () => {
mockReadPairCookie.mockResolvedValue(null)
const res = await POST(makePost({ pairingId: PAIRING_ID }))
expect(res.status).toBe(401)
expect(mockPrisma.loginPairing.updateMany).not.toHaveBeenCalled()
})
it('400 zonder body', async () => {
mockReadPairCookie.mockResolvedValue(COOKIE_TOKEN)
const res = await POST(makePost('not-json'))
expect(res.status).toBe(400)
})
it('400 zonder pairingId', async () => {
mockReadPairCookie.mockResolvedValue(COOKIE_TOKEN)
const res = await POST(makePost({}))
expect(res.status).toBe(400)
})
it('410 op tweede claim — pairing al consumed', async () => {
mockReadPairCookie.mockResolvedValue(COOKIE_TOKEN)
mockPrisma.loginPairing.updateMany.mockResolvedValue({ count: 0 })
mockPrisma.loginPairing.findFirst.mockResolvedValue({ status: 'consumed' })
const res = await POST(makePost({ pairingId: PAIRING_ID }))
expect(res.status).toBe(410)
expect(mockClearPairCookie).toHaveBeenCalledTimes(1)
expect(mockSession.save).not.toHaveBeenCalled()
})
it('401 op cookie/hash-mismatch (pairing bestaat niet voor deze cookie)', async () => {
mockReadPairCookie.mockResolvedValue(COOKIE_TOKEN)
mockPrisma.loginPairing.updateMany.mockResolvedValue({ count: 0 })
mockPrisma.loginPairing.findFirst.mockResolvedValue(null)
const res = await POST(makePost({ pairingId: PAIRING_ID }))
expect(res.status).toBe(401)
expect(mockClearPairCookie).toHaveBeenCalledTimes(1)
})
})