diff --git a/tests/unit/cliproxy/account-registry-optional-nickname.test.ts b/tests/unit/cliproxy/account-registry-optional-nickname.test.ts new file mode 100644 index 00000000..04ef857a --- /dev/null +++ b/tests/unit/cliproxy/account-registry-optional-nickname.test.ts @@ -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(fn: () => Promise | T): Promise { + 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(); + }); +}); diff --git a/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts b/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts index 1b8da8a9..9ad75161 100644 --- a/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts +++ b/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts @@ -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(); + }); +}); diff --git a/tests/unit/web-server/cliproxy-auth-routes-manual-callback.test.ts b/tests/unit/web-server/cliproxy-auth-routes-manual-callback.test.ts new file mode 100644 index 00000000..eb7ccb03 --- /dev/null +++ b/tests/unit/web-server/cliproxy-auth-routes-manual-callback.test.ts @@ -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((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((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) { + 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; + }; + }; + }; + + expect(registry.providers.kiro.accounts['github-ABC123']?.nickname).toBe('work'); + }); +}); diff --git a/tests/unit/web-server/cliproxy-auth-routes.test.ts b/tests/unit/web-server/cliproxy-auth-routes.test.ts index 9314f32c..ec2ce8d3 100644 --- a/tests/unit/web-server/cliproxy-auth-routes.test.ts +++ b/tests/unit/web-server/cliproxy-auth-routes.test.ts @@ -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(); + }); +});