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)
})
})