codeburn/tests/sync-headless-e2e.test.ts
Andrew Lee d8a7b2a95f fix(sync): address PR review — https enforcement, keychain test isolation, golden ID pins
Before-merge items:
1. HTTPS enforced on every remote endpoint (RFC 8252 §8.3): baseUrl,
   issuer, authorization/token/revocation endpoints, and the traces
   endpoint all reject non-https, with a loopback (127.0.0.1/::1/
   localhost) exception for offline tests and local dev. Enforcement is
   central (assertHttps) — the browser-open guard is no longer the only
   check whose failure was swallowed.
2. Credential store test isolation: CODEBURN_SYNC_TOKEN_STORE=file
   forces the file store (honors HOME) so the offline suite never
   touches the real macOS login keychain. Set in the e2e suite.
3. Golden pins for deriveSpanId/deriveTraceId/deriveDeviceId with fixed
   inputs and expected hex — the idempotency contract depends on these
   encodings being stable across releases; a green-tests encoding change
   would silently double-count history on span-ID-keyed backends.
   getDeviceId refactored over a pure deriveDeviceId(host, user).

Smaller review items:
- Callback server: ready promise resolves the actually-bound port from
  the listening event (kills the 100ms-sleep race after port fallback);
  Connection: close on all responses + closeAllConnections() on
  shutdown (pooled keep-alive sockets from a closed server could
  swallow requests aimed at a later server on the same port); error
  handler guarded so a post-bind error can never rebind to a different
  port than advertised; optional ports param ([0] = ephemeral) removes
  fixed-port contention between parallel test workers.
- fetchOidcConfig verifies the issuer claim matches the fetch origin
  (OIDC Discovery §4.3 mix-up defense).
- partialSuccess.rejectedSpans wrapped in Number() — proto3 int64 JSON
  mapping sends strings from strict protojson servers; += would
  concatenate.
- Ledger writes are atomic (temp + rename); corrupt-ledger recovery and
  no-tmp-left-behind tests added; XDG_CACHE_HOME honored (ledger is
  reconstructible state, not config).
- Mock IdP now implements /oauth2/authorize (registers PKCE challenge,
  302s to redirect_uri) and verifies S256 code_verifier + single-use
  codes at the token endpoint. The e2e drives the real redirect flow
  and asserts wrong-verifier and code-reuse are rejected — PKCE binding
  is now exercised end to end.
- sync reset calls clearLedger() instead of reimplementing the path.
- push sets exit code 1 on rate-limited/server-error outcomes so cron
  and script callers can detect incomplete pushes.

Deferred (noted for fast-follow): macOS 'security -i' stdin mode
(untestable on this Linux box), ai.cost_estimated as a real
ParsedApiCall flag (touches core parser types).

Sync suite: 81 passing (5x stable), 5 developer-only.

AI-Origin: human
2026-07-13 16:45:39 +00:00

143 lines
5.2 KiB
TypeScript

/**
* 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, ready } = startCallbackServer(state, 30000)
const port = await ready
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
})