codeburn/tests/sync.test.ts
iamtoruk 62cb4f8630 test: isolate the real home on Windows and make the CLI suite pass there
os.homedir() reads USERPROFILE on Windows and never HOME, so the
isolation setup and every test that moved home were still resolving the
developer's real home directory. Fixtures came back empty, real sessions
leaked into counts, and the code under test wrote plans, receipts and a
sync ledger into the real ~/.config/codeburn during a run.

USERPROFILE now joins the redirected variables and a shared setHome()
helper moves HOME and USERPROFILE together; every spawn env carries both.
The remaining Windows-only failures were POSIX-shaped fixtures: chmod
tricks that only toggle a read-only attribute on NTFS, pid 1 as an
always-alive lock holder, splitting paths on '/', bare tar, mode-bit
expectations, and per-file timeouts too short for the cost of a tsx spawn.
Each gets a platform-aware equivalent so nothing is skipped on Windows and
the POSIX branches stay byte-for-byte what they were.

Full run on Windows: 267 files passed, 2 skipped, 3592 tests passed.
2026-09-02 13:43:57 -07:00

279 lines
10 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtemp, rm, writeFile, readFile, mkdir } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { createServer, type Server } from 'http'
import { parseDiscoveryDoc, DiscoveryError } from '../src/sync/discovery.js'
import {
generatePkce,
buildAuthUrl,
resolveScopes,
startCallbackServer,
renderCallbackPage,
CALLBACK_PORTS,
} from '../src/sync/auth.js'
import { setHome } from './setup/home.js'
// ── Discovery Doc Parser ──────────────────────────────────────────────
describe('parseDiscoveryDoc', () => {
it('parses valid v1 doc', () => {
const doc = parseDiscoveryDoc({
version: 1,
issuer: 'https://cognito-idp.us-east-1.amazonaws.com/us-east-1_XXXX',
client_id: 'abc123',
scopes: ['openid', 'codeburn:write'],
traces_path: '/v1/traces',
max_batch_size: 500,
})
expect(doc.version).toBe(1)
expect(doc.issuer).toBe('https://cognito-idp.us-east-1.amazonaws.com/us-east-1_XXXX')
expect(doc.client_id).toBe('abc123')
expect(doc.scopes).toEqual(['openid', 'codeburn:write'])
expect(doc.traces_path).toBe('/v1/traces')
expect(doc.max_batch_size).toBe(500)
})
it('rejects version > 1', () => {
expect(() => parseDiscoveryDoc({ version: 2, issuer: 'https://idp.example', client_id: 'y' }))
.toThrow('Please update codeburn')
})
it('rejects missing issuer', () => {
expect(() => parseDiscoveryDoc({ version: 1, client_id: 'y' }))
.toThrow('missing required field: issuer')
})
it('rejects missing client_id', () => {
expect(() => parseDiscoveryDoc({ version: 1, issuer: 'https://idp.example' }))
.toThrow('missing required field: client_id')
})
it('defaults traces_path to /v1/traces when absent', () => {
const doc = parseDiscoveryDoc({ issuer: 'https://idp.example', client_id: 'y' })
expect(doc.traces_path).toBe('/v1/traces')
})
it('defaults max_batch_size to 1000 when absent', () => {
const doc = parseDiscoveryDoc({ issuer: 'https://idp.example', client_id: 'y' })
expect(doc.max_batch_size).toBe(1000)
})
it('defaults scopes to ["openid"] when absent', () => {
const doc = parseDiscoveryDoc({ issuer: 'https://idp.example', client_id: 'y' })
expect(doc.scopes).toEqual(['openid'])
})
it('treats absent version as v1', () => {
const doc = parseDiscoveryDoc({ issuer: 'https://idp.example', client_id: 'y' })
expect(doc.version).toBe(1)
})
it('rejects non-https issuer (RFC 8252 §8.3)', () => {
expect(() => parseDiscoveryDoc({ version: 1, issuer: 'http://idp.example', client_id: 'y' }))
.toThrow(/must use https/)
})
it('rejects non-object input', () => {
expect(() => parseDiscoveryDoc(null)).toThrow('must be a JSON object')
expect(() => parseDiscoveryDoc('string')).toThrow('must be a JSON object')
expect(() => parseDiscoveryDoc([1, 2])).toThrow('must be a JSON object')
})
})
// ── PKCE ──────────────────────────────────────────────────────────────
describe('generatePkce', () => {
it('generates code_verifier of valid length (43+ chars)', () => {
const { code_verifier } = generatePkce()
expect(code_verifier.length).toBeGreaterThanOrEqual(43)
})
it('generates different values each time', () => {
const a = generatePkce()
const b = generatePkce()
expect(a.code_verifier).not.toBe(b.code_verifier)
})
it('code_challenge is base64url of SHA-256(verifier)', () => {
const { code_verifier, code_challenge } = generatePkce()
const { createHash } = require('crypto')
const expected = createHash('sha256').update(code_verifier).digest('base64url')
expect(code_challenge).toBe(expected)
})
it('uses S256 method', () => {
const { code_challenge_method } = generatePkce()
expect(code_challenge_method).toBe('S256')
})
})
// ── Auth URL ──────────────────────────────────────────────────────────
describe('buildAuthUrl', () => {
const baseParams = {
authorization_endpoint: 'https://idp.example.com/oauth2/authorize',
client_id: 'test-client',
redirect_uri: 'http://127.0.0.1:19876/callback',
scopes: ['openid', 'codeburn:write'],
state: 'random-state-value',
pkce: generatePkce(),
}
it('includes all required parameters', () => {
const url = new URL(buildAuthUrl(baseParams))
expect(url.searchParams.get('response_type')).toBe('code')
expect(url.searchParams.get('client_id')).toBe('test-client')
expect(url.searchParams.get('redirect_uri')).toBe('http://127.0.0.1:19876/callback')
expect(url.searchParams.get('state')).toBe('random-state-value')
expect(url.searchParams.get('code_challenge_method')).toBe('S256')
})
it('joins scopes with space', () => {
const url = new URL(buildAuthUrl(baseParams))
expect(url.searchParams.get('scope')).toBe('openid codeburn:write')
})
it('uses 127.0.0.1 literal (not localhost)', () => {
const url = buildAuthUrl(baseParams)
expect(url).toContain('127.0.0.1')
expect(url).not.toContain('localhost')
})
})
// ── Scope Resolution ──────────────────────────────────────────────────
describe('resolveScopes', () => {
it('appends offline_access when IdP supports it', () => {
const result = resolveScopes(['openid'], ['openid', 'offline_access', 'profile'])
expect(result).toContain('offline_access')
})
it('does not append offline_access when IdP does not support it', () => {
const result = resolveScopes(['openid'], ['openid', 'profile'])
expect(result).not.toContain('offline_access')
})
it('does not duplicate offline_access if already requested', () => {
const result = resolveScopes(['openid', 'offline_access'], ['openid', 'offline_access'])
expect(result.filter(s => s === 'offline_access')).toHaveLength(1)
})
it('passes through scopes unchanged when IdP scopes unknown', () => {
const result = resolveScopes(['openid', 'codeburn:write'], undefined)
expect(result).toEqual(['openid', 'codeburn:write'])
})
})
// ── Callback Server ──────────────────────────────────────────────────
describe('startCallbackServer', () => {
it('accepts valid callback with matching state', async () => {
const state = 'test-state-123'
const { promise, ready } = startCallbackServer(state, 5000, [0])
// Wait for server to bind
const port = await ready
// Simulate IdP callback
await fetch(`http://127.0.0.1:${port}/callback?code=auth-code-xyz&state=${state}`)
const result = await promise
expect(result.code).toBe('auth-code-xyz')
}, 10000)
it('rejects callback with wrong state', async () => {
const state = 'correct-state'
const { promise, ready } = startCallbackServer(state, 5000, [0])
const port = await ready
// Send with wrong state — server stays running
const resp = await fetch(`http://127.0.0.1:${port}/callback?code=xxx&state=wrong-state`)
expect(resp.status).toBe(400)
// Now send correct one
await fetch(`http://127.0.0.1:${port}/callback?code=real-code&state=${state}`)
const result = await promise
expect(result.code).toBe('real-code')
}, 10000)
it('times out after configured duration', async () => {
const { promise } = startCallbackServer('state', 300, [0]) // 300ms timeout
await expect(promise).rejects.toThrow('timed out')
}, 5000)
})
// ── Config ────────────────────────────────────────────────────────────
describe('syncConfig', () => {
let tmpDir: string
const originalHome = process.env.HOME
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'codeburn-sync-config-'))
setHome(tmpDir)
})
afterEach(async () => {
setHome(originalHome)
await rm(tmpDir, { recursive: true, force: true })
})
it('writes and reads config', async () => {
// Dynamically import to pick up new HOME
const { writeSyncConfig, readSyncConfig } = await import('../src/sync/config.js')
writeSyncConfig({
baseUrl: 'https://metrics.test.com',
clientId: 'test-client',
tracesPath: '/v1/traces',
issuer: 'https://idp.test.com',
})
const config = readSyncConfig()
expect(config).not.toBeNull()
expect(config!.baseUrl).toBe('https://metrics.test.com')
expect(config!.clientId).toBe('test-client')
})
it('returns null when no config exists', async () => {
const { readSyncConfig } = await import('../src/sync/config.js')
const config = readSyncConfig()
expect(config).toBeNull()
})
it('config file does not contain tokens', async () => {
const { writeSyncConfig } = await import('../src/sync/config.js')
writeSyncConfig({
baseUrl: 'https://metrics.test.com',
clientId: 'client',
tracesPath: '/v1/traces',
issuer: 'https://idp.test.com',
})
const raw = await readFile(join(tmpDir, '.config', 'codeburn', 'sync.json'), 'utf-8')
expect(raw).not.toContain('token')
expect(raw).not.toContain('secret')
expect(raw).not.toContain('password')
})
})
// ── Callback Page Escaping ────────────────────────────────────────────
describe('renderCallbackPage', () => {
it('escapes HTML metacharacters in title and message', () => {
const html = renderCallbackPage(false, '<script>alert(1)</script>', 'a & b "c" \'d\'')
expect(html).not.toContain('<script>alert(1)</script>')
expect(html).toContain('&lt;script&gt;alert(1)&lt;/script&gt;')
expect(html).toContain('a &amp; b &quot;c&quot; &#39;d&#39;')
})
it('leaves literal copy intact', () => {
const html = renderCallbackPage(true, 'Login successful', 'You can close this tab.')
expect(html).toContain('Login successful')
expect(html).toContain('You can close this tab.')
})
})