mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-29 10:55:35 +00:00
Before-merge items: 1. HTTPS enforced on every remote endpoint (RFC 8252 §8.3): baseUrl, issuer, authorization/token/revocation endpoints, and the traces endpoint all reject non-https, with a loopback (127.0.0.1/::1/ localhost) exception for offline tests and local dev. Enforcement is central (assertHttps) — the browser-open guard is no longer the only check whose failure was swallowed. 2. Credential store test isolation: CODEBURN_SYNC_TOKEN_STORE=file forces the file store (honors HOME) so the offline suite never touches the real macOS login keychain. Set in the e2e suite. 3. Golden pins for deriveSpanId/deriveTraceId/deriveDeviceId with fixed inputs and expected hex — the idempotency contract depends on these encodings being stable across releases; a green-tests encoding change would silently double-count history on span-ID-keyed backends. getDeviceId refactored over a pure deriveDeviceId(host, user). Smaller review items: - Callback server: ready promise resolves the actually-bound port from the listening event (kills the 100ms-sleep race after port fallback); Connection: close on all responses + closeAllConnections() on shutdown (pooled keep-alive sockets from a closed server could swallow requests aimed at a later server on the same port); error handler guarded so a post-bind error can never rebind to a different port than advertised; optional ports param ([0] = ephemeral) removes fixed-port contention between parallel test workers. - fetchOidcConfig verifies the issuer claim matches the fetch origin (OIDC Discovery §4.3 mix-up defense). - partialSuccess.rejectedSpans wrapped in Number() — proto3 int64 JSON mapping sends strings from strict protojson servers; += would concatenate. - Ledger writes are atomic (temp + rename); corrupt-ledger recovery and no-tmp-left-behind tests added; XDG_CACHE_HOME honored (ledger is reconstructible state, not config). - Mock IdP now implements /oauth2/authorize (registers PKCE challenge, 302s to redirect_uri) and verifies S256 code_verifier + single-use codes at the token endpoint. The e2e drives the real redirect flow and asserts wrong-verifier and code-reuse are rejected — PKCE binding is now exercised end to end. - sync reset calls clearLedger() instead of reimplementing the path. - push sets exit code 1 on rate-limited/server-error outcomes so cron and script callers can detect incomplete pushes. Deferred (noted for fast-follow): macOS 'security -i' stdin mode (untestable on this Linux box), ai.cost_estimated as a real ParsedApiCall flag (touches core parser types). Sync suite: 81 passing (5x stable), 5 developer-only. AI-Origin: human
260 lines
9.5 KiB
TypeScript
260 lines
9.5 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,
|
|
CALLBACK_PORTS,
|
|
} from '../src/sync/auth.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-'))
|
|
process.env.HOME = tmpDir
|
|
})
|
|
|
|
afterEach(async () => {
|
|
process.env.HOME = 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')
|
|
})
|
|
})
|