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
143 lines
5.2 KiB
TypeScript
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
|
|
})
|