diff --git a/api/server/services/Config/loadAsyncEndpoints.js b/api/server/services/Config/loadAsyncEndpoints.js index bf19e2ea29..b88744e9ad 100644 --- a/api/server/services/Config/loadAsyncEndpoints.js +++ b/api/server/services/Config/loadAsyncEndpoints.js @@ -22,8 +22,7 @@ async function loadAsyncEndpoints(req) { } else { /** Only attempt to load service key if GOOGLE_KEY is not provided */ const serviceKeyPath = - process.env.GOOGLE_SERVICE_KEY_FILE_PATH || - path.join(__dirname, '../../..', 'data', 'auth.json'); + process.env.GOOGLE_SERVICE_KEY_FILE || path.join(__dirname, '../../..', 'data', 'auth.json'); try { serviceKey = await loadServiceKey(serviceKeyPath); diff --git a/api/server/services/Endpoints/google/initialize.js b/api/server/services/Endpoints/google/initialize.js index 4e56cccb3b..75a31a8c09 100644 --- a/api/server/services/Endpoints/google/initialize.js +++ b/api/server/services/Endpoints/google/initialize.js @@ -25,7 +25,7 @@ const initializeClient = async ({ req, res, endpointOption, overrideModel, optio /** Only attempt to load service key if GOOGLE_KEY is not provided */ try { const serviceKeyPath = - process.env.GOOGLE_SERVICE_KEY_FILE_PATH || + process.env.GOOGLE_SERVICE_KEY_FILE || path.join(__dirname, '../../../..', 'data', 'auth.json'); serviceKey = await loadServiceKey(serviceKeyPath); if (!serviceKey) { diff --git a/packages/api/src/files/mistral/crud.ts b/packages/api/src/files/mistral/crud.ts index 5aef8edc2b..eac8433103 100644 --- a/packages/api/src/files/mistral/crud.ts +++ b/packages/api/src/files/mistral/crud.ts @@ -442,7 +442,7 @@ async function loadGoogleAuthConfig(): Promise<{ }> { /** Path from environment variable or default location */ const serviceKeyPath = - process.env.GOOGLE_SERVICE_KEY_FILE_PATH || + process.env.GOOGLE_SERVICE_KEY_FILE || path.join(__dirname, '..', '..', '..', 'api', 'data', 'auth.json'); const serviceKey = await loadServiceKey(serviceKeyPath); diff --git a/packages/api/src/utils/key.test.ts b/packages/api/src/utils/key.test.ts new file mode 100644 index 0000000000..aa55cfbedf --- /dev/null +++ b/packages/api/src/utils/key.test.ts @@ -0,0 +1,97 @@ +import fs from 'fs'; +import path from 'path'; +import axios from 'axios'; +import { loadServiceKey } from './key'; + +jest.mock('fs'); +jest.mock('axios'); +jest.mock('@librechat/data-schemas', () => ({ + logger: { + error: jest.fn(), + }, +})); + +describe('loadServiceKey', () => { + const mockServiceKey = { + type: 'service_account', + project_id: 'test-project', + private_key_id: 'test-key-id', + private_key: '-----BEGIN PRIVATE KEY-----\ntest-key\n-----END PRIVATE KEY-----', + client_email: 'test@test-project.iam.gserviceaccount.com', + client_id: '123456789', + auth_uri: 'https://accounts.google.com/o/oauth2/auth', + token_uri: 'https://oauth2.googleapis.com/token', + auth_provider_x509_cert_url: 'https://www.googleapis.com/oauth2/v1/certs', + client_x509_cert_url: + 'https://www.googleapis.com/robot/v1/metadata/x509/test%40test-project.iam.gserviceaccount.com', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return null if keyPath is empty', async () => { + const result = await loadServiceKey(''); + expect(result).toBeNull(); + }); + + it('should parse stringified JSON directly', async () => { + const jsonString = JSON.stringify(mockServiceKey); + const result = await loadServiceKey(jsonString); + expect(result).toEqual(mockServiceKey); + }); + + it('should parse stringified JSON with leading/trailing whitespace', async () => { + const jsonString = ` ${JSON.stringify(mockServiceKey)} `; + const result = await loadServiceKey(jsonString); + expect(result).toEqual(mockServiceKey); + }); + + it('should load from file path', async () => { + const filePath = '/path/to/service-key.json'; + (fs.readFileSync as jest.Mock).mockReturnValue(JSON.stringify(mockServiceKey)); + + const result = await loadServiceKey(filePath); + expect(fs.readFileSync).toHaveBeenCalledWith(path.resolve(filePath), 'utf8'); + expect(result).toEqual(mockServiceKey); + }); + + it('should load from URL', async () => { + const url = 'https://example.com/service-key.json'; + (axios.get as jest.Mock).mockResolvedValue({ data: mockServiceKey }); + + const result = await loadServiceKey(url); + expect(axios.get).toHaveBeenCalledWith(url); + expect(result).toEqual(mockServiceKey); + }); + + it('should handle invalid JSON string', async () => { + const invalidJson = '{ invalid json }'; + const result = await loadServiceKey(invalidJson); + expect(result).toBeNull(); + }); + + it('should handle file read errors', async () => { + const filePath = '/path/to/nonexistent.json'; + (fs.readFileSync as jest.Mock).mockImplementation(() => { + throw new Error('File not found'); + }); + + const result = await loadServiceKey(filePath); + expect(result).toBeNull(); + }); + + it('should handle URL fetch errors', async () => { + const url = 'https://example.com/service-key.json'; + (axios.get as jest.Mock).mockRejectedValue(new Error('Network error')); + + const result = await loadServiceKey(url); + expect(result).toBeNull(); + }); + + it('should validate service key format', async () => { + const invalidServiceKey = { invalid: 'key' }; + const result = await loadServiceKey(JSON.stringify(invalidServiceKey)); + expect(result).toEqual(invalidServiceKey); // It returns the object as-is, validation is minimal + }); +}); diff --git a/packages/api/src/utils/key.ts b/packages/api/src/utils/key.ts index 7d603afd3f..f0ccb3e451 100644 --- a/packages/api/src/utils/key.ts +++ b/packages/api/src/utils/key.ts @@ -18,8 +18,8 @@ export interface GoogleServiceKey { } /** - * Load Google service key from file path or URL - * @param keyPath - The path or URL to the service key file + * Load Google service key from file path, URL, or stringified JSON + * @param keyPath - The path to the service key file, URL to fetch it from, or stringified JSON * @returns The parsed service key object or null if failed */ export async function loadServiceKey(keyPath: string): Promise { @@ -29,8 +29,17 @@ export async function loadServiceKey(keyPath: string): Promise