Files
ccs/tests/unit/codex-auth/commands/remove-command.test.ts
T

273 lines
9.1 KiB
TypeScript

/**
* Tests for codex-auth remove command.
*/
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } 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(), 'codex-remove-test-'));
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 });
mock.restore();
});
async function makeCtx(...names: string[]) {
const { CodexProfileRegistry } = await import(
'../../../../src/codex-auth/codex-profile-registry'
);
const reg = new CodexProfileRegistry();
for (const n of names) {
reg.createProfile(n, { created: new Date().toISOString(), last_used: null });
// Create the profile dir too
const dir = path.join(ccsHome, '.ccs', 'codex-instances', n);
fs.mkdirSync(dir, { recursive: true });
}
return { registry: reg, version: '0.0.0-test' };
}
function mockConfirmYes() {
return import('../../../../src/utils/prompt').then((mod) => {
spyOn(mod.InteractivePrompt, 'confirm').mockResolvedValue(true);
});
}
function mockConfirmNo() {
return import('../../../../src/utils/prompt').then((mod) => {
spyOn(mod.InteractivePrompt, 'confirm').mockResolvedValue(false);
});
}
// ── non-default removes cleanly ───────────────────────────────────────────────
describe('handleRemoveCodex — normal removal', () => {
it('removes a non-default profile cleanly', async () => {
await mockConfirmYes();
const { handleRemoveCodex } = await import(
'../../../../src/codex-auth/commands/remove-command'
);
const ctx = await makeCtx('alpha', 'beta');
ctx.registry.setDefault('alpha');
const out: string[] = [];
const origLog = console.log;
console.log = (...a: unknown[]) => out.push(a.join(' '));
try {
await handleRemoveCodex(ctx, ['beta', '--yes']);
} finally {
console.log = origLog;
}
expect(ctx.registry.hasProfile('beta')).toBe(false);
expect(out.some((l) => l.includes('removed'))).toBe(true);
});
});
// ── default with others → refuses without --force ────────────────────────────
describe('handleRemoveCodex — default guard', () => {
it('refuses to remove default when others exist without --force', async () => {
const { handleRemoveCodex } = await import(
'../../../../src/codex-auth/commands/remove-command'
);
const ctx = await makeCtx('alpha', 'beta');
ctx.registry.setDefault('alpha');
let exitCode = -1;
const origExit = process.exit;
process.exit = (code?: number) => {
exitCode = code ?? 0;
throw new Error('exit');
};
const out: string[] = [];
const origLog = console.log;
console.log = (...a: unknown[]) => out.push(a.join(' '));
try {
await handleRemoveCodex(ctx, ['alpha']);
} catch {
/* process.exit */
} finally {
process.exit = origExit;
console.log = origLog;
}
expect(exitCode).toBeGreaterThan(0);
expect(ctx.registry.hasProfile('alpha')).toBe(true); // not removed
// Hint lines still go to stdout; user-facing error now goes to stderr via exitWithError
expect(out.some((l) => l.includes('ccsx auth switch'))).toBe(true);
});
it('allows removal of default with --force', async () => {
await mockConfirmYes();
const { handleRemoveCodex } = await import(
'../../../../src/codex-auth/commands/remove-command'
);
const ctx = await makeCtx('alpha', 'beta');
ctx.registry.setDefault('alpha');
await handleRemoveCodex(ctx, ['alpha', '--force', '--yes']);
expect(ctx.registry.hasProfile('alpha')).toBe(false);
});
});
// ── only profile → allows removal ────────────────────────────────────────────
describe('handleRemoveCodex — only profile', () => {
it('allows removal of the only profile (even if default)', async () => {
await mockConfirmYes();
const { handleRemoveCodex } = await import(
'../../../../src/codex-auth/commands/remove-command'
);
const ctx = await makeCtx('solo');
ctx.registry.setDefault('solo');
await handleRemoveCodex(ctx, ['solo', '--yes']);
expect(ctx.registry.hasProfile('solo')).toBe(false);
});
});
// ── confirmation prompt ───────────────────────────────────────────────────────
describe('handleRemoveCodex — confirmation', () => {
it('cancels when user declines confirmation', async () => {
await mockConfirmNo();
const { handleRemoveCodex } = await import(
'../../../../src/codex-auth/commands/remove-command'
);
const ctx = await makeCtx('keepme');
const out: string[] = [];
const origLog = console.log;
console.log = (...a: unknown[]) => out.push(a.join(' '));
try {
await handleRemoveCodex(ctx, ['keepme']); // no --yes
} finally {
console.log = origLog;
}
expect(ctx.registry.hasProfile('keepme')).toBe(true); // not removed
expect(out.some((l) => l.includes('Cancelled'))).toBe(true);
});
it('--yes skips prompt entirely', async () => {
// No mock — if prompt were called it would hang/throw in test
const promptMod = await import('../../../../src/utils/prompt');
let promptCalled = false;
spyOn(promptMod.InteractivePrompt, 'confirm').mockImplementation(async () => {
promptCalled = true;
return true;
});
const { handleRemoveCodex } = await import(
'../../../../src/codex-auth/commands/remove-command'
);
const ctx = await makeCtx('skipconfirm');
const origLog = console.log;
console.log = () => {};
try {
await handleRemoveCodex(ctx, ['skipconfirm', '--yes']);
} finally {
console.log = origLog;
}
expect(promptCalled).toBe(false);
expect(ctx.registry.hasProfile('skipconfirm')).toBe(false);
});
it('preserves profile data when registry removal fails', async () => {
const { handleRemoveCodex } = await import(
'../../../../src/codex-auth/commands/remove-command'
);
const ctx = await makeCtx('preserveme');
const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'preserveme');
const authJsonPath = path.join(profileDir, 'auth.json');
fs.writeFileSync(authJsonPath, JSON.stringify({ tokens: { id_token: 'h.e30K.s' } }));
spyOn(ctx.registry, 'removeProfile').mockImplementation(() => {
throw new Error('registry write denied');
});
let exitCode = -1;
const origExit = process.exit;
const origErr = console.error;
process.exit = (code?: number) => {
exitCode = code ?? 0;
throw new Error('exit');
};
console.error = () => {};
try {
await handleRemoveCodex(ctx, ['preserveme', '--yes']);
} catch {
/* process.exit */
} finally {
process.exit = origExit;
console.error = origErr;
}
expect(exitCode).toBeGreaterThan(0);
expect(fs.existsSync(authJsonPath)).toBe(true);
expect(ctx.registry.hasProfile('preserveme')).toBe(true);
});
it('restores profile data and registry when final deletion fails', async () => {
const { handleRemoveCodex } = await import(
'../../../../src/codex-auth/commands/remove-command'
);
const ctx = await makeCtx('restoreme');
ctx.registry.setDefault('restoreme');
const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'restoreme');
const authJsonPath = path.join(profileDir, 'auth.json');
fs.writeFileSync(authJsonPath, JSON.stringify({ tokens: { id_token: 'h.e30K.s' } }));
const realRmSync = fs.rmSync;
spyOn(fs, 'rmSync').mockImplementation((target, options) => {
if (typeof target === 'string' && target.includes('.deleting.')) {
throw new Error('delete denied');
}
return realRmSync(target, options);
});
let exitCode = -1;
const origExit = process.exit;
const origErr = console.error;
process.exit = (code?: number) => {
exitCode = code ?? 0;
throw new Error('exit');
};
console.error = () => {};
try {
await handleRemoveCodex(ctx, ['restoreme', '--yes']);
} catch {
/* process.exit */
} finally {
process.exit = origExit;
console.error = origErr;
}
expect(exitCode).toBeGreaterThan(0);
expect(fs.existsSync(authJsonPath)).toBe(true);
expect(ctx.registry.hasProfile('restoreme')).toBe(true);
expect(ctx.registry.getDefault()).toBe('restoreme');
});
});