mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 14:14:47 +00:00
test(cliproxy): cover Gemini OAuth capabilities
This commit is contained in:
1 parent
19c6c3f457
commit
c4fc5e8985
6 files changed
+402
-3
No files matched your search
@@ -40,6 +40,7 @@ const slowTests = [
|
|||||||
'tests/unit/targets/settings-profile-websearch-launch.test.ts',
|
'tests/unit/targets/settings-profile-websearch-launch.test.ts',
|
||||||
'tests/unit/web-server/cursor-routes.test.ts',
|
'tests/unit/web-server/cursor-routes.test.ts',
|
||||||
'tests/unit/web-server/websearch-routes.test.ts',
|
'tests/unit/web-server/websearch-routes.test.ts',
|
||||||
|
'src/cliproxy/auth/__tests__/oauth-handler-gemini-backend-guidance.test.ts',
|
||||||
];
|
];
|
||||||
// CommonJS-heavy JS suites stay slow by default because many of them mutate
|
// CommonJS-heavy JS suites stay slow by default because many of them mutate
|
||||||
// module cache or process state. Opt them into `test:fast` only after they are
|
// module cache or process state. Opt them into `test:fast` only after they are
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, expect, it, spyOn } from 'bun:test';
|
||||||
|
import * as childProcess from 'child_process';
|
||||||
|
import {
|
||||||
|
extractAdvertisedCliFlags,
|
||||||
|
getOAuthFlagCandidatesForProvider,
|
||||||
|
OAUTH_HELP_PROBE_TIMEOUT_MS,
|
||||||
|
probeCliProxyAdvertisedFlags,
|
||||||
|
resolveAdvertisedAuthFlag,
|
||||||
|
} from '../oauth-cli-capabilities';
|
||||||
|
|
||||||
|
describe('oauth CLI capability probing', () => {
|
||||||
|
it('extracts advertised flags from Go help output', () => {
|
||||||
|
const flags = extractAdvertisedCliFlags(`
|
||||||
|
Usage of cli-proxy-api-plus
|
||||||
|
-login
|
||||||
|
Login Google Account
|
||||||
|
-codex-login
|
||||||
|
Login to Codex using OAuth
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(flags.has('--login')).toBe(true);
|
||||||
|
expect(flags.has('--codex-login')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the legacy Kiro Google alias when advertised', () => {
|
||||||
|
const selected = resolveAdvertisedAuthFlag(
|
||||||
|
'kiro',
|
||||||
|
getOAuthFlagCandidatesForProvider('kiro', 'google'),
|
||||||
|
new Set(['--kiro-login'])
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(selected).toBe('--kiro-login');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('probes help with a raw argv array so paths with spaces stay intact', () => {
|
||||||
|
const spawnSpy = spyOn(childProcess, 'spawnSync').mockReturnValue({
|
||||||
|
status: 0,
|
||||||
|
stdout: ' -login\n',
|
||||||
|
stderr: '',
|
||||||
|
pid: 0,
|
||||||
|
output: [],
|
||||||
|
signal: null,
|
||||||
|
error: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const binaryPath = 'C:\\Program Files\\CCS\\cli-proxy-api-plus.exe';
|
||||||
|
const flags = probeCliProxyAdvertisedFlags(binaryPath);
|
||||||
|
|
||||||
|
expect(flags.has('--login')).toBe(true);
|
||||||
|
expect(spawnSpy).toHaveBeenCalledWith(
|
||||||
|
binaryPath,
|
||||||
|
['--help'],
|
||||||
|
expect.objectContaining({
|
||||||
|
encoding: 'utf8',
|
||||||
|
shell: false,
|
||||||
|
timeout: OAUTH_HELP_PROBE_TIMEOUT_MS,
|
||||||
|
windowsHide: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
spawnSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails fast when help probing times out', () => {
|
||||||
|
const spawnSpy = spyOn(childProcess, 'spawnSync').mockReturnValue({
|
||||||
|
status: null,
|
||||||
|
stdout: '',
|
||||||
|
stderr: '',
|
||||||
|
pid: 0,
|
||||||
|
output: [],
|
||||||
|
signal: 'SIGTERM',
|
||||||
|
error: Object.assign(new Error('spawnSync timed out'), { code: 'ETIMEDOUT' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(() => probeCliProxyAdvertisedFlags('/tmp/cli-proxy-api')).toThrow(
|
||||||
|
`Timed out after ${OAUTH_HELP_PROBE_TIMEOUT_MS}ms`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
spawnSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import { buildOAuthArgs } from '../oauth-cli-args';
|
||||||
|
|
||||||
|
describe('buildOAuthArgs capability negotiation', () => {
|
||||||
|
it('uses the advertised Gemini login flag when supported', () => {
|
||||||
|
expect(
|
||||||
|
buildOAuthArgs('gemini', '/tmp/cliproxy-config.yaml', false, false, {
|
||||||
|
advertisedFlags: new Set(['--login']),
|
||||||
|
})
|
||||||
|
).toEqual(['--config', '/tmp/cliproxy-config.yaml', '--login']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the legacy Kiro Google alias when the binary only advertises it', () => {
|
||||||
|
expect(
|
||||||
|
buildOAuthArgs('kiro', '/tmp/path with spaces/cliproxy config.yaml', false, false, {
|
||||||
|
kiroMethod: 'google',
|
||||||
|
advertisedFlags: new Set(['--kiro-login']),
|
||||||
|
})
|
||||||
|
).toEqual(['--config', '/tmp/path with spaces/cliproxy config.yaml', '--kiro-login']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails early when Gemini login is unsupported by the installed binary', () => {
|
||||||
|
expect(() =>
|
||||||
|
buildOAuthArgs('gemini', '/tmp/cliproxy-config.yaml', false, false, {
|
||||||
|
backend: 'original',
|
||||||
|
advertisedFlags: new Set(['--codex-login']),
|
||||||
|
})
|
||||||
|
).toThrow('cliproxy.backend: original');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves Windows config paths as a separate argv entry', () => {
|
||||||
|
const args = buildOAuthArgs(
|
||||||
|
'gemini',
|
||||||
|
'C:\\Users\\Kai Tran\\AppData\\Roaming\\CCS\\cliproxy config.yaml',
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
{
|
||||||
|
advertisedFlags: new Set(['--login']),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(args[1]).toBe('C:\\Users\\Kai Tran\\AppData\\Roaming\\CCS\\cliproxy config.yaml');
|
||||||
|
expect(args).toContain('--no-browser');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import * as childProcess from 'child_process';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
|
||||||
|
const childTestPath = path.resolve(
|
||||||
|
import.meta.dir,
|
||||||
|
'../test-fixtures/gemini-backend-guidance-child.scenario.ts'
|
||||||
|
);
|
||||||
|
|
||||||
|
function runScenario(scenario: string): void {
|
||||||
|
const result = childProcess.spawnSync(process.execPath, ['test', childTestPath], {
|
||||||
|
cwd: path.resolve(import.meta.dir, '../../..'),
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
GEMINI_GUIDANCE_SCENARIO: scenario,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim();
|
||||||
|
expect(result.status, output || `child scenario ${scenario} failed`).toBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('triggerOAuth Gemini backend guidance', () => {
|
||||||
|
it('fails early on plus when Gemini OAuth client env vars are missing', () => {
|
||||||
|
runScenario('plus-missing-env');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports original backend Gemini incompatibility after probing direct CLI auth', () => {
|
||||||
|
runScenario('original-direct-unsupported');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports original backend Gemini incompatibility after probing headless auto paste mode', () => {
|
||||||
|
runScenario('original-headless-auto-paste-unsupported');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows pinned original binaries that still advertise Gemini login to continue past the probe', () => {
|
||||||
|
runScenario('original-headless-auto-paste-supported');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
import { describe, expect, it } from 'bun:test';
|
import { describe, expect, it } from 'bun:test';
|
||||||
import { OAUTH_CALLBACK_PORTS } from '../../../management/oauth-port-diagnostics';
|
import { buildOAuthArgs } from '../oauth-cli-args';
|
||||||
import { buildOAuthArgs } from '../oauth-handler';
|
|
||||||
|
|
||||||
describe('xAI OAuth process arguments', () => {
|
describe('xAI OAuth process arguments', () => {
|
||||||
it('starts CLIProxy device auth without a callback port argument', () => {
|
it('starts CLIProxy device auth without a callback port argument', () => {
|
||||||
expect(OAUTH_CALLBACK_PORTS.xai).toBeNull();
|
|
||||||
expect(buildOAuthArgs('xai', '/tmp/cliproxy-config.yaml', false, false)).toEqual([
|
expect(buildOAuthArgs('xai', '/tmp/cliproxy-config.yaml', false, false)).toEqual([
|
||||||
'--config',
|
'--config',
|
||||||
'/tmp/cliproxy-config.yaml',
|
'/tmp/cliproxy-config.yaml',
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
||||||
|
import * as childProcess from 'child_process';
|
||||||
|
import { ConfigError } from '../../../errors/error-types';
|
||||||
|
import type { ProxyTarget } from '../../proxy/proxy-target-resolver';
|
||||||
|
|
||||||
|
const scenario = process.env['GEMINI_GUIDANCE_SCENARIO'];
|
||||||
|
if (!scenario) {
|
||||||
|
throw new ConfigError('GEMINI_GUIDANCE_SCENARIO is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
let activeBackend: 'original' | 'plus' = 'original';
|
||||||
|
let headlessEnvironment = false;
|
||||||
|
const proxyTarget: ProxyTarget = {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 8317,
|
||||||
|
protocol: 'http',
|
||||||
|
isRemote: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureBinaryMock = mock(async () => '/tmp/fake-cli-proxy-api');
|
||||||
|
const generateConfigMock = mock(() => '/tmp/cliproxy-config.yaml');
|
||||||
|
const preflightCheckMock = mock(async () => ({
|
||||||
|
ready: true,
|
||||||
|
checks: [],
|
||||||
|
firewallWarning: false,
|
||||||
|
firewallFixCommand: undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const originalIsTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY');
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
function restoreIsTTY(): void {
|
||||||
|
if (originalIsTTY) {
|
||||||
|
Object.defineProperty(process.stdin, 'isTTY', originalIsTTY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAdvertisedHelpText(localScenario: string): string {
|
||||||
|
switch (localScenario) {
|
||||||
|
case 'original-headless-auto-paste-supported':
|
||||||
|
return ' -login\n';
|
||||||
|
case 'original-direct-unsupported':
|
||||||
|
case 'original-headless-auto-paste-unsupported':
|
||||||
|
return ' -codex-login\n';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerScenarioMocks(): Promise<void> {
|
||||||
|
const [
|
||||||
|
realConfigGenerator,
|
||||||
|
realAccountManager,
|
||||||
|
realTokenManager,
|
||||||
|
realEnvironmentDetector,
|
||||||
|
realProxyTargetResolver,
|
||||||
|
realAccountSafety,
|
||||||
|
realAccountSafetyCrossLane,
|
||||||
|
realPoolOptInPrompt,
|
||||||
|
realOAuthPortDiagnostics,
|
||||||
|
] = await Promise.all([
|
||||||
|
import('../../config/config-generator'),
|
||||||
|
import('../../accounts/account-manager'),
|
||||||
|
import('../token-manager'),
|
||||||
|
import('../environment-detector'),
|
||||||
|
import('../../proxy/proxy-target-resolver'),
|
||||||
|
import('../../accounts/account-safety'),
|
||||||
|
import('../../accounts/account-safety-cross-lane'),
|
||||||
|
import('../../routing/pool-opt-in-prompt'),
|
||||||
|
import('../../../management/oauth-port-diagnostics'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
mock.module('../../binary-manager', () => ({
|
||||||
|
ensureCLIProxyBinary: ensureBinaryMock,
|
||||||
|
getConfiguredBackend: () => activeBackend,
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../../config/config-generator', () => ({
|
||||||
|
...realConfigGenerator,
|
||||||
|
generateConfig: generateConfigMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../../../management/oauth-port-diagnostics', () => ({
|
||||||
|
...realOAuthPortDiagnostics,
|
||||||
|
enhancedPreflightOAuthCheck: preflightCheckMock,
|
||||||
|
OAUTH_CALLBACK_PORTS: { gemini: 8085 },
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../environment-detector', () => ({
|
||||||
|
...realEnvironmentDetector,
|
||||||
|
isHeadlessEnvironment: () => headlessEnvironment,
|
||||||
|
killProcessOnPort: () => false,
|
||||||
|
showStep: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../../proxy/proxy-target-resolver', () => ({
|
||||||
|
...realProxyTargetResolver,
|
||||||
|
getProxyTarget: () => proxyTarget,
|
||||||
|
buildProxyUrl: (target: ProxyTarget, endpointPath: string) =>
|
||||||
|
`${target.protocol}://${target.host}:${target.port}${
|
||||||
|
endpointPath.startsWith('/') ? endpointPath : `/${endpointPath}`
|
||||||
|
}`,
|
||||||
|
buildManagementHeaders: () => ({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../../accounts/account-manager', () => ({
|
||||||
|
...realAccountManager,
|
||||||
|
getProviderAccounts: () => [],
|
||||||
|
getDefaultAccount: () => null,
|
||||||
|
touchAccount: () => undefined,
|
||||||
|
hasAccountNameConflict: () => false,
|
||||||
|
findAccountNameMatch: () => null,
|
||||||
|
PROVIDERS_WITHOUT_EMAIL: [],
|
||||||
|
validateNickname: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../token-manager', () => ({
|
||||||
|
...realTokenManager,
|
||||||
|
getProviderTokenDir: () => '/tmp/ccs-gemini-auth-tests',
|
||||||
|
isAuthenticated: () => false,
|
||||||
|
listProviderTokenSnapshots: () => [],
|
||||||
|
findNewTokenSnapshotForAuthAttempt: () => null,
|
||||||
|
registerAccountFromToken: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../../accounts/account-safety', () => ({
|
||||||
|
...realAccountSafety,
|
||||||
|
checkNewAccountConflict: () => null,
|
||||||
|
warnNewAccountConflict: () => undefined,
|
||||||
|
warnOAuthBanRisk: () => undefined,
|
||||||
|
warnPossible403Ban: () => undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../../accounts/account-safety-cross-lane', () => ({
|
||||||
|
...realAccountSafetyCrossLane,
|
||||||
|
checkCrossLaneEmailOverlap: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../../routing/pool-opt-in-prompt', () => ({
|
||||||
|
...realPoolOptInPrompt,
|
||||||
|
maybeOfferPoolRouting: async () => undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.CLIPROXY_GEMINI_OAUTH_CLIENT_ID;
|
||||||
|
delete process.env.CLIPROXY_GEMINI_OAUTH_CLIENT_SECRET;
|
||||||
|
restoreIsTTY();
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('child Gemini backend guidance scenario', () => {
|
||||||
|
it(`validates ${scenario}`, async () => {
|
||||||
|
if (scenario === 'plus-missing-env') {
|
||||||
|
activeBackend = 'plus';
|
||||||
|
} else if (
|
||||||
|
scenario === 'original-headless-auto-paste-unsupported' ||
|
||||||
|
scenario === 'original-headless-auto-paste-supported'
|
||||||
|
) {
|
||||||
|
headlessEnvironment = true;
|
||||||
|
Object.defineProperty(process.stdin, 'isTTY', {
|
||||||
|
value: false,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scenario === 'original-headless-auto-paste-supported') {
|
||||||
|
globalThis.fetch = mock(async () => {
|
||||||
|
throw new ConfigError('fetch failed');
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
const spawnSpy = spyOn(childProcess, 'spawnSync').mockReturnValue({
|
||||||
|
status: 0,
|
||||||
|
stdout: getAdvertisedHelpText(scenario),
|
||||||
|
stderr: '',
|
||||||
|
pid: 0,
|
||||||
|
output: [],
|
||||||
|
signal: null,
|
||||||
|
error: undefined,
|
||||||
|
});
|
||||||
|
const logSpy = spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await registerScenarioMocks();
|
||||||
|
const { triggerOAuth } = await import(`../oauth-handler?gemini-guidance-child=${Date.now()}`);
|
||||||
|
const account = await triggerOAuth('gemini', {
|
||||||
|
headless: scenario === 'original-direct-unsupported' ? false : undefined,
|
||||||
|
});
|
||||||
|
const output = logSpy.mock.calls.map(([message]) => String(message)).join('\n');
|
||||||
|
|
||||||
|
expect(account).toBeNull();
|
||||||
|
|
||||||
|
if (scenario === 'plus-missing-env') {
|
||||||
|
expect(ensureBinaryMock).not.toHaveBeenCalled();
|
||||||
|
expect(output).toContain('CLIPROXY_GEMINI_OAUTH_CLIENT_ID');
|
||||||
|
expect(output).toContain('CLIPROXY_GEMINI_OAUTH_CLIENT_SECRET');
|
||||||
|
expect(output).toContain(
|
||||||
|
'Current `cliproxy.backend: original` releases do not advertise Gemini login'
|
||||||
|
);
|
||||||
|
expect(output).not.toContain('switch `cliproxy.backend` to `original` for Gemini');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(ensureBinaryMock).toHaveBeenCalledTimes(1);
|
||||||
|
const firstEnsureBinaryCall = ensureBinaryMock.mock.calls.at(0) as unknown[] | undefined;
|
||||||
|
expect(firstEnsureBinaryCall?.[1]).toEqual(
|
||||||
|
expect.objectContaining({ backend: 'original', skipAutoUpdate: true })
|
||||||
|
);
|
||||||
|
|
||||||
|
if (scenario === 'original-headless-auto-paste-supported') {
|
||||||
|
expect(output).not.toContain(
|
||||||
|
'The active `cliproxy.backend: original` runtime cannot start Gemini OAuth from CCS'
|
||||||
|
);
|
||||||
|
expect(output).toContain('Starting Google Gemini OAuth (paste-callback mode)...');
|
||||||
|
expect(output).toContain('Failed to start OAuth flow');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(output).toContain(
|
||||||
|
'The active `cliproxy.backend: original` runtime cannot start Gemini OAuth from CCS'
|
||||||
|
);
|
||||||
|
expect(output).toContain('CLIPROXY_GEMINI_OAUTH_CLIENT_ID');
|
||||||
|
expect(output).toContain('CLIPROXY_GEMINI_OAUTH_CLIENT_SECRET');
|
||||||
|
} finally {
|
||||||
|
spawnSpy.mockRestore();
|
||||||
|
logSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user