Files
ccs/tests/integration/codex-auth/import-default.integration.test.ts
T
Tam Nhu Tran 631c799322 feat(codex-auth): add import-default migration + integration tests + docs
Adds opt-in `ccsx auth import-default <name>` to migrate the existing
~/.codex/auth.json into a new profile, plus the cross-system integration
tests and user-facing documentation.

- import-default-command (C3 torn-write protection):
  - readFileSync + JSON.parse with 3x retry / 100ms backoff to survive
    Codex's truncate-then-write auth.json refresh race
  - decode-id-token sanity-check on JWT shape (catches mid-write JWT
    corruption that JSON.parse alone wouldn't notice)
  - pgrep -f codex best-effort detection; warns + refuses without
    --force-while-running flag if a live codex process is found
  - rejects cliproxy-format auth files ({type: "codex", ...} wrapper)
    with a clear "use ccs cliproxy ..." pointer
  - atomic write to <dest>.tmp.<pid>.<rand> + rename
  - --with-history defaults to false per D8 (auth-only is the safer
    default; opt in for bulkier data)
  - --force backs up existing auth.json to .bak-<ts> before overwrite
  - non-destructive — never modifies ~/.codex/; legacy mode keeps
    working without ever running this command
- integration tests:
  - two-terminal-isolation: two profiles with separate CODEX_HOMEs
    write to their own auth.json/history.jsonl with no crosstalk
  - ccsxp-independence: codex-auth profile set; ccsxp still uses its
    own CCSXP_CODEX_HOME / ~/.codex pool (H5 stderr notice present)
  - legacy-fallback: no profiles registered → codex-runtime-router
    leaves CODEX_HOME unset → codex falls back to ~/.codex
  - import-default.integration: real fs copy + decode + register
- docs/codex-auth.md: user guide covering quick start, two-terminal
  example, migration, dashboard, and caveats (cmd.exe, Windows
  symlinks, ccsx vs ccsxp distinction)

155 codex-auth-scope tests green (45 Phase 1 + 57 Phase 2 + 19 Phase 3
+ 15 Phase 4 + 19 Phase 5). Full suite 3051/3082 — the 1 failure is a
pre-existing test-pollution issue between ccsxp-runtime.test.ts and
codex-runtime-integration.test.ts that exists on dev today; the test
passes in isolation.
2026-05-17 14:46:16 -04:00

223 lines
7.7 KiB
TypeScript

/**
* Integration tests for `ccsx auth import-default`.
*
* Uses real filesystem rooted at temp dirs. Sets LEGACY_CODEX_HOME env for
* test hermeticity. Verifies the full import pipeline end-to-end.
*
* Cases:
* - End-to-end import: registry entry + symlink + atomic write (no tmp leftovers)
* - Decoded email present in registry after import
* - --with-history copies history.jsonl + sessions/
* - Re-run without --force refuses; re-run with --force succeeds + backup created
*/
import { afterEach, beforeEach, describe, expect, it, spyOn, mock } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as childProcess from 'child_process';
// Build a minimal valid JWT for test fixtures
function makeJwt(payload: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
return `${header}.${body}.fakesig`;
}
const TEST_EMAIL = 'integration@example.com';
const TEST_JWT = makeJwt({
email: TEST_EMAIL,
'https://api.openai.com/auth': {
chatgpt_plan_type: 'plus',
chatgpt_account_id: 'acct-integration-001',
},
});
const TEST_AUTH_JSON = JSON.stringify({ tokens: { id_token: TEST_JWT } }, null, 2);
let tempDir: string;
let ccsHome: string;
let legacyCodexHome: string;
const ORIG_CCS_HOME = process.env.CCS_HOME;
const ORIG_LEGACY = process.env.LEGACY_CODEX_HOME;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-import-integ-'));
ccsHome = path.join(tempDir, 'ccs');
legacyCodexHome = path.join(tempDir, 'legacy-codex');
fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true });
fs.mkdirSync(legacyCodexHome, { recursive: true });
process.env.CCS_HOME = ccsHome;
process.env.LEGACY_CODEX_HOME = legacyCodexHome;
// Prevent pgrep from finding the test runner itself
spyOn(childProcess, 'spawnSync').mockReturnValue({
status: 1,
stdout: '',
stderr: '',
pid: 0,
output: [],
signal: null,
error: undefined,
});
});
afterEach(() => {
if (ORIG_CCS_HOME === undefined) delete process.env.CCS_HOME;
else process.env.CCS_HOME = ORIG_CCS_HOME;
if (ORIG_LEGACY === undefined) delete process.env.LEGACY_CODEX_HOME;
else process.env.LEGACY_CODEX_HOME = ORIG_LEGACY;
fs.rmSync(tempDir, { recursive: true, force: true });
mock.restore();
});
async function makeCtx() {
const { CodexProfileRegistry } = await import('../../../src/codex-auth/codex-profile-registry');
return { registry: new CodexProfileRegistry(), version: '0.0.0-test' };
}
function silence(): () => void {
const origLog = console.log;
const origErr = process.stderr.write.bind(process.stderr);
console.log = () => {};
process.stderr.write = () => true;
return () => {
console.log = origLog;
process.stderr.write = origErr;
};
}
// ─────────────────────────────────────────────────────────────────────────────
describe('import-default integration — end-to-end happy path', () => {
it('creates registry entry with decoded email and symlink after import', async () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), TEST_AUTH_JSON);
const { handleImportDefaultCodex } = await import(
'../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
const restore = silence();
try {
await handleImportDefaultCodex(ctx, ['integ-profile']);
} finally {
restore();
}
// Registry entry created with email
expect(ctx.registry.hasProfile('integ-profile')).toBe(true);
const meta = ctx.registry.getProfile('integ-profile');
expect(meta.email).toBe(TEST_EMAIL);
expect(meta.plan_type).toBe('plus');
// auth.json written to profile dir
const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'integ-profile');
const destAuth = path.join(profileDir, 'auth.json');
expect(fs.existsSync(destAuth)).toBe(true);
// config.toml is a symlink (or was attempted — skip check on no-symlink platforms)
const configLink = path.join(profileDir, 'config.toml');
if (fs.existsSync(configLink)) {
const stat = fs.lstatSync(configLink);
expect(stat.isSymbolicLink()).toBe(true);
}
// No tmp leftovers in profile dir (atomic write cleanup)
const files = fs.readdirSync(profileDir);
const tmpFiles = files.filter((f) => f.includes('.tmp.'));
expect(tmpFiles.length).toBe(0);
});
});
describe('import-default integration — with-history flag', () => {
it('copies history.jsonl and sessions/ when --with-history is passed', async () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), TEST_AUTH_JSON);
fs.writeFileSync(
path.join(legacyCodexHome, 'history.jsonl'),
'{"prompt":"hello","response":"world"}\n'
);
const sessDir = path.join(legacyCodexHome, 'sessions');
fs.mkdirSync(sessDir, { recursive: true });
fs.writeFileSync(path.join(sessDir, 'sess-001.json'), JSON.stringify({ id: 'sess-001' }));
fs.writeFileSync(path.join(sessDir, 'sess-002.json'), JSON.stringify({ id: 'sess-002' }));
const { handleImportDefaultCodex } = await import(
'../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
const restore = silence();
try {
await handleImportDefaultCodex(ctx, ['with-hist', '--with-history']);
} finally {
restore();
}
const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'with-hist');
expect(fs.existsSync(path.join(profileDir, 'history.jsonl'))).toBe(true);
expect(fs.existsSync(path.join(profileDir, 'sessions', 'sess-001.json'))).toBe(true);
expect(fs.existsSync(path.join(profileDir, 'sessions', 'sess-002.json'))).toBe(true);
});
});
describe('import-default integration — force re-import', () => {
it('refuses re-import without --force; succeeds with --force + creates backup', async () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), TEST_AUTH_JSON);
const { handleImportDefaultCodex } = await import(
'../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
// First import — should succeed
const r1 = silence();
try {
await handleImportDefaultCodex(ctx, ['force-test']);
} finally {
r1();
}
expect(ctx.registry.hasProfile('force-test')).toBe(true);
// Re-import without --force — should refuse
let exitCalled = false;
const origExit = process.exit;
process.exit = () => {
exitCalled = true;
throw new Error('exit');
};
const r2 = silence();
try {
await handleImportDefaultCodex(ctx, ['force-test']);
} catch {
/* expected */
} finally {
r2();
process.exit = origExit;
}
expect(exitCalled).toBe(true);
// Re-import with --force — should overwrite + backup
const newJwt = makeJwt({ email: 'updated@example.com' });
fs.writeFileSync(
path.join(legacyCodexHome, 'auth.json'),
JSON.stringify({ tokens: { id_token: newJwt } })
);
const r3 = silence();
try {
await handleImportDefaultCodex(ctx, ['force-test', '--force']);
} finally {
r3();
}
// Registry updated with new email
const meta = ctx.registry.getProfile('force-test');
expect(meta.email).toBe('updated@example.com');
// Backup file created
const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'force-test');
const files = fs.readdirSync(profileDir);
const bakFile = files.find((f) => f.startsWith('auth.json.bak-'));
expect(bakFile).toBeDefined();
});
});