Scrum4Me/__tests__/actions/pairing.test.ts
Madhura68 a9616ff122 fix(M10): close pair/stream race + demo-block on cancelPairing
Twee P1's uit code-review:

(1) pair/stream race: de findUnique die de pairing-status leest gebeurde vóór
LISTEN actief was. Als de mobiel approvet tussen die query en LISTEN: pg_notify
fired in dat venster gaat verloren (Postgres queuet niet voor abonnees die
nog niet listen) én was de eerder gelezen status stale. De catch-up state-
event emitte dus 'pending' terwijl de DB inmiddels 'approved' was, en de
desktop bleef hangen tot expiry.

Tweede findUnique toegevoegd ná LISTEN actief is: het venster sluit, omdat
elke approve na dat punt via de notify-handler doorkomt. Aanvullend op de
eerdere client-side fix die 'state' events nu ook routeert (commit d6e71f9).

(2) cancelPairing demo-block: cancel was een DB-write zonder demo-guard,
in tegenspraak met de "demo = 403 op writes"-regel. Demo-blokkade
toegevoegd; bestaande test omgedraaid naar 'wordt geblokkeerd, geen DB-write'.

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:56:21 +02:00

170 lines
6.2 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockGetSession } = vi.hoisted(() => ({
mockGetSession: vi.fn(),
}))
vi.mock('@/lib/auth', () => ({
getSession: mockGetSession,
}))
vi.mock('@/lib/prisma', () => ({
prisma: {
loginPairing: {
findUnique: vi.fn(),
update: vi.fn(),
},
user: {
findUnique: vi.fn(),
},
},
}))
import { prisma } from '@/lib/prisma'
import { hashToken } from '@/lib/auth/pairing'
import {
getPairingForApproval,
approvePairing,
cancelPairing,
} from '@/actions/pairing'
const mockPrisma = prisma as unknown as {
loginPairing: {
findUnique: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
user: { findUnique: ReturnType<typeof vi.fn> }
}
const VALID_PAIRING_ID = 'cmohmk0a' + 'g008bs417mzik8x9w'.padEnd(17, 'a').slice(0, 17)
const VALID_SECRET = 'A'.repeat(43) // ≥40 chars voor Zod min(40)
const SESSION_USER = { userId: 'user-1', isDemo: false }
const SESSION_DEMO = { userId: 'demo-1', isDemo: true }
beforeEach(() => {
vi.clearAllMocks()
mockPrisma.user.findUnique.mockResolvedValue({ username: 'lars' })
})
function pendingPairing(secret = VALID_SECRET) {
return {
status: 'pending' as const,
expires_at: new Date(Date.now() + 60_000),
secret_hash: hashToken(secret),
desktop_ua: 'TestUA/1.0',
desktop_ip: '198.51.100.1',
}
}
describe('actions/pairing', () => {
describe('getPairingForApproval', () => {
it('ok-pad: pending + correct secret → desktop-info + username', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockPrisma.loginPairing.findUnique.mockResolvedValue(pendingPairing())
const res = await getPairingForApproval(VALID_PAIRING_ID, VALID_SECRET)
expect(res).toEqual({
ok: true,
desktop_ua: 'TestUA/1.0',
desktop_ip: '198.51.100.1',
username: 'lars',
})
})
it('faalt zonder sessie', async () => {
mockGetSession.mockResolvedValue({ userId: undefined, isDemo: false })
const res = await getPairingForApproval(VALID_PAIRING_ID, VALID_SECRET)
expect(res).toEqual({ ok: false, error: 'Niet ingelogd' })
})
it('faalt op al-approved pairing', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockPrisma.loginPairing.findUnique.mockResolvedValue({
...pendingPairing(),
status: 'approved',
})
const res = await getPairingForApproval(VALID_PAIRING_ID, VALID_SECRET)
expect(res).toEqual({ ok: false, error: 'Pairing al afgehandeld' })
})
it('faalt op verlopen pairing', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockPrisma.loginPairing.findUnique.mockResolvedValue({
...pendingPairing(),
expires_at: new Date(Date.now() - 1000),
})
const res = await getPairingForApproval(VALID_PAIRING_ID, VALID_SECRET)
expect(res).toEqual({ ok: false, error: 'Pairing verlopen' })
})
it('faalt op verkeerd secret', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockPrisma.loginPairing.findUnique.mockResolvedValue(pendingPairing('echt'))
const res = await getPairingForApproval(VALID_PAIRING_ID, VALID_SECRET)
expect(res).toEqual({ ok: false, error: 'Ongeldig pairing-geheim' })
})
it('faalt op ongeldige cuid (Zod)', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
const res = await getPairingForApproval('niet-cuid', VALID_SECRET)
expect(res).toEqual({ ok: false, error: 'Ongeldige invoer' })
expect(mockPrisma.loginPairing.findUnique).not.toHaveBeenCalled()
})
})
describe('approvePairing', () => {
it('happy-pad: status pending→approved, user_id gezet, expires_at +5min', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockPrisma.loginPairing.findUnique.mockResolvedValue(pendingPairing())
mockPrisma.loginPairing.update.mockResolvedValue({})
const res = await approvePairing(VALID_PAIRING_ID, VALID_SECRET)
expect(res).toEqual({ ok: true })
expect(mockPrisma.loginPairing.update).toHaveBeenCalledTimes(1)
const arg = mockPrisma.loginPairing.update.mock.calls[0][0]
expect(arg.where).toEqual({ id: VALID_PAIRING_ID })
expect(arg.data.status).toBe('approved')
expect(arg.data.user_id).toBe('user-1')
const dt = new Date(arg.data.expires_at).getTime() - Date.now()
expect(dt).toBeGreaterThan(295_000)
expect(dt).toBeLessThan(305_000)
})
it('demo-user wordt geblokkeerd, geen DB-write', async () => {
mockGetSession.mockResolvedValue(SESSION_DEMO)
const res = await approvePairing(VALID_PAIRING_ID, VALID_SECRET)
expect(res).toEqual({ ok: false, error: 'Niet beschikbaar in demo-modus' })
expect(mockPrisma.loginPairing.findUnique).not.toHaveBeenCalled()
expect(mockPrisma.loginPairing.update).not.toHaveBeenCalled()
})
it('faalt op verkeerd secret zonder DB-write', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockPrisma.loginPairing.findUnique.mockResolvedValue(pendingPairing('echt'))
const res = await approvePairing(VALID_PAIRING_ID, VALID_SECRET)
expect(res).toEqual({ ok: false, error: 'Ongeldig pairing-geheim' })
expect(mockPrisma.loginPairing.update).not.toHaveBeenCalled()
})
})
describe('cancelPairing', () => {
it('happy-pad: status pending→cancelled', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockPrisma.loginPairing.findUnique.mockResolvedValue(pendingPairing())
mockPrisma.loginPairing.update.mockResolvedValue({})
const res = await cancelPairing(VALID_PAIRING_ID, VALID_SECRET)
expect(res).toEqual({ ok: true })
const arg = mockPrisma.loginPairing.update.mock.calls[0][0]
expect(arg.data.status).toBe('cancelled')
})
it('demo-user wordt geblokkeerd, geen DB-write', async () => {
mockGetSession.mockResolvedValue(SESSION_DEMO)
const res = await cancelPairing(VALID_PAIRING_ID, VALID_SECRET)
expect(res).toEqual({ ok: false, error: 'Niet beschikbaar in demo-modus' })
expect(mockPrisma.loginPairing.findUnique).not.toHaveBeenCalled()
expect(mockPrisma.loginPairing.update).not.toHaveBeenCalled()
})
})
})