mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 12:15:57 +00:00
1018 lines
34 KiB
TypeScript
1018 lines
34 KiB
TypeScript
/**
|
|
* Gemini CLI Quota Fetcher Unit Tests
|
|
*
|
|
* Tests for Gemini CLI bucket parsing and transformation logic
|
|
*/
|
|
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
import { getCapturedFetchRequests, mockFetch, restoreFetch } from '../../mocks';
|
|
|
|
describe('Gemini CLI Quota Fetcher', () => {
|
|
const GEMINI_QUOTA_URL = 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota';
|
|
const GEMINI_CODE_ASSIST_URL = 'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist';
|
|
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
let tempHome: string;
|
|
let originalCcsHome: string | undefined;
|
|
let originalCcsDir: string | undefined;
|
|
let originalGeminiClientId: string | undefined;
|
|
let originalGeminiClientSecret: string | undefined;
|
|
let moduleVersion = 0;
|
|
let buildGeminiCliBuckets: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').buildGeminiCliBuckets;
|
|
let fetchGeminiCliQuota: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').fetchGeminiCliQuota;
|
|
let resolveGeminiCliProjectId: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').resolveGeminiCliProjectId;
|
|
let geminiTestExports: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').__testExports;
|
|
let refreshGeminiToken: typeof import('../../../src/cliproxy/auth/gemini-token-refresh').refreshGeminiToken;
|
|
let getProviderAuthDir: typeof import('../../../src/cliproxy/config-generator').getProviderAuthDir;
|
|
|
|
function writeGeminiToken(token: Record<string, unknown>, filename = 'gemini-test.json'): string {
|
|
const authDir = getProviderAuthDir('gemini');
|
|
fs.mkdirSync(authDir, { recursive: true });
|
|
const tokenPath = path.join(authDir, filename);
|
|
fs.writeFileSync(tokenPath, JSON.stringify(token, null, 2));
|
|
return tokenPath;
|
|
}
|
|
|
|
function writeActiveGeminiAccount(
|
|
accountId: string,
|
|
overrides: Record<string, unknown> = {}
|
|
): string {
|
|
return writeGeminiToken({
|
|
type: 'gemini',
|
|
email: accountId,
|
|
project_id: 'cloudaicompanion-test-123',
|
|
token: {
|
|
access_token: 'access-token',
|
|
refresh_token: 'refresh-token',
|
|
expiry: Date.now() + 60 * 60 * 1000,
|
|
client_id: 'test-client-id',
|
|
client_secret: 'test-client-secret',
|
|
token_uri: GOOGLE_TOKEN_URL,
|
|
},
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
moduleVersion += 1;
|
|
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-gemini-refresh-'));
|
|
originalCcsHome = process.env.CCS_HOME;
|
|
originalCcsDir = process.env.CCS_DIR;
|
|
originalGeminiClientId = process.env.CCS_GEMINI_OAUTH_CLIENT_ID;
|
|
originalGeminiClientSecret = process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET;
|
|
process.env.CCS_HOME = tempHome;
|
|
|
|
delete process.env.CCS_GEMINI_OAUTH_CLIENT_ID;
|
|
delete process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET;
|
|
delete process.env.CCS_DIR;
|
|
|
|
const configGenerator = await import(
|
|
`../../../src/cliproxy/config-generator?gemini-config-generator=${moduleVersion}`
|
|
);
|
|
({
|
|
buildGeminiCliBuckets,
|
|
fetchGeminiCliQuota,
|
|
resolveGeminiCliProjectId,
|
|
__testExports: geminiTestExports,
|
|
} = await import(
|
|
`../../../src/cliproxy/quota-fetcher-gemini-cli?gemini-quota-fetcher=${moduleVersion}`
|
|
));
|
|
({ refreshGeminiToken } = await import(
|
|
`../../../src/cliproxy/auth/gemini-token-refresh?gemini-refresh=${moduleVersion}`
|
|
));
|
|
({ getProviderAuthDir } = configGenerator);
|
|
});
|
|
|
|
afterEach(() => {
|
|
restoreFetch();
|
|
fs.rmSync(tempHome, { recursive: true, force: true });
|
|
|
|
if (originalCcsHome === undefined) {
|
|
delete process.env.CCS_HOME;
|
|
} else {
|
|
process.env.CCS_HOME = originalCcsHome;
|
|
}
|
|
|
|
if (originalCcsDir === undefined) {
|
|
delete process.env.CCS_DIR;
|
|
} else {
|
|
process.env.CCS_DIR = originalCcsDir;
|
|
}
|
|
|
|
if (originalGeminiClientId === undefined) {
|
|
delete process.env.CCS_GEMINI_OAUTH_CLIENT_ID;
|
|
} else {
|
|
process.env.CCS_GEMINI_OAUTH_CLIENT_ID = originalGeminiClientId;
|
|
}
|
|
|
|
if (originalGeminiClientSecret === undefined) {
|
|
delete process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET;
|
|
} else {
|
|
process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET = originalGeminiClientSecret;
|
|
}
|
|
});
|
|
|
|
describe('resolveGeminiCliProjectId', () => {
|
|
it('should extract project ID from account field', () => {
|
|
const account = 'user@example.com (cloudaicompanion-abc-123)';
|
|
const projectId = resolveGeminiCliProjectId(account);
|
|
expect(projectId).toBe('cloudaicompanion-abc-123');
|
|
});
|
|
|
|
it('should return last parenthetical when multiple exist', () => {
|
|
const account = 'user (org) (cloudaicompanion-xyz-789)';
|
|
const projectId = resolveGeminiCliProjectId(account);
|
|
expect(projectId).toBe('cloudaicompanion-xyz-789');
|
|
});
|
|
|
|
it('should return null for account without parentheses', () => {
|
|
const account = 'user@example.com';
|
|
const projectId = resolveGeminiCliProjectId(account);
|
|
expect(projectId).toBeNull();
|
|
});
|
|
|
|
it('should return null for empty string', () => {
|
|
const projectId = resolveGeminiCliProjectId('');
|
|
expect(projectId).toBeNull();
|
|
});
|
|
|
|
it('should handle nested parentheses', () => {
|
|
const account = 'user@example.com (project-id)';
|
|
const projectId = resolveGeminiCliProjectId(account);
|
|
expect(projectId).toBe('project-id');
|
|
});
|
|
});
|
|
|
|
describe('buildGeminiCliBuckets', () => {
|
|
it('should group models by series', () => {
|
|
const rawBuckets = [
|
|
{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.8 },
|
|
{ model_id: 'gemini-2.5-flash', remaining_fraction: 0.6 },
|
|
{ model_id: 'gemini-3-pro-preview', remaining_fraction: 0.9 },
|
|
];
|
|
|
|
const buckets = buildGeminiCliBuckets(rawBuckets);
|
|
|
|
// Should have 2 groups: Flash Series and Pro Series
|
|
expect(buckets.length).toBeGreaterThanOrEqual(2);
|
|
|
|
const flashBucket = buckets.find((b) => b.label === 'Gemini Flash Series');
|
|
expect(flashBucket).toBeDefined();
|
|
// Uses the preferred representative model when it exists
|
|
expect(flashBucket!.remainingFraction).toBe(0.8);
|
|
expect(flashBucket!.remainingPercent).toBe(80);
|
|
|
|
const proBucket = buckets.find((b) => b.label === 'Gemini Pro Series');
|
|
expect(proBucket).toBeDefined();
|
|
expect(proBucket!.remainingFraction).toBe(0.9);
|
|
});
|
|
|
|
it('should split Flash Lite into its own bucket', () => {
|
|
const rawBuckets = [
|
|
{ model_id: 'gemini-2.5-flash-lite', remaining_fraction: 1 },
|
|
{ model_id: 'gemini-2.5-flash', remaining_fraction: 0.7 },
|
|
];
|
|
|
|
const buckets = buildGeminiCliBuckets(rawBuckets);
|
|
|
|
expect(buckets.map((bucket) => bucket.label)).toEqual([
|
|
'Gemini Flash Lite Series',
|
|
'Gemini Flash Series',
|
|
]);
|
|
});
|
|
|
|
it('should keep Gemini 3.1 Flash Lite preview inside the Flash Lite family', () => {
|
|
const rawBuckets = [{ model_id: 'gemini-3.1-flash-lite-preview', remaining_fraction: 0.65 }];
|
|
|
|
const buckets = buildGeminiCliBuckets(rawBuckets);
|
|
|
|
expect(buckets).toHaveLength(1);
|
|
expect(buckets[0].label).toBe('Gemini Flash Lite Series');
|
|
expect(buckets[0].modelIds).toContain('gemini-3.1-flash-lite-preview');
|
|
});
|
|
|
|
it('should recognize Gemini 3.1 preview IDs during the rollout', () => {
|
|
const rawBuckets = [
|
|
{ model_id: 'gemini-3.1-flash-preview', remaining_fraction: 0.7 },
|
|
{ model_id: 'gemini-3.1-pro-preview', remaining_fraction: 0.4 },
|
|
];
|
|
|
|
const buckets = buildGeminiCliBuckets(rawBuckets);
|
|
|
|
const flashBucket = buckets.find((b) => b.label === 'Gemini Flash Series');
|
|
const proBucket = buckets.find((b) => b.label === 'Gemini Pro Series');
|
|
|
|
expect(flashBucket).toBeDefined();
|
|
expect(flashBucket!.modelIds).toContain('gemini-3.1-flash-preview');
|
|
expect(proBucket).toBeDefined();
|
|
expect(proBucket!.modelIds).toContain('gemini-3.1-pro-preview');
|
|
});
|
|
|
|
it('should handle camelCase API response', () => {
|
|
const rawBuckets = [{ modelId: 'gemini-3-flash-preview', remainingFraction: 0.75 }];
|
|
|
|
const buckets = buildGeminiCliBuckets(rawBuckets);
|
|
|
|
expect(buckets).toHaveLength(1);
|
|
expect(buckets[0].remainingFraction).toBe(0.75);
|
|
});
|
|
|
|
it('should clamp remainingFraction to 0-1 range', () => {
|
|
const rawBuckets = [
|
|
{ model_id: 'gemini-3-flash-preview', remaining_fraction: 1.5 },
|
|
{ model_id: 'gemini-3-pro-preview', remaining_fraction: -0.2 },
|
|
];
|
|
|
|
const buckets = buildGeminiCliBuckets(rawBuckets);
|
|
|
|
const flashBucket = buckets.find((b) => b.label === 'Gemini Flash Series');
|
|
expect(flashBucket!.remainingFraction).toBe(1);
|
|
expect(flashBucket!.remainingPercent).toBe(100);
|
|
|
|
const proBucket = buckets.find((b) => b.label === 'Gemini Pro Series');
|
|
expect(proBucket!.remainingFraction).toBe(0);
|
|
expect(proBucket!.remainingPercent).toBe(0);
|
|
});
|
|
|
|
it('should group by token type', () => {
|
|
const rawBuckets = [
|
|
{ model_id: 'gemini-3-flash-preview', token_type: 'input', remaining_fraction: 0.8 },
|
|
{ model_id: 'gemini-3-flash-preview', token_type: 'output', remaining_fraction: 0.5 },
|
|
];
|
|
|
|
const buckets = buildGeminiCliBuckets(rawBuckets);
|
|
|
|
// Should have separate buckets for input and output
|
|
expect(buckets.length).toBe(2);
|
|
const inputBucket = buckets.find((b) => b.tokenType === 'input');
|
|
const outputBucket = buckets.find((b) => b.tokenType === 'output');
|
|
expect(inputBucket).toBeDefined();
|
|
expect(outputBucket).toBeDefined();
|
|
expect(inputBucket!.remainingFraction).toBe(0.8);
|
|
expect(outputBucket!.remainingFraction).toBe(0.5);
|
|
});
|
|
|
|
it('should ignore deprecated models', () => {
|
|
const rawBuckets = [
|
|
{ model_id: 'gemini-2.0-flash-deprecated', remaining_fraction: 0.1 },
|
|
{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.9 },
|
|
];
|
|
|
|
const buckets = buildGeminiCliBuckets(rawBuckets);
|
|
|
|
// Only gemini-3-flash-preview should be included
|
|
expect(buckets).toHaveLength(1);
|
|
expect(buckets[0].remainingFraction).toBe(0.9);
|
|
});
|
|
|
|
it('should preserve unknown model IDs instead of collapsing them', () => {
|
|
const rawBuckets = [{ model_id: 'unknown-model-xyz', remaining_fraction: 0.7 }];
|
|
|
|
const buckets = buildGeminiCliBuckets(rawBuckets);
|
|
|
|
expect(buckets).toHaveLength(1);
|
|
expect(buckets[0].label).toBe('unknown-model-xyz');
|
|
});
|
|
|
|
it('should handle empty buckets array', () => {
|
|
const buckets = buildGeminiCliBuckets([]);
|
|
expect(buckets).toHaveLength(0);
|
|
});
|
|
|
|
it('should skip buckets with empty model_id', () => {
|
|
const rawBuckets = [
|
|
{ model_id: '', remaining_fraction: 0.5 },
|
|
{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.8 },
|
|
];
|
|
|
|
const buckets = buildGeminiCliBuckets(rawBuckets);
|
|
|
|
expect(buckets).toHaveLength(1);
|
|
expect(buckets[0].remainingFraction).toBe(0.8);
|
|
});
|
|
|
|
it('should keep the representative model reset time when it exists', () => {
|
|
const rawBuckets = [
|
|
{
|
|
model_id: 'gemini-3-flash-preview',
|
|
remaining_fraction: 0.8,
|
|
reset_time: '2026-01-30T12:00:00Z',
|
|
},
|
|
{
|
|
model_id: 'gemini-2.5-flash',
|
|
remaining_fraction: 0.6,
|
|
reset_time: '2026-01-30T10:00:00Z', // Earlier
|
|
},
|
|
];
|
|
|
|
const buckets = buildGeminiCliBuckets(rawBuckets);
|
|
|
|
const flashBucket = buckets.find((b) => b.label === 'Gemini Flash Series');
|
|
expect(flashBucket!.resetTime).toBe('2026-01-30T12:00:00Z');
|
|
});
|
|
|
|
it('should keep earliest reset time when the representative model is missing', () => {
|
|
const rawBuckets = [
|
|
{
|
|
model_id: 'gemini-3.1-flash-preview',
|
|
remaining_fraction: 0.8,
|
|
reset_time: '2026-01-30T12:00:00Z',
|
|
},
|
|
{
|
|
model_id: 'gemini-2.5-flash',
|
|
remaining_fraction: 0.6,
|
|
reset_time: '2026-01-30T10:00:00Z',
|
|
},
|
|
];
|
|
|
|
const buckets = buildGeminiCliBuckets(rawBuckets);
|
|
|
|
const flashBucket = buckets.find((b) => b.label === 'Gemini Flash Series');
|
|
expect(flashBucket!.resetTime).toBe('2026-01-30T10:00:00Z');
|
|
});
|
|
|
|
it('should default remainingFraction to 1 when missing', () => {
|
|
const rawBuckets = [{ model_id: 'gemini-3-flash-preview' }];
|
|
|
|
const buckets = buildGeminiCliBuckets(rawBuckets);
|
|
|
|
expect(buckets[0].remainingFraction).toBe(1);
|
|
expect(buckets[0].remainingPercent).toBe(100);
|
|
});
|
|
|
|
it('should collect modelIds in bucket', () => {
|
|
const rawBuckets = [
|
|
{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.8 },
|
|
{ model_id: 'gemini-2.5-flash', remaining_fraction: 0.6 },
|
|
];
|
|
|
|
const buckets = buildGeminiCliBuckets(rawBuckets);
|
|
|
|
const flashBucket = buckets.find((b) => b.label === 'Gemini Flash Series');
|
|
expect(flashBucket!.modelIds).toContain('gemini-3-flash-preview');
|
|
expect(flashBucket!.modelIds).toContain('gemini-2.5-flash');
|
|
});
|
|
});
|
|
|
|
describe('fetchGeminiCliQuota success metadata', () => {
|
|
it('merges tier and credit metadata into successful quota responses', async () => {
|
|
writeActiveGeminiAccount('success@example.com');
|
|
|
|
mockFetch([
|
|
{
|
|
url: GEMINI_QUOTA_URL,
|
|
method: 'POST',
|
|
status: 200,
|
|
response: {
|
|
buckets: [
|
|
{
|
|
model_id: 'gemini-2.5-flash-lite',
|
|
remaining_fraction: 1,
|
|
remaining_amount: 100,
|
|
reset_time: '2026-01-30T09:00:00Z',
|
|
},
|
|
{
|
|
model_id: 'gemini-3-flash-preview',
|
|
remaining_fraction: 0.82,
|
|
remaining_amount: 82,
|
|
reset_time: '2026-01-30T14:00:00Z',
|
|
},
|
|
{
|
|
model_id: 'gemini-2.5-flash',
|
|
remaining_fraction: 0.4,
|
|
remaining_amount: 40,
|
|
reset_time: '2026-01-30T10:00:00Z',
|
|
},
|
|
{
|
|
model_id: 'gemini-3.1-pro-preview',
|
|
remaining_fraction: 0.91,
|
|
remaining_amount: 91,
|
|
reset_time: '2026-01-30T15:00:00Z',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
url: GEMINI_CODE_ASSIST_URL,
|
|
method: 'POST',
|
|
status: 200,
|
|
response: {
|
|
paidTier: {
|
|
id: 'g1-pro-tier',
|
|
availableCredits: [{ creditType: 'GOOGLE_ONE_AI', creditAmount: 12 }],
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
|
|
const result = await fetchGeminiCliQuota('success@example.com');
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.tierLabel).toBe('Pro');
|
|
expect(result.tierId).toBe('g1-pro-tier');
|
|
expect(result.creditBalance).toBe(12);
|
|
expect(result.entitlement).toMatchObject({
|
|
normalizedTier: 'pro',
|
|
rawTierId: 'g1-pro-tier',
|
|
rawTierLabel: 'Pro',
|
|
accessState: 'entitled',
|
|
capacityState: 'available',
|
|
});
|
|
expect(result.buckets.map((bucket) => bucket.label)).toEqual([
|
|
'Gemini Flash Lite Series',
|
|
'Gemini Flash Series',
|
|
'Gemini Pro Series',
|
|
]);
|
|
expect(result.buckets[0].remainingAmount).toBe(100);
|
|
|
|
const flashBucket = result.buckets.find((bucket) => bucket.label === 'Gemini Flash Series');
|
|
expect(flashBucket?.remainingPercent).toBe(82);
|
|
expect(flashBucket?.remainingAmount).toBe(82);
|
|
expect(flashBucket?.resetTime).toBe('2026-01-30T14:00:00Z');
|
|
|
|
const requestUrls = getCapturedFetchRequests().map((request) => request.url);
|
|
expect(requestUrls).toContain(GEMINI_QUOTA_URL);
|
|
expect(requestUrls).toContain(GEMINI_CODE_ASSIST_URL);
|
|
});
|
|
|
|
it('keeps base quota success when supplementary metadata fails', async () => {
|
|
writeActiveGeminiAccount('supplementary-failure@example.com');
|
|
|
|
mockFetch([
|
|
{
|
|
url: GEMINI_QUOTA_URL,
|
|
method: 'POST',
|
|
status: 200,
|
|
response: {
|
|
buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.75 }],
|
|
},
|
|
},
|
|
{
|
|
url: GEMINI_CODE_ASSIST_URL,
|
|
method: 'POST',
|
|
status: 503,
|
|
response: { error: { message: 'Service unavailable' } },
|
|
},
|
|
]);
|
|
|
|
const result = await fetchGeminiCliQuota('supplementary-failure@example.com');
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.tierLabel).toBeNull();
|
|
expect(result.tierId).toBeNull();
|
|
expect(result.creditBalance).toBeNull();
|
|
expect(result.buckets[0].remainingPercent).toBe(75);
|
|
});
|
|
|
|
it('keeps base quota success when supplementary metadata throws a network error', async () => {
|
|
writeActiveGeminiAccount('supplementary-network@example.com');
|
|
|
|
mockFetch([
|
|
{
|
|
url: GEMINI_QUOTA_URL,
|
|
method: 'POST',
|
|
status: 200,
|
|
response: {
|
|
buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.75 }],
|
|
},
|
|
},
|
|
]);
|
|
|
|
const mockedFetch = globalThis.fetch;
|
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url =
|
|
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
if (url === GEMINI_CODE_ASSIST_URL) {
|
|
throw new TypeError('supplementary network down');
|
|
}
|
|
return mockedFetch(input, init);
|
|
}) as typeof fetch;
|
|
|
|
try {
|
|
const result = await fetchGeminiCliQuota('supplementary-network@example.com');
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.tierLabel).toBeNull();
|
|
expect(result.tierId).toBeNull();
|
|
expect(result.creditBalance).toBeNull();
|
|
expect(result.buckets[0].remainingPercent).toBe(75);
|
|
} finally {
|
|
globalThis.fetch = mockedFetch;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('fetchGeminiCliQuota failure metadata', () => {
|
|
it('maps 401 responses to reauth-required metadata', async () => {
|
|
writeActiveGeminiAccount('reauth@example.com');
|
|
|
|
mockFetch([
|
|
{
|
|
url: GEMINI_QUOTA_URL,
|
|
method: 'POST',
|
|
status: 401,
|
|
response: {
|
|
error: {
|
|
message: 'Session expired',
|
|
status: 'UNAUTHENTICATED',
|
|
},
|
|
},
|
|
},
|
|
{
|
|
url: GOOGLE_TOKEN_URL,
|
|
method: 'POST',
|
|
status: 400,
|
|
response: {
|
|
error: 'invalid_grant',
|
|
},
|
|
},
|
|
]);
|
|
|
|
const result = await fetchGeminiCliQuota('reauth@example.com');
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.httpStatus).toBe(401);
|
|
expect(result.errorCode).toBe('UNAUTHENTICATED');
|
|
expect(result.needsReauth).toBe(true);
|
|
expect(result.retryable).toBe(false);
|
|
expect(result.actionHint).toContain('ccs gemini --auth');
|
|
expect(result.error).toBe('Session expired');
|
|
});
|
|
|
|
it('preserves 403 verification detail and exposes a helpful action hint', async () => {
|
|
writeActiveGeminiAccount('verify@example.com');
|
|
|
|
mockFetch([
|
|
{
|
|
url: GEMINI_QUOTA_URL,
|
|
method: 'POST',
|
|
status: 403,
|
|
response: {
|
|
error: {
|
|
message: 'Google requires you to verify this account before using Gemini CLI quota.',
|
|
status: 'PERMISSION_DENIED',
|
|
details: [
|
|
{
|
|
reason: 'ACCOUNT_VERIFICATION_REQUIRED',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
|
|
const result = await fetchGeminiCliQuota('verify@example.com');
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.httpStatus).toBe(403);
|
|
expect(result.isForbidden).toBe(true);
|
|
expect(result.retryable).toBe(false);
|
|
expect(result.error).toContain('verify this account');
|
|
expect(result.actionHint).toContain('verification');
|
|
expect(result.errorDetail).toContain('ACCOUNT_VERIFICATION_REQUIRED');
|
|
});
|
|
|
|
it('marks 429 responses as retryable', async () => {
|
|
writeActiveGeminiAccount('rate-limit@example.com');
|
|
|
|
mockFetch([
|
|
{
|
|
url: GEMINI_QUOTA_URL,
|
|
method: 'POST',
|
|
status: 429,
|
|
response: {
|
|
error: {
|
|
message: 'Too many quota requests',
|
|
status: 'RESOURCE_EXHAUSTED',
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
|
|
const result = await fetchGeminiCliQuota('rate-limit@example.com');
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.httpStatus).toBe(429);
|
|
expect(result.retryable).toBe(true);
|
|
expect(result.errorCode).toBe('RESOURCE_EXHAUSTED');
|
|
expect(result.actionHint).toContain('Retry');
|
|
expect(result.error).toBe('Too many quota requests');
|
|
});
|
|
|
|
it('preserves non-JSON upstream error text when Gemini returns a plain-text failure', async () => {
|
|
writeActiveGeminiAccount('plaintext@example.com');
|
|
|
|
mockFetch([
|
|
{
|
|
url: GEMINI_QUOTA_URL,
|
|
method: 'POST',
|
|
status: 418,
|
|
headers: { 'Content-Type': 'text/plain' },
|
|
response: 'Internal Server Error',
|
|
},
|
|
]);
|
|
|
|
const result = await fetchGeminiCliQuota('plaintext@example.com');
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.httpStatus).toBe(418);
|
|
expect(result.errorCode).toBe('quota_request_failed');
|
|
expect(result.retryable).toBe(false);
|
|
expect(result.error).toBe('Internal Server Error');
|
|
expect(result.errorDetail).toBe('Internal Server Error');
|
|
});
|
|
|
|
it('marks 5xx Gemini quota responses as retryable provider outages', async () => {
|
|
writeActiveGeminiAccount('outage@example.com');
|
|
|
|
mockFetch([
|
|
{
|
|
url: GEMINI_QUOTA_URL,
|
|
method: 'POST',
|
|
status: 503,
|
|
headers: { 'Content-Type': 'text/plain' },
|
|
response: 'Service temporarily unavailable',
|
|
},
|
|
]);
|
|
|
|
const result = await fetchGeminiCliQuota('outage@example.com');
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.httpStatus).toBe(503);
|
|
expect(result.errorCode).toBe('provider_unavailable');
|
|
expect(result.retryable).toBe(true);
|
|
expect(result.actionHint).toContain('temporary Google upstream problem');
|
|
expect(result.error).toBe('Service temporarily unavailable');
|
|
});
|
|
|
|
it('omits raw HTML upstream bodies from Gemini quota error detail', async () => {
|
|
writeActiveGeminiAccount('html@example.com');
|
|
|
|
mockFetch([
|
|
{
|
|
url: GEMINI_QUOTA_URL,
|
|
method: 'POST',
|
|
status: 502,
|
|
headers: { 'Content-Type': 'text/html' },
|
|
response: '<!doctype html><html><body>bad gateway</body></html>',
|
|
},
|
|
]);
|
|
|
|
const result = await fetchGeminiCliQuota('html@example.com');
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('Gemini quota service unavailable (HTTP 502)');
|
|
expect(result.errorDetail).toBe('[HTML error response omitted]');
|
|
});
|
|
|
|
it('refreshes the requested Gemini account instead of the default account', async () => {
|
|
writeGeminiToken(
|
|
{
|
|
type: 'gemini',
|
|
email: 'default@example.com',
|
|
project_id: 'default-project',
|
|
token: {
|
|
access_token: 'default-access-token',
|
|
refresh_token: 'default-refresh-token',
|
|
expiry: Date.now() + 60 * 60 * 1000,
|
|
client_id: 'default-client-id',
|
|
client_secret: 'default-client-secret',
|
|
token_uri: GOOGLE_TOKEN_URL,
|
|
},
|
|
},
|
|
'gemini-default.json'
|
|
);
|
|
|
|
writeGeminiToken(
|
|
{
|
|
type: 'gemini',
|
|
email: 'target@example.com',
|
|
project_id: 'target-project',
|
|
token: {
|
|
access_token: 'target-stale-token',
|
|
refresh_token: 'target-refresh-token',
|
|
expiry: Date.now() - 1000,
|
|
client_id: 'target-client-id',
|
|
client_secret: 'target-client-secret',
|
|
token_uri: GOOGLE_TOKEN_URL,
|
|
},
|
|
},
|
|
'gemini-target.json'
|
|
);
|
|
|
|
mockFetch([
|
|
{
|
|
url: GOOGLE_TOKEN_URL,
|
|
method: 'POST',
|
|
response: { access_token: 'target-fresh-token', expires_in: 1800 },
|
|
},
|
|
{
|
|
url: GEMINI_QUOTA_URL,
|
|
method: 'POST',
|
|
status: 200,
|
|
response: {
|
|
buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.88 }],
|
|
},
|
|
},
|
|
{
|
|
url: GEMINI_CODE_ASSIST_URL,
|
|
method: 'POST',
|
|
status: 503,
|
|
response: { error: { message: 'supplementary unavailable' } },
|
|
},
|
|
]);
|
|
|
|
const result = await fetchGeminiCliQuota('target@example.com');
|
|
|
|
expect(result.success).toBe(true);
|
|
|
|
const [refreshRequest, quotaRequest] = getCapturedFetchRequests();
|
|
expect(refreshRequest.url).toBe(GOOGLE_TOKEN_URL);
|
|
expect(refreshRequest.body).toContain('refresh_token=target-refresh-token');
|
|
expect(refreshRequest.body).not.toContain('default-refresh-token');
|
|
expect(quotaRequest.headers.Authorization).toBe('Bearer target-fresh-token');
|
|
});
|
|
|
|
it('retries a 401 quota failure after a transient proactive refresh failure', async () => {
|
|
writeGeminiToken(
|
|
{
|
|
type: 'gemini',
|
|
email: 'retry@example.com',
|
|
project_id: 'retry-project',
|
|
token: {
|
|
access_token: 'retry-stale-token',
|
|
refresh_token: 'retry-refresh-token',
|
|
expiry: Date.now() + 60 * 1000,
|
|
client_id: 'retry-client-id',
|
|
client_secret: 'retry-client-secret',
|
|
token_uri: GOOGLE_TOKEN_URL,
|
|
},
|
|
},
|
|
'gemini-retry.json'
|
|
);
|
|
|
|
mockFetch([
|
|
{
|
|
url: GOOGLE_TOKEN_URL,
|
|
method: 'POST',
|
|
response: { access_token: 'unused-default', expires_in: 1800 },
|
|
},
|
|
{
|
|
url: GEMINI_QUOTA_URL,
|
|
method: 'POST',
|
|
status: 200,
|
|
response: {
|
|
buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.9 }],
|
|
},
|
|
},
|
|
{
|
|
url: GEMINI_CODE_ASSIST_URL,
|
|
method: 'POST',
|
|
status: 503,
|
|
response: { error: { message: 'supplementary unavailable' } },
|
|
},
|
|
]);
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
let refreshAttempt = 0;
|
|
let quotaAttempt = 0;
|
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url =
|
|
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
|
|
if (url === GOOGLE_TOKEN_URL) {
|
|
refreshAttempt += 1;
|
|
return refreshAttempt === 1
|
|
? new Response(JSON.stringify({ error: 'temporarily_unavailable' }), {
|
|
status: 503,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
: new Response(JSON.stringify({ access_token: 'retry-fresh-token', expires_in: 1800 }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
if (url === GEMINI_QUOTA_URL) {
|
|
quotaAttempt += 1;
|
|
return quotaAttempt === 1
|
|
? new Response(
|
|
JSON.stringify({
|
|
error: {
|
|
message: 'Session expired',
|
|
status: 'UNAUTHENTICATED',
|
|
},
|
|
}),
|
|
{
|
|
status: 401,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}
|
|
)
|
|
: new Response(
|
|
JSON.stringify({
|
|
buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.9 }],
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}
|
|
);
|
|
}
|
|
|
|
return originalFetch(input, init);
|
|
}) as typeof fetch;
|
|
|
|
try {
|
|
const result = await fetchGeminiCliQuota('retry@example.com');
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(refreshAttempt).toBe(2);
|
|
expect(quotaAttempt).toBe(2);
|
|
|
|
const storedToken = JSON.parse(
|
|
fs.readFileSync(path.join(getProviderAuthDir('gemini'), 'gemini-retry.json'), 'utf8')
|
|
) as { token?: { access_token?: string } };
|
|
expect(storedToken.token?.access_token).toBe('retry-fresh-token');
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
it('classifies model capacity exhaustion separately from generic rate limits', async () => {
|
|
writeActiveGeminiAccount('capacity@example.com');
|
|
|
|
mockFetch([
|
|
{
|
|
url: GEMINI_QUOTA_URL,
|
|
method: 'POST',
|
|
status: 429,
|
|
response: {
|
|
error: {
|
|
code: 429,
|
|
message: 'No capacity available for model gemini-3.1-pro-preview on the server',
|
|
status: 'RESOURCE_EXHAUSTED',
|
|
details: [
|
|
{
|
|
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
|
reason: 'MODEL_CAPACITY_EXHAUSTED',
|
|
metadata: { model: 'gemini-3.1-pro-preview' },
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
|
|
const result = await fetchGeminiCliQuota('capacity@example.com');
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.httpStatus).toBe(429);
|
|
expect(result.errorCode).toBe('capacity_exhausted');
|
|
expect(result.retryable).toBe(true);
|
|
expect(result.entitlement).toMatchObject({
|
|
accessState: 'entitled',
|
|
capacityState: 'capacity_exhausted',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('direct Gemini error helper coverage', () => {
|
|
it('sanitizes HTML and truncates oversized token-bearing error details', () => {
|
|
const longTokenBody = JSON.stringify({
|
|
access_token: 'super-secret-token',
|
|
detail: `Bearer top-secret ${'x'.repeat(400)}`,
|
|
});
|
|
|
|
const sanitized = geminiTestExports.sanitizeGeminiCliErrorDetail(longTokenBody);
|
|
|
|
expect(sanitized).toContain('[redacted]');
|
|
expect(sanitized).toContain('Bearer [redacted]');
|
|
expect(sanitized?.endsWith('...[truncated]')).toBe(true);
|
|
expect(sanitized?.length).toBeLessThanOrEqual(320);
|
|
expect(geminiTestExports.sanitizeGeminiCliErrorDetail('<html>bad gateway</html>')).toBe(
|
|
'[HTML error response omitted]'
|
|
);
|
|
});
|
|
|
|
it('extracts nested messages and parses structured JSON error bodies', () => {
|
|
expect(
|
|
geminiTestExports.extractGeminiCliNestedMessage([
|
|
{ reason: 'ACCOUNT_VERIFICATION_REQUIRED' },
|
|
])
|
|
).toBe('ACCOUNT_VERIFICATION_REQUIRED');
|
|
|
|
const parsed = geminiTestExports.parseGeminiCliErrorBody(
|
|
JSON.stringify({
|
|
error: {
|
|
message: 'Verification required',
|
|
status: 'PERMISSION_DENIED',
|
|
details: [{ reason: 'ACCOUNT_VERIFICATION_REQUIRED' }],
|
|
},
|
|
})
|
|
);
|
|
|
|
expect(parsed.message).toBe('Verification required');
|
|
expect(parsed.errorCode).toBe('PERMISSION_DENIED');
|
|
expect(parsed.errorDetail).toContain('ACCOUNT_VERIFICATION_REQUIRED');
|
|
});
|
|
|
|
it('builds verification and project-specific forbidden action hints', () => {
|
|
expect(
|
|
geminiTestExports.buildGeminiCliForbiddenActionHint({
|
|
message: 'Please verify this account',
|
|
errorDetail: 'ACCOUNT_VERIFICATION_REQUIRED',
|
|
})
|
|
).toContain('verification');
|
|
|
|
expect(
|
|
geminiTestExports.buildGeminiCliForbiddenActionHint({
|
|
message: 'Project no longer has access',
|
|
})
|
|
).toContain('project');
|
|
});
|
|
});
|
|
|
|
describe('refreshGeminiToken', () => {
|
|
it('uses OAuth client metadata stored in the token file', async () => {
|
|
writeGeminiToken({
|
|
type: 'gemini',
|
|
email: 'file@example.com',
|
|
token: {
|
|
access_token: 'old-token',
|
|
refresh_token: 'refresh-from-file',
|
|
expiry: Date.now() - 1000,
|
|
client_id: 'file-client-id',
|
|
client_secret: 'file-client-secret',
|
|
token_uri: 'https://oauth2.googleapis.com/token',
|
|
},
|
|
});
|
|
|
|
mockFetch([
|
|
{
|
|
url: 'https://oauth2.googleapis.com/token',
|
|
method: 'POST',
|
|
response: { access_token: 'fresh-token', expires_in: 1800 },
|
|
},
|
|
]);
|
|
|
|
const result = await refreshGeminiToken();
|
|
|
|
expect(result.success).toBe(true);
|
|
const [request] = getCapturedFetchRequests();
|
|
expect(request.body).toContain('client_id=file-client-id');
|
|
expect(request.body).toContain('client_secret=file-client-secret');
|
|
expect(request.body).toContain('refresh_token=refresh-from-file');
|
|
});
|
|
|
|
it('falls back to CCS_GEMINI_OAUTH_CLIENT_* env vars when token metadata is missing', async () => {
|
|
process.env.CCS_GEMINI_OAUTH_CLIENT_ID = 'env-client-id';
|
|
process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET = 'env-client-secret';
|
|
|
|
writeGeminiToken({
|
|
type: 'gemini',
|
|
email: 'env@example.com',
|
|
token: {
|
|
access_token: 'old-token',
|
|
refresh_token: 'refresh-from-file',
|
|
expiry: Date.now() - 1000,
|
|
},
|
|
});
|
|
|
|
mockFetch([
|
|
{
|
|
url: 'https://oauth2.googleapis.com/token',
|
|
method: 'POST',
|
|
response: { access_token: 'fresh-token', expires_in: 1800 },
|
|
},
|
|
]);
|
|
|
|
const result = await refreshGeminiToken();
|
|
|
|
expect(result.success).toBe(true);
|
|
const [request] = getCapturedFetchRequests();
|
|
expect(request.body).toContain('client_id=env-client-id');
|
|
expect(request.body).toContain('client_secret=env-client-secret');
|
|
});
|
|
|
|
it('returns a clear error when no refresh client credentials are available', async () => {
|
|
writeGeminiToken({
|
|
type: 'gemini',
|
|
email: 'missing@example.com',
|
|
token: {
|
|
access_token: 'old-token',
|
|
refresh_token: 'refresh-from-file',
|
|
expiry: Date.now() - 1000,
|
|
},
|
|
});
|
|
|
|
const result = await refreshGeminiToken();
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('CCS_GEMINI_OAUTH_CLIENT_ID');
|
|
expect(result.error).toContain('CCS_GEMINI_OAUTH_CLIENT_SECRET');
|
|
});
|
|
});
|
|
});
|