mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-20 11:44:39 +00:00
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:
parent
ca39a69b6b
commit
163842edac
7 changed files with 1752 additions and 0 deletions
186
tests/fixtures/mock-idp.ts
vendored
Normal file
186
tests/fixtures/mock-idp.ts
vendored
Normal 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
244
tests/sync-e2e.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
145
tests/sync-headless-e2e.test.ts
Normal file
145
tests/sync-headless-e2e.test.ts
Normal 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
|
||||
})
|
||||
232
tests/sync-infra-e2e.test.ts
Normal file
232
tests/sync-infra-e2e.test.ts
Normal 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
|
||||
})
|
||||
})
|
||||
286
tests/sync-ledger-otlp.test.ts
Normal file
286
tests/sync-ledger-otlp.test.ts
Normal 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
399
tests/sync-push.test.ts
Normal 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
260
tests/sync.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue