actions/pairing.ts (Server Actions, volgt docs/patterns/server-action.md): - getPairingForApproval(pairingId, mobileSecret): auth + Zod + lookup + status + expiry + verifyToken-check; retourneert UA/IP/username voor de bevestigingspagina. Demo MAG aanroepen (read-only). - approvePairing: zelfde checks PLUS demo-blokkade (session.isDemo). Update status pending→approved, zet user_id + approved_at, bumpt expires_at +5min. Postgres-trigger emit pg_notify automatisch — desktop-SSE pikt het op. - cancelPairing: status pending→cancelled. Demo mag annuleren. - Tagged-union return-type uit loadPendingPairing voor schone discriminatie. app/(app)/m/pair/page.tsx (Server Component, achter (app)/layout-guard): - Geen searchParams uitlezen — page leest URL niet. Alleen statische uitleg + PairConfirmation client-island. app/(app)/m/pair/pair-confirmation.tsx (Client Component): - useEffect parseert window.location.hash voor #id=…&s=… (server ziet de fragment nooit) - Roept getPairingForApproval om UA/IP/username op te halen - Toont kaart "Inloggen als <username> op dit apparaat?" met UA + IP + expliciete waarschuwing tegen phishing-QR; Bevestig/Annuleer-knoppen - Na approve: window.history.replaceState wist de hash zodat back/forward de secret niet meer onthult; transitioneert naar success-state - queueMicrotask voor synchrone setState om React-Compiler "cascading renders" warning te vermijden Tests __tests__/actions/pairing.test.ts (11 cases): - getPairingForApproval: ok + 5 fail-paths (geen sessie, approved, verlopen, verkeerd secret, ongeldige cuid) - approvePairing: happy + demo-block + verkeerd secret (geen DB-write) - cancelPairing: happy + demo mag annuleren Quality gates: lint 0 errors, tsc clean, vitest 132/132. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
171 lines
6.1 KiB
TypeScript
171 lines
6.1 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 mag annuleren', async () => {
|
|
mockGetSession.mockResolvedValue(SESSION_DEMO)
|
|
mockPrisma.loginPairing.findUnique.mockResolvedValue(pendingPairing())
|
|
mockPrisma.loginPairing.update.mockResolvedValue({})
|
|
|
|
const res = await cancelPairing(VALID_PAIRING_ID, VALID_SECRET)
|
|
expect(res).toEqual({ ok: true })
|
|
})
|
|
})
|
|
})
|