import { describe, it, expect, afterEach, vi } from 'vitest' import { createHash } from 'node:crypto' import { generatePKCEPair, generateState, parseCallbackParams, tokenStore, countCallbackFailures, } from './auth' // ── Helpers ────────────────────────────────────────────────────────────────── function b64urlNode(buf: Buffer): string { return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') } // ── generatePKCEPair ───────────────────────────────────────────────────────── describe('generatePKCEPair', () => { it('verifier length is 43–128 characters (RFC 7636 §4.1)', async () => { const { verifier } = await generatePKCEPair() expect(verifier.length).toBeGreaterThanOrEqual(43) expect(verifier.length).toBeLessThanOrEqual(128) }) it('verifier contains only base64url-no-pad chars', async () => { const { verifier } = await generatePKCEPair() expect(verifier).toMatch(/^[A-Za-z0-9\-_]+$/) }) it('challenge has no padding characters', async () => { const { challenge } = await generatePKCEPair() expect(challenge).not.toContain('=') expect(challenge).not.toContain('+') expect(challenge).not.toContain('/') }) it('challenge is SHA-256(verifier) in base64url-no-pad', async () => { const { verifier, challenge } = await generatePKCEPair() const expected = b64urlNode(createHash('sha256').update(verifier).digest()) expect(challenge).toBe(expected) }) it('two consecutive calls produce different verifiers', async () => { const a = await generatePKCEPair() const b = await generatePKCEPair() expect(a.verifier).not.toBe(b.verifier) }) }) // ── generateState ──────────────────────────────────────────────────────────── describe('generateState', () => { it('returns a non-empty string', () => { expect(generateState().length).toBeGreaterThan(0) }) it('two consecutive calls differ', () => { expect(generateState()).not.toBe(generateState()) }) it('always the same fixed length (22 chars for 16 bytes base64url-no-pad)', () => { const len = generateState().length for (let i = 0; i < 5; i++) { expect(generateState().length).toBe(len) } }) it('contains only base64url-no-pad chars', () => { expect(generateState()).toMatch(/^[A-Za-z0-9\-_]+$/) }) }) // ── parseCallbackParams ────────────────────────────────────────────────────── describe('parseCallbackParams', () => { it('?code=abc&state=xyz → {code, state}', () => { const result = parseCallbackParams('?code=abc&state=xyz') expect(result).toEqual({ code: 'abc', state: 'xyz' }) }) it('?error=access_denied&error_description=user-cancelled → {error, errorDescription}', () => { const result = parseCallbackParams('?error=access_denied&error_description=user-cancelled') expect(result).toEqual({ error: 'access_denied', errorDescription: 'user-cancelled' }) }) it('empty string → {error: missing_params}', () => { const result = parseCallbackParams('') expect(result).toEqual({ error: 'missing_params' }) }) it('?error=access_denied without description → {error} only', () => { const result = parseCallbackParams('?error=access_denied') expect(result).toEqual({ error: 'access_denied' }) }) it('?foo=bar with neither code nor error → {error: missing_params}', () => { const result = parseCallbackParams('?foo=bar') expect(result).toEqual({ error: 'missing_params' }) }) }) // ── tokenStore ─────────────────────────────────────────────────────────────── describe('tokenStore', () => { afterEach(() => { tokenStore.set(null) vi.restoreAllMocks() vi.unstubAllGlobals() }) it('get() is initially null', () => { expect(tokenStore.get()).toBeNull() }) it('set("x") → get() === "x"', () => { tokenStore.set('x') expect(tokenStore.get()).toBe('x') }) it('set(null) → get() === null', () => { tokenStore.set('x') tokenStore.set(null) expect(tokenStore.get()).toBeNull() }) it('subscribe receives values in order', () => { const received: (string | null)[] = [] tokenStore.subscribe((v) => received.push(v)) tokenStore.set('x') tokenStore.set(null) expect(received).toEqual(['x', null]) }) it('unsubscribe stops further notifications', () => { const received: (string | null)[] = [] const unsub = tokenStore.subscribe((v) => received.push(v)) tokenStore.set('a') unsub() tokenStore.set('b') expect(received).toEqual(['a']) }) it('does NOT touch window.localStorage (IV4)', () => { const setItemSpy = vi.fn() vi.stubGlobal('localStorage', { getItem: vi.fn(), setItem: setItemSpy, removeItem: vi.fn(), clear: vi.fn(), key: vi.fn(), length: 0, }) tokenStore.set('secret') tokenStore.set(null) expect(setItemSpy).not.toHaveBeenCalled() }) }) // ── countCallbackFailures ──────────────────────────────────────────────────── describe('countCallbackFailures', () => { const FIVE_MIN = 300_000 const now = 1_000_000 it('empty log → {count: 0, blocked: false}', () => { expect(countCallbackFailures(now, [])).toEqual({ count: 0, blocked: false }) }) it('3 failures within 5min → {count: 3, blocked: false} (boundary: >3 blocks, not >=3)', () => { const log = [now - 1000, now - 2000, now - 3000] expect(countCallbackFailures(now, log)).toEqual({ count: 3, blocked: false }) }) it('4 failures within 5min → {count: 4, blocked: true}', () => { const log = [now - 1000, now - 2000, now - 3000, now - 4000] expect(countCallbackFailures(now, log)).toEqual({ count: 4, blocked: true }) }) it('4 failures but oldest is > 5min ago → only recent ones count', () => { // 3 recent + 1 old (> 5min ago) const log = [now - 1000, now - 2000, now - 3000, now - FIVE_MIN - 1] expect(countCallbackFailures(now, log)).toEqual({ count: 3, blocked: false }) }) it('is pure — does not mutate log array', () => { const log = [now - 1000, now - 2000] const original = [...log] countCallbackFailures(now, log) expect(log).toEqual(original) }) })