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
855 lines
31 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|