mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user