From 42c7a0c10dffa27cde2db7c427568c875aba7ba1 Mon Sep 17 00:00:00 2001 From: sudhanshu112233shukla Date: Tue, 20 Jan 2026 22:47:43 +0000 Subject: [PATCH] feat(gerrit): add basic authentication support (#596) --- packages/backend/src/gerrit.test.ts | 136 ++++++++++++++++++++++++++++ packages/backend/src/gerrit.ts | 12 ++- schemas/v3/gerrit.json | 8 ++ 3 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 packages/backend/src/gerrit.test.ts diff --git a/packages/backend/src/gerrit.test.ts b/packages/backend/src/gerrit.test.ts new file mode 100644 index 000000000..27a574ca5 --- /dev/null +++ b/packages/backend/src/gerrit.test.ts @@ -0,0 +1,136 @@ + +import { expect, test, vi, describe, beforeEach } from 'vitest'; +import { getGerritReposFromConfig } from './gerrit'; +import fetch from 'cross-fetch'; + +vi.mock('cross-fetch', () => { + return { + default: vi.fn(), + }; +}); + +vi.mock('@sourcebot/shared', () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +vi.mock('./utils', () => ({ + measure: async (fn: () => any) => { + const data = await fn(); + return { durationMs: 0, data }; + }, + fetchWithRetry: async (fn: () => any) => fn(), +})); + +describe('getGerritReposFromConfig', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('sends Basic Auth header when username and password are provided', async () => { + const mockFetch = fetch as unknown as ReturnType; + mockFetch.mockResolvedValue({ + ok: true, + text: async () => '[]', + json: async () => ([]), + }); + + const config = { + type: 'gerrit' as const, + url: 'https://gerrit.example.com', + username: 'user', + password: 'password', + }; + + await getGerritReposFromConfig(config); + + const expectedToken = Buffer.from('user:password').toString('base64'); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('https://gerrit.example.com/projects/?S=0'), + expect.objectContaining({ + headers: { + Authorization: `Basic ${expectedToken}`, + }, + }) + ); + }); + + test('does not send Authorization header when credentials are missing', async () => { + const mockFetch = fetch as unknown as ReturnType; + mockFetch.mockResolvedValue({ + ok: true, + text: async () => '[]', + json: async () => ([]), + }); + + const config = { + type: 'gerrit' as const, + url: 'https://gerrit.example.com', + }; + + await getGerritReposFromConfig(config); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('https://gerrit.example.com/projects/?S=0'), + expect.objectContaining({ + headers: {}, + }) + ); + }); + + test('does not send Authorization header when only one credential is provided', async () => { + const mockFetch = fetch as unknown as ReturnType; + mockFetch.mockResolvedValue({ + ok: true, + text: async () => '[]', + json: async () => ([]), + }); + + const config = { + type: 'gerrit' as const, + url: 'https://gerrit.example.com', + username: 'user', + }; + + await getGerritReposFromConfig(config); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('https://gerrit.example.com/projects/?S=0'), + expect.objectContaining({ + headers: {}, + }) + ); + }); + + test('correctly encodes credentials with special characters', async () => { + const mockFetch = fetch as unknown as ReturnType; + mockFetch.mockResolvedValue({ + ok: true, + text: async () => '[]', + json: async () => ([]), + }); + + const config = { + type: 'gerrit' as const, + url: 'https://gerrit.example.com', + username: 'user@example.com', + password: 'p@ss:w0rd', + }; + + await getGerritReposFromConfig(config); + + const expectedToken = Buffer.from('user@example.com:p@ss:w0rd').toString('base64'); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('https://gerrit.example.com/projects/?S=0'), + expect.objectContaining({ + headers: { + Authorization: `Basic ${expectedToken}`, + }, + }) + ); + }); +}); diff --git a/packages/backend/src/gerrit.ts b/packages/backend/src/gerrit.ts index 34feb2081..d22936efc 100644 --- a/packages/backend/src/gerrit.ts +++ b/packages/backend/src/gerrit.ts @@ -36,7 +36,7 @@ export const getGerritReposFromConfig = async (config: GerritConnectionConfig): const url = config.url.endsWith('/') ? config.url : `${config.url}/`; let { durationMs, data: projects } = await measure(async () => { - const fetchFn = () => fetchAllProjects(url); + const fetchFn = () => fetchAllProjects(url, config.username, config.password); return fetchWithRetry(fetchFn, `projects from ${url}`, logger); }); @@ -61,7 +61,7 @@ export const getGerritReposFromConfig = async (config: GerritConnectionConfig): return projects; }; -const fetchAllProjects = async (url: string): Promise => { +const fetchAllProjects = async (url: string, username?: string, password?: string): Promise => { const projectsEndpoint = `${url}projects/`; let allProjects: GerritProject[] = []; let start = 0; // Start offset for pagination @@ -71,8 +71,14 @@ const fetchAllProjects = async (url: string): Promise => { const endpointWithParams = `${projectsEndpoint}?S=${start}`; logger.debug(`Fetching projects from Gerrit at ${endpointWithParams}`); + const headers: Record = {}; + if (username && password) { + const token = Buffer.from(`${username}:${password}`).toString('base64'); + headers['Authorization'] = `Basic ${token}`; + } + let response: Response; - response = await fetch(endpointWithParams); + response = await fetch(endpointWithParams, { headers }); if (!response.ok) { throw new Error(`Failed to fetch projects from Gerrit at ${endpointWithParams} with status ${response.status}`); } diff --git a/schemas/v3/gerrit.json b/schemas/v3/gerrit.json index 3ff031dfa..2f5abe9ab 100644 --- a/schemas/v3/gerrit.json +++ b/schemas/v3/gerrit.json @@ -16,6 +16,14 @@ ], "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" }, + "username": { + "type": "string", + "description": "The username to use for authentication." + }, + "password": { + "type": "string", + "description": "The password (or HTTP password) to use for authentication." + }, "projects": { "type": "array", "items": {