mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-23 15:19:37 +00:00
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.
279 lines
10 KiB
TypeScript
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('<script>alert(1)</script>')
|
|
expect(html).toContain('a & b "c" 'd'')
|
|
})
|
|
|
|
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.')
|
|
})
|
|
})
|