Scrum4Me/__tests__/lib/auth/pairing.test.ts
Madhura68 b4813e6e54 feat(ST-1002): add pairing helpers, pre-auth cookie + paired-session guard
lib/auth/pairing.ts: pure crypto-helpers voor de QR-pairing flow.
- generateMobileSecret() / generateDesktopToken() — beide 32 bytes base64url, los
  zodat ze elkaar niet onthullen
- hashToken(t) — sha256-hex
- verifyToken(t, hash) — timingSafeEqual met length-guard
- isPairedSessionExpired(session) — geëxtraheerde helper zodat de Server-
  Component-render Date.now() niet rechtstreeks aanroept (React Compiler-flag)

lib/auth/pair-cookie.ts: HttpOnly pre-auth cookie helpers (s4m_pair).
- Path=/api/auth/pair, Max-Age=120s (gelijk aan pending-TTL pairing),
  SameSite=Lax, Secure in productie

lib/session.ts: SessionData uitgebreid met optionele paired + pairedExpiresAt.

app/(app)/layout.tsx: guard die paired-sessies vernietigt zodra
pairedExpiresAt verstreken is en redirect naar /login.

Tests: 14 unit-tests in __tests__/lib/auth/pairing.test.ts dekken hash-
determinisme, timing-safe verify (true/false/length-mismatch), generator-
uniciteit en vier expiry-scenario's voor isPairedSessionExpired.

Quality gates: npm run lint (0 errors), tsc --noEmit clean, vitest 111/111.

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

88 lines
2.8 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import {
generateMobileSecret,
generateDesktopToken,
hashToken,
verifyToken,
isPairedSessionExpired,
} from '@/lib/auth/pairing'
describe('lib/auth/pairing', () => {
describe('generateMobileSecret / generateDesktopToken', () => {
it('produceert 43-karakter base64url (32 bytes)', () => {
// 32 bytes → ceil(32/3) * 4 = 44 chars zonder padding → 43 chars in base64url (geen '=')
expect(generateMobileSecret()).toMatch(/^[A-Za-z0-9_-]{43}$/)
expect(generateDesktopToken()).toMatch(/^[A-Za-z0-9_-]{43}$/)
})
it('twee opeenvolgende calls leveren verschillende waardes', () => {
const a = generateMobileSecret()
const b = generateMobileSecret()
expect(a).not.toBe(b)
})
it('mobile en desktop generators delen geen state — paren zijn onafhankelijk', () => {
const m1 = generateMobileSecret()
const d1 = generateDesktopToken()
const m2 = generateMobileSecret()
const d2 = generateDesktopToken()
expect(new Set([m1, d1, m2, d2]).size).toBe(4)
})
})
describe('hashToken', () => {
it('is deterministisch — zelfde input → zelfde hash', () => {
const t = 'voorbeeld-token'
expect(hashToken(t)).toBe(hashToken(t))
})
it('produceert 64-karakter hex (sha256)', () => {
expect(hashToken('x')).toMatch(/^[a-f0-9]{64}$/)
})
it('verschillende inputs → verschillende hashes', () => {
expect(hashToken('a')).not.toBe(hashToken('b'))
})
})
describe('verifyToken', () => {
it('true voor geldig (token, hashOf(token))', () => {
const token = generateMobileSecret()
expect(verifyToken(token, hashToken(token))).toBe(true)
})
it('false voor onjuist token', () => {
const realHash = hashToken('echt-token')
expect(verifyToken('verkeerd-token', realHash)).toBe(false)
})
it('false bij hash met afwijkende lengte', () => {
expect(verifyToken('iets', 'abc')).toBe(false)
})
it('false bij lege hash', () => {
expect(verifyToken('iets', '')).toBe(false)
})
})
describe('isPairedSessionExpired', () => {
it('false als paired niet gezet is (reguliere wachtwoord-sessie)', () => {
expect(isPairedSessionExpired({})).toBe(false)
})
it('false als pairedExpiresAt ontbreekt', () => {
expect(isPairedSessionExpired({ paired: true })).toBe(false)
})
it('false als de paired-sessie nog niet vervallen is', () => {
const future = Date.now() + 60_000
expect(isPairedSessionExpired({ paired: true, pairedExpiresAt: future })).toBe(false)
})
it('true als paired én vervaltijd in het verleden ligt', () => {
const past = Date.now() - 1_000
expect(isPairedSessionExpired({ paired: true, pairedExpiresAt: past })).toBe(true)
})
})
})