mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 20:20:09 +00:00
181 lines
7.3 KiB
TypeScript
181 lines
7.3 KiB
TypeScript
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 ensureSharedConfigSymlink: (profileDir: string, sharedConfigPath?: string) => void;
|
|
|
|
let tempDir: string;
|
|
let profileDir: string;
|
|
let sharedConfigPath: string;
|
|
|
|
beforeEach(async () => {
|
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-symlink-test-'));
|
|
profileDir = path.join(tempDir, 'profile');
|
|
// Use a temp path for the shared config so tests never touch real ~/.codex/config.toml
|
|
sharedConfigPath = path.join(tempDir, 'shared-config.toml');
|
|
const mod = await import('../../../src/codex-auth/codex-config-symlink');
|
|
ensureSharedConfigSymlink = mod.ensureSharedConfigSymlink;
|
|
});
|
|
|
|
afterEach(() => {
|
|
mock.restore();
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('ensureSharedConfigSymlink', () => {
|
|
it('creates empty shared config and symlink when neither exists', () => {
|
|
// Neither profileDir nor sharedConfigPath exist yet
|
|
ensureSharedConfigSymlink(profileDir, sharedConfigPath);
|
|
|
|
expect(fs.existsSync(sharedConfigPath)).toBe(true);
|
|
expect(fs.readFileSync(sharedConfigPath, 'utf8')).toBe('');
|
|
|
|
const linkPath = path.join(profileDir, 'config.toml');
|
|
const stat = fs.lstatSync(linkPath);
|
|
expect(stat.isSymbolicLink()).toBe(true);
|
|
expect(fs.readlinkSync(linkPath)).toBe(sharedConfigPath);
|
|
});
|
|
|
|
it('is idempotent when symlink already points to correct target', () => {
|
|
ensureSharedConfigSymlink(profileDir, sharedConfigPath);
|
|
// Call again — should not throw
|
|
expect(() => ensureSharedConfigSymlink(profileDir, sharedConfigPath)).not.toThrow();
|
|
|
|
const linkPath = path.join(profileDir, 'config.toml');
|
|
expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true);
|
|
expect(fs.readlinkSync(linkPath)).toBe(sharedConfigPath);
|
|
});
|
|
|
|
it('preserves existing shared config content (does not overwrite)', () => {
|
|
fs.writeFileSync(sharedConfigPath, '[model]\nname = "o4"', { mode: 0o600 });
|
|
ensureSharedConfigSymlink(profileDir, sharedConfigPath);
|
|
expect(fs.readFileSync(sharedConfigPath, 'utf8')).toBe('[model]\nname = "o4"');
|
|
});
|
|
|
|
it('replaces a stale symlink pointing to a wrong target with correct one', () => {
|
|
fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 });
|
|
const wrongTarget = path.join(tempDir, 'wrong.toml');
|
|
fs.writeFileSync(wrongTarget, '', { mode: 0o600 });
|
|
const linkPath = path.join(profileDir, 'config.toml');
|
|
fs.symlinkSync(wrongTarget, linkPath);
|
|
|
|
ensureSharedConfigSymlink(profileDir, sharedConfigPath);
|
|
|
|
expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true);
|
|
expect(fs.readlinkSync(linkPath)).toBe(sharedConfigPath);
|
|
});
|
|
|
|
it('preserves an edited regular file at link path by default', () => {
|
|
fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 });
|
|
const linkPath = path.join(profileDir, 'config.toml');
|
|
fs.writeFileSync(sharedConfigPath, '[shared]\ndata = true', { mode: 0o600 });
|
|
fs.writeFileSync(linkPath, '[existing]\ndata = true', { mode: 0o600 });
|
|
|
|
// Capture stderr to verify warning was written
|
|
const stderrChunks: string[] = [];
|
|
const origWrite = process.stderr.write.bind(process.stderr);
|
|
process.stderr.write = (chunk: string | Uint8Array): boolean => {
|
|
stderrChunks.push(typeof chunk === 'string' ? chunk : chunk.toString());
|
|
return origWrite(chunk);
|
|
};
|
|
|
|
try {
|
|
ensureSharedConfigSymlink(profileDir, sharedConfigPath);
|
|
} finally {
|
|
process.stderr.write = origWrite;
|
|
}
|
|
|
|
expect(fs.lstatSync(linkPath).isFile()).toBe(true);
|
|
expect(fs.readFileSync(linkPath, 'utf8')).toBe('[existing]\ndata = true');
|
|
// A warning should have been emitted
|
|
expect(stderrChunks.join('')).toMatch(/preserving existing regular config/i);
|
|
});
|
|
|
|
it('replaces a regular file when explicit overwrite repair is requested', () => {
|
|
fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 });
|
|
const linkPath = path.join(profileDir, 'config.toml');
|
|
fs.writeFileSync(linkPath, '[existing]\ndata = true', { mode: 0o600 });
|
|
|
|
ensureSharedConfigSymlink(profileDir, sharedConfigPath, { overwriteRegularFile: true });
|
|
|
|
expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true);
|
|
expect(fs.readlinkSync(linkPath)).toBe(sharedConfigPath);
|
|
});
|
|
|
|
it('replaces a broken symlink (dangling) with correct symlink', () => {
|
|
fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 });
|
|
const linkPath = path.join(profileDir, 'config.toml');
|
|
// Create symlink to non-existent target
|
|
fs.symlinkSync(path.join(tempDir, 'does-not-exist.toml'), linkPath);
|
|
// Verify it's broken
|
|
expect(fs.existsSync(linkPath)).toBe(false);
|
|
expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true);
|
|
|
|
ensureSharedConfigSymlink(profileDir, sharedConfigPath);
|
|
|
|
expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true);
|
|
expect(fs.readlinkSync(linkPath)).toBe(sharedConfigPath);
|
|
});
|
|
|
|
it('copies shared config when symlink creation fails', () => {
|
|
fs.writeFileSync(sharedConfigPath, 'model = "gpt-5.5"\n', { mode: 0o600 });
|
|
const linkPath = path.join(profileDir, 'config.toml');
|
|
const symlinkSpy = spyOn(fs, 'symlinkSync').mockImplementation(() => {
|
|
throw Object.assign(new Error('simulated symlink failure'), { code: 'EPERM' });
|
|
});
|
|
const stderrChunks: string[] = [];
|
|
const origWrite = process.stderr.write.bind(process.stderr);
|
|
process.stderr.write = (chunk: string | Uint8Array): boolean => {
|
|
stderrChunks.push(typeof chunk === 'string' ? chunk : chunk.toString());
|
|
return true;
|
|
};
|
|
|
|
try {
|
|
ensureSharedConfigSymlink(profileDir, sharedConfigPath);
|
|
} finally {
|
|
process.stderr.write = origWrite;
|
|
symlinkSpy.mockRestore();
|
|
}
|
|
|
|
expect(fs.lstatSync(linkPath).isFile()).toBe(true);
|
|
expect(fs.readFileSync(linkPath, 'utf8')).toBe('model = "gpt-5.5"\n');
|
|
expect(stderrChunks.join('')).toContain('symlink unavailable');
|
|
});
|
|
|
|
|
|
it('rethrows symlink errors that are not fallback-safe', () => {
|
|
fs.writeFileSync(sharedConfigPath, 'model = "gpt-5.5"\n', { mode: 0o600 });
|
|
const symlinkSpy = spyOn(fs, 'symlinkSync').mockImplementation(() => {
|
|
throw Object.assign(new Error('simulated race'), { code: 'EEXIST' });
|
|
});
|
|
|
|
try {
|
|
expect(() => ensureSharedConfigSymlink(profileDir, sharedConfigPath)).toThrow(/simulated race/);
|
|
} finally {
|
|
symlinkSpy.mockRestore();
|
|
}
|
|
});
|
|
it('preserves edited fallback copies on later repair attempts', () => {
|
|
fs.writeFileSync(sharedConfigPath, 'model = "gpt-5.5"\n', { mode: 0o600 });
|
|
const linkPath = path.join(profileDir, 'config.toml');
|
|
const symlinkSpy = spyOn(fs, 'symlinkSync').mockImplementation(() => {
|
|
throw Object.assign(new Error('simulated symlink failure'), { code: 'EPERM' });
|
|
});
|
|
const origWrite = process.stderr.write.bind(process.stderr);
|
|
process.stderr.write = () => true;
|
|
|
|
try {
|
|
ensureSharedConfigSymlink(profileDir, sharedConfigPath);
|
|
fs.writeFileSync(linkPath, 'model = "local-edit"\n', { mode: 0o600 });
|
|
ensureSharedConfigSymlink(profileDir, sharedConfigPath);
|
|
} finally {
|
|
process.stderr.write = origWrite;
|
|
symlinkSpy.mockRestore();
|
|
}
|
|
|
|
expect(fs.lstatSync(linkPath).isFile()).toBe(true);
|
|
expect(fs.readFileSync(linkPath, 'utf8')).toBe('model = "local-edit"\n');
|
|
});
|
|
});
|