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
375 lines
14 KiB
TypeScript
375 lines
14 KiB
TypeScript
/**
|
|
* 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,
|
|
deriveDeviceId,
|
|
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)
|
|
})
|
|
|
|
// GOLDEN PIN — do not update this value. The idempotency contract depends
|
|
// on span IDs being stable across releases: if the hash input or encoding
|
|
// ever changes, every re-sent span gets a new identity and backends that
|
|
// key on span ID double-count history. If this test fails, revert the
|
|
// encoding change (or design an explicit migration).
|
|
it('golden: SHA-256(deduplicationKey) first 8 bytes as hex', () => {
|
|
expect(deriveSpanId('golden-dedup-key')).toBe('ec3ca28cceacf381')
|
|
})
|
|
})
|
|
|
|
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)
|
|
})
|
|
|
|
// GOLDEN PIN — see deriveSpanId golden test for why this must not change.
|
|
it('golden: SHA-256(sessionId) first 16 bytes as hex', () => {
|
|
expect(deriveTraceId('golden-session-id')).toBe('ff1b1358ef64c52f80e50e7ae47ca176')
|
|
})
|
|
})
|
|
|
|
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())
|
|
})
|
|
|
|
// GOLDEN PIN — device ID must be stable across releases so a developer's
|
|
// machine keeps one identity in the backend.
|
|
it('golden: SHA-256(hostname:username) first 8 bytes as hex', () => {
|
|
expect(deriveDeviceId('host.example', 'alice')).toBe('004d1a2fc048f575')
|
|
})
|
|
})
|
|
|
|
// ── 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
|
|
// env-isolation.ts redirects XDG_CACHE_HOME to a per-worker sandbox shared
|
|
// across tests — the ledger honors XDG, so point it at the per-test dir.
|
|
process.env.XDG_CACHE_HOME = join(tmpDir, '.cache')
|
|
})
|
|
|
|
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)
|
|
})
|
|
|
|
it('corrupt ledger file reads as empty (crash-safe recovery)', async () => {
|
|
const { readLedger } = await import('../src/sync/ledger.js')
|
|
const { mkdirSync, writeFileSync } = await import('fs')
|
|
const { join } = await import('path')
|
|
const dir = join(process.env.HOME!, '.cache', 'codeburn')
|
|
mkdirSync(dir, { recursive: true })
|
|
writeFileSync(join(dir, 'sync-ledger.json'), '{"truncated mid-wri')
|
|
expect(readLedger()).toEqual([])
|
|
})
|
|
|
|
it('writes are atomic — no .tmp file left behind', async () => {
|
|
const { writeLedger } = await import('../src/sync/ledger.js')
|
|
const { existsSync } = await import('fs')
|
|
const { join } = await import('path')
|
|
writeLedger([{ key: 'a', ts: '2026-07-01T00:00:00Z' }])
|
|
const dir = join(process.env.HOME!, '.cache', 'codeburn')
|
|
expect(existsSync(join(dir, 'sync-ledger.json'))).toBe(true)
|
|
expect(existsSync(join(dir, 'sync-ledger.json.tmp'))).toBe(false)
|
|
})
|
|
|
|
it('honors XDG_CACHE_HOME when set', async () => {
|
|
const { writeLedger, readLedger } = await import('../src/sync/ledger.js')
|
|
const { existsSync } = await import('fs')
|
|
const { join } = await import('path')
|
|
const xdgDir = join(process.env.HOME!, 'xdg-cache')
|
|
const original = process.env.XDG_CACHE_HOME
|
|
process.env.XDG_CACHE_HOME = xdgDir
|
|
try {
|
|
writeLedger([{ key: 'xdg-entry', ts: '2026-07-01T00:00:00Z' }])
|
|
expect(existsSync(join(xdgDir, 'codeburn', 'sync-ledger.json'))).toBe(true)
|
|
expect(readLedger().map(e => e.key)).toEqual(['xdg-entry'])
|
|
} finally {
|
|
if (original === undefined) delete process.env.XDG_CACHE_HOME
|
|
else process.env.XDG_CACHE_HOME = original
|
|
}
|
|
})
|
|
})
|
|
|
|
// ── assertHttps (RFC 8252 §8.3) ───────────────────────────────────────
|
|
|
|
describe('assertHttps', () => {
|
|
it('accepts https URLs', async () => {
|
|
const { assertHttps } = await import('../src/sync/discovery.js')
|
|
expect(() => assertHttps('https://telemetry.example.com', 'Base URL')).not.toThrow()
|
|
})
|
|
|
|
it('accepts http on loopback (offline tests, local dev)', async () => {
|
|
const { assertHttps } = await import('../src/sync/discovery.js')
|
|
expect(() => assertHttps('http://127.0.0.1:8080/x', 'Base URL')).not.toThrow()
|
|
expect(() => assertHttps('http://localhost:3000', 'Base URL')).not.toThrow()
|
|
expect(() => assertHttps('http://[::1]:9999', 'Base URL')).not.toThrow()
|
|
})
|
|
|
|
it('rejects plain http on non-loopback hosts', async () => {
|
|
const { assertHttps } = await import('../src/sync/discovery.js')
|
|
expect(() => assertHttps('http://telemetry.example.com', 'Base URL')).toThrow(/must use https/)
|
|
expect(() => assertHttps('http://192.168.1.10', 'Issuer')).toThrow(/must use https/)
|
|
})
|
|
|
|
it('rejects non-http(s) schemes and garbage', async () => {
|
|
const { assertHttps } = await import('../src/sync/discovery.js')
|
|
expect(() => assertHttps('ftp://example.com', 'Base URL')).toThrow(/must use https/)
|
|
expect(() => assertHttps('not a url', 'Base URL')).toThrow(/not a valid URL/)
|
|
})
|
|
})
|