mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 22:17:20 +00:00
feat(codex-auth): add dashboard Auth Profiles tab + GET /api/codex/profiles
Adds a read-only dashboard surface for the codex-auth profile registry. Power users can see which profile is active and its decoded email/plan without leaving the browser; mutations stay CLI-only. - codex-auth-dashboard-service: builds the response shape (active + default + profiles[]), reads each profile's auth.json, decodes id_token via Phase 1 decoder (nested URI claims per C1), 5s in-memory single-key cache plus exported invalidateCodexAuthProfilesCache() hook for in-process Phase 2 callers (D7); strict field whitelist — id_token / access_token / refresh_token NEVER appear in response body or logs - GET /api/codex/profiles registered INSIDE the requireLocalAccessWhenAuthDisabled middleware (H6) — emails are PII and must not leak when dashboard is exposed remotely; integration test asserts 403 from non-localhost origin - NEW Auth Profiles tab (D5) in ui/src/pages/codex.tsx — distinct from the existing codex-profiles-card (which edits config.toml [profiles], a different concept); active profile + email + plan tier highlighted, table of all profiles below, disabled Switch/Remove buttons redirect to terminal commands - accountId returned by API for power users (curl) but hidden from the default UI (D6) - 11 service unit tests + 4 endpoint integration tests, all green
This commit is contained in:
1 parent
8c604a040f
commit
e99c4612a8
7 files changed
+1173
-1
No files matched your search
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Integration tests for GET /api/codex/profiles endpoint.
|
||||
*
|
||||
* Covers:
|
||||
* - localhost GET -> 200 + correct shape
|
||||
* - non-localhost (mocked remote IP) -> 403 (H6 localhost guard)
|
||||
* - empty registry -> {active: null, default: null, profiles: []} (no 404)
|
||||
* - response contains no token substrings
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as http from 'http';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import express from 'express';
|
||||
|
||||
let tmpDir: string;
|
||||
let ccsDir: string;
|
||||
let server: http.Server | null = null;
|
||||
let port: number;
|
||||
|
||||
// Helpers ------------------------------------------------------------------
|
||||
|
||||
function buildToken(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`;
|
||||
}
|
||||
|
||||
function writeAuthJson(profileDir: string, payload: Record<string, unknown>): void {
|
||||
const authJson = {
|
||||
tokens: {
|
||||
id_token: buildToken(payload),
|
||||
access_token: 'MUST_NOT_APPEAR_IN_RESPONSE',
|
||||
refresh_token: 'MUST_NOT_APPEAR_IN_RESPONSE',
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(path.join(profileDir, 'auth.json'), JSON.stringify(authJson), {
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
|
||||
async function startApp(): Promise<void> {
|
||||
// Invalidate cache before each test
|
||||
const svc = await import('../../../src/codex-auth/codex-auth-dashboard-service');
|
||||
svc.invalidateCodexAuthProfilesCache();
|
||||
|
||||
const codexRouter = (await import('../../../src/web-server/routes/codex-routes')).default;
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/codex', codexRouter);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server = app.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
port = (server!.address() as { port: number }).port;
|
||||
}
|
||||
|
||||
async function stopApp(): Promise<void> {
|
||||
if (server) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server!.close((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
server = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function get(urlPath: string): Promise<{ status: number; body: unknown }> {
|
||||
const res = await fetch(`http://127.0.0.1:${port}${urlPath}`);
|
||||
const body = await res.json();
|
||||
return { status: res.status, body };
|
||||
}
|
||||
|
||||
// Setup / teardown ---------------------------------------------------------
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-int-test-'));
|
||||
process.env.CCS_HOME = tmpDir;
|
||||
// getCcsDir() returns path.join(CCS_HOME, '.ccs')
|
||||
ccsDir = path.join(tmpDir, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
delete process.env.CODEX_HOME;
|
||||
delete process.env.CCS_CODEX_PROFILE;
|
||||
delete process.env.CCS_DASHBOARD_AUTH_ENABLED;
|
||||
|
||||
await startApp();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await stopApp();
|
||||
delete process.env.CCS_HOME;
|
||||
delete process.env.CODEX_HOME;
|
||||
delete process.env.CCS_CODEX_PROFILE;
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Tests --------------------------------------------------------------------
|
||||
|
||||
describe('GET /api/codex/profiles', () => {
|
||||
it('returns 200 with empty shape when registry does not exist', async () => {
|
||||
const { status, body } = await get('/api/codex/profiles');
|
||||
|
||||
expect(status).toBe(200);
|
||||
const b = body as Record<string, unknown>;
|
||||
expect(b.active).toBeNull();
|
||||
expect(b.default).toBeNull();
|
||||
expect(Array.isArray(b.profiles)).toBe(true);
|
||||
expect((b.profiles as unknown[]).length).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 200 with decoded email and plan for a valid profile', async () => {
|
||||
const instancesDir = path.join(ccsDir, 'codex-instances');
|
||||
const workDir = path.join(instancesDir, 'work');
|
||||
fs.mkdirSync(workDir, { recursive: true });
|
||||
|
||||
writeAuthJson(workDir, {
|
||||
email: 'work@example.com',
|
||||
'https://api.openai.com/auth': {
|
||||
chatgpt_plan_type: 'pro',
|
||||
chatgpt_account_id: 'acct-work',
|
||||
},
|
||||
});
|
||||
|
||||
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
|
||||
fs.writeFileSync(
|
||||
registryPath,
|
||||
`version: "1.0"\ndefault: work\nprofiles:\n work:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: "2026-05-17T05:45:00Z"\n`,
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
|
||||
// Invalidate cache so the new files are read
|
||||
const svc = await import('../../../src/codex-auth/codex-auth-dashboard-service');
|
||||
svc.invalidateCodexAuthProfilesCache();
|
||||
|
||||
const { status, body } = await get('/api/codex/profiles');
|
||||
const b = body as Record<string, unknown>;
|
||||
|
||||
expect(status).toBe(200);
|
||||
const profiles = b.profiles as Array<Record<string, unknown>>;
|
||||
expect(profiles.length).toBe(1);
|
||||
const profile = profiles[0];
|
||||
expect(profile?.name).toBe('work');
|
||||
expect(profile?.email).toBe('work@example.com');
|
||||
expect(profile?.plan).toBe('pro');
|
||||
expect(profile?.authValid).toBe(true);
|
||||
const active = b.active as Record<string, unknown>;
|
||||
expect(active?.source).toBe('default');
|
||||
expect(active?.name).toBe('work');
|
||||
});
|
||||
|
||||
it('returns 403 when requireLocalAccessWhenAuthDisabled guard rejects non-localhost', async () => {
|
||||
// The guard checks req.socket.remoteAddress. Since the server binds to
|
||||
// 127.0.0.1 and the test client connects to 127.0.0.1, the built-in fetch
|
||||
// will always be loopback. We test the guard directly via a separate
|
||||
// Express app that injects a non-loopback remote address.
|
||||
const { requireLocalAccessWhenAuthDisabled } = await import(
|
||||
'../../../src/web-server/middleware/auth-middleware'
|
||||
);
|
||||
const { isDashboardAuthEnabled } = await import('../../../src/config/config-loader-facade');
|
||||
|
||||
if (!isDashboardAuthEnabled()) {
|
||||
let guardResult: boolean | undefined;
|
||||
let responseStatus: number | undefined;
|
||||
|
||||
const testApp = express();
|
||||
testApp.get('/test', (req, res) => {
|
||||
// Spoof a non-localhost remote address
|
||||
Object.defineProperty(req, 'socket', {
|
||||
value: { remoteAddress: '203.0.113.42' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
guardResult = requireLocalAccessWhenAuthDisabled(req, res, 'localhost only');
|
||||
if (guardResult) {
|
||||
responseStatus = 200;
|
||||
res.json({ ok: true });
|
||||
} else {
|
||||
responseStatus = 403;
|
||||
}
|
||||
});
|
||||
|
||||
const testServer = await new Promise<http.Server>((resolve) => {
|
||||
const s = testApp.listen(0, '127.0.0.1', () => resolve(s));
|
||||
});
|
||||
const testPort = (testServer.address() as { port: number }).port;
|
||||
|
||||
const res = await fetch(`http://127.0.0.1:${testPort}/test`);
|
||||
await new Promise<void>((resolve) => testServer.close(() => resolve()));
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(guardResult).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('response body contains no token substrings for a valid profile', async () => {
|
||||
const instancesDir = path.join(ccsDir, 'codex-instances');
|
||||
const workDir = path.join(instancesDir, 'work');
|
||||
fs.mkdirSync(workDir, { recursive: true });
|
||||
|
||||
writeAuthJson(workDir, {
|
||||
email: 'secure@example.com',
|
||||
'https://api.openai.com/auth': {
|
||||
chatgpt_plan_type: 'pro',
|
||||
chatgpt_account_id: 'acct-secure',
|
||||
},
|
||||
});
|
||||
|
||||
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
|
||||
fs.writeFileSync(
|
||||
registryPath,
|
||||
`version: "1.0"\ndefault: work\nprofiles:\n work:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: null\n`,
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
|
||||
// Invalidate cache so the new files are read
|
||||
const svc = await import('../../../src/codex-auth/codex-auth-dashboard-service');
|
||||
svc.invalidateCodexAuthProfilesCache();
|
||||
|
||||
const { status, body } = await get('/api/codex/profiles');
|
||||
expect(status).toBe(200);
|
||||
|
||||
const bodyStr = JSON.stringify(body);
|
||||
expect(bodyStr).not.toContain('id_token');
|
||||
expect(bodyStr).not.toContain('access_token');
|
||||
expect(bodyStr).not.toContain('refresh_token');
|
||||
expect(bodyStr).not.toContain('MUST_NOT_APPEAR_IN_RESPONSE');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* Unit tests for codex-auth-dashboard-service.
|
||||
*
|
||||
* Tests cover: empty registry, decoded fields, corrupt auth.json,
|
||||
* active resolution precedence (4 paths), cache TTL, cache invalidation,
|
||||
* and token redaction from response.
|
||||
*/
|
||||
|
||||
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 tmpDir: string;
|
||||
|
||||
// Helpers ------------------------------------------------------------------
|
||||
|
||||
function buildToken(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`;
|
||||
}
|
||||
|
||||
function writeAuthJson(profileDir: string, idTokenPayload: Record<string, unknown>): void {
|
||||
const authJson = {
|
||||
tokens: {
|
||||
id_token: buildToken(idTokenPayload),
|
||||
access_token: 'access-token-should-not-appear',
|
||||
refresh_token: 'refresh-token-should-not-appear',
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(path.join(profileDir, 'auth.json'), JSON.stringify(authJson), {
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
|
||||
function writeRegistry(registryPath: string, data: unknown): void {
|
||||
const dir = path.dirname(registryPath);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const yaml = (d: unknown): string => {
|
||||
// Minimal YAML serialiser for the test fixture
|
||||
if (typeof d === 'object' && d !== null) {
|
||||
return Object.entries(d as Record<string, unknown>)
|
||||
.map(([k, v]) => {
|
||||
if (typeof v === 'object' && v !== null) {
|
||||
const nested = Object.entries(v as Record<string, unknown>)
|
||||
.map(([nk, nv]) => ` ${nk}: ${nv === null ? 'null' : String(nv)}`)
|
||||
.join('\n');
|
||||
return `${k}:\n${nested}`;
|
||||
}
|
||||
return `${k}: ${v === null ? 'null' : String(v)}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
return '';
|
||||
};
|
||||
fs.writeFileSync(registryPath, yaml(data), { mode: 0o600 });
|
||||
}
|
||||
|
||||
// Module cache helpers -----------------------------------------------------
|
||||
|
||||
async function importService() {
|
||||
// Bust module cache on each test by importing fresh via dynamic import
|
||||
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await import(
|
||||
'../../../src/codex-auth/codex-auth-dashboard-service'
|
||||
);
|
||||
return { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache };
|
||||
}
|
||||
|
||||
// Setup / teardown ---------------------------------------------------------
|
||||
|
||||
// getCcsDir() returns path.join(CCS_HOME, '.ccs') when CCS_HOME is set.
|
||||
// Tests must write to tmpDir/.ccs/ to be visible to the service.
|
||||
let ccsDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-'));
|
||||
process.env.CCS_HOME = tmpDir;
|
||||
ccsDir = path.join(tmpDir, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
// Clear module cache so cache state doesn't bleed between tests
|
||||
// Bun doesn't have require.cache; we rely on invalidateCodexAuthProfilesCache
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.CCS_HOME;
|
||||
delete process.env.CODEX_HOME;
|
||||
delete process.env.CCS_CODEX_PROFILE;
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Tests --------------------------------------------------------------------
|
||||
|
||||
describe('getCodexAuthProfilesSummary', () => {
|
||||
it('returns empty profiles and active=null when registry does not exist', async () => {
|
||||
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
|
||||
invalidateCodexAuthProfilesCache();
|
||||
const result = await getCodexAuthProfilesSummary();
|
||||
expect(result.profiles).toEqual([]);
|
||||
expect(result.active).toBeNull();
|
||||
expect(result.default).toBeNull();
|
||||
});
|
||||
|
||||
it('returns decoded email, plan, accountId for valid registry with 2 profiles', async () => {
|
||||
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
|
||||
invalidateCodexAuthProfilesCache();
|
||||
|
||||
const instancesDir = path.join(ccsDir, 'codex-instances');
|
||||
const workDir = path.join(instancesDir, 'work');
|
||||
const personalDir = path.join(instancesDir, 'personal');
|
||||
fs.mkdirSync(workDir, { recursive: true });
|
||||
fs.mkdirSync(personalDir, { recursive: true });
|
||||
|
||||
writeAuthJson(workDir, {
|
||||
email: 'work@example.com',
|
||||
'https://api.openai.com/auth': {
|
||||
chatgpt_plan_type: 'pro',
|
||||
chatgpt_account_id: 'acct-work-123',
|
||||
},
|
||||
});
|
||||
writeAuthJson(personalDir, {
|
||||
email: 'personal@example.com',
|
||||
'https://api.openai.com/auth': {
|
||||
chatgpt_plan_type: 'free',
|
||||
chatgpt_account_id: 'acct-personal-456',
|
||||
},
|
||||
});
|
||||
|
||||
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
|
||||
const yamlContent = `version: "1.0"\ndefault: work\nprofiles:\n work:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: "2026-05-17T05:45:00Z"\n personal:\n type: codex\n created: "2026-01-02T00:00:00Z"\n last_used: null\n`;
|
||||
fs.writeFileSync(registryPath, yamlContent, { mode: 0o600 });
|
||||
|
||||
const result = await getCodexAuthProfilesSummary();
|
||||
expect(result.profiles).toHaveLength(2);
|
||||
|
||||
const work = result.profiles.find((p) => p.name === 'work');
|
||||
expect(work).toBeDefined();
|
||||
expect(work?.email).toBe('work@example.com');
|
||||
expect(work?.plan).toBe('pro');
|
||||
expect(work?.accountId).toBe('acct-work-123');
|
||||
expect(work?.authValid).toBe(true);
|
||||
expect(work?.lastUsed).toBe('2026-05-17T05:45:00Z');
|
||||
|
||||
const personal = result.profiles.find((p) => p.name === 'personal');
|
||||
expect(personal).toBeDefined();
|
||||
expect(personal?.email).toBe('personal@example.com');
|
||||
expect(personal?.plan).toBe('free');
|
||||
expect(personal?.accountId).toBe('acct-personal-456');
|
||||
expect(personal?.authValid).toBe(true);
|
||||
expect(personal?.lastUsed).toBeNull();
|
||||
});
|
||||
|
||||
it('sets authValid=false and nulls identity fields when auth.json is corrupt JSON', async () => {
|
||||
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
|
||||
invalidateCodexAuthProfilesCache();
|
||||
|
||||
const instancesDir = path.join(ccsDir, 'codex-instances');
|
||||
const brokenDir = path.join(instancesDir, 'broken');
|
||||
fs.mkdirSync(brokenDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(brokenDir, 'auth.json'), '{ not valid json', {
|
||||
mode: 0o600,
|
||||
});
|
||||
|
||||
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
|
||||
fs.writeFileSync(
|
||||
registryPath,
|
||||
`version: "1.0"\ndefault: broken\nprofiles:\n broken:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: null\n`,
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
|
||||
const result = await getCodexAuthProfilesSummary();
|
||||
expect(result.profiles).toHaveLength(1);
|
||||
const broken = result.profiles[0];
|
||||
expect(broken?.authValid).toBe(false);
|
||||
expect(broken?.email).toBeNull();
|
||||
expect(broken?.plan).toBeNull();
|
||||
expect(broken?.accountId).toBeNull();
|
||||
});
|
||||
|
||||
it('sets authValid=false and nulls identity fields when auth.json is missing', async () => {
|
||||
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
|
||||
invalidateCodexAuthProfilesCache();
|
||||
|
||||
const instancesDir = path.join(ccsDir, 'codex-instances');
|
||||
const noAuthDir = path.join(instancesDir, 'noauth');
|
||||
fs.mkdirSync(noAuthDir, { recursive: true });
|
||||
// No auth.json written
|
||||
|
||||
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
|
||||
fs.writeFileSync(
|
||||
registryPath,
|
||||
`version: "1.0"\ndefault: noauth\nprofiles:\n noauth:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: null\n`,
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
|
||||
const result = await getCodexAuthProfilesSummary();
|
||||
const profile = result.profiles[0];
|
||||
expect(profile?.authValid).toBe(false);
|
||||
expect(profile?.email).toBeNull();
|
||||
expect(profile?.plan).toBeNull();
|
||||
expect(profile?.accountId).toBeNull();
|
||||
});
|
||||
|
||||
it('active resolution: CODEX_HOME set externally -> source=explicit-codex-home', async () => {
|
||||
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
|
||||
invalidateCodexAuthProfilesCache();
|
||||
|
||||
const externalHome = path.join(tmpDir, 'external-codex');
|
||||
process.env.CODEX_HOME = externalHome;
|
||||
|
||||
const result = await getCodexAuthProfilesSummary();
|
||||
expect(result.active).not.toBeNull();
|
||||
expect(result.active?.source).toBe('explicit-codex-home');
|
||||
expect(result.active?.codexHome).toBe(externalHome);
|
||||
});
|
||||
|
||||
it('active resolution: CCS_CODEX_PROFILE set -> source=env, name=that profile', async () => {
|
||||
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
|
||||
invalidateCodexAuthProfilesCache();
|
||||
|
||||
process.env.CCS_CODEX_PROFILE = 'work';
|
||||
|
||||
const instancesDir = path.join(ccsDir, 'codex-instances');
|
||||
const workDir = path.join(instancesDir, 'work');
|
||||
fs.mkdirSync(workDir, { recursive: true });
|
||||
|
||||
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
|
||||
fs.writeFileSync(
|
||||
registryPath,
|
||||
`version: "1.0"\ndefault: null\nprofiles:\n work:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: null\n`,
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
|
||||
const result = await getCodexAuthProfilesSummary();
|
||||
expect(result.active?.source).toBe('env');
|
||||
expect(result.active?.name).toBe('work');
|
||||
});
|
||||
|
||||
it('active resolution: registry default set -> source=default', async () => {
|
||||
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
|
||||
invalidateCodexAuthProfilesCache();
|
||||
|
||||
const instancesDir = path.join(ccsDir, 'codex-instances');
|
||||
const workDir = path.join(instancesDir, 'work');
|
||||
fs.mkdirSync(workDir, { recursive: true });
|
||||
|
||||
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
|
||||
fs.writeFileSync(
|
||||
registryPath,
|
||||
`version: "1.0"\ndefault: work\nprofiles:\n work:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: null\n`,
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
|
||||
const result = await getCodexAuthProfilesSummary();
|
||||
expect(result.active?.source).toBe('default');
|
||||
expect(result.active?.name).toBe('work');
|
||||
});
|
||||
|
||||
it('active resolution: no env, no default -> active=null', async () => {
|
||||
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
|
||||
invalidateCodexAuthProfilesCache();
|
||||
// No registry file, no env vars
|
||||
const result = await getCodexAuthProfilesSummary();
|
||||
expect(result.active).toBeNull();
|
||||
});
|
||||
|
||||
it('returns cached value on second call within 5s (no extra fs reads)', async () => {
|
||||
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
|
||||
invalidateCodexAuthProfilesCache();
|
||||
|
||||
const instancesDir = path.join(ccsDir, 'codex-instances');
|
||||
const workDir = path.join(instancesDir, 'work');
|
||||
fs.mkdirSync(workDir, { recursive: true });
|
||||
|
||||
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
|
||||
fs.writeFileSync(
|
||||
registryPath,
|
||||
`version: "1.0"\ndefault: work\nprofiles:\n work:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: null\n`,
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
|
||||
const first = await getCodexAuthProfilesSummary();
|
||||
// Modify registry after first call — should NOT be seen within cache TTL
|
||||
fs.writeFileSync(registryPath, `version: "1.0"\ndefault: null\nprofiles: {}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
|
||||
const second = await getCodexAuthProfilesSummary();
|
||||
// Both calls should return same reference (cache hit)
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it('invalidateCodexAuthProfilesCache forces re-read on next call', async () => {
|
||||
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
|
||||
invalidateCodexAuthProfilesCache();
|
||||
|
||||
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
|
||||
fs.writeFileSync(registryPath, `version: "1.0"\ndefault: null\nprofiles: {}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
|
||||
const first = await getCodexAuthProfilesSummary();
|
||||
expect(first.profiles).toHaveLength(0);
|
||||
|
||||
// Now add a profile
|
||||
const instancesDir = path.join(ccsDir, 'codex-instances');
|
||||
const newDir = path.join(instancesDir, 'newprofile');
|
||||
fs.mkdirSync(newDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
registryPath,
|
||||
`version: "1.0"\ndefault: newprofile\nprofiles:\n newprofile:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: null\n`,
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
|
||||
invalidateCodexAuthProfilesCache();
|
||||
const second = await getCodexAuthProfilesSummary();
|
||||
expect(second.profiles).toHaveLength(1);
|
||||
expect(second.profiles[0]?.name).toBe('newprofile');
|
||||
});
|
||||
|
||||
it('response JSON contains no token substrings', async () => {
|
||||
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
|
||||
invalidateCodexAuthProfilesCache();
|
||||
|
||||
const instancesDir = path.join(ccsDir, 'codex-instances');
|
||||
const workDir = path.join(instancesDir, 'work');
|
||||
fs.mkdirSync(workDir, { recursive: true });
|
||||
|
||||
writeAuthJson(workDir, {
|
||||
email: 'work@example.com',
|
||||
'https://api.openai.com/auth': {
|
||||
chatgpt_plan_type: 'pro',
|
||||
chatgpt_account_id: 'acct-work',
|
||||
},
|
||||
});
|
||||
|
||||
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
|
||||
fs.writeFileSync(
|
||||
registryPath,
|
||||
`version: "1.0"\ndefault: work\nprofiles:\n work:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: null\n`,
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
|
||||
const result = await getCodexAuthProfilesSummary();
|
||||
const serialized = JSON.stringify(result);
|
||||
|
||||
// Security: no raw token material in response
|
||||
expect(serialized).not.toContain('access_token');
|
||||
expect(serialized).not.toContain('refresh_token');
|
||||
expect(serialized).not.toContain('id_token');
|
||||
// The known sentinel values from writeAuthJson
|
||||
expect(serialized).not.toContain('access-token-should-not-appear');
|
||||
expect(serialized).not.toContain('refresh-token-should-not-appear');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user