mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-19 18:23:41 +00:00
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.
266 lines
9 KiB
TypeScript
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()
|
|
})
|
|
})
|