Files
ccs/tests/integration/codex-auth/legacy-fallback.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

111 lines
4.5 KiB
TypeScript

/**
* Integration tests: legacy fallback when no profiles are registered.
*
* Verifies that with no codex-auth profiles and no CCS_CODEX_PROFILE env set,
* resolveActiveProfile returns null — allowing codex to fall back to ~/.codex
* (legacy mode). This guarantees zero behaviour change for users who never
* run `ccsx auth create`.
*
* Cases:
* - Empty registry → resolveActiveProfile returns null (legacy mode)
* - Missing registry file → returns null (no registry = legacy mode)
* - CCS_CODEX_PROFILE set but registry missing → returns null + stderr warning
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
let tempDir: string;
let ccsHome: string;
const ORIG_CCS_HOME = process.env.CCS_HOME;
const ORIG_CCS_CODEX_PROFILE = process.env.CCS_CODEX_PROFILE;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-legacy-fallback-'));
ccsHome = path.join(tempDir, 'ccs');
fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true });
process.env.CCS_HOME = ccsHome;
delete process.env.CCS_CODEX_PROFILE;
});
afterEach(() => {
if (ORIG_CCS_HOME === undefined) delete process.env.CCS_HOME;
else process.env.CCS_HOME = ORIG_CCS_HOME;
if (ORIG_CCS_CODEX_PROFILE === undefined) delete process.env.CCS_CODEX_PROFILE;
else process.env.CCS_CODEX_PROFILE = ORIG_CCS_CODEX_PROFILE;
fs.rmSync(tempDir, { recursive: true, force: true });
});
// ─────────────────────────────────────────────────────────────────────────────
describe('legacy fallback — no registry file', () => {
it('returns null when registry file does not exist (CODEX_HOME stays unset)', async () => {
// CCS_HOME points to empty temp dir — no codex-profiles.yaml created
const registryPath = path.join(ccsHome, '.ccs', 'codex-profiles.yaml');
expect(fs.existsSync(registryPath)).toBe(false);
const { resolveActiveProfile } = await import('../../../src/codex-auth/resolve-active-profile');
const result = resolveActiveProfile({});
expect(result).toBeNull();
// When null: caller (codex-runtime.ts) leaves CODEX_HOME unset → Codex uses ~/.codex
});
});
describe('legacy fallback — empty registry', () => {
it('returns null when registry exists but has no profiles and no default', async () => {
const { CodexProfileRegistry } = await import('../../../src/codex-auth/codex-profile-registry');
// Touch registry by constructing (which cleans orphan tmps but doesn't write)
// Write an empty registry manually
const registryPath = path.join(ccsHome, '.ccs', 'codex-profiles.yaml');
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
fs.writeFileSync(registryPath, 'version: "1.0"\ndefault: null\nprofiles: {}\n', {
mode: 0o600,
});
const { resolveActiveProfile } = await import('../../../src/codex-auth/resolve-active-profile');
const result = resolveActiveProfile({});
expect(result).toBeNull();
// Registry exists but no profiles → legacy mode
void new CodexProfileRegistry(); // verify registry reads cleanly
});
});
describe('legacy fallback — CCS_CODEX_PROFILE set but no matching profile', () => {
it('returns null and emits warning when env points to non-existent profile', async () => {
// Create registry with no profiles
const registryPath = path.join(ccsHome, '.ccs', 'codex-profiles.yaml');
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
fs.writeFileSync(registryPath, 'version: "1.0"\ndefault: null\nprofiles: {}\n', {
mode: 0o600,
});
const stderrLines: string[] = [];
const origWrite = process.stderr.write.bind(process.stderr);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
process.stderr.write = (chunk: any): boolean => {
stderrLines.push(String(chunk));
return true;
};
let result;
try {
const { resolveActiveProfile } = await import(
'../../../src/codex-auth/resolve-active-profile'
);
result = resolveActiveProfile({ CCS_CODEX_PROFILE: 'ghost-profile' });
} finally {
process.stderr.write = origWrite;
}
// Should fall back to null (not throw)
expect(result).toBeNull();
// Warning emitted to stderr about missing profile
const allStderr = stderrLines.join('');
expect(allStderr).toContain('ghost-profile');
});
});