Files
ccs/tests/unit/web-server/cliproxy-auth-routes-manual-callback.test.ts
T

568 lines
17 KiB
TypeScript

import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
import express from 'express';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as http from 'http';
import type { Server } from 'http';
import cliproxyAuthRoutes from '../../../src/web-server/routes/cliproxy-auth-routes';
import { restoreFetch, mockFetch } from '../../mocks';
describe('cliproxy-auth-routes manual callback nickname persistence', () => {
let server: Server;
let baseUrl = '';
let tempHome = '';
let originalCcsHome: string | undefined;
beforeAll(async () => {
const app = express();
app.use(express.json());
app.use('/api/cliproxy/auth', cliproxyAuthRoutes);
await new Promise<void>((resolve, reject) => {
server = app.listen(0, '127.0.0.1');
const onError = (error: Error) => reject(error);
server.once('error', onError);
server.once('listening', () => {
server.off('error', onError);
resolve();
});
});
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Unable to resolve test server port');
}
baseUrl = `http://127.0.0.1:${address.port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-manual-callback-'));
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tempHome;
});
afterEach(() => {
restoreFetch();
if (originalCcsHome === undefined) {
delete process.env.CCS_HOME;
} else {
process.env.CCS_HOME = originalCcsHome;
}
fs.rmSync(tempHome, { recursive: true, force: true });
});
async function postJson(route: string, body: Record<string, unknown>) {
return await new Promise<{ status: number; body: unknown }>((resolve, reject) => {
const payload = JSON.stringify(body);
const url = new URL(`${baseUrl}${route}`);
const request = http.request(
{
method: 'POST',
hostname: url.hostname,
port: url.port,
path: url.pathname,
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
},
},
(response) => {
let responseBody = '';
response.setEncoding('utf8');
response.on('data', (chunk) => {
responseBody += chunk;
});
response.on('end', () => {
resolve({
status: response.statusCode || 0,
body: responseBody ? JSON.parse(responseBody) : null,
});
});
}
);
request.on('error', reject);
request.write(payload);
request.end();
});
}
async function getJson(route: string) {
return await new Promise<{ status: number; body: unknown }>((resolve, reject) => {
const url = new URL(`${baseUrl}${route}`);
const request = http.request(
{
method: 'GET',
hostname: url.hostname,
port: url.port,
path: `${url.pathname}${url.search}`,
},
(response) => {
let responseBody = '';
response.setEncoding('utf8');
response.on('data', (chunk) => {
responseBody += chunk;
});
response.on('end', () => {
resolve({
status: response.statusCode || 0,
body: responseBody ? JSON.parse(responseBody) : null,
});
});
}
);
request.on('error', reject);
request.end();
});
}
it('persists the supplied nickname for Kiro social start-url flows after callback submission', async () => {
mockFetch([
{
url: /\/v0\/management\/kiro-auth-url\?is_webui=true&method=google$/,
response: {
auth_url: 'https://auth.example.com/authorize?state=state-123',
state: 'state-123',
},
},
{
url: /\/v0\/management\/oauth-callback$/,
method: 'POST',
response: { status: 'ok' },
},
]);
const startResponse = await postJson('/api/cliproxy/auth/kiro/start-url', {
nickname: 'work',
kiroMethod: 'google',
});
expect(startResponse.status).toBe(200);
const tokenDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth');
fs.mkdirSync(tokenDir, { recursive: true });
fs.writeFileSync(
path.join(tokenDir, 'kiro-github-ABC123.json'),
JSON.stringify({ type: 'kiro' }),
'utf8'
);
const callbackResponse = await postJson('/api/cliproxy/auth/kiro/submit-callback', {
redirectUrl: 'http://localhost/callback?code=abc123&state=state-123',
});
expect(callbackResponse.status).toBe(200);
const registryPath = path.join(tempHome, '.ccs', 'cliproxy', 'accounts.json');
const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as {
providers: {
kiro: {
accounts: Record<string, { nickname?: string }>;
};
};
};
expect(registry.providers.kiro.accounts['github-ABC123']?.nickname).toBe('work');
});
it('returns wait after callback submission when the local token is not yet available', async () => {
mockFetch([
{
url: /\/v0\/management\/codex-auth-url\?is_webui=true$/,
response: {
auth_url: 'https://auth.example.com/authorize?state=state-callback-wait',
state: 'state-callback-wait',
},
},
{
url: /\/v0\/management\/oauth-callback$/,
method: 'POST',
response: { status: 'ok' },
},
{
url: /\/v0\/management\/get-auth-status\?state=state-callback-wait$/,
response: { status: 'ok' },
},
]);
const startResponse = await postJson('/api/cliproxy/auth/codex/start-url', {});
expect(startResponse.status).toBe(200);
const realDateNow = Date.now;
let now = realDateNow();
Date.now = () => now;
try {
const callbackResponse = await postJson('/api/cliproxy/auth/codex/submit-callback', {
redirectUrl: 'http://localhost/callback?code=abc123&state=state-callback-wait',
});
expect(callbackResponse.status).toBe(200);
expect(callbackResponse.body).toEqual({ status: 'wait' });
now += 16_000;
const statusResponse = await getJson(
'/api/cliproxy/auth/codex/status?state=state-callback-wait'
);
expect(statusResponse.status).toBe(409);
expect(statusResponse.body).toEqual({
status: 'error',
error:
'Authentication completed upstream, but no new local token was saved for this account. Update CCS/CLIProxy and retry.',
});
} finally {
Date.now = realDateNow;
}
});
it('keeps polling briefly after upstream completion before surfacing the missing-token error', async () => {
const tokenDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth');
fs.mkdirSync(tokenDir, { recursive: true });
fs.writeFileSync(
path.join(tokenDir, 'codex-existing@example.com.json'),
JSON.stringify({ type: 'codex', email: 'existing@example.com' }),
'utf8'
);
mockFetch([
{
url: /\/v0\/management\/codex-auth-url\?is_webui=true$/,
response: {
auth_url: 'https://auth.example.com/authorize?state=state-status-missing',
state: 'state-status-missing',
},
},
{
url: /\/v0\/management\/get-auth-status\?state=state-status-missing$/,
response: { status: 'ok' },
},
]);
const startResponse = await postJson('/api/cliproxy/auth/codex/start-url', {});
expect(startResponse.status).toBe(200);
const realDateNow = Date.now;
let now = realDateNow();
Date.now = () => now;
try {
const firstStatusResponse = await getJson(
'/api/cliproxy/auth/codex/status?state=state-status-missing'
);
expect(firstStatusResponse.status).toBe(200);
expect(firstStatusResponse.body).toEqual({ status: 'wait' });
now += 16_000;
const secondStatusResponse = await getJson(
'/api/cliproxy/auth/codex/status?state=state-status-missing'
);
expect(secondStatusResponse.status).toBe(409);
expect(secondStatusResponse.body).toEqual({
status: 'error',
error:
'Authentication completed upstream, but no new local token was saved for this account. Update CCS/CLIProxy and retry.',
});
} finally {
Date.now = realDateNow;
}
});
it('continues polling until the local token appears after upstream completion', async () => {
mockFetch([
{
url: /\/v0\/management\/codex-auth-url\?is_webui=true$/,
response: {
auth_url: 'https://auth.example.com/authorize?state=state-status-delayed',
state: 'state-status-delayed',
},
},
{
url: /\/v0\/management\/get-auth-status\?state=state-status-delayed$/,
response: { status: 'ok' },
},
]);
const startResponse = await postJson('/api/cliproxy/auth/codex/start-url', {});
expect(startResponse.status).toBe(200);
const firstStatusResponse = await getJson(
'/api/cliproxy/auth/codex/status?state=state-status-delayed'
);
expect(firstStatusResponse.status).toBe(200);
expect(firstStatusResponse.body).toEqual({ status: 'wait' });
const tokenDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth');
fs.mkdirSync(tokenDir, { recursive: true });
fs.writeFileSync(
path.join(tokenDir, 'codex-delayed@example.com.json'),
JSON.stringify({ type: 'codex', email: 'delayed@example.com' }),
'utf8'
);
const secondStatusResponse = await getJson(
'/api/cliproxy/auth/codex/status?state=state-status-delayed'
);
expect(secondStatusResponse.status).toBe(200);
expect(secondStatusResponse.body).toEqual({
status: 'ok',
account: {
id: 'delayed@example.com',
email: 'delayed@example.com',
nickname: 'delayed',
provider: 'codex',
isDefault: true,
},
});
});
it('lets polling finish successfully after callback submission once the token appears', async () => {
mockFetch([
{
url: /\/v0\/management\/codex-auth-url\?is_webui=true$/,
response: {
auth_url: 'https://auth.example.com/authorize?state=state-callback-delayed',
state: 'state-callback-delayed',
},
},
{
url: /\/v0\/management\/oauth-callback$/,
method: 'POST',
response: { status: 'ok' },
},
{
url: /\/v0\/management\/get-auth-status\?state=state-callback-delayed$/,
response: { status: 'ok' },
},
]);
const startResponse = await postJson('/api/cliproxy/auth/codex/start-url', {});
expect(startResponse.status).toBe(200);
const callbackResponse = await postJson('/api/cliproxy/auth/codex/submit-callback', {
redirectUrl: 'http://localhost/callback?code=abc123&state=state-callback-delayed',
});
expect(callbackResponse.status).toBe(200);
expect(callbackResponse.body).toEqual({ status: 'wait' });
const tokenDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth');
fs.mkdirSync(tokenDir, { recursive: true });
fs.writeFileSync(
path.join(tokenDir, 'codex-callback@example.com.json'),
JSON.stringify({ type: 'codex', email: 'callback@example.com' }),
'utf8'
);
const statusResponse = await getJson(
'/api/cliproxy/auth/codex/status?state=state-callback-delayed'
);
expect(statusResponse.status).toBe(200);
expect(statusResponse.body).toEqual({
status: 'ok',
account: {
id: 'callback@example.com',
email: 'callback@example.com',
nickname: 'callback',
provider: 'codex',
isDefault: true,
},
});
});
it('keeps the pending auth state alive through the local-token grace window even near TTL expiry', async () => {
mockFetch([
{
url: /\/v0\/management\/codex-auth-url\?is_webui=true$/,
response: {
auth_url: 'https://auth.example.com/authorize?state=state-ttl-grace',
state: 'state-ttl-grace',
},
},
{
url: /\/v0\/management\/get-auth-status\?state=state-ttl-grace$/,
response: { status: 'ok' },
},
{
url: /\/v0\/management\/get-auth-status\?state=state-ttl-grace$/,
response: { status: 'ok' },
},
]);
const realDateNow = Date.now;
let now = realDateNow();
Date.now = () => now;
try {
const startResponse = await postJson('/api/cliproxy/auth/codex/start-url', {});
expect(startResponse.status).toBe(200);
now += 10 * 60 * 1000 - 1_000;
const firstStatusResponse = await getJson(
'/api/cliproxy/auth/codex/status?state=state-ttl-grace'
);
expect(firstStatusResponse.status).toBe(200);
expect(firstStatusResponse.body).toEqual({ status: 'wait' });
const tokenDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth');
fs.mkdirSync(tokenDir, { recursive: true });
fs.writeFileSync(
path.join(tokenDir, 'codex-ttl@example.com.json'),
JSON.stringify({ type: 'codex', email: 'ttl@example.com' }),
'utf8'
);
now += 2_000;
const secondStatusResponse = await getJson(
'/api/cliproxy/auth/codex/status?state=state-ttl-grace'
);
expect(secondStatusResponse.status).toBe(200);
expect(secondStatusResponse.body).toEqual({
status: 'ok',
account: {
id: 'ttl@example.com',
email: 'ttl@example.com',
nickname: 'ttl',
provider: 'codex',
isDefault: true,
},
});
} finally {
Date.now = realDateNow;
}
});
it('does not treat rewrites of pre-existing token files as a newly added account', async () => {
const tokenDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth');
fs.mkdirSync(tokenDir, { recursive: true });
const tokenPath = path.join(tokenDir, 'codex-existing@example.com.json');
fs.writeFileSync(
tokenPath,
JSON.stringify({ type: 'codex', email: 'existing@example.com', version: 1 }),
'utf8'
);
mockFetch([
{
url: /\/v0\/management\/codex-auth-url\?is_webui=true$/,
response: {
auth_url: 'https://auth.example.com/authorize?state=state-existing-rewrite',
state: 'state-existing-rewrite',
},
},
{
url: /\/v0\/management\/get-auth-status\?state=state-existing-rewrite$/,
response: { status: 'ok' },
},
{
url: /\/v0\/management\/get-auth-status\?state=state-existing-rewrite$/,
response: { status: 'ok' },
},
]);
const startResponse = await postJson('/api/cliproxy/auth/codex/start-url', {});
expect(startResponse.status).toBe(200);
fs.writeFileSync(
tokenPath,
JSON.stringify({ type: 'codex', email: 'existing@example.com', version: 2 }),
'utf8'
);
const realDateNow = Date.now;
let now = realDateNow();
Date.now = () => now;
try {
const firstStatusResponse = await getJson(
'/api/cliproxy/auth/codex/status?state=state-existing-rewrite'
);
expect(firstStatusResponse.status).toBe(200);
expect(firstStatusResponse.body).toEqual({ status: 'wait' });
now += 16_000;
const secondStatusResponse = await getJson(
'/api/cliproxy/auth/codex/status?state=state-existing-rewrite'
);
expect(secondStatusResponse.status).toBe(409);
expect(secondStatusResponse.body).toEqual({
status: 'error',
error:
'Authentication completed upstream, but no new local token was saved for this account. Update CCS/CLIProxy and retry.',
});
} finally {
Date.now = realDateNow;
}
});
it('registers the new account before reporting polled auth success', async () => {
mockFetch([
{
url: /\/v0\/management\/codex-auth-url\?is_webui=true$/,
response: {
auth_url: 'https://auth.example.com/authorize?state=state-status-ok',
state: 'state-status-ok',
},
},
{
url: /\/v0\/management\/get-auth-status\?state=state-status-ok$/,
response: { status: 'ok' },
},
]);
const startResponse = await postJson('/api/cliproxy/auth/codex/start-url', {});
expect(startResponse.status).toBe(200);
const tokenDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth');
fs.mkdirSync(tokenDir, { recursive: true });
fs.writeFileSync(
path.join(tokenDir, 'codex-new@example.com.json'),
JSON.stringify({ type: 'codex', email: 'new@example.com' }),
'utf8'
);
const statusResponse = await getJson('/api/cliproxy/auth/codex/status?state=state-status-ok');
expect(statusResponse.status).toBe(200);
expect(statusResponse.body).toEqual({
status: 'ok',
account: {
id: 'new@example.com',
email: 'new@example.com',
nickname: 'new',
provider: 'codex',
isDefault: true,
},
});
const registryPath = path.join(tempHome, '.ccs', 'cliproxy', 'accounts.json');
const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as {
providers: {
codex: {
accounts: Record<string, { email?: string }>;
};
};
};
expect(registry.providers.codex.accounts['new@example.com']?.email).toBe('new@example.com');
});
});