mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 10:16:49 +00:00
568 lines
17 KiB
TypeScript
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');
|
|
});
|
|
});
|