codeburn/tests/sync-e2e.test.ts
iamtoruk 62cb4f8630 test: isolate the real home on Windows and make the CLI suite pass there
os.homedir() reads USERPROFILE on Windows and never HOME, so the
isolation setup and every test that moved home were still resolving the
developer's real home directory. Fixtures came back empty, real sessions
leaked into counts, and the code under test wrote plans, receipts and a
sync ledger into the real ~/.config/codeburn during a run.

USERPROFILE now joins the redirected variables and a shared setHome()
helper moves HOME and USERPROFILE together; every spawn env carries both.
The remaining Windows-only failures were POSIX-shaped fixtures: chmod
tricks that only toggle a read-only attribute on NTFS, pid 1 as an
always-alive lock holder, splitting paths on '/', bare tar, mode-bit
expectations, and per-file timeouts too short for the cost of a tsx spawn.
Each gets a platform-aware equivalent so nothing is skipped on Windows and
the POSIX branches stay byte-for-byte what they were.

Full run on Windows: 267 files passed, 2 skipped, 3592 tests passed.
2026-09-02 13:43:57 -07:00

266 lines
9 KiB
TypeScript

/**
* 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'
import { setHome } from './setup/home.js'
let idp: MockIdp
let tmpHome: string
const originalHome = process.env.HOME
const originalStore = process.env.CODEBURN_SYNC_TOKEN_STORE
beforeAll(async () => {
idp = await startMockIdp({ rotateTokens: false })
// Force the file store so this suite never touches the real OS keychain
// (on darwin, createCredentialStore() would otherwise ignore HOME and
// read/write the login keychain under the real service/account names).
process.env.CODEBURN_SYNC_TOKEN_STORE = 'file'
})
afterAll(async () => {
await idp.close()
if (originalStore === undefined) delete process.env.CODEBURN_SYNC_TOKEN_STORE
else process.env.CODEBURN_SYNC_TOKEN_STORE = originalStore
})
beforeEach(async () => {
tmpHome = await mkdtemp(join(tmpdir(), 'codeburn-sync-e2e-'))
setHome(tmpHome)
})
afterEach(async () => {
setHome(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, ready } = startCallbackServer(state, 5000, [0])
const port = await ready
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. Drive the real authorize flow: hit the IdP's authorize endpoint
// (registers the PKCE challenge, issues a code), then follow its
// redirect to our local callback server — like a browser would.
const authResp = await fetch(authUrl, { redirect: 'manual' })
expect(authResp.status).toBe(302)
const location = authResp.headers.get('location')!
expect(location).toContain(`http://127.0.0.1:${port}/callback`)
await fetch(location)
const callbackResult = await callbackPromise
expect(callbackResult.code).toMatch(/^mock-code-/)
// 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(callbackResult.code)
// 8b. PKCE negative checks: wrong verifier rejected; code is single-use
const authResp2 = await fetch(authUrl.replace(`state=${state}`, 'state=neg-test'), { redirect: 'manual' })
const loc2 = new URL(authResp2.headers.get('location')!)
const code2 = loc2.searchParams.get('code')!
await expect(
exchangeCode(oidc.token_endpoint, code2, 'wrong-verifier-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', redirectUri, discovery.client_id)
).rejects.toThrow(/PKCE verification failed|Token exchange failed/)
await expect(
exchangeCode(oidc.token_endpoint, callbackResult.code, pkce.code_verifier, redirectUri, discovery.client_id)
).rejects.toThrow(/unknown or reused code|Token exchange failed/)
// 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()
})
})