test(cliproxy): cover optional nickname auth flows

- add registry coverage for generated ids, reauth, and ambiguous prefixes

- add CLI and dashboard validation tests for optional nickname handling

- add manual callback coverage for persisted social OAuth nicknames
This commit is contained in:
Tam Nhu Tran
2026-03-23 11:02:08 -04:00
parent bdb7ac2937
commit 834704938d
4 changed files with 339 additions and 0 deletions
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { runWithScopedCcsHome } from '../../../src/utils/config-manager';
async function loadAccountManager() {
return import(`../../../src/cliproxy/account-manager?optional-nickname=${Date.now()}`);
}
async function withIsolatedHome<T>(fn: () => Promise<T> | T): Promise<T> {
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-optional-nickname-'));
try {
return await runWithScopedCcsHome(testDir, fn);
} finally {
fs.rmSync(testDir, { recursive: true, force: true });
}
}
describe('registerAccount optional nickname flow', () => {
it('uses a filename-derived id when Kiro/GHCP nickname is omitted', async () => {
const account = await withIsolatedHome(async () => {
const { registerAccount } = await loadAccountManager();
return registerAccount('kiro', 'kiro-github-ABC123.json');
});
expect(account.id).toBe('github-ABC123');
expect(account.nickname).toBe('github-ABC123');
});
it('falls back to provider-scoped sequential ids when the filename is not descriptive', async () => {
const { first, second } = await withIsolatedHome(async () => {
const { registerAccount } = await loadAccountManager();
return {
first: registerAccount('kiro', 'kiro-nomail.json'),
second: registerAccount('kiro', 'kiro-second.json'),
};
});
expect(first.id).toBe('kiro-1');
expect(first.nickname).toBe('kiro-1');
expect(second.id).toBe('kiro-2');
expect(second.nickname).toBe('kiro-2');
});
it('keeps user nicknames optional metadata separate from internal ids', async () => {
const account = await withIsolatedHome(async () => {
const { registerAccount } = await loadAccountManager();
return registerAccount('ghcp', 'ghcp-amazon-XYZ789.json', undefined, 'work');
});
expect(account.id).toBe('amazon-XYZ789');
expect(account.nickname).toBe('work');
});
it('preserves an existing custom nickname when the same token file is re-registered', async () => {
const reauthenticated = await withIsolatedHome(async () => {
const { registerAccount } = await loadAccountManager();
registerAccount('kiro', 'kiro-github-ABC123.json', undefined, 'work');
return registerAccount('kiro', 'kiro-github-ABC123.json');
});
expect(reauthenticated.id).toBe('github-ABC123');
expect(reauthenticated.nickname).toBe('work');
});
it('rejects nickname collisions against existing account ids and nicknames', async () => {
await withIsolatedHome(async () => {
const { registerAccount, renameAccount } = await loadAccountManager();
registerAccount('kiro', 'kiro-github-ABC123.json');
const second = registerAccount('kiro', 'kiro-google-XYZ789.json', undefined, 'personal');
expect(() =>
registerAccount('kiro', 'kiro-google-NEW123.json', undefined, 'github-ABC123')
).toThrow(/already exists/i);
expect(() => renameAccount('kiro', second.id, 'github-ABC123')).toThrow(/already used/i);
});
});
it('avoids auto-generated ids that would collide with an existing nickname', async () => {
const added = await withIsolatedHome(async () => {
const { registerAccount } = await loadAccountManager();
registerAccount('kiro', 'kiro-github-ABC123.json', undefined, 'google-XYZ789');
return registerAccount('kiro', 'kiro-google-XYZ789.json');
});
expect(added.id).toBe('kiro-1');
expect(added.nickname).toBe('kiro-1');
});
it('does not resolve ambiguous nickname prefixes to the first generated account', async () => {
const match = await withIsolatedHome(async () => {
const { registerAccount, findAccountByQuery } = await loadAccountManager();
registerAccount('kiro', 'kiro-github-ABC123.json');
registerAccount('kiro', 'kiro-github-DEF456.json');
return findAccountByQuery('kiro', 'github');
});
expect(match).toBeNull();
});
});
@@ -102,3 +102,54 @@ describe('resolvePasteCallbackAuthUrl', () => {
expect(request.headers['Authorization']).toBe('Bearer test-mgmt-key');
});
});
describe('getCliAuthNicknameError', () => {
it('allows omitted nicknames for no-email providers', async () => {
const { getCliAuthNicknameError } = await import(
`../../../src/cliproxy/auth/oauth-handler?cli-nickname-empty=${Date.now()}`
);
expect(getCliAuthNicknameError('kiro', undefined, [])).toBeNull();
expect(getCliAuthNicknameError('ghcp', undefined, [])).toBeNull();
});
it('rejects invalid supplied nicknames before OAuth starts', async () => {
const { getCliAuthNicknameError } = await import(
`../../../src/cliproxy/auth/oauth-handler?cli-nickname-invalid=${Date.now()}`
);
expect(getCliAuthNicknameError('kiro', 'bad nickname', [])).toBe(
'Nickname cannot contain whitespace'
);
});
it('rejects supplied nicknames that collide with existing ids or nicknames', async () => {
const { getCliAuthNicknameError } = await import(
`../../../src/cliproxy/auth/oauth-handler?cli-nickname-conflict=${Date.now()}`
);
const existingAccounts = [
{ id: 'github-ABC123', nickname: 'work' },
{ id: 'ghcp-2', nickname: 'personal' },
];
expect(getCliAuthNicknameError('ghcp', 'github-ABC123', existingAccounts)).toBe(
'Nickname "github-ABC123" is already in use. Choose a different one.'
);
expect(getCliAuthNicknameError('ghcp', 'work', existingAccounts)).toBe(
'Nickname "work" is already in use. Choose a different one.'
);
});
it('allows reauth when the supplied nickname already belongs to the same account', async () => {
const { getCliAuthNicknameError } = await import(
`../../../src/cliproxy/auth/oauth-handler?cli-nickname-reauth=${Date.now()}`
);
const existingAccounts = [
{ id: 'github-ABC123', nickname: 'work' },
{ id: 'amazon-XYZ789', nickname: 'personal' },
];
expect(getCliAuthNicknameError('kiro', 'work', existingAccounts, 'github-ABC123')).toBeNull();
expect(getCliAuthNicknameError('kiro', 'github-ABC123', existingAccounts, 'github-ABC123')).toBeNull();
});
});
@@ -0,0 +1,145 @@
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();
});
}
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');
});
});
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'bun:test';
import {
getStartAuthFailureMessage,
getStartAuthNicknameError,
getStartUrlUnsupportedReason,
} from '../../../src/web-server/routes/cliproxy-auth-routes';
@@ -43,3 +44,44 @@ describe('cliproxy-auth-routes start failure messaging', () => {
expect(getStartAuthFailureMessage('kiro')).toBe('Authentication failed or was cancelled');
});
});
describe('cliproxy-auth-routes nickname validation', () => {
it('allows Kiro and GHCP start requests without a nickname', () => {
expect(getStartAuthNicknameError('kiro', undefined, [])).toBeNull();
expect(getStartAuthNicknameError('ghcp', undefined, [])).toBeNull();
});
it('rejects invalid supplied nicknames for no-email providers', () => {
expect(getStartAuthNicknameError('kiro', 'bad nickname', [])).toEqual({
error: 'Nickname cannot contain whitespace',
code: 'INVALID_NICKNAME',
});
});
it('rejects nicknames that collide with an existing account id or nickname', () => {
const existingAccounts = [
{ id: 'github-ABC123', nickname: 'work' },
{ id: 'ghcp-2', nickname: 'personal' },
];
expect(getStartAuthNicknameError('ghcp', 'github-ABC123', existingAccounts)).toEqual({
error: 'Nickname "github-ABC123" is already in use. Choose a different one.',
code: 'NICKNAME_EXISTS',
});
expect(getStartAuthNicknameError('ghcp', 'work', existingAccounts)).toEqual({
error: 'Nickname "work" is already in use. Choose a different one.',
code: 'NICKNAME_EXISTS',
});
});
it('allows reauth when the nickname already belongs to the same account', () => {
const existingAccounts = [
{ id: 'github-ABC123', nickname: 'work' },
{ id: 'ghcp-2', nickname: 'personal' },
];
expect(getStartAuthNicknameError('kiro', 'work', existingAccounts, 'github-ABC123')).toBeNull();
expect(getStartAuthNicknameError('kiro', 'github-ABC123', existingAccounts, 'github-ABC123')).toBeNull();
});
});