Files
wehub-resource-sync 0d3cb498a3
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

855 lines
31 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { GoogleAuthManager } from '../../../src/providers/google/auth';
import { mockProcessEnv } from '../../util/utils';
// Mock dependencies
vi.mock('../../../src/envars', () => ({
getEnvString: vi.fn(),
}));
vi.mock('../../../src/logger', () => ({
default: {
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('../../../src/util/file', () => ({
maybeLoadFromExternalFile: vi.fn(),
}));
const mockGoogleAuthInstance = {
getClient: vi.fn().mockResolvedValue({}),
getProjectId: vi.fn().mockResolvedValue('detected-project'),
fromJSON: vi.fn().mockResolvedValue({}),
};
vi.mock('google-auth-library', () => {
const MockGoogleAuth = vi.fn().mockImplementation(() => mockGoogleAuthInstance);
return {
GoogleAuth: MockGoogleAuth,
default: { GoogleAuth: MockGoogleAuth },
};
});
import { getEnvString } from '../../../src/envars';
import logger from '../../../src/logger';
import { maybeLoadFromExternalFile } from '../../../src/util/file';
describe('GoogleAuthManager', () => {
beforeEach(() => {
vi.clearAllMocks();
GoogleAuthManager.clearCache();
// Reset mock instance methods
mockGoogleAuthInstance.getClient.mockClear().mockResolvedValue({});
mockGoogleAuthInstance.getProjectId.mockClear().mockResolvedValue('detected-project');
mockGoogleAuthInstance.fromJSON.mockClear().mockResolvedValue({});
// Clear environment variables
mockProcessEnv({ GOOGLE_API_KEY: undefined });
mockProcessEnv({ GEMINI_API_KEY: undefined });
mockProcessEnv({ VERTEX_API_KEY: undefined });
mockProcessEnv({ PALM_API_KEY: undefined });
mockProcessEnv({ GOOGLE_GENAI_USE_VERTEXAI: undefined });
mockProcessEnv({ GOOGLE_CLOUD_PROJECT: undefined });
mockProcessEnv({ GOOGLE_CLOUD_LOCATION: undefined });
});
// Captured once during collection so afterEach can guarantee no test leaks a patched
// process.emitWarning into the next one (the suppression helper swaps it process-globally).
const realEmitWarning = process.emitWarning;
afterEach(() => {
vi.clearAllMocks();
if (process.emitWarning !== realEmitWarning) {
process.emitWarning = realEmitWarning;
}
});
describe('getApiKey', () => {
it('should prioritize config.apiKey over all env vars', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'GOOGLE_API_KEY') {
return 'google-key';
}
if (key === 'GEMINI_API_KEY') {
return 'gemini-key';
}
return defaultValue as string;
});
const result = GoogleAuthManager.getApiKey({ apiKey: 'config-key' });
expect(result.apiKey).toBe('config-key');
expect(result.source).toBe('config');
});
it('should prioritize VERTEX_API_KEY in vertex mode', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'VERTEX_API_KEY') {
return 'vertex-key';
}
if (key === 'GOOGLE_API_KEY') {
return 'google-key';
}
if (key === 'GEMINI_API_KEY') {
return 'gemini-key';
}
return defaultValue as string;
});
const result = GoogleAuthManager.getApiKey({}, undefined, true);
expect(result.apiKey).toBe('vertex-key');
expect(result.source).toBe('VERTEX_API_KEY');
});
it('should not use VERTEX_API_KEY in non-vertex mode', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'VERTEX_API_KEY') {
return 'vertex-key';
}
if (key === 'GOOGLE_API_KEY') {
return 'google-key';
}
return defaultValue as string;
});
const result = GoogleAuthManager.getApiKey({}, undefined, false);
expect(result.apiKey).toBe('google-key');
expect(result.source).toBe('GOOGLE_API_KEY');
});
it('should prioritize GOOGLE_API_KEY over GEMINI_API_KEY (Python SDK alignment)', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'GOOGLE_API_KEY') {
return 'google-key';
}
if (key === 'GEMINI_API_KEY') {
return 'gemini-key';
}
return defaultValue as string;
});
const result = GoogleAuthManager.getApiKey({});
expect(result.apiKey).toBe('google-key');
expect(result.source).toBe('GOOGLE_API_KEY');
});
it('should fall back to GEMINI_API_KEY when GOOGLE_API_KEY is not set', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'GEMINI_API_KEY') {
return 'gemini-key';
}
return defaultValue as string;
});
const result = GoogleAuthManager.getApiKey({});
expect(result.apiKey).toBe('gemini-key');
expect(result.source).toBe('GEMINI_API_KEY');
});
it('should fall back to PALM_API_KEY in non-vertex mode', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'PALM_API_KEY') {
return 'palm-key';
}
return defaultValue as string;
});
const result = GoogleAuthManager.getApiKey({}, undefined, false);
expect(result.apiKey).toBe('palm-key');
expect(result.source).toBe('PALM_API_KEY');
});
it('should not use PALM_API_KEY in vertex mode', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'PALM_API_KEY') {
return 'palm-key';
}
return defaultValue as string;
});
const result = GoogleAuthManager.getApiKey({}, undefined, true);
expect(result.apiKey).toBeUndefined();
expect(result.source).toBe('none');
});
it('should not use GEMINI_API_KEY in vertex mode', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'GEMINI_API_KEY') {
return 'gemini-key';
}
return defaultValue as string;
});
const result = GoogleAuthManager.getApiKey({}, undefined, true);
expect(result.apiKey).toBeUndefined();
expect(result.source).toBe('none');
});
it('should log debug when both GOOGLE_API_KEY and GEMINI_API_KEY are set (SDK aligned)', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'GOOGLE_API_KEY') {
return 'google-key';
}
if (key === 'GEMINI_API_KEY') {
return 'gemini-key';
}
return defaultValue as string;
});
GoogleAuthManager.getApiKey({});
// This is not an error condition - GOOGLE_API_KEY correctly takes precedence
expect(logger.debug).toHaveBeenCalledWith(
'[Google] Both GOOGLE_API_KEY and GEMINI_API_KEY are set. Using GOOGLE_API_KEY.',
);
});
it('should log debug even when both keys have the same value (SDK aligned)', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'GOOGLE_API_KEY') {
return 'same-key';
}
if (key === 'GEMINI_API_KEY') {
return 'same-key';
}
return defaultValue as string;
});
GoogleAuthManager.getApiKey({});
// This is not an error condition - just informational
expect(logger.debug).toHaveBeenCalledWith(
'[Google] Both GOOGLE_API_KEY and GEMINI_API_KEY are set. Using GOOGLE_API_KEY.',
);
});
it('should use env overrides over process.env', () => {
vi.mocked(getEnvString).mockReturnValue(undefined as unknown as string);
const result = GoogleAuthManager.getApiKey({}, { GOOGLE_API_KEY: 'override-key' });
expect(result.apiKey).toBe('override-key');
expect(result.source).toBe('GOOGLE_API_KEY');
});
it('should return none when no API key is available', () => {
vi.mocked(getEnvString).mockReturnValue(undefined as unknown as string);
const result = GoogleAuthManager.getApiKey({});
expect(result.apiKey).toBeUndefined();
expect(result.source).toBe('none');
});
});
describe('determineVertexMode', () => {
it('should use explicit vertexai config flag', () => {
expect(GoogleAuthManager.determineVertexMode({ vertexai: true })).toBe(true);
expect(GoogleAuthManager.determineVertexMode({ vertexai: false })).toBe(false);
});
it('should check GOOGLE_GENAI_USE_VERTEXAI env var', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'GOOGLE_GENAI_USE_VERTEXAI') {
return 'true';
}
return defaultValue as string;
});
expect(GoogleAuthManager.determineVertexMode({})).toBe(true);
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining('Vertex AI mode enabled via GOOGLE_GENAI_USE_VERTEXAI'),
);
});
it('should handle GOOGLE_GENAI_USE_VERTEXAI=1', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'GOOGLE_GENAI_USE_VERTEXAI') {
return '1';
}
return defaultValue as string;
});
expect(GoogleAuthManager.determineVertexMode({})).toBe(true);
});
it('should handle GOOGLE_GENAI_USE_VERTEXAI=false', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'GOOGLE_GENAI_USE_VERTEXAI') {
return 'false';
}
return defaultValue as string;
});
expect(GoogleAuthManager.determineVertexMode({})).toBe(false);
});
it('should auto-detect vertex mode from projectId', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'VERTEX_PROJECT_ID') {
return 'my-project';
}
return defaultValue as string;
});
expect(GoogleAuthManager.determineVertexMode({})).toBe(true);
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining('Auto-detected Vertex AI mode'),
);
});
it('should auto-detect vertex mode from GOOGLE_CLOUD_PROJECT', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'GOOGLE_CLOUD_PROJECT') {
return 'my-project';
}
return defaultValue as string;
});
expect(GoogleAuthManager.determineVertexMode({})).toBe(true);
});
it('should auto-detect vertex mode from credentials config', () => {
vi.mocked(getEnvString).mockReturnValue(undefined as unknown as string);
expect(GoogleAuthManager.determineVertexMode({ credentials: '{}' })).toBe(true);
});
it('should default to Google AI Studio mode', () => {
vi.mocked(getEnvString).mockReturnValue(undefined as unknown as string);
expect(GoogleAuthManager.determineVertexMode({})).toBe(false);
});
it('should prefer explicit config over env var', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'GOOGLE_GENAI_USE_VERTEXAI') {
return 'true';
}
return defaultValue as string;
});
expect(GoogleAuthManager.determineVertexMode({ vertexai: false })).toBe(false);
});
});
describe('validateAndWarn', () => {
it('should warn when GOOGLE_GENAI_USE_VERTEXAI conflicts with config', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'GOOGLE_GENAI_USE_VERTEXAI') {
return 'true';
}
return defaultValue as string;
});
GoogleAuthManager.validateAndWarn({ vertexai: false });
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('GOOGLE_GENAI_USE_VERTEXAI is set but vertexai: false'),
);
});
it('should warn when GOOGLE_CLOUD_PROJECT conflicts with config.projectId', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'GOOGLE_CLOUD_PROJECT') {
return 'env-project';
}
return defaultValue as string;
});
GoogleAuthManager.validateAndWarn({ projectId: 'config-project' });
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('Both GOOGLE_CLOUD_PROJECT and config.projectId are set'),
);
});
it('should log debug when both apiKey and credentials are set', () => {
// When both are set, API key takes precedence (express mode is automatic)
GoogleAuthManager.validateAndWarn({ apiKey: 'key', credentials: '{}' });
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining('Both apiKey and credentials are set'),
);
});
describe('mutual exclusivity (strictMutualExclusivity)', () => {
it('should warn by default when both apiKey and projectId are set', () => {
vi.mocked(getEnvString).mockReturnValue(undefined as unknown as string);
// Should not throw by default (permissive mode)
expect(() => {
GoogleAuthManager.validateAndWarn({ apiKey: 'key', projectId: 'project' });
}).not.toThrow();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('Project/location and API key are mutually exclusive'),
);
});
it('should warn by default when both apiKey and region are set', () => {
vi.mocked(getEnvString).mockReturnValue(undefined as unknown as string);
// Should not throw by default (permissive mode)
expect(() => {
GoogleAuthManager.validateAndWarn({ apiKey: 'key', region: 'us-central1' });
}).not.toThrow();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('Project/location and API key are mutually exclusive'),
);
});
it('should throw error when strictMutualExclusivity is true', () => {
vi.mocked(getEnvString).mockReturnValue(undefined as unknown as string);
expect(() => {
GoogleAuthManager.validateAndWarn({
apiKey: 'key',
projectId: 'project',
strictMutualExclusivity: true,
});
}).toThrow('Project/location and API key are mutually exclusive');
});
it('should only warn when strictMutualExclusivity is false', () => {
vi.mocked(getEnvString).mockReturnValue(undefined as unknown as string);
// Should not throw
expect(() => {
GoogleAuthManager.validateAndWarn({
apiKey: 'key',
projectId: 'project',
strictMutualExclusivity: false,
});
}).not.toThrow();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('Project/location and API key are mutually exclusive'),
);
});
it('should not throw when only apiKey is set', () => {
vi.mocked(getEnvString).mockReturnValue(undefined as unknown as string);
expect(() => {
GoogleAuthManager.validateAndWarn({ apiKey: 'key' });
}).not.toThrow();
});
it('should not throw when only projectId is set', () => {
vi.mocked(getEnvString).mockReturnValue(undefined as unknown as string);
expect(() => {
GoogleAuthManager.validateAndWarn({ projectId: 'project' });
}).not.toThrow();
});
});
});
describe('resolveRegion', () => {
it('should prioritize config.region', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'VERTEX_REGION') {
return 'env-region';
}
return defaultValue as string;
});
expect(GoogleAuthManager.resolveRegion({ region: 'config-region' })).toBe('config-region');
});
it('should use VERTEX_REGION env var', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'VERTEX_REGION') {
return 'env-region';
}
return defaultValue as string;
});
expect(GoogleAuthManager.resolveRegion({})).toBe('env-region');
});
it('should use GOOGLE_CLOUD_LOCATION env var (Python SDK)', () => {
vi.mocked(getEnvString).mockImplementation((key: string, defaultValue?: string) => {
if (key === 'GOOGLE_CLOUD_LOCATION') {
return 'cloud-location';
}
return defaultValue as string;
});
expect(GoogleAuthManager.resolveRegion({})).toBe('cloud-location');
});
it('should default to us-central1 when hasApiKey is undefined', () => {
vi.mocked(getEnvString).mockReturnValue(undefined as unknown as string);
expect(GoogleAuthManager.resolveRegion({})).toBe('us-central1');
});
it('should default to us-central1 when hasApiKey is true (API key mode)', () => {
vi.mocked(getEnvString).mockReturnValue(undefined as unknown as string);
expect(GoogleAuthManager.resolveRegion({}, undefined, true)).toBe('us-central1');
});
it('should default to global when hasApiKey is false (OAuth mode - SDK aligned)', () => {
vi.mocked(getEnvString).mockReturnValue(undefined as unknown as string);
expect(GoogleAuthManager.resolveRegion({}, undefined, false)).toBe('global');
});
it('should use env overrides', () => {
vi.mocked(getEnvString).mockReturnValue(undefined as unknown as string);
expect(GoogleAuthManager.resolveRegion({}, { VERTEX_REGION: 'override-region' })).toBe(
'override-region',
);
});
it('should prioritize config.region over hasApiKey default', () => {
vi.mocked(getEnvString).mockReturnValue(undefined as unknown as string);
// Even with hasApiKey: false, explicit config should win
expect(GoogleAuthManager.resolveRegion({ region: 'explicit-region' }, undefined, false)).toBe(
'explicit-region',
);
});
});
describe('hasDefaultCredentials', () => {
it('should cache successful default credential probes', async () => {
const getOAuthClientSpy = vi
.spyOn(GoogleAuthManager, 'getOAuthClient')
.mockResolvedValue({ client: {}, projectId: 'detected-project' });
await expect(GoogleAuthManager.hasDefaultCredentials()).resolves.toBe(true);
await expect(GoogleAuthManager.hasDefaultCredentials()).resolves.toBe(true);
expect(getOAuthClientSpy).toHaveBeenCalledTimes(1);
getOAuthClientSpy.mockRestore();
});
it('should cache failed default credential probes', async () => {
const getOAuthClientSpy = vi
.spyOn(GoogleAuthManager, 'getOAuthClient')
.mockRejectedValue(new Error('no default credentials'));
await expect(GoogleAuthManager.hasDefaultCredentials()).resolves.toBe(false);
await expect(GoogleAuthManager.hasDefaultCredentials()).resolves.toBe(false);
expect(getOAuthClientSpy).toHaveBeenCalledTimes(1);
getOAuthClientSpy.mockRestore();
});
it('should suppress expected metadata lookup warnings during optional probes', async () => {
const emitWarningSpy = vi.spyOn(process, 'emitWarning').mockImplementation(() => {});
const getOAuthClientSpy = vi
.spyOn(GoogleAuthManager, 'getOAuthClient')
.mockImplementation(async () => {
process.emitWarning(
'received unexpected error = All promises were rejected code = UNKNOWN',
'MetadataLookupWarning',
);
throw new Error('no default credentials');
});
try {
await expect(GoogleAuthManager.hasDefaultCredentials()).resolves.toBe(false);
expect(emitWarningSpy).not.toHaveBeenCalled();
} finally {
getOAuthClientSpy.mockRestore();
emitWarningSpy.mockRestore();
}
});
it('should suppress metadata lookup warnings for any unreachable-host error code', async () => {
// gcp-metadata interpolates the underlying error + code into the message, so the suffix
// varies by failure mode (the codeless `All promises were rejected`/`UNKNOWN` aggregate,
// plus non-allowlisted codes like ETIMEDOUT or EAI_AGAIN). All are the same optional probe
// failing to reach metadata, so the stable prefix must suppress every variant — and tolerate
// a dependency reword of the interpolated suffix.
const emitWarningSpy = vi.spyOn(process, 'emitWarning').mockImplementation(() => {});
const getOAuthClientSpy = vi
.spyOn(GoogleAuthManager, 'getOAuthClient')
.mockImplementation(async () => {
process.emitWarning(
'received unexpected error = connect ETIMEDOUT 169.254.169.254:80 code = ETIMEDOUT',
'MetadataLookupWarning',
);
throw new Error('no default credentials');
});
try {
await expect(GoogleAuthManager.hasDefaultCredentials()).resolves.toBe(false);
expect(emitWarningSpy).not.toHaveBeenCalled();
} finally {
getOAuthClientSpy.mockRestore();
emitWarningSpy.mockRestore();
}
});
it('should forward unrelated warnings during optional probes', async () => {
const emitWarningSpy = vi.spyOn(process, 'emitWarning').mockImplementation(() => {});
const getOAuthClientSpy = vi
.spyOn(GoogleAuthManager, 'getOAuthClient')
.mockImplementation(async () => {
process.emitWarning('different warning', 'MetadataLookupWarning');
throw new Error('no default credentials');
});
try {
await expect(GoogleAuthManager.hasDefaultCredentials()).resolves.toBe(false);
expect(emitWarningSpy).toHaveBeenCalledWith('different warning', 'MetadataLookupWarning');
} finally {
getOAuthClientSpy.mockRestore();
emitWarningSpy.mockRestore();
}
});
it('should forward matching metadata warning messages with a different warning type', async () => {
const emitWarningSpy = vi.spyOn(process, 'emitWarning').mockImplementation(() => {});
const getOAuthClientSpy = vi
.spyOn(GoogleAuthManager, 'getOAuthClient')
.mockImplementation(async () => {
process.emitWarning(
'received unexpected error = All promises were rejected code = UNKNOWN',
'DeprecationWarning',
);
throw new Error('no default credentials');
});
try {
await expect(GoogleAuthManager.hasDefaultCredentials()).resolves.toBe(false);
expect(emitWarningSpy).toHaveBeenCalledWith(
'received unexpected error = All promises were rejected code = UNKNOWN',
'DeprecationWarning',
);
} finally {
getOAuthClientSpy.mockRestore();
emitWarningSpy.mockRestore();
}
});
it('should forward Error instance warnings during optional probes', async () => {
const emitWarningSpy = vi.spyOn(process, 'emitWarning').mockImplementation(() => {});
const warning = new Error('different warning');
warning.name = 'MetadataLookupWarning';
const getOAuthClientSpy = vi
.spyOn(GoogleAuthManager, 'getOAuthClient')
.mockImplementation(async () => {
process.emitWarning(warning);
throw new Error('no default credentials');
});
try {
await expect(GoogleAuthManager.hasDefaultCredentials()).resolves.toBe(false);
expect(emitWarningSpy).toHaveBeenCalledWith(warning);
} finally {
getOAuthClientSpy.mockRestore();
emitWarningSpy.mockRestore();
}
});
it('should restore process.emitWarning after an optional probe', async () => {
const originalEmitWarning = process.emitWarning;
const getOAuthClientSpy = vi
.spyOn(GoogleAuthManager, 'getOAuthClient')
.mockRejectedValue(new Error('no default credentials'));
try {
await expect(GoogleAuthManager.hasDefaultCredentials()).resolves.toBe(false);
expect(process.emitWarning).toBe(originalEmitWarning);
} finally {
getOAuthClientSpy.mockRestore();
}
});
it('should preserve a newer process.emitWarning owner after an optional probe', async () => {
const originalEmitWarning = process.emitWarning;
const replacementEmitWarning = vi.fn() as typeof process.emitWarning;
const getOAuthClientSpy = vi
.spyOn(GoogleAuthManager, 'getOAuthClient')
.mockImplementation(async () => {
process.emitWarning = replacementEmitWarning;
throw new Error('no default credentials');
});
try {
await expect(GoogleAuthManager.hasDefaultCredentials()).resolves.toBe(false);
expect(process.emitWarning).toBe(replacementEmitWarning);
} finally {
process.emitWarning = originalEmitWarning;
getOAuthClientSpy.mockRestore();
}
});
it('should restore process.emitWarning after overlapping probes settle out of order', async () => {
const originalEmitWarning = process.emitWarning;
let rejectFirstProbe!: (reason?: unknown) => void;
let rejectSecondProbe!: (reason?: unknown) => void;
const firstProbeAuthCall = new Promise((_, reject) => {
rejectFirstProbe = reject;
});
const secondProbeAuthCall = new Promise((_, reject) => {
rejectSecondProbe = reject;
});
const getOAuthClientSpy = vi.spyOn(GoogleAuthManager, 'getOAuthClient');
getOAuthClientSpy
.mockImplementationOnce(() => firstProbeAuthCall as Promise<any>)
.mockImplementationOnce(() => secondProbeAuthCall as Promise<any>);
const firstProbe = GoogleAuthManager.hasDefaultCredentials();
GoogleAuthManager.clearCache();
const secondProbe = GoogleAuthManager.hasDefaultCredentials();
try {
rejectFirstProbe(new Error('first probe failed'));
await expect(firstProbe).resolves.toBe(false);
expect(process.emitWarning).not.toBe(originalEmitWarning);
rejectSecondProbe(new Error('second probe failed'));
await expect(secondProbe).resolves.toBe(false);
expect(process.emitWarning).toBe(originalEmitWarning);
} finally {
rejectFirstProbe(new Error('cleanup'));
rejectSecondProbe(new Error('cleanup'));
await Promise.allSettled([firstProbe, secondProbe]);
process.emitWarning = originalEmitWarning;
getOAuthClientSpy.mockRestore();
}
});
it('should clear cached default credential probe results', async () => {
const getOAuthClientSpy = vi
.spyOn(GoogleAuthManager, 'getOAuthClient')
.mockRejectedValue(new Error('no default credentials'));
await expect(GoogleAuthManager.hasDefaultCredentials()).resolves.toBe(false);
GoogleAuthManager.clearCache();
await expect(GoogleAuthManager.hasDefaultCredentials()).resolves.toBe(false);
expect(getOAuthClientSpy).toHaveBeenCalledTimes(2);
getOAuthClientSpy.mockRestore();
});
it('should deduplicate concurrent default credential probes', async () => {
const getOAuthClientSpy = vi.spyOn(GoogleAuthManager, 'getOAuthClient').mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => resolve({ client: {}, projectId: 'detected-project' }), 0);
}),
);
const firstProbe = GoogleAuthManager.hasDefaultCredentials();
const secondProbe = GoogleAuthManager.hasDefaultCredentials();
const [firstResult, secondResult] = await Promise.all([firstProbe, secondProbe]);
expect(firstResult).toBe(true);
expect(secondResult).toBe(true);
expect(getOAuthClientSpy).toHaveBeenCalledTimes(1);
getOAuthClientSpy.mockRestore();
});
it('should not allow a stale probe to overwrite cache after clearCache', async () => {
let rejectFirstProbe!: (reason?: unknown) => void;
const firstProbeAuthCall = new Promise((_, reject) => {
rejectFirstProbe = reject;
});
const getOAuthClientSpy = vi.spyOn(GoogleAuthManager, 'getOAuthClient');
getOAuthClientSpy
.mockImplementationOnce(() => firstProbeAuthCall as Promise<any>)
.mockResolvedValueOnce({ client: {}, projectId: 'detected-project' });
const firstProbe = GoogleAuthManager.hasDefaultCredentials();
GoogleAuthManager.clearCache();
await expect(GoogleAuthManager.hasDefaultCredentials()).resolves.toBe(true);
rejectFirstProbe(new Error('first probe failed'));
await expect(firstProbe).resolves.toBe(false);
await expect(GoogleAuthManager.hasDefaultCredentials()).resolves.toBe(true);
expect(getOAuthClientSpy).toHaveBeenCalledTimes(2);
getOAuthClientSpy.mockRestore();
});
});
describe('loadCredentials', () => {
it('should return undefined for undefined input', () => {
expect(GoogleAuthManager.loadCredentials(undefined)).toBeUndefined();
});
it('should return raw credentials string as-is', () => {
const creds = '{"type":"service_account"}';
expect(GoogleAuthManager.loadCredentials(creds)).toBe(creds);
});
it('should load credentials from file:// path', () => {
const fileCreds = '{"type":"service_account","project_id":"test"}';
vi.mocked(maybeLoadFromExternalFile).mockReturnValue(fileCreds);
const result = GoogleAuthManager.loadCredentials('file:///path/to/creds.json');
expect(maybeLoadFromExternalFile).toHaveBeenCalledWith('file:///path/to/creds.json');
expect(result).toBe(fileCreds);
});
it('should throw error when file loading fails', () => {
vi.mocked(maybeLoadFromExternalFile).mockImplementation(() => {
throw new Error('File not found');
});
expect(() => GoogleAuthManager.loadCredentials('file:///bad/path.json')).toThrow(
'Failed to load credentials from file',
);
});
it('should handle pre-parsed object credentials (from config loading pipeline)', () => {
// When config loading processes file:// paths to JSON files, it may return
// parsed objects instead of strings. The fix handles this case.
const parsedCreds = { type: 'service_account', project_id: 'test-project' };
const result = GoogleAuthManager.loadCredentials(parsedCreds as unknown as string);
expect(result).toBe(JSON.stringify(parsedCreds));
});
it('should handle maybeLoadFromExternalFile returning parsed object for JSON files', () => {
// maybeLoadFromExternalFile parses JSON files and returns objects, not strings
const parsedCreds = { type: 'service_account', project_id: 'test-project' };
vi.mocked(maybeLoadFromExternalFile).mockReturnValue(parsedCreds);
const result = GoogleAuthManager.loadCredentials('file:///path/to/creds.json');
expect(maybeLoadFromExternalFile).toHaveBeenCalledWith('file:///path/to/creds.json');
expect(result).toBe(JSON.stringify(parsedCreds));
});
});
// Note: getOAuthClient, resolveProjectId (OAuth path), and clearCache tests require
// the google-auth-library package and use dynamic imports that are difficult to mock.
// These are tested in integration tests with the actual library.
// See: test/providers/google/vertex.test.ts for integration coverage
// Note: getOAuthClient and resolveProjectId tests require the google-auth-library
// package with proper constructor mocking. These are tested in integration tests.
// See: test/providers/google/vertex.test.ts for integration coverage
describe('clearCache', () => {
it('should clear internal credential detection caches', () => {
expect(() => GoogleAuthManager.clearCache()).not.toThrow();
});
});
});