codeburn/app/electron/quota/codex-refresh.test.ts
iamtoruk 5f09d9735f fix(windows): make the cross-process lock test poll, not spin, so it passes on Windows
The rename retry landed, but its test kept the watcher reading in a
tight loop, and on real Windows that lost 8 runs in 12: a reader holding
the file open every instant is a race no bounded retry can win, because
an open handle blocks a rename until it closes. That is not a reader
either app has. The watcher now polls at 1ms, which still samples the
file thousands of times and still catches a lost update, while leaving
the writers room to rename. The locked path passes 15 of 15 on Windows,
and the unlocked path still reports a rolled-back write.

Also restores the lineage case the review asked for: a refresh where the
account id and refresh token are unchanged and only the id_token subject
moves, so the subject comparison is what discards the write. Both copies.
2026-09-03 11:28:01 -07:00

368 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @vitest-environment node
// What happens to `~/.codex/auth.json` when the desktop app refreshes a Codex login.
//
// The grant retires the refresh token on disk the moment the endpoint accepts it, so
// everything between parsing the response and having the new one on disk is a window in
// which losing the process signs the user out of Codex. These run against a real credential
// file, because the write is the thing being pinned.
import { readFileSync } from 'node:fs'
import { mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { fetchCodexQuota } from './codex'
const NOW = Date.parse('2026-09-02T12:00:00.000Z')
const OLD_REFRESH = 'refresh-token-that-the-grant-retires'
const NEW_REFRESH = 'refresh-token-the-grant-returns'
let root: string
let authPath: string
// `last_refresh` is older than the eight-day staleness window, so every fetch here takes the
// proactive refresh path.
function authDoc(): Record<string, unknown> {
return {
auth_mode: 'chatgpt',
tokens: {
access_token: 'access-old',
refresh_token: OLD_REFRESH,
id_token: 'id-old',
account_id: 'account-1',
},
last_refresh: new Date(NOW - 30 * 86_400_000).toISOString(),
}
}
async function writeAuth(doc: Record<string, unknown>, mode = 0o600): Promise<void> {
// A real login file is private to its user, and readSecureFile rejects group or world bits.
await writeFile(authPath, `${JSON.stringify(doc, null, 2)}\n`, { encoding: 'utf8', mode })
}
async function readAuth(): Promise<Record<string, any>> {
return JSON.parse(await readFile(authPath, 'utf8')) as Record<string, any>
}
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
}
const rotatedGrant = () => json({ access_token: 'access-new', refresh_token: NEW_REFRESH, id_token: 'id-new' })
const usagePayload = () => json({
plan_type: 'pro',
rate_limit: { primary_window: { used_percent: 10, limit_window_seconds: 18_000 } },
})
type Handler = () => Response | Promise<Response>
function routes(handlers: { token: Handler; usage: Handler }): typeof fetch {
return (async (input: unknown) => {
const url = typeof input === 'string' ? input : String((input as { url?: string }).url ?? input)
return url.includes('auth.openai.com') ? handlers.token() : handlers.usage()
}) as unknown as typeof fetch
}
beforeEach(async () => {
root = await mkdtemp(path.join(tmpdir(), 'codeburn-app-codex-refresh-'))
authPath = path.join(root, 'auth.json')
})
afterEach(async () => {
vi.restoreAllMocks()
await rm(root, { recursive: true, force: true })
})
describe('Codex quota credential rotation', () => {
it('writes the rotated refresh token to auth.json and leaves no temp file behind', async () => {
await writeAuth(authDoc())
const result = await fetchCodexQuota({
authPath,
now: () => NOW,
fetch: routes({ token: rotatedGrant, usage: usagePayload }),
})
expect(result.quota.connection).toBe('connected')
const saved = await readAuth()
expect(saved.tokens.refresh_token).toBe(NEW_REFRESH)
expect(saved.tokens.access_token).toBe('access-new')
expect(saved.tokens.id_token).toBe('id-new')
expect(saved.tokens.account_id).toBe('account-1')
expect(saved.auth_mode).toBe('chatgpt')
expect(saved.last_refresh).toBe(new Date(NOW).toISOString())
expect(await readdir(root)).toEqual(['auth.json'])
})
it('keeps the rotated refresh token when the quota request fails after the refresh', async () => {
await writeAuth(authDoc())
const result = await fetchCodexQuota({
authPath,
now: () => NOW,
fetch: routes({ token: rotatedGrant, usage: () => json({ error: 'boom' }, 500) }),
})
expect(result.quota.connection).toBe('transientFailure')
expect((await readAuth()).tokens.refresh_token).toBe(NEW_REFRESH)
})
it('has the rotated refresh token on disk before it makes another request', async () => {
await writeAuth(authDoc())
// The renderer's poll is racing a per-provider timeout, and quit reaps the main process
// outright, so anything still awaited after the grant can simply be killed. Reading the
// file from inside the next request proves the write already completed.
let onDiskAtUsage: string | null = null
const result = await fetchCodexQuota({
authPath,
now: () => NOW,
fetch: routes({
token: rotatedGrant,
usage: () => {
onDiskAtUsage = readFileSync(authPath, 'utf8')
throw Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })
},
}),
})
expect(result.quota.connection).toBe('transientFailure')
expect(onDiskAtUsage).not.toBeNull()
expect(JSON.parse(onDiskAtUsage!).tokens.refresh_token).toBe(NEW_REFRESH)
expect((await readAuth()).tokens.refresh_token).toBe(NEW_REFRESH)
})
it('persists a rotated refresh token even when the grant returns no access token', async () => {
await writeAuth(authDoc())
await fetchCodexQuota({
authPath,
now: () => NOW,
fetch: routes({ token: () => json({ refresh_token: NEW_REFRESH }), usage: usagePayload }),
})
const saved = await readAuth()
expect(saved.tokens.refresh_token).toBe(NEW_REFRESH)
expect(saved.tokens.access_token).toBe('access-old')
})
it('falls back to the credential it holds when the merge re-read fails', async () => {
await writeAuth(authDoc())
await fetchCodexQuota({
authPath,
now: () => NOW,
readFileSync: () => { throw new Error('EBUSY: resource busy or locked') },
fetch: routes({ token: rotatedGrant, usage: usagePayload }),
})
expect((await readAuth()).tokens.refresh_token).toBe(NEW_REFRESH)
})
it('reports a credential write it could not complete instead of failing silently', async () => {
await writeAuth(authDoc())
const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
await fetchCodexQuota({
authPath,
now: () => NOW,
writeFileSync: () => { throw new Error('EROFS: read-only file system') },
fetch: routes({ token: rotatedGrant, usage: usagePayload }),
})
expect(errors).toHaveBeenCalledTimes(1)
const message = String(errors.mock.calls[0]?.[0])
expect(message).toContain(authPath)
expect(message).toContain('codex login')
expect(message).not.toContain(NEW_REFRESH)
})
it('discards the rotation rather than resurrecting a login that was removed', async () => {
await writeAuth(authDoc())
const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
await fetchCodexQuota({
authPath,
now: () => NOW,
// The Codex CLI signed the user out between our read and the grant.
readFileSync: () => null,
fetch: routes({ token: rotatedGrant, usage: usagePayload }),
})
expect((await readAuth()).tokens.refresh_token).toBe(OLD_REFRESH)
expect(errors).toHaveBeenCalledTimes(1)
})
it('sends the grant on its own timeout rather than the caller’s abort signal', async () => {
await writeAuth(authDoc())
const caller = new AbortController()
const signals: Array<AbortSignal | null | undefined> = []
await fetchCodexQuota({
authPath,
now: () => NOW,
signal: caller.signal,
fetch: (async (input: unknown, init?: RequestInit) => {
const url = typeof input === 'string' ? input : String(input)
signals.push(init?.signal)
if (url.includes('auth.openai.com')) {
// A per-provider timeout firing here would cancel a rotation nothing can undo.
caller.abort()
return rotatedGrant()
}
return usagePayload()
}) as unknown as typeof fetch,
})
// The grant request never carried the caller's signal, so aborting it did not reach it.
expect(signals[0]?.aborted).toBe(false)
// The usage request that follows does carry it, which is what the abort is for.
expect(signals[1]?.aborted).toBe(true)
expect((await readAuth()).tokens.refresh_token).toBe(NEW_REFRESH)
})
it('discards a rotation when the login on disk changes while the grant is in flight', async () => {
await writeAuth(authDoc())
const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
let grantStarted = (): void => {}
const reachedGrant = new Promise<void>(resolve => { grantStarted = resolve })
let releaseGrant = (): void => {}
const heldGrant = new Promise<void>(resolve => { releaseGrant = resolve })
const pending = fetchCodexQuota({
authPath,
now: () => NOW,
fetch: routes({
token: async () => { grantStarted(); await heldGrant; return rotatedGrant() },
usage: usagePayload,
}),
})
// The user ran `codex login` and switched accounts while our grant was open.
await reachedGrant
await writeAuth({
auth_mode: 'chatgpt',
tokens: {
access_token: 'access-other',
refresh_token: 'refresh-other-account',
id_token: 'id-other',
account_id: 'account-2',
},
last_refresh: new Date(NOW).toISOString(),
})
releaseGrant()
await pending
// The new account's document is left exactly as the CLI wrote it: our grant
// spent account-1's refresh token, so writing it back here would splice
// account-1's token onto account-2's identity.
const saved = await readAuth()
expect(saved.tokens.account_id).toBe('account-2')
expect(saved.tokens.refresh_token).toBe('refresh-other-account')
expect(saved.tokens.refresh_token).not.toBe(NEW_REFRESH)
expect(errors).toHaveBeenCalledTimes(1)
})
// The id_token subject is the last of the three lineage fields and the only one the earlier
// account-switch case does not reach, since that one differs on account_id first. Here the
// account_id and refresh_token are identical on both documents and only the JWT subject
// moves, so a discard proves the subject check itself fires.
it('discards the rotation when only the id_token subject has changed', async () => {
const jwtWithSubject = (sub: string): string =>
`h.${Buffer.from(JSON.stringify({ sub })).toString('base64url')}.s`
const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
await writeAuth({
auth_mode: 'chatgpt',
tokens: { access_token: 'access-old', refresh_token: OLD_REFRESH, id_token: jwtWithSubject('user-a'), account_id: 'account-1' },
last_refresh: new Date(NOW - 30 * 86_400_000).toISOString(),
})
let grantStarted = (): void => {}
const reachedGrant = new Promise<void>(resolve => { grantStarted = resolve })
let releaseGrant = (): void => {}
const heldGrant = new Promise<void>(resolve => { releaseGrant = resolve })
const pending = fetchCodexQuota({
authPath,
now: () => NOW,
fetch: routes({
token: async () => { grantStarted(); await heldGrant; return rotatedGrant() },
usage: usagePayload,
}),
})
// Same account_id and refresh_token, a different person behind the same slot.
await reachedGrant
await writeAuth({
auth_mode: 'chatgpt',
tokens: { access_token: 'access-b', refresh_token: OLD_REFRESH, id_token: jwtWithSubject('user-b'), account_id: 'account-1' },
last_refresh: new Date(NOW).toISOString(),
})
releaseGrant()
await pending
const saved = await readAuth()
expect(saved.tokens.id_token).toBe(jwtWithSubject('user-b'))
expect(saved.tokens.refresh_token).not.toBe(NEW_REFRESH)
expect(errors).toHaveBeenCalledTimes(1)
})
it('still writes the rotation when the login on disk is unchanged', async () => {
await writeAuth(authDoc())
let grantStarted = (): void => {}
const reachedGrant = new Promise<void>(resolve => { grantStarted = resolve })
let releaseGrant = (): void => {}
const heldGrant = new Promise<void>(resolve => { releaseGrant = resolve })
const pending = fetchCodexQuota({
authPath,
now: () => NOW,
fetch: routes({
token: async () => { grantStarted(); await heldGrant; return rotatedGrant() },
usage: usagePayload,
}),
})
// Same account, same refresh token: an unrelated rewrite is not a lineage change.
await reachedGrant
await writeAuth({ ...authDoc(), extra_field: 'harmless' })
releaseGrant()
await pending
expect((await readAuth()).tokens.refresh_token).toBe(NEW_REFRESH)
})
it('keeps the rotated refresh token when the reread file merely omits lineage keys', async () => {
await writeAuth(authDoc())
await fetchCodexQuota({
authPath,
now: () => NOW,
// A cleanly parsed ChatGPT document that simply lacks account_id and
// refresh_token is not proof of a different login.
readFileSync: () => JSON.stringify({ auth_mode: 'chatgpt', tokens: { access_token: 'access-old' } }),
fetch: routes({ token: rotatedGrant, usage: usagePayload }),
})
expect((await readAuth()).tokens.refresh_token).toBe(NEW_REFRESH)
})
// POSIX mode bits do not exist on Windows, where the ACL carries the privacy.
it.skipIf(process.platform === 'win32')('rewrites the credential with the permissions it already had', async () => {
for (const mode of [0o600, 0o400]) {
await rm(authPath, { force: true })
await writeAuth(authDoc(), mode)
const before = (await stat(authPath)).mode & 0o777
await fetchCodexQuota({
authPath,
now: () => NOW,
fetch: routes({ token: rotatedGrant, usage: usagePayload }),
})
expect((await readAuth()).tokens.refresh_token).toBe(NEW_REFRESH)
expect((await stat(authPath)).mode & 0o777).toBe(before)
expect(before & 0o077).toBe(0)
}
})
})