test(sync): unit, mock-IdP e2e, and developer-only integration tests

Offline, CI-safe (70 tests):
- sync.test.ts (26): discovery parsing, PKCE, auth URL, scope
  negotiation, callback server (state mismatch, IdP error, timeout,
  port fallback), config round-trip
- sync-ledger-otlp.test.ts (23): deterministic ID derivation, OTLP
  structure/attributes, batching, ledger append/dedup/prune/clear
- sync-push.test.ts (15): pipeline against a scriptable mock OTLP
  server — success+ledger+cost, partial-success batch NOT ledgered,
  401 stop, 429 wait-and-retry / persistent-429 give-up / Retry-After
  parsing (delta-seconds, HTTP-date, garbage), 5xx deferral,
  idempotent failure recovery
- sync-e2e.test.ts (6) + fixtures/mock-idp.ts: full setup→refresh→
  rotate→logout round-trip against an in-process mock IdP

Developer-only (skip unless env vars set, never CI):
- sync-headless-e2e.test.ts (1): real browser PKCE flow via Playwright
- sync-infra-e2e.test.ts (4): push/auth-reject/batch/idempotent
  re-push against a deployed backend

AI-Origin: human
This commit is contained in:
Andrew Lee 2026-07-12 16:05:34 +00:00
parent ca39a69b6b
commit 163842edac
7 changed files with 1752 additions and 0 deletions

186
tests/fixtures/mock-idp.ts vendored Normal file
View file

@ -0,0 +1,186 @@
/**
* Mock OIDC Identity Provider for codeburn sync e2e tests.
*
* Serves:
* - /.well-known/codeburn-export.json (discovery doc)
* - /.well-known/openid-configuration (OIDC discovery)
* - /oauth2/authorize (redirect with code — not used directly in tests)
* - /oauth2/token (exchanges code for tokens)
* - /oauth2/revoke (token revocation)
*/
import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'http'
export interface MockIdpOptions {
/** Port to listen on (0 = ephemeral) */
port?: number
/** Refresh token to issue */
refreshToken?: string
/** Access token to issue */
accessToken?: string
/** Simulate rotation: return a new refresh token on each exchange */
rotateTokens?: boolean
}
export interface MockIdp {
port: number
baseUrl: string
server: Server
close(): Promise<void>
/** Tokens issued so far */
issuedTokens: { access: string[]; refresh: string[] }
/** Revoked tokens */
revokedTokens: string[]
/** Authorization codes that have been exchanged */
exchangedCodes: string[]
}
export async function startMockIdp(opts: MockIdpOptions = {}): Promise<MockIdp> {
const refreshToken = opts.refreshToken ?? 'mock-refresh-token-v1'
const accessToken = opts.accessToken ?? 'mock-access-token-xyz'
let currentRefreshToken = refreshToken
let rotationCounter = 0
const state: MockIdp = {
port: 0,
baseUrl: '',
server: null!,
issuedTokens: { access: [], refresh: [] },
revokedTokens: [],
exchangedCodes: [],
close: async () => {},
}
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
const url = new URL(req.url ?? '/', `http://127.0.0.1:${state.port}`)
const path = url.pathname
// --- Discovery doc ---
if (path === '/.well-known/codeburn-export.json') {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({
version: 1,
issuer: state.baseUrl,
client_id: 'mock-client-id',
scopes: ['openid', 'codeburn:write'],
traces_path: '/v1/traces',
max_batch_size: 100,
}))
return
}
// --- OIDC Discovery ---
if (path === '/.well-known/openid-configuration') {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({
issuer: state.baseUrl,
authorization_endpoint: `${state.baseUrl}/oauth2/authorize`,
token_endpoint: `${state.baseUrl}/oauth2/token`,
revocation_endpoint: `${state.baseUrl}/oauth2/revoke`,
scopes_supported: ['openid', 'offline_access', 'codeburn:write'],
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256'],
}))
return
}
// --- Token endpoint ---
if (path === '/oauth2/token' && req.method === 'POST') {
let body = ''
req.on('data', chunk => { body += chunk })
req.on('end', () => {
const params = new URLSearchParams(body)
const grantType = params.get('grant_type')
if (grantType === 'authorization_code') {
const code = params.get('code')
if (!code) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'invalid_request', error_description: 'missing code' }))
return
}
state.exchangedCodes.push(code)
state.issuedTokens.access.push(accessToken)
state.issuedTokens.refresh.push(currentRefreshToken)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({
access_token: accessToken,
refresh_token: currentRefreshToken,
token_type: 'Bearer',
expires_in: 3600,
}))
return
}
if (grantType === 'refresh_token') {
const rt = params.get('refresh_token')
if (rt !== currentRefreshToken) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'invalid_grant' }))
return
}
// Simulate rotation if enabled
if (opts.rotateTokens) {
rotationCounter++
currentRefreshToken = `mock-refresh-token-v${rotationCounter + 1}`
}
const newAccess = `${accessToken}-refreshed-${Date.now()}`
state.issuedTokens.access.push(newAccess)
state.issuedTokens.refresh.push(currentRefreshToken)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({
access_token: newAccess,
refresh_token: currentRefreshToken,
token_type: 'Bearer',
expires_in: 3600,
}))
return
}
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'unsupported_grant_type' }))
})
return
}
// --- Revocation endpoint ---
if (path === '/oauth2/revoke' && req.method === 'POST') {
let body = ''
req.on('data', chunk => { body += chunk })
req.on('end', () => {
const params = new URLSearchParams(body)
const token = params.get('token')
if (token) state.revokedTokens.push(token)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end('{}')
})
return
}
// --- 404 ---
res.writeHead(404, { 'Content-Type': 'text/plain' })
res.end('Not Found')
})
await new Promise<void>((resolve, reject) => {
server.listen(opts.port ?? 0, '127.0.0.1', () => {
const addr = server.address()
if (typeof addr === 'object' && addr) {
state.port = addr.port
state.baseUrl = `http://127.0.0.1:${addr.port}`
}
resolve()
})
server.once('error', reject)
})
state.server = server
state.close = () => new Promise(resolve => server.close(() => resolve()))
return state
}

244
tests/sync-e2e.test.ts Normal file
View file

@ -0,0 +1,244 @@
/**
* End-to-end test for codeburn sync setup flow.
*
* Spins up a mock IdP, runs the auth flow programmatically
* (simulating the browser callback), and verifies tokens are
* stored and retrievable.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'
import { mkdtemp, rm } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { startMockIdp, type MockIdp } from './fixtures/mock-idp.js'
import { fetchDiscoveryDoc } from '../src/sync/discovery.js'
import {
fetchOidcConfig,
generatePkce,
buildAuthUrl,
resolveScopes,
startCallbackServer,
exchangeCode,
refreshToken,
revokeToken,
} from '../src/sync/auth.js'
import { createCredentialStore } from '../src/sync/credentials.js'
import { writeSyncConfig, readSyncConfig, deleteSyncConfig } from '../src/sync/config.js'
let idp: MockIdp
let tmpHome: string
const originalHome = process.env.HOME
beforeAll(async () => {
idp = await startMockIdp({ rotateTokens: false })
})
afterAll(async () => {
await idp.close()
})
beforeEach(async () => {
tmpHome = await mkdtemp(join(tmpdir(), 'codeburn-sync-e2e-'))
process.env.HOME = tmpHome
})
afterEach(async () => {
process.env.HOME = originalHome
await rm(tmpHome, { recursive: true, force: true })
})
describe('sync e2e (mock IdP)', () => {
it('full setup flow: discovery → OIDC → callback → token → store', async () => {
// 1. Fetch discovery doc
const discovery = await fetchDiscoveryDoc(idp.baseUrl)
expect(discovery.version).toBe(1)
expect(discovery.issuer).toBe(idp.baseUrl)
expect(discovery.client_id).toBe('mock-client-id')
expect(discovery.scopes).toContain('codeburn:write')
// 2. Fetch OIDC config
const oidc = await fetchOidcConfig(discovery.issuer)
expect(oidc.authorization_endpoint).toContain('/oauth2/authorize')
expect(oidc.token_endpoint).toContain('/oauth2/token')
expect(oidc.revocation_endpoint).toContain('/oauth2/revoke')
expect(oidc.scopes_supported).toContain('offline_access')
// 3. Resolve scopes (offline_access should be added since IdP supports it)
const scopes = resolveScopes(discovery.scopes, oidc.scopes_supported)
expect(scopes).toContain('offline_access')
// 4. Generate PKCE + state
const pkce = generatePkce()
const state = 'e2e-test-state'
// 5. Start callback server
const { promise: callbackPromise, server } = startCallbackServer(state, 5000)
await new Promise(resolve => setTimeout(resolve, 100))
const addr = server.address()
const port = typeof addr === 'object' && addr ? addr.port : 19876
const redirectUri = `http://127.0.0.1:${port}/callback`
// 6. Build auth URL (verify it's well-formed)
const authUrl = buildAuthUrl({
authorization_endpoint: oidc.authorization_endpoint,
client_id: discovery.client_id,
redirect_uri: redirectUri,
scopes,
state,
pkce,
})
const parsedUrl = new URL(authUrl)
expect(parsedUrl.searchParams.get('code_challenge_method')).toBe('S256')
expect(parsedUrl.searchParams.get('client_id')).toBe('mock-client-id')
// 7. Simulate browser callback (as if user logged in and IdP redirected)
const authCode = 'e2e-auth-code-12345'
await fetch(`http://127.0.0.1:${port}/callback?code=${authCode}&state=${state}`)
const callbackResult = await callbackPromise
expect(callbackResult.code).toBe(authCode)
// 8. Exchange code for tokens
const tokens = await exchangeCode(
oidc.token_endpoint,
callbackResult.code,
pkce.code_verifier,
redirectUri,
discovery.client_id,
)
expect(tokens.access_token).toBe('mock-access-token-xyz')
expect(tokens.refresh_token).toBe('mock-refresh-token-v1')
expect(tokens.expires_in).toBe(3600)
// Verify the mock IdP received the code
expect(idp.exchangedCodes).toContain(authCode)
// 9. Store refresh token
const store = createCredentialStore()
store.store(tokens.refresh_token!)
const retrieved = store.retrieve()
expect(retrieved).toBe('mock-refresh-token-v1')
// 10. Write config
writeSyncConfig({
baseUrl: idp.baseUrl,
clientId: discovery.client_id,
tracesPath: discovery.traces_path,
issuer: discovery.issuer,
})
const config = readSyncConfig()
expect(config).not.toBeNull()
expect(config!.baseUrl).toBe(idp.baseUrl)
expect(config!.clientId).toBe('mock-client-id')
}, 10000)
it('token refresh flow', async () => {
// Store a refresh token
const store = createCredentialStore()
store.store('mock-refresh-token-v1')
// Refresh it
const oidc = await fetchOidcConfig(idp.baseUrl)
const tokens = await refreshToken(oidc.token_endpoint, 'mock-refresh-token-v1', 'mock-client-id')
expect(tokens.access_token).toContain('mock-access-token-xyz-refreshed')
expect(tokens.refresh_token).toBe('mock-refresh-token-v1') // no rotation
expect(tokens.token_type).toBe('Bearer')
})
it('refresh with invalid token returns auth error', async () => {
const oidc = await fetchOidcConfig(idp.baseUrl)
await expect(
refreshToken(oidc.token_endpoint, 'wrong-token', 'mock-client-id')
).rejects.toThrow('Sync auth expired')
})
it('logout revokes token at IdP', async () => {
const store = createCredentialStore()
store.store('token-to-revoke')
writeSyncConfig({
baseUrl: idp.baseUrl,
clientId: 'mock-client-id',
tracesPath: '/v1/traces',
issuer: idp.baseUrl,
})
// Revoke
const oidc = await fetchOidcConfig(idp.baseUrl)
await revokeToken(oidc.revocation_endpoint!, 'token-to-revoke', 'mock-client-id')
expect(idp.revokedTokens).toContain('token-to-revoke')
// Clean up
store.delete()
deleteSyncConfig()
expect(store.retrieve()).toBeNull()
expect(readSyncConfig()).toBeNull()
})
it('status shows correct info after setup', async () => {
const store = createCredentialStore()
store.store('status-test-token')
writeSyncConfig({
baseUrl: idp.baseUrl,
clientId: 'mock-client-id',
tracesPath: '/v1/traces',
issuer: idp.baseUrl,
lastSync: '2026-07-07T20:00:00Z',
})
const config = readSyncConfig()
const token = store.retrieve()
expect(config!.baseUrl).toBe(idp.baseUrl)
expect(config!.lastSync).toBe('2026-07-07T20:00:00Z')
expect(token).toBe('status-test-token')
expect(store.method()).toMatch(/keychain|secret-tool|dpapi|file/)
// Clean up
store.delete()
deleteSyncConfig()
})
})
describe('sync e2e — token rotation', () => {
let rotatingIdp: MockIdp
beforeAll(async () => {
rotatingIdp = await startMockIdp({ rotateTokens: true, refreshToken: 'rt-rotation-v1' })
})
afterAll(async () => {
await rotatingIdp.close()
})
it('stores rotated refresh token after refresh', async () => {
const store = createCredentialStore()
store.store('rt-rotation-v1')
const oidc = await fetchOidcConfig(rotatingIdp.baseUrl)
// First refresh — should get rt-rotation-v2
const tokens1 = await refreshToken(oidc.token_endpoint, 'rt-rotation-v1', 'mock-client-id')
expect(tokens1.refresh_token).toBe('mock-refresh-token-v2')
// Store the new one (as the client would)
store.store(tokens1.refresh_token!)
// Second refresh with the new token
const tokens2 = await refreshToken(oidc.token_endpoint, tokens1.refresh_token!, 'mock-client-id')
expect(tokens2.refresh_token).toBe('mock-refresh-token-v3')
// Old token should fail
await expect(
refreshToken(oidc.token_endpoint, 'rt-rotation-v1', 'mock-client-id')
).rejects.toThrow('Sync auth expired')
store.delete()
})
})

View file

@ -0,0 +1,145 @@
/**
* E2E test: codeburn sync setup with headless browser against real Cognito.
*
* This test exercises the FULL PKCE flow:
* 1. Starts the callback server (simulating `codeburn sync setup`)
* 2. Builds the auth URL with PKCE challenge
* 3. Opens a headless Chromium to the Cognito Hosted UI
* 4. Fills the login form with test credentials
* 5. Submits → Cognito redirects to localhost callback
* 6. Callback server receives code + state
* 7. Exchanges code for tokens
*
* Requirements:
* - Playwright + Chromium installed (`npx playwright install chromium`)
* - Real Cognito endpoint deployed (CodeburnSyncBackend stack)
* - Test user created in the pool
*
* Run: npx vitest run tests/sync-headless-e2e.test.ts
* Skip in CI without infra: set SKIP_HEADLESS_E2E=1
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { chromium, type Browser, type Page } from 'playwright'
import { fetchDiscoveryDoc } from '../src/sync/discovery.js'
import {
fetchOidcConfig,
generatePkce,
buildAuthUrl,
resolveScopes,
startCallbackServer,
exchangeCode,
} from '../src/sync/auth.js'
import { randomBytes } from 'crypto'
// --- Configuration (from deployed stack outputs) ---
const BASE_URL = process.env.CODEBURN_SYNC_URL
const TEST_EMAIL = process.env.CODEBURN_SYNC_EMAIL
const TEST_PASSWORD = process.env.CODEBURN_SYNC_PASSWORD
// Only runs when ALL three env vars are set. Developer-only test.
const SKIP = !BASE_URL || !TEST_EMAIL || !TEST_PASSWORD
describe.skipIf(SKIP)('sync setup — headless browser PKCE flow', () => {
let browser: Browser
beforeAll(async () => {
browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
})
})
afterAll(async () => {
await browser?.close()
})
it('full PKCE flow: callback server → Cognito login → token exchange', async () => {
// 1. Fetch discovery + OIDC config
const discovery = await fetchDiscoveryDoc(BASE_URL)
const oidc = await fetchOidcConfig(discovery.issuer)
const scopes = resolveScopes(discovery.scopes, oidc.scopes_supported)
// 2. PKCE + state
const pkce = generatePkce()
const state = randomBytes(16).toString('hex')
// 3. Start callback server
const { promise: callbackPromise, server } = startCallbackServer(state, 30000)
await new Promise(r => setTimeout(r, 200))
const addr = server.address()
const port = typeof addr === 'object' && addr ? addr.port : 19876
const redirectUri = `http://127.0.0.1:${port}/callback`
// 4. Build auth URL
const authUrl = buildAuthUrl({
authorization_endpoint: oidc.authorization_endpoint,
client_id: discovery.client_id,
redirect_uri: redirectUri,
scopes,
state,
pkce,
})
// 5. Open headless browser to Cognito Hosted UI
const page: Page = await browser.newPage()
try {
await page.goto(authUrl, { waitUntil: 'networkidle' })
// 6. Fill login form
// Cognito Hosted UI renders two tabbed forms (Sign In + Sign Up).
// The inputs exist but may not be CSS-visible. Use JS to fill the
// sign-in form directly, targeting inputs within the form that has
// the signInSubmitButton.
await page.evaluate((creds) => {
const forms = document.querySelectorAll('form')
for (const form of forms) {
if (!form.querySelector('input[name="signInSubmitButton"]')) continue
const username = form.querySelector('input[name="username"]') as HTMLInputElement
const password = form.querySelector('input[name="password"]') as HTMLInputElement
if (username) { username.value = creds.email; username.dispatchEvent(new Event('input', { bubbles: true })) }
if (password) { password.value = creds.password; password.dispatchEvent(new Event('input', { bubbles: true })) }
}
}, { email: TEST_EMAIL, password: TEST_PASSWORD })
// 7. Submit the form via JS
await page.evaluate(() => {
const btn = document.querySelector('input[name="signInSubmitButton"]') as HTMLInputElement
if (btn) btn.click()
})
// 8. Wait for redirect to our callback server
// Cognito will redirect to http://127.0.0.1:{port}/callback?code=...&state=...
// The page will load our callback server's "Login successful" response
await page.waitForURL(`http://127.0.0.1:${port}/callback*`, { timeout: 15000 })
// 9. Callback server should have received the code
const result = await callbackPromise
expect(result.code).toBeTruthy()
expect(result.code.length).toBeGreaterThan(10)
// 10. Exchange code for tokens
const tokens = await exchangeCode(
oidc.token_endpoint,
result.code,
pkce.code_verifier,
redirectUri,
discovery.client_id,
)
expect(tokens.access_token).toBeTruthy()
expect(tokens.refresh_token).toBeTruthy()
expect(tokens.token_type).toBe('Bearer')
expect(tokens.expires_in).toBeGreaterThan(0)
console.log('✅ Full PKCE flow completed successfully!')
console.log(` Access token: ${tokens.access_token.slice(0, 30)}...`)
console.log(` Refresh token: ${tokens.refresh_token!.slice(0, 30)}...`)
console.log(` Expires in: ${tokens.expires_in}s`)
} finally {
await page.close()
}
}, 60000) // 60s timeout for the full browser flow
})

View file

@ -0,0 +1,232 @@
/**
* Integration test: codeburn sync push against real AWS infrastructure.
*
* Requires:
* - Deployed CodeburnSyncBackend stack
* - Environment variables: CODEBURN_SYNC_URL, CODEBURN_SYNC_EMAIL, CODEBURN_SYNC_PASSWORD
* - AWS credentials (for verifying CloudWatch logs)
*
* Run:
* CODEBURN_SYNC_URL=https://xxx.execute-api.us-west-2.amazonaws.com \
* CODEBURN_SYNC_EMAIL=andklee@amazon.com \
* CODEBURN_SYNC_PASSWORD='password' \
* AWS_PROFILE=andklee-dev \
* npx vitest run tests/sync-infra-e2e.test.ts
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { randomBytes, createHash } from 'crypto'
import { fetchDiscoveryDoc } from '../src/sync/discovery.js'
import { fetchOidcConfig, refreshToken } from '../src/sync/auth.js'
import { buildOtlpPayload, deriveSpanId, type CallWithSession } from '../src/sync/otlp.js'
import type { ParsedApiCall, TokenUsage } from '../src/types.js'
const BASE_URL = process.env.CODEBURN_SYNC_URL
const TEST_EMAIL = process.env.CODEBURN_SYNC_EMAIL
const TEST_PASSWORD = process.env.CODEBURN_SYNC_PASSWORD
const AWS_REGION = process.env.AWS_REGION || 'us-west-2'
const SKIP = !BASE_URL || !TEST_EMAIL || !TEST_PASSWORD
describe.skipIf(SKIP)('sync infra e2e — push + verify', () => {
let accessToken: string
let clientId: string
let tracesEndpoint: string
const testRunId = randomBytes(4).toString('hex')
beforeAll(async () => {
// Get tokens via admin API (Cognito USER_PASSWORD_AUTH)
const discovery = await fetchDiscoveryDoc(BASE_URL!)
clientId = discovery.client_id
tracesEndpoint = `${BASE_URL}${discovery.traces_path}`
// Direct auth (USER_PASSWORD_AUTH) for testing
const tokenEndpoint = `https://codeburn-sync.auth.${AWS_REGION}.amazoncognito.com/oauth2/token`
// Use Cognito initiateAuth via fetch to the Cognito service
const cognitoUrl = `https://cognito-idp.${AWS_REGION}.amazonaws.com/`
const userPoolId = discovery.issuer.split('/').pop()!
const authResp = await fetch(cognitoUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-amz-json-1.1',
'X-Amz-Target': 'AWSCognitoIdentityProviderService.InitiateAuth',
},
body: JSON.stringify({
AuthFlow: 'USER_PASSWORD_AUTH',
ClientId: clientId,
AuthParameters: {
USERNAME: TEST_EMAIL,
PASSWORD: TEST_PASSWORD,
},
}),
})
const authResult = await authResp.json() as { AuthenticationResult?: { AccessToken?: string } }
accessToken = authResult.AuthenticationResult?.AccessToken ?? ''
expect(accessToken).toBeTruthy()
})
it('pushes a test span and receives 200', async () => {
const testDedup = `test:infra-e2e:${testRunId}:span1`
const usage: TokenUsage = {
inputTokens: 999,
outputTokens: 111,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
}
const call: ParsedApiCall = {
provider: 'test',
model: 'test-model',
usage,
costUSD: 0.001,
tools: ['TestTool'],
mcpTools: [],
skills: [],
subagentTypes: [],
hasAgentSpawn: false,
hasPlanMode: false,
speed: 'standard',
timestamp: new Date().toISOString(),
bashCommands: [],
deduplicationKey: testDedup,
}
const callWithSession: CallWithSession = {
call,
sessionId: `test-session-${testRunId}`,
project: 'infra-e2e-test',
}
const payload = buildOtlpPayload([callWithSession])
const response = await fetch(tracesEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
body: JSON.stringify(payload),
})
expect(response.status).toBe(200)
const body = await response.json() as Record<string, unknown>
// No partial_success means full success
expect(body.partialSuccess?.rejectedSpans).toBeUndefined()
})
it('rejects unauthenticated requests', async () => {
const payload = buildOtlpPayload([])
const response = await fetch(tracesEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer invalid-token',
},
body: JSON.stringify(payload),
})
expect(response.status).toBe(401)
})
it('pushes multiple spans in one batch', async () => {
const calls: CallWithSession[] = Array.from({ length: 5 }, (_, i) => {
const usage: TokenUsage = {
inputTokens: 100 * (i + 1),
outputTokens: 50 * (i + 1),
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
}
return {
call: {
provider: 'test',
model: 'test-batch-model',
usage,
costUSD: 0.01 * (i + 1),
tools: [],
mcpTools: [],
skills: [],
subagentTypes: [],
hasAgentSpawn: false,
hasPlanMode: false,
speed: 'standard' as const,
timestamp: new Date().toISOString(),
bashCommands: [],
deduplicationKey: `test:infra-e2e:${testRunId}:batch:${i}`,
},
sessionId: `test-batch-session-${testRunId}`,
project: 'infra-e2e-batch',
}
})
const payload = buildOtlpPayload(calls)
const response = await fetch(tracesEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
body: JSON.stringify(payload),
})
expect(response.status).toBe(200)
})
it('deterministic span IDs allow safe re-push', async () => {
const dedup = `test:infra-e2e:${testRunId}:idempotent`
const spanId = deriveSpanId(dedup)
// Push same data twice
const call: CallWithSession = {
call: {
provider: 'test',
model: 'test-idempotent',
usage: { inputTokens: 1, outputTokens: 1, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0 },
costUSD: 0,
tools: [],
mcpTools: [],
skills: [],
subagentTypes: [],
hasAgentSpawn: false,
hasPlanMode: false,
speed: 'standard',
timestamp: new Date().toISOString(),
bashCommands: [],
deduplicationKey: dedup,
},
sessionId: 'idem-session',
project: 'idem-project',
}
const payload = buildOtlpPayload([call])
// First push
const r1 = await fetch(tracesEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${accessToken}` },
body: JSON.stringify(payload),
})
expect(r1.status).toBe(200)
// Second push (identical payload)
const r2 = await fetch(tracesEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${accessToken}` },
body: JSON.stringify(payload),
})
expect(r2.status).toBe(200)
// Both succeed — server tolerates duplicates per OTLP spec
})
})

View file

@ -0,0 +1,286 @@
/**
* Unit tests for sync ledger and OTLP payload builder.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtemp, rm } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import {
deriveSpanId,
deriveTraceId,
buildOtlpPayload,
batchCalls,
getDeviceId,
type CallWithSession,
} from '../src/sync/otlp.js'
import type { ParsedApiCall, TokenUsage } from '../src/types.js'
// ── Helpers ───────────────────────────────────────────────────────────
function makeCall(overrides: Partial<ParsedApiCall> & { deduplicationKey: string }): ParsedApiCall {
const usage: TokenUsage = {
inputTokens: 1000,
outputTokens: 500,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
}
return {
provider: 'kiro',
model: 'claude-sonnet-4-6',
usage,
costUSD: 0.05,
tools: ['Edit', 'Bash'],
mcpTools: [],
skills: [],
subagentTypes: [],
hasAgentSpawn: false,
hasPlanMode: false,
speed: 'standard',
timestamp: '2026-07-10T10:00:00.000Z',
bashCommands: [],
deduplicationKey: 'test:key:1',
...overrides,
}
}
function makeCallWithSession(overrides?: Partial<ParsedApiCall> & { deduplicationKey?: string }): CallWithSession {
return {
call: makeCall({ deduplicationKey: overrides?.deduplicationKey ?? 'test:key:1', ...overrides }),
sessionId: 'session-abc',
project: 'my-project',
}
}
// ── OTLP Span/Trace ID Derivation ────────────────────────────────────
describe('deriveSpanId', () => {
it('returns 16 hex chars', () => {
const id = deriveSpanId('cursor:bubble:abc123')
expect(id).toMatch(/^[0-9a-f]{16}$/)
})
it('is deterministic (same input = same output)', () => {
const a = deriveSpanId('my:dedup:key')
const b = deriveSpanId('my:dedup:key')
expect(a).toBe(b)
})
it('different inputs produce different IDs', () => {
const a = deriveSpanId('key-1')
const b = deriveSpanId('key-2')
expect(a).not.toBe(b)
})
})
describe('deriveTraceId', () => {
it('returns 32 hex chars', () => {
const id = deriveTraceId('session-xyz')
expect(id).toMatch(/^[0-9a-f]{32}$/)
})
it('is deterministic', () => {
const a = deriveTraceId('session-1')
const b = deriveTraceId('session-1')
expect(a).toBe(b)
})
})
describe('getDeviceId', () => {
it('returns 16 hex chars', () => {
const id = getDeviceId()
expect(id).toMatch(/^[0-9a-f]{16}$/)
})
it('is stable across calls', () => {
expect(getDeviceId()).toBe(getDeviceId())
})
})
// ── OTLP Payload Builder ──────────────────────────────────────────────
describe('buildOtlpPayload', () => {
it('builds valid OTLP structure with one span', () => {
const payload = buildOtlpPayload([makeCallWithSession()])
expect(payload.resourceSpans).toHaveLength(1)
expect(payload.resourceSpans[0]!.resource.attributes).toEqual([
{ key: 'codeburn.device_id', value: { stringValue: expect.stringMatching(/^[0-9a-f]{16}$/) } },
])
const spans = payload.resourceSpans[0]!.scopeSpans[0]!.spans
expect(spans).toHaveLength(1)
const span = spans[0]!
expect(span.traceId).toMatch(/^[0-9a-f]{32}$/)
expect(span.spanId).toMatch(/^[0-9a-f]{16}$/)
expect(span.name).toBe('kiro/claude-sonnet-4-6')
expect(span.startTimeUnixNano).toBe('1783677600000000000')
})
it('includes correct span attributes', () => {
const payload = buildOtlpPayload([makeCallWithSession()])
const attrs = payload.resourceSpans[0]!.scopeSpans[0]!.spans[0]!.attributes
const attrMap = Object.fromEntries(attrs.map(a => [a.key, a.value]))
expect(attrMap['ai.provider']).toEqual({ stringValue: 'kiro' })
expect(attrMap['ai.model']).toEqual({ stringValue: 'claude-sonnet-4-6' })
expect(attrMap['ai.input_tokens']).toEqual({ intValue: '1000' })
expect(attrMap['ai.output_tokens']).toEqual({ intValue: '500' })
expect(attrMap['ai.cost_usd']).toEqual({ doubleValue: 0.05 })
expect(attrMap['ai.project']).toEqual({ stringValue: 'my-project' })
expect(attrMap['ai.speed']).toEqual({ stringValue: 'standard' })
})
it('includes tools as array attribute', () => {
const payload = buildOtlpPayload([makeCallWithSession()])
const attrs = payload.resourceSpans[0]!.scopeSpans[0]!.spans[0]!.attributes
const toolsAttr = attrs.find(a => a.key === 'ai.tools')
expect(toolsAttr).toBeDefined()
expect(toolsAttr!.value).toEqual({
arrayValue: { values: [{ stringValue: 'Edit' }, { stringValue: 'Bash' }] },
})
})
it('omits tools attribute when empty', () => {
const call = makeCallWithSession({ tools: [] as string[] } as any)
const payload = buildOtlpPayload([call])
const attrs = payload.resourceSpans[0]!.scopeSpans[0]!.spans[0]!.attributes
const toolsAttr = attrs.find(a => a.key === 'ai.tools')
expect(toolsAttr).toBeUndefined()
})
it('multiple calls produce multiple spans', () => {
const calls = [
makeCallWithSession({ deduplicationKey: 'k1' }),
makeCallWithSession({ deduplicationKey: 'k2' }),
makeCallWithSession({ deduplicationKey: 'k3' }),
]
const payload = buildOtlpPayload(calls)
const spans = payload.resourceSpans[0]!.scopeSpans[0]!.spans
expect(spans).toHaveLength(3)
// Each span has a unique spanId
const ids = new Set(spans.map(s => s.spanId))
expect(ids.size).toBe(3)
})
it('same deduplicationKey produces same spanId (idempotent re-send)', () => {
const call = makeCallWithSession({ deduplicationKey: 'stable-key' })
const p1 = buildOtlpPayload([call])
const p2 = buildOtlpPayload([call])
expect(p1.resourceSpans[0]!.scopeSpans[0]!.spans[0]!.spanId)
.toBe(p2.resourceSpans[0]!.scopeSpans[0]!.spans[0]!.spanId)
})
})
// ── Batching ──────────────────────────────────────────────────────────
describe('batchCalls', () => {
it('returns single batch when under limit', () => {
const calls = Array.from({ length: 5 }, (_, i) =>
makeCallWithSession({ deduplicationKey: `k${i}` })
)
const batches = batchCalls(calls, 1000)
expect(batches).toHaveLength(1)
expect(batches[0]).toHaveLength(5)
})
it('splits into multiple batches at the limit', () => {
const calls = Array.from({ length: 2500 }, (_, i) =>
makeCallWithSession({ deduplicationKey: `k${i}` })
)
const batches = batchCalls(calls, 1000)
expect(batches).toHaveLength(3)
expect(batches[0]).toHaveLength(1000)
expect(batches[1]).toHaveLength(1000)
expect(batches[2]).toHaveLength(500)
})
it('empty input returns empty array', () => {
expect(batchCalls([], 1000)).toEqual([])
})
})
// ── Ledger ────────────────────────────────────────────────────────────
describe('ledger', () => {
let tmpDir: string
const originalHome = process.env.HOME
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'codeburn-ledger-'))
process.env.HOME = tmpDir
})
afterEach(async () => {
process.env.HOME = originalHome
await rm(tmpDir, { recursive: true, force: true })
})
it('readLedger returns empty array when no file', async () => {
const { readLedger } = await import('../src/sync/ledger.js')
expect(readLedger()).toEqual([])
})
it('writeLedger + readLedger round-trips', async () => {
const { writeLedger, readLedger } = await import('../src/sync/ledger.js')
const entries = [
{ key: 'k1', ts: '2026-07-10T00:00:00Z' },
{ key: 'k2', ts: '2026-07-11T00:00:00Z' },
]
writeLedger(entries)
expect(readLedger()).toEqual(entries)
})
it('appendToLedger adds new entries and deduplicates', async () => {
const { writeLedger, appendToLedger, readLedger } = await import('../src/sync/ledger.js')
writeLedger([{ key: 'existing', ts: '2026-07-01T00:00:00Z' }])
appendToLedger([
{ key: 'existing', ts: '2026-07-01T00:00:00Z' }, // duplicate
{ key: 'new-one', ts: '2026-07-10T00:00:00Z' },
])
const result = readLedger()
expect(result).toHaveLength(2)
expect(result.map(e => e.key).sort()).toEqual(['existing', 'new-one'])
})
it('appendToLedger prunes entries older than 6 months', async () => {
const { writeLedger, appendToLedger, readLedger } = await import('../src/sync/ledger.js')
const old = new Date(Date.now() - 200 * 24 * 60 * 60 * 1000).toISOString() // 200 days ago
writeLedger([{ key: 'old-entry', ts: old }])
appendToLedger([{ key: 'fresh', ts: new Date().toISOString() }])
const result = readLedger()
expect(result.map(e => e.key)).toEqual(['fresh'])
})
it('ledgerKeySet returns set of keys', async () => {
const { writeLedger, ledgerKeySet } = await import('../src/sync/ledger.js')
writeLedger([
{ key: 'a', ts: '2026-07-01T00:00:00Z' },
{ key: 'b', ts: '2026-07-02T00:00:00Z' },
])
const keys = ledgerKeySet()
expect(keys.has('a')).toBe(true)
expect(keys.has('b')).toBe(true)
expect(keys.has('c')).toBe(false)
})
it('clearLedger removes the file and returns count', async () => {
const { writeLedger, clearLedger, readLedger } = await import('../src/sync/ledger.js')
writeLedger([{ key: 'x', ts: '2026-07-01T00:00:00Z' }])
const count = clearLedger()
expect(count).toBe(1)
expect(readLedger()).toEqual([])
})
it('clearLedger returns 0 when no file', async () => {
const { clearLedger } = await import('../src/sync/ledger.js')
expect(clearLedger()).toBe(0)
})
})

399
tests/sync-push.test.ts Normal file
View file

@ -0,0 +1,399 @@
/**
* Unit tests for sync push orchestration (src/sync/push.ts).
*
* Covers the review gaps: partial-success handling, 429 rate limiting,
* 401 auth rejection, 5xx server errors, and the flatten→filter pipeline.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { createServer, type Server } from 'http'
import { mkdtemp, rm } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import type { ParsedApiCall, TokenUsage, ProjectSummary } from '../src/types.js'
import type { CallWithSession } from '../src/sync/otlp.js'
// ── Helpers ───────────────────────────────────────────────────────────
function makeUsage(): TokenUsage {
return {
inputTokens: 100,
outputTokens: 50,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
}
}
function makeCall(key: string, costUSD = 0.01): ParsedApiCall {
return {
provider: 'test',
model: 'test-model',
usage: makeUsage(),
costUSD,
tools: [],
mcpTools: [],
skills: [],
subagentTypes: [],
hasAgentSpawn: false,
hasPlanMode: false,
speed: 'standard',
timestamp: '2026-07-10T10:00:00.000Z',
bashCommands: [],
deduplicationKey: key,
}
}
function makeCws(key: string, costUSD = 0.01): CallWithSession {
return { call: makeCall(key, costUSD), sessionId: 'sess-1', project: 'proj-1' }
}
/** Minimal mock OTLP server with scriptable responses per request. */
type MockResponse = { status: number; body?: unknown; headers?: Record<string, string> }
function startMockOtlp(responses: MockResponse[]): Promise<{
url: string
server: Server
requests: Array<{ auth: string | undefined; body: unknown }>
}> {
const requests: Array<{ auth: string | undefined; body: unknown }> = []
let idx = 0
return new Promise(resolve => {
const server = createServer((req, res) => {
let raw = ''
req.on('data', c => { raw += c })
req.on('end', () => {
requests.push({ auth: req.headers.authorization, body: JSON.parse(raw || '{}') })
const r = responses[Math.min(idx, responses.length - 1)]!
idx++
res.writeHead(r.status, { 'Content-Type': 'application/json', ...r.headers })
res.end(r.body !== undefined ? JSON.stringify(r.body) : '{}')
})
})
server.listen(0, '127.0.0.1', () => {
const addr = server.address() as { port: number }
resolve({ url: `http://127.0.0.1:${addr.port}/v1/traces`, server, requests })
})
})
}
// ── Test env: isolated HOME so ledger writes go to a temp dir ─────────
let tmpDir: string
const originalHome = process.env.HOME
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'codeburn-push-'))
process.env.HOME = tmpDir
})
afterEach(async () => {
process.env.HOME = originalHome
await rm(tmpDir, { recursive: true, force: true })
})
// ── collectUnsentCalls ────────────────────────────────────────────────
describe('collectUnsentCalls', () => {
it('flattens projects → sessions → turns → calls', async () => {
const { collectUnsentCalls } = await import('../src/sync/push.js')
const projects = [{
project: 'proj-a',
sessions: [{
sessionId: 's1',
turns: [
{ assistantCalls: [makeCall('k1'), makeCall('k2')] },
{ assistantCalls: [makeCall('k3')] },
],
}],
}] as unknown as ProjectSummary[]
const { allCalls, unsent } = collectUnsentCalls(projects)
expect(allCalls).toHaveLength(3)
expect(unsent).toHaveLength(3)
expect(allCalls[0]!.project).toBe('proj-a')
expect(allCalls[0]!.sessionId).toBe('s1')
})
it('filters out calls already in the ledger', async () => {
const { collectUnsentCalls } = await import('../src/sync/push.js')
const { writeLedger } = await import('../src/sync/ledger.js')
writeLedger([{ key: 'k1', ts: '2026-07-10T00:00:00Z' }])
const projects = [{
project: 'p',
sessions: [{
sessionId: 's1',
turns: [{ assistantCalls: [makeCall('k1'), makeCall('k2')] }],
}],
}] as unknown as ProjectSummary[]
const { allCalls, unsent } = collectUnsentCalls(projects)
expect(allCalls).toHaveLength(2)
expect(unsent).toHaveLength(1)
expect(unsent[0]!.call.deduplicationKey).toBe('k2')
})
})
// ── sendBatches: success path ─────────────────────────────────────────
describe('sendBatches — success', () => {
it('sends all batches, ledgers all calls, accumulates cost', async () => {
const { sendBatches } = await import('../src/sync/push.js')
const { readLedger } = await import('../src/sync/ledger.js')
const { url, server, requests } = await startMockOtlp([{ status: 200, body: {} }])
try {
const batches = [
[makeCws('a', 0.10), makeCws('b', 0.20)],
[makeCws('c', 0.30)],
]
const result = await sendBatches({ endpoint: url, accessToken: 'tok-123', batches })
expect(result.outcome).toBe('complete')
expect(result.totalSent).toBe(3)
expect(result.totalRejected).toBe(0)
expect(result.totalCostSent).toBeCloseTo(0.60)
// Two HTTP requests with Bearer auth
expect(requests).toHaveLength(2)
expect(requests[0]!.auth).toBe('Bearer tok-123')
// All three keys ledgered
const keys = readLedger().map(e => e.key).sort()
expect(keys).toEqual(['a', 'b', 'c'])
} finally {
server.close()
}
})
})
// ── sendBatches: partial success ──────────────────────────────────────
describe('sendBatches — partial success', () => {
it('does NOT ledger a partially-rejected batch (whole batch retries)', async () => {
const { sendBatches } = await import('../src/sync/push.js')
const { readLedger } = await import('../src/sync/ledger.js')
const { url, server } = await startMockOtlp([
{ status: 200, body: { partialSuccess: { rejectedSpans: 1 } } }, // batch 1: partial
{ status: 200, body: {} }, // batch 2: full success
])
try {
const batches = [
[makeCws('p1'), makeCws('p2')], // partially rejected — must NOT ledger
[makeCws('ok1')], // fully accepted — must ledger
]
const result = await sendBatches({ endpoint: url, accessToken: 't', batches })
expect(result.outcome).toBe('complete')
expect(result.totalSent).toBe(1)
expect(result.totalRejected).toBe(1)
const keys = readLedger().map(e => e.key)
expect(keys).toEqual(['ok1']) // p1/p2 absent → they retry next push
} finally {
server.close()
}
})
})
// ── sendBatches: error paths ──────────────────────────────────────────
describe('sendBatches — errors', () => {
it('401 → auth-rejected, stops immediately, ledgers nothing further', async () => {
const { sendBatches } = await import('../src/sync/push.js')
const { readLedger } = await import('../src/sync/ledger.js')
const { url, server, requests } = await startMockOtlp([{ status: 401 }])
try {
const result = await sendBatches({
endpoint: url, accessToken: 'bad',
batches: [[makeCws('x')], [makeCws('y')]],
})
expect(result.outcome).toBe('auth-rejected')
expect(result.httpStatus).toBe(401)
expect(result.totalSent).toBe(0)
expect(requests).toHaveLength(1) // second batch never sent
expect(readLedger()).toEqual([])
} finally {
server.close()
}
})
it('429 → waits Retry-After and retries the same batch until it succeeds', async () => {
const { sendBatches } = await import('../src/sync/push.js')
const { readLedger } = await import('../src/sync/ledger.js')
const sleeps: number[] = []
const { url, server, requests } = await startMockOtlp([
{ status: 200, body: {} }, // batch 1: ok
{ status: 429, headers: { 'Retry-After': '2' } }, // batch 2: limited
{ status: 200, body: {} }, // batch 2 retry: ok
{ status: 200, body: {} }, // batch 3: ok
])
try {
const result = await sendBatches({
endpoint: url, accessToken: 't',
batches: [[makeCws('sent')], [makeCws('limited')], [makeCws('third')]],
sleep: async ms => { sleeps.push(ms) },
})
expect(result.outcome).toBe('complete')
expect(result.totalSent).toBe(3) // ALL batches sent
expect(result.totalWaitMs).toBe(2000)
expect(sleeps).toEqual([2000]) // honored Retry-After: 2
expect(requests).toHaveLength(4) // 3 batches + 1 retry
expect(readLedger().map(e => e.key).sort()).toEqual(['limited', 'sent', 'third'])
} finally {
server.close()
}
})
it('persistent 429 → gives up after max retries, remaining deferred', async () => {
const { sendBatches } = await import('../src/sync/push.js')
const { readLedger } = await import('../src/sync/ledger.js')
const sleeps: number[] = []
const { url, server, requests } = await startMockOtlp([
{ status: 429, headers: { 'Retry-After': '1' } }, // every request limited
])
try {
const result = await sendBatches({
endpoint: url, accessToken: 't',
batches: [[makeCws('stuck')], [makeCws('never')]],
sleep: async ms => { sleeps.push(ms) },
max429Retries: 2,
})
expect(result.outcome).toBe('rate-limited')
expect(result.totalSent).toBe(0)
expect(sleeps).toEqual([1000, 1000]) // 2 retries = 2 waits
expect(requests).toHaveLength(3) // initial + 2 retries; 2nd batch never sent
expect(readLedger()).toEqual([])
} finally {
server.close()
}
})
it('429 without Retry-After uses 5s default; wait capped at maxWaitMs', async () => {
const { sendBatches } = await import('../src/sync/push.js')
const sleeps: number[] = []
const { url, server } = await startMockOtlp([
{ status: 429 }, // no Retry-After → 5s default
{ status: 429, headers: { 'Retry-After': '999' } }, // 999s → capped
{ status: 200, body: {} },
])
try {
const result = await sendBatches({
endpoint: url, accessToken: 't',
batches: [[makeCws('x')]],
sleep: async ms => { sleeps.push(ms) },
maxWaitMs: 10_000,
})
expect(result.outcome).toBe('complete')
expect(sleeps).toEqual([5000, 10_000]) // default, then capped
} finally {
server.close()
}
})
it('5xx → server-error, batch not ledgered, remaining deferred', async () => {
const { sendBatches } = await import('../src/sync/push.js')
const { readLedger } = await import('../src/sync/ledger.js')
const { url, server, requests } = await startMockOtlp([{ status: 503 }])
try {
const result = await sendBatches({
endpoint: url, accessToken: 't',
batches: [[makeCws('a')], [makeCws('b')]],
})
expect(result.outcome).toBe('server-error')
expect(result.httpStatus).toBe(503)
expect(result.totalSent).toBe(0)
expect(requests).toHaveLength(1)
expect(readLedger()).toEqual([])
} finally {
server.close()
}
})
it('retry after failure re-sends the unledgered calls (idempotent recovery)', async () => {
const { sendBatches, collectUnsentCalls } = await import('../src/sync/push.js')
// First attempt: server error → nothing ledgered
const first = await startMockOtlp([{ status: 500 }])
try {
await sendBatches({ endpoint: first.url, accessToken: 't', batches: [[makeCws('r1')]] })
} finally {
first.server.close()
}
// Simulate the next push: the same call is still unsent
const projects = [{
project: 'p',
sessions: [{ sessionId: 's1', turns: [{ assistantCalls: [makeCall('r1')] }] }],
}] as unknown as ProjectSummary[]
const { unsent } = collectUnsentCalls(projects)
expect(unsent).toHaveLength(1)
// Second attempt succeeds and ledgers
const second = await startMockOtlp([{ status: 200, body: {} }])
try {
const result = await sendBatches({ endpoint: second.url, accessToken: 't', batches: [unsent] })
expect(result.outcome).toBe('complete')
expect(result.totalSent).toBe(1)
} finally {
second.server.close()
}
// Now filtered out
const { unsent: after } = collectUnsentCalls(projects)
expect(after).toHaveLength(0)
})
})
// ── MAX_PER_PUSH safety valve ─────────────────────────────────────────
describe('MAX_PER_PUSH', () => {
it('is a 50K safety valve (pushes run to completion, not capped at 5K)', async () => {
const { MAX_PER_PUSH } = await import('../src/sync/push.js')
expect(MAX_PER_PUSH).toBe(50_000)
})
})
// ── parseRetryAfterMs ─────────────────────────────────────────────────
describe('parseRetryAfterMs', () => {
it('parses delta-seconds', async () => {
const { parseRetryAfterMs } = await import('../src/sync/push.js')
expect(parseRetryAfterMs('30')).toBe(30_000)
expect(parseRetryAfterMs('0')).toBe(0)
})
it('parses HTTP-date', async () => {
const { parseRetryAfterMs } = await import('../src/sync/push.js')
const future = new Date(Date.now() + 10_000).toUTCString()
const ms = parseRetryAfterMs(future)
expect(ms).toBeGreaterThan(8_000)
expect(ms).toBeLessThanOrEqual(10_500)
})
it('past HTTP-date clamps to 0', async () => {
const { parseRetryAfterMs } = await import('../src/sync/push.js')
const past = new Date(Date.now() - 60_000).toUTCString()
expect(parseRetryAfterMs(past)).toBe(0)
})
it('returns null for missing or garbage values', async () => {
const { parseRetryAfterMs } = await import('../src/sync/push.js')
expect(parseRetryAfterMs(null)).toBeNull()
expect(parseRetryAfterMs('soon™')).toBeNull()
expect(parseRetryAfterMs('-5')).toBeNull()
})
})

260
tests/sync.test.ts Normal file
View file

@ -0,0 +1,260 @@
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: 'x', 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: 'x' }))
.toThrow('missing required field: client_id')
})
it('defaults traces_path to /v1/traces when absent', () => {
const doc = parseDiscoveryDoc({ issuer: 'x', client_id: 'y' })
expect(doc.traces_path).toBe('/v1/traces')
})
it('defaults max_batch_size to 1000 when absent', () => {
const doc = parseDiscoveryDoc({ issuer: 'x', client_id: 'y' })
expect(doc.max_batch_size).toBe(1000)
})
it('defaults scopes to ["openid"] when absent', () => {
const doc = parseDiscoveryDoc({ issuer: 'x', client_id: 'y' })
expect(doc.scopes).toEqual(['openid'])
})
it('treats absent version as v1', () => {
const doc = parseDiscoveryDoc({ issuer: 'x', client_id: 'y' })
expect(doc.version).toBe(1)
})
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, server } = startCallbackServer(state, 5000)
// Wait for server to bind
await new Promise(resolve => setTimeout(resolve, 200))
const addr = server.address()
const port = typeof addr === 'object' && addr ? addr.port : CALLBACK_PORTS[0]
// 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, server } = startCallbackServer(state, 5000)
await new Promise(resolve => setTimeout(resolve, 200))
const addr = server.address()
const port = typeof addr === 'object' && addr ? addr.port : CALLBACK_PORTS[0]
// 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) // 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')
})
})