codeburn/tests/sync.test.ts
Andrew Lee d8a7b2a95f fix(sync): address PR review — https enforcement, keychain test isolation, golden ID pins
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
2026-07-13 16:45:39 +00:00

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