diff --git a/.gitignore b/.gitignore index 17ad0f22..dc6cf2a1 100644 --- a/.gitignore +++ b/.gitignore @@ -163,4 +163,7 @@ dist .sourcebot /bin /config.json -.DS_Store \ No newline at end of file +.DS_Store + +# Claude Code generated files +CLAUDE.md \ No newline at end of file diff --git a/docs/docs/connections/gerrit.mdx b/docs/docs/connections/gerrit.mdx index b7253259..688b4636 100644 --- a/docs/docs/connections/gerrit.mdx +++ b/docs/docs/connections/gerrit.mdx @@ -6,72 +6,278 @@ icon: crow import GerritSchema from '/snippets/schemas/v3/gerrit.schema.mdx' -Authenticating with Gerrit is currently not supported. If you need this capability, please raise a [feature request](https://github.com/sourcebot-dev/sourcebot/discussions/categories/ideas). +Sourcebot can sync code from self-hosted Gerrit instances, including both public and authenticated repositories. -Sourcebot can sync code from self-hosted gerrit instances. +## Authentication Support + + +**Authentication Status**: Gerrit authentication is supported through HTTP Basic Auth using username and HTTP password credentials. This guide documents the verified authentication methods and implementation details. + + +### Authentication Methods + +Gerrit supports multiple authentication methods with Sourcebot: + +1. **Public Access**: For publicly accessible projects (no authentication required) +2. **HTTP Basic Auth**: Using Gerrit username and HTTP password +3. **Cookie-based Auth**: Using Gerrit session cookies (advanced) ## Connecting to a Gerrit instance -To connect to a gerrit instance, provide the `url` property to your config: +### Basic Connection (Public Projects) + +For publicly accessible Gerrit projects: ```json { "type": "gerrit", - "url": "https://gerrit.example.com" - // .. rest of config .. + "url": "https://gerrit.example.com", + "projects": ["public-project-name"] +} +``` + +### Authenticated Connection + +For private/authenticated Gerrit projects, you need to provide credentials: + +```json +{ + "type": "gerrit", + "url": "https://gerrit.example.com", + "projects": ["private-project-name"], + "auth": { + "username": "your-gerrit-username", + "password": { + "env": "GERRIT_HTTP_PASSWORD" + } + } +} +``` + + +Use **HTTP Password**, not your Gerrit account password. Generate an HTTP password in Gerrit: **Settings → HTTP Credentials → Generate Password**. + + +Set your Gerrit HTTP password as an environment variable: + +```bash +export GERRIT_HTTP_PASSWORD="your-generated-http-password" +``` + +When running with Docker: + +```bash +docker run -e GERRIT_HTTP_PASSWORD="your-http-password" ... +``` + +## Authentication Setup Guide + +### Step 1: Generate HTTP Password in Gerrit + +1. Log into your Gerrit instance +2. Go to **Settings** (top-right menu) +3. Navigate to **HTTP Credentials** +4. Click **Generate Password** +5. Copy the generated password (this is your HTTP password) + +### Step 2: Test API Access + +Verify your credentials work with Gerrit's API: + +```bash +curl -u "username:http-password" \ + "https://gerrit.example.com/a/projects/?d" +``` + +Expected response: JSON list of projects you have access to. + +### Step 3: Test Git Clone Access + +Verify git clone works with your credentials: + +```bash +git clone https://username@gerrit.example.com/a/project-name +# When prompted, enter your HTTP password +``` + + +**Special Characters in Passwords**: If your HTTP password contains special characters like `/`, `+`, or `=`, Sourcebot automatically handles URL encoding for git operations. No manual encoding is required on your part. + + +### Step 4: Configure Sourcebot + +Add the authenticated connection to your `config.json`: + +```json +{ + "connections": { + "my-gerrit": { + "type": "gerrit", + "url": "https://gerrit.example.com", + "projects": ["project-name"], + "auth": { + "username": "your-username", + "password": { + "env": "GERRIT_HTTP_PASSWORD" + } + } + } + } } ``` ## Examples + + ```json + { + "type": "gerrit", + "url": "https://gerrit.googlesource.com", + "projects": ["android/platform/build"] + } + ``` + + + + ```json + { + "type": "gerrit", + "url": "https://gerrit.company.com", + "projects": ["internal-project"], + "auth": { + "username": "john.doe", + "password": { + "env": "GERRIT_HTTP_PASSWORD" + } + } + } + ``` + + ```json { "type": "gerrit", "url": "https://gerrit.example.com", - // Sync all repos under project1 and project2/sub-project "projects": [ "project1/**", "project2/sub-project/**" - ] + ], + "auth": { + "username": "your-username", + "password": { + "env": "GERRIT_HTTP_PASSWORD" + } + } } ``` + ```json { "type": "gerrit", "url": "https://gerrit.example.com", - // Sync all repos under project1 and project2/sub-project... "projects": [ "project1/**", "project2/sub-project/**" ], - // ...except: "exclude": { - // any project that matches these glob patterns "projects": [ "project1/foo-project", "project2/sub-project/some-sub-folder/**" ], - - // projects that have state READ_ONLY "readOnly": true, - - // projects that have state HIDDEN "hidden": true + }, + "auth": { + "username": "your-username", + "password": { + "env": "GERRIT_HTTP_PASSWORD" + } } } ``` -## Schema reference +## Troubleshooting + +### Common Issues + + + + **Symptoms**: Sourcebot logs show authentication errors or 401 status codes. + + **Solutions**: + 1. Verify you're using the **HTTP password**, not your account password + 2. Test credentials manually: + ```bash + curl -u "username:password" "https://gerrit.example.com/a/projects/" + ``` + 3. Check if your Gerrit username is correct + 4. Regenerate HTTP password in Gerrit settings + 5. Ensure the environment variable is properly set + + + + **Symptoms**: Sourcebot connects but finds 0 repositories to sync. + + **Solutions**: + 1. Verify project names exist and are accessible + 2. Check project permissions in Gerrit + 3. Test project access manually: + ```bash + curl -u "username:password" \ + "https://gerrit.example.com/a/projects/project-name" + ``` + 4. Use glob patterns if unsure of exact project names: + ```json + "projects": ["*"] // Sync all accessible projects + ``` + + + + **Symptoms**: Git clone operations fail during repository sync. + + **Solutions**: + 1. Verify git clone works manually: + ```bash + git clone https://username@gerrit.example.com/a/project-name + ``` + 2. Check network connectivity and firewall rules + 3. Ensure Gerrit server supports HTTPS + 4. Verify the `/a/` prefix is included in clone URLs + + + + **Symptoms**: Config validation errors about additional properties. + + **Solutions**: + 1. Ensure your configuration matches the schema exactly + 2. Check that all required fields are present + 3. Verify the `auth` object structure: + ```json + "auth": { + "username": "string", + "password": { + "env": "ENVIRONMENT_VARIABLE" + } + } + ``` + + + + + + +## Schema Reference [schemas/v3/gerrit.json](https://github.com/sourcebot-dev/sourcebot/blob/main/schemas/v3/gerrit.json) - \ No newline at end of file + + diff --git a/docs/snippets/schemas/v3/bitbucket.schema.mdx b/docs/snippets/schemas/v3/bitbucket.schema.mdx index 829d0254..24c9ba86 100644 --- a/docs/snippets/schemas/v3/bitbucket.schema.mdx +++ b/docs/snippets/schemas/v3/bitbucket.schema.mdx @@ -26,6 +26,7 @@ "properties": { "secret": { "type": "string", + "minLength": 1, "description": "The name of the secret that contains the token." } }, @@ -39,6 +40,7 @@ "properties": { "env": { "type": "string", + "minLength": 1, "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." } }, diff --git a/docs/snippets/schemas/v3/connection.schema.mdx b/docs/snippets/schemas/v3/connection.schema.mdx index 9731bdeb..f00afa8f 100644 --- a/docs/snippets/schemas/v3/connection.schema.mdx +++ b/docs/snippets/schemas/v3/connection.schema.mdx @@ -26,6 +26,7 @@ "properties": { "secret": { "type": "string", + "minLength": 1, "description": "The name of the secret that contains the token." } }, @@ -39,6 +40,7 @@ "properties": { "env": { "type": "string", + "minLength": 1, "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." } }, @@ -239,6 +241,7 @@ "properties": { "secret": { "type": "string", + "minLength": 1, "description": "The name of the secret that contains the token." } }, @@ -252,6 +255,7 @@ "properties": { "env": { "type": "string", + "minLength": 1, "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." } }, @@ -441,6 +445,7 @@ "properties": { "secret": { "type": "string", + "minLength": 1, "description": "The name of the secret that contains the token." } }, @@ -454,6 +459,7 @@ "properties": { "env": { "type": "string", + "minLength": 1, "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." } }, @@ -597,6 +603,65 @@ ], "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" }, + "auth": { + "type": "object", + "description": "Authentication configuration for Gerrit", + "properties": { + "username": { + "type": "string", + "description": "Gerrit username for authentication", + "examples": [ + "john.doe" + ] + }, + "password": { + "description": "Gerrit HTTP password (not your account password). Generate this in Gerrit → Settings → HTTP Credentials → Generate Password. Note: HTTP password authentication requires Gerrit's auth.gitBasicAuthPolicy to be set to HTTP or HTTP_LDAP.", + "examples": [ + { + "env": "GERRIT_HTTP_PASSWORD" + }, + { + "secret": "GERRIT_PASSWORD_SECRET" + } + ], + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "minLength": 1, + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "minLength": 1, + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "username", + "password" + ], + "additionalProperties": false + }, "projects": { "type": "array", "items": { @@ -672,6 +737,7 @@ "properties": { "secret": { "type": "string", + "minLength": 1, "description": "The name of the secret that contains the token." } }, @@ -685,6 +751,7 @@ "properties": { "env": { "type": "string", + "minLength": 1, "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." } }, diff --git a/docs/snippets/schemas/v3/gerrit.schema.mdx b/docs/snippets/schemas/v3/gerrit.schema.mdx index 561bda80..aaffc197 100644 --- a/docs/snippets/schemas/v3/gerrit.schema.mdx +++ b/docs/snippets/schemas/v3/gerrit.schema.mdx @@ -18,6 +18,65 @@ ], "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" }, + "auth": { + "type": "object", + "description": "Authentication configuration for Gerrit", + "properties": { + "username": { + "type": "string", + "description": "Gerrit username for authentication", + "examples": [ + "john.doe" + ] + }, + "password": { + "description": "Gerrit HTTP password (not your account password). Generate this in Gerrit → Settings → HTTP Credentials → Generate Password. Note: HTTP password authentication requires Gerrit's auth.gitBasicAuthPolicy to be set to HTTP or HTTP_LDAP.", + "examples": [ + { + "env": "GERRIT_HTTP_PASSWORD" + }, + { + "secret": "GERRIT_PASSWORD_SECRET" + } + ], + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "minLength": 1, + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "minLength": 1, + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "username", + "password" + ], + "additionalProperties": false + }, "projects": { "type": "array", "items": { diff --git a/docs/snippets/schemas/v3/gitea.schema.mdx b/docs/snippets/schemas/v3/gitea.schema.mdx index f236e3fe..e3bc184e 100644 --- a/docs/snippets/schemas/v3/gitea.schema.mdx +++ b/docs/snippets/schemas/v3/gitea.schema.mdx @@ -22,6 +22,7 @@ "properties": { "secret": { "type": "string", + "minLength": 1, "description": "The name of the secret that contains the token." } }, @@ -35,6 +36,7 @@ "properties": { "env": { "type": "string", + "minLength": 1, "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." } }, diff --git a/docs/snippets/schemas/v3/github.schema.mdx b/docs/snippets/schemas/v3/github.schema.mdx index 1858eee8..c7b4c0d4 100644 --- a/docs/snippets/schemas/v3/github.schema.mdx +++ b/docs/snippets/schemas/v3/github.schema.mdx @@ -22,6 +22,7 @@ "properties": { "secret": { "type": "string", + "minLength": 1, "description": "The name of the secret that contains the token." } }, @@ -35,6 +36,7 @@ "properties": { "env": { "type": "string", + "minLength": 1, "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." } }, diff --git a/docs/snippets/schemas/v3/gitlab.schema.mdx b/docs/snippets/schemas/v3/gitlab.schema.mdx index feadeaac..1cac764e 100644 --- a/docs/snippets/schemas/v3/gitlab.schema.mdx +++ b/docs/snippets/schemas/v3/gitlab.schema.mdx @@ -22,6 +22,7 @@ "properties": { "secret": { "type": "string", + "minLength": 1, "description": "The name of the secret that contains the token." } }, @@ -35,6 +36,7 @@ "properties": { "env": { "type": "string", + "minLength": 1, "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." } }, diff --git a/docs/snippets/schemas/v3/index.schema.mdx b/docs/snippets/schemas/v3/index.schema.mdx index 79bcda80..f3e3edf9 100644 --- a/docs/snippets/schemas/v3/index.schema.mdx +++ b/docs/snippets/schemas/v3/index.schema.mdx @@ -265,6 +265,7 @@ "properties": { "secret": { "type": "string", + "minLength": 1, "description": "The name of the secret that contains the token." } }, @@ -278,6 +279,7 @@ "properties": { "env": { "type": "string", + "minLength": 1, "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." } }, @@ -478,6 +480,7 @@ "properties": { "secret": { "type": "string", + "minLength": 1, "description": "The name of the secret that contains the token." } }, @@ -491,6 +494,7 @@ "properties": { "env": { "type": "string", + "minLength": 1, "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." } }, @@ -680,6 +684,7 @@ "properties": { "secret": { "type": "string", + "minLength": 1, "description": "The name of the secret that contains the token." } }, @@ -693,6 +698,7 @@ "properties": { "env": { "type": "string", + "minLength": 1, "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." } }, @@ -836,6 +842,65 @@ ], "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" }, + "auth": { + "type": "object", + "description": "Authentication configuration for Gerrit", + "properties": { + "username": { + "type": "string", + "description": "Gerrit username for authentication", + "examples": [ + "john.doe" + ] + }, + "password": { + "description": "Gerrit HTTP password (not your account password). Generate this in Gerrit → Settings → HTTP Credentials → Generate Password. Note: HTTP password authentication requires Gerrit's auth.gitBasicAuthPolicy to be set to HTTP or HTTP_LDAP.", + "examples": [ + { + "env": "GERRIT_HTTP_PASSWORD" + }, + { + "secret": "GERRIT_PASSWORD_SECRET" + } + ], + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "minLength": 1, + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "minLength": 1, + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "username", + "password" + ], + "additionalProperties": false + }, "projects": { "type": "array", "items": { @@ -911,6 +976,7 @@ "properties": { "secret": { "type": "string", + "minLength": 1, "description": "The name of the secret that contains the token." } }, @@ -924,6 +990,7 @@ "properties": { "env": { "type": "string", + "minLength": 1, "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." } }, diff --git a/docs/snippets/schemas/v3/shared.schema.mdx b/docs/snippets/schemas/v3/shared.schema.mdx index 97fdbabf..4fd31856 100644 --- a/docs/snippets/schemas/v3/shared.schema.mdx +++ b/docs/snippets/schemas/v3/shared.schema.mdx @@ -11,6 +11,7 @@ "properties": { "secret": { "type": "string", + "minLength": 1, "description": "The name of the secret that contains the token." } }, @@ -24,6 +25,7 @@ "properties": { "env": { "type": "string", + "minLength": 1, "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." } }, diff --git a/packages/backend/src/connectionManager.ts b/packages/backend/src/connectionManager.ts index f025bdf7..7c143a9f 100644 --- a/packages/backend/src/connectionManager.ts +++ b/packages/backend/src/connectionManager.ts @@ -172,7 +172,7 @@ export class ConnectionManager implements IConnectionManager { return await compileGiteaConfig(config, job.data.connectionId, orgId, this.db); } case 'gerrit': { - return await compileGerritConfig(config, job.data.connectionId, orgId); + return await compileGerritConfig(config, job.data.connectionId, orgId, this.db); } case 'bitbucket': { return await compileBitbucketConfig(config, job.data.connectionId, orgId, this.db); diff --git a/packages/backend/src/gerrit.test.ts b/packages/backend/src/gerrit.test.ts new file mode 100644 index 00000000..fd8d8195 --- /dev/null +++ b/packages/backend/src/gerrit.test.ts @@ -0,0 +1,1011 @@ +import { expect, test, vi, beforeEach, afterEach } from 'vitest'; +import { shouldExcludeProject, GerritProject, getGerritReposFromConfig } from './gerrit'; +import { GerritConnectionConfig } from '@sourcebot/schemas/v3/index.type'; +import { PrismaClient } from '@sourcebot/db'; +import { BackendException, BackendError } from '@sourcebot/error'; +import fetch from 'cross-fetch'; + +// Mock dependencies +vi.mock('cross-fetch'); +vi.mock('./logger.js', () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }) +})); +vi.mock('./utils.js', async () => { + const actual = await vi.importActual('./utils.js'); + return { + ...actual, + measure: vi.fn(async (fn) => { + const result = await fn(); + return { data: result, durationMs: 100 }; + }), + fetchWithRetry: vi.fn(async (fn) => { + const result = await fn(); + return result; + }), + getTokenFromConfig: vi.fn().mockImplementation(async (token) => { + // String tokens are no longer supported (security measure) + if (typeof token === 'string') { + throw new Error('Invalid token configuration'); + } + // For objects (env/secret), return mock value + if (token && typeof token === 'object' && ('secret' in token || 'env' in token)) { + return 'mock-password'; + } + throw new Error('Invalid token configuration'); + }), + }; +}); +vi.mock('@sentry/node', () => ({ + captureException: vi.fn(), +})); + +const mockFetch = vi.mocked(fetch); +const mockDb = {} as PrismaClient; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +test('shouldExcludeProject returns false when the project is not excluded', () => { + const project: GerritProject = { + name: 'test/project', + id: 'test%2Fproject', + state: 'ACTIVE' + }; + + expect(shouldExcludeProject({ + project, + })).toBe(false); +}); + +test('shouldExcludeProject returns true for special Gerrit projects', () => { + const specialProjects = [ + 'All-Projects', + 'All-Users', + 'All-Avatars', + 'All-Archived-Projects' + ]; + + specialProjects.forEach(projectName => { + const project: GerritProject = { + name: projectName, + id: projectName.replace(/-/g, '%2D'), + state: 'ACTIVE' + }; + + expect(shouldExcludeProject({ project })).toBe(true); + }); +}); + +test('shouldExcludeProject handles readOnly projects correctly', () => { + const project: GerritProject = { + name: 'test/readonly-project', + id: 'test%2Freadonly-project', + state: 'READ_ONLY' + }; + + expect(shouldExcludeProject({ project })).toBe(false); + expect(shouldExcludeProject({ + project, + exclude: { readOnly: true } + })).toBe(true); + expect(shouldExcludeProject({ + project, + exclude: { readOnly: false } + })).toBe(false); +}); + +test('shouldExcludeProject handles hidden projects correctly', () => { + const project: GerritProject = { + name: 'test/hidden-project', + id: 'test%2Fhidden-project', + state: 'HIDDEN' + }; + + expect(shouldExcludeProject({ project })).toBe(false); + expect(shouldExcludeProject({ + project, + exclude: { hidden: true } + })).toBe(true); + expect(shouldExcludeProject({ + project, + exclude: { hidden: false } + })).toBe(false); +}); + +test('shouldExcludeProject handles exclude.projects correctly', () => { + const project: GerritProject = { + name: 'test/example-project', + id: 'test%2Fexample-project', + state: 'ACTIVE' + }; + + expect(shouldExcludeProject({ + project, + exclude: { + projects: [] + } + })).toBe(false); + + expect(shouldExcludeProject({ + project, + exclude: { + projects: ['test/example-project'] + } + })).toBe(true); + + expect(shouldExcludeProject({ + project, + exclude: { + projects: ['test/*'] + } + })).toBe(true); + + expect(shouldExcludeProject({ + project, + exclude: { + projects: ['other/project'] + } + })).toBe(false); + + expect(shouldExcludeProject({ + project, + exclude: { + projects: ['test/different-*'] + } + })).toBe(false); +}); + +test('shouldExcludeProject handles complex glob patterns correctly', () => { + const project: GerritProject = { + name: 'android/platform/build', + id: 'android%2Fplatform%2Fbuild', + state: 'ACTIVE' + }; + + expect(shouldExcludeProject({ + project, + exclude: { + projects: ['android/**'] + } + })).toBe(true); + + expect(shouldExcludeProject({ + project, + exclude: { + projects: ['android/platform/*'] + } + })).toBe(true); + + expect(shouldExcludeProject({ + project, + exclude: { + projects: ['android/*/build'] + } + })).toBe(true); + + expect(shouldExcludeProject({ + project, + exclude: { + projects: ['ios/**'] + } + })).toBe(false); +}); + +test('shouldExcludeProject handles multiple exclusion criteria', () => { + const readOnlyProject: GerritProject = { + name: 'archived/old-project', + id: 'archived%2Fold-project', + state: 'READ_ONLY' + }; + + expect(shouldExcludeProject({ + project: readOnlyProject, + exclude: { + readOnly: true, + projects: ['archived/*'] + } + })).toBe(true); + + const hiddenProject: GerritProject = { + name: 'secret/internal-project', + id: 'secret%2Finternal-project', + state: 'HIDDEN' + }; + + expect(shouldExcludeProject({ + project: hiddenProject, + exclude: { + hidden: true, + projects: ['public/*'] + } + })).toBe(true); +}); + +test('shouldExcludeProject handles edge cases', () => { + // Test with minimal project data + const minimalProject: GerritProject = { + name: 'minimal', + id: 'minimal' + }; + + expect(shouldExcludeProject({ project: minimalProject })).toBe(false); + + // Test with empty exclude object + expect(shouldExcludeProject({ + project: minimalProject, + exclude: {} + })).toBe(false); + + // Test with undefined exclude + expect(shouldExcludeProject({ + project: minimalProject, + exclude: undefined + })).toBe(false); +}); + +test('shouldExcludeProject handles case sensitivity in project names', () => { + const project: GerritProject = { + name: 'Test/Example-Project', + id: 'Test%2FExample-Project', + state: 'ACTIVE' + }; + + // micromatch should handle case sensitivity based on its default behavior + expect(shouldExcludeProject({ + project, + exclude: { + projects: ['test/example-project'] + } + })).toBe(false); + + expect(shouldExcludeProject({ + project, + exclude: { + projects: ['Test/Example-Project'] + } + })).toBe(true); +}); + +test('shouldExcludeProject handles project with web_links', () => { + const projectWithLinks: GerritProject = { + name: 'test/project-with-links', + id: 'test%2Fproject-with-links', + state: 'ACTIVE', + web_links: [ + { + name: 'browse', + url: 'https://gerrit.example.com/plugins/gitiles/test/project-with-links' + } + ] + }; + + expect(shouldExcludeProject({ project: projectWithLinks })).toBe(false); + + expect(shouldExcludeProject({ + project: projectWithLinks, + exclude: { + projects: ['test/*'] + } + })).toBe(true); +}); + +// === HTTP Authentication Tests === + +test('getGerritReposFromConfig handles public access without authentication', async () => { + const config: GerritConnectionConfig = { + type: 'gerrit', + url: 'https://gerrit.example.com', + projects: ['test-project'] + }; + + const mockResponse = { + ok: true, + text: () => Promise.resolve(')]}\'\n{"test-project": {"id": "test%2Dproject", "state": "ACTIVE"}}'), + }; + mockFetch.mockResolvedValueOnce(mockResponse as any); + + const result = await getGerritReposFromConfig(config, 1, mockDb); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + name: 'test-project', + id: 'test%2Dproject', + state: 'ACTIVE' + }); + + // Verify that public endpoint was called (no /a/ prefix) + expect(mockFetch).toHaveBeenCalledWith( + 'https://gerrit.example.com/projects/?S=0', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + 'Accept': 'application/json', + 'User-Agent': 'Sourcebot-Gerrit-Client/1.0' + }) + }) + ); + + // Verify no Authorization header for public access + const [, options] = mockFetch.mock.calls[0]; + const headers = options?.headers as Record; + expect(headers).not.toHaveProperty('Authorization'); +}); + +test('getGerritReposFromConfig handles authenticated access with HTTP Basic Auth', async () => { + const config: GerritConnectionConfig = { + type: 'gerrit', + url: 'https://gerrit.example.com', + projects: ['test-project'], + auth: { + username: 'testuser', + password: 'test-password' + } + }; + + const mockResponse = { + ok: true, + text: () => Promise.resolve(')]}\'\n{"test-project": {"id": "test%2Dproject", "state": "ACTIVE"}}'), + }; + mockFetch.mockResolvedValueOnce(mockResponse as any); + + const result = await getGerritReposFromConfig(config, 1, mockDb); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + name: 'test-project', + id: 'test%2Dproject', + state: 'ACTIVE' + }); + + // Verify that authenticated endpoint was called (with /a/ prefix) + expect(mockFetch).toHaveBeenCalledWith( + 'https://gerrit.example.com/a/projects/?S=0', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + 'Accept': 'application/json', + 'User-Agent': 'Sourcebot-Gerrit-Client/1.0', + 'Authorization': expect.stringMatching(/^Basic /) + }) + }) + ); + + // Verify that Authorization header is present and properly formatted + const [, options] = mockFetch.mock.calls[0]; + const headers = options?.headers as Record; + const authHeader = headers?.Authorization; + + // Verify Basic Auth format exists + expect(authHeader).toMatch(/^Basic [A-Za-z0-9+/]+=*$/); + + // Verify it contains the username (password will be mocked) + const encodedCredentials = authHeader?.replace('Basic ', ''); + const decodedCredentials = Buffer.from(encodedCredentials || '', 'base64').toString(); + expect(decodedCredentials).toContain('testuser:'); +}); + +test('getGerritReposFromConfig handles environment variable password', async () => { + const config: GerritConnectionConfig = { + type: 'gerrit', + url: 'https://gerrit.example.com', + projects: ['test-project'], + auth: { + username: 'testuser', + password: { env: 'GERRIT_HTTP_PASSWORD' } + } + }; + + const mockResponse = { + ok: true, + text: () => Promise.resolve(')]}\'\n{"test-project": {"id": "test%2Dproject", "state": "ACTIVE"}}'), + }; + mockFetch.mockResolvedValueOnce(mockResponse as any); + + const result = await getGerritReposFromConfig(config, 1, mockDb); + + expect(result).toHaveLength(1); + + // Verify that getTokenFromConfig was called for environment variable + const { getTokenFromConfig } = await import('./utils.js'); + expect(getTokenFromConfig).toHaveBeenCalledWith( + { env: 'GERRIT_HTTP_PASSWORD' }, + 1, + mockDb, + expect.any(Object) + ); +}); + +test('getGerritReposFromConfig handles secret-based password', async () => { + const config: GerritConnectionConfig = { + type: 'gerrit', + url: 'https://gerrit.example.com', + projects: ['test-project'], + auth: { + username: 'testuser', + password: { secret: 'GERRIT_SECRET' } + } + }; + + const mockResponse = { + ok: true, + text: () => Promise.resolve(')]}\'\n{"test-project": {"id": "test%2Dproject", "state": "ACTIVE"}}'), + }; + mockFetch.mockResolvedValueOnce(mockResponse as any); + + const result = await getGerritReposFromConfig(config, 1, mockDb); + + expect(result).toHaveLength(1); + + // Verify that getTokenFromConfig was called for secret + const { getTokenFromConfig } = await import('./utils.js'); + expect(getTokenFromConfig).toHaveBeenCalledWith( + { secret: 'GERRIT_SECRET' }, + 1, + mockDb, + expect.any(Object) + ); +}); + +test('getGerritReposFromConfig handles authentication errors', async () => { + const config: GerritConnectionConfig = { + type: 'gerrit', + url: 'https://gerrit.example.com', + projects: ['test-project'], + auth: { + username: 'testuser', + password: 'invalid-password' + } + }; + + const mockResponse = { + ok: false, + status: 401, + text: () => Promise.resolve('Unauthorized'), + }; + mockFetch.mockResolvedValueOnce(mockResponse as any); + + await expect(getGerritReposFromConfig(config, 1, mockDb)).rejects.toThrow(BackendException); +}); + +test('getGerritReposFromConfig handles network errors', async () => { + const config: GerritConnectionConfig = { + type: 'gerrit', + url: 'https://gerrit.example.com', + projects: ['test-project'] + }; + + const networkError = new Error('Network error'); + (networkError as any).code = 'ECONNREFUSED'; + mockFetch.mockRejectedValueOnce(networkError); + + await expect(getGerritReposFromConfig(config, 1, mockDb)).rejects.toThrow(BackendException); +}); + +test('getGerritReposFromConfig handles malformed JSON response', async () => { + const config: GerritConnectionConfig = { + type: 'gerrit', + url: 'https://gerrit.example.com', + projects: ['test-project'] + }; + + const mockResponse = { + ok: true, + text: () => Promise.resolve('invalid json'), + }; + mockFetch.mockResolvedValueOnce(mockResponse as any); + + await expect(getGerritReposFromConfig(config, 1, mockDb)).rejects.toThrow(); +}); + +test('getGerritReposFromConfig strips XSSI protection prefix correctly', async () => { + const config: GerritConnectionConfig = { + type: 'gerrit', + url: 'https://gerrit.example.com', + projects: ['test-project'] + }; + + const mockResponse = { + ok: true, + text: () => Promise.resolve(')]}\'\n{"test-project": {"id": "test%2Dproject", "state": "ACTIVE"}}'), + }; + mockFetch.mockResolvedValueOnce(mockResponse as any); + + const result = await getGerritReposFromConfig(config, 1, mockDb); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + name: 'test-project', + id: 'test%2Dproject', + state: 'ACTIVE' + }); +}); + +test('getGerritReposFromConfig handles pagination correctly', async () => { + const config: GerritConnectionConfig = { + type: 'gerrit', + url: 'https://gerrit.example.com' + }; + + // First page response + const firstPageResponse = { + ok: true, + text: () => Promise.resolve(')]}\'\n{"project1": {"id": "project1", "_more_projects": true}}'), + }; + + // Second page response + const secondPageResponse = { + ok: true, + text: () => Promise.resolve(')]}\'\n{"project2": {"id": "project2"}}'), + }; + + mockFetch + .mockResolvedValueOnce(firstPageResponse as any) + .mockResolvedValueOnce(secondPageResponse as any); + + const result = await getGerritReposFromConfig(config, 1, mockDb); + + expect(result).toHaveLength(2); + expect(result[0].name).toBe('project1'); + expect(result[1].name).toBe('project2'); + + // Verify pagination calls + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenNthCalledWith(1, + 'https://gerrit.example.com/projects/?S=0', + expect.any(Object) + ); + expect(mockFetch).toHaveBeenNthCalledWith(2, + 'https://gerrit.example.com/projects/?S=1', + expect.any(Object) + ); +}); + +test('getGerritReposFromConfig filters projects based on config.projects', async () => { + const config: GerritConnectionConfig = { + type: 'gerrit', + url: 'https://gerrit.example.com', + projects: ['test-*'] // Only projects matching this pattern + }; + + const mockResponse = { + ok: true, + text: () => Promise.resolve(')]}\'\n{"test-project": {"id": "test%2Dproject"}, "other-project": {"id": "other%2Dproject"}}'), + }; + mockFetch.mockResolvedValueOnce(mockResponse as any); + + const result = await getGerritReposFromConfig(config, 1, mockDb); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('test-project'); +}); + +test('getGerritReposFromConfig excludes projects based on config.exclude', async () => { + const config: GerritConnectionConfig = { + type: 'gerrit', + url: 'https://gerrit.example.com', + exclude: { + readOnly: true, + hidden: true, + projects: ['excluded-*'] + } + }; + + const mockResponse = { + ok: true, + text: () => Promise.resolve(')]}\'\n{' + + '"active-project": {"id": "active%2Dproject", "state": "ACTIVE"}, ' + + '"readonly-project": {"id": "readonly%2Dproject", "state": "READ_ONLY"}, ' + + '"hidden-project": {"id": "hidden%2Dproject", "state": "HIDDEN"}, ' + + '"excluded-project": {"id": "excluded%2Dproject", "state": "ACTIVE"}' + + '}'), + }; + mockFetch.mockResolvedValueOnce(mockResponse as any); + + const result = await getGerritReposFromConfig(config, 1, mockDb); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('active-project'); +}); + +test('getGerritReposFromConfig handles trailing slash in URL correctly', async () => { + const config: GerritConnectionConfig = { + type: 'gerrit', + url: 'https://gerrit.example.com/', // Note trailing slash + projects: ['test-project'] + }; + + const mockResponse = { + ok: true, + text: () => Promise.resolve(')]}\'\n{"test-project": {"id": "test%2Dproject"}}'), + }; + mockFetch.mockResolvedValueOnce(mockResponse as any); + + await getGerritReposFromConfig(config, 1, mockDb); + + // Verify URL is normalized correctly + expect(mockFetch).toHaveBeenCalledWith( + 'https://gerrit.example.com/projects/?S=0', + expect.any(Object) + ); +}); + +test('getGerritReposFromConfig handles projects with web_links', async () => { + const config: GerritConnectionConfig = { + type: 'gerrit', + url: 'https://gerrit.example.com', + projects: ['test-project'] + }; + + const mockResponse = { + ok: true, + text: () => Promise.resolve(')]}\'\n{' + + '"test-project": {' + + '"id": "test%2Dproject", ' + + '"state": "ACTIVE", ' + + '"web_links": [{"name": "browse", "url": "https://gerrit.example.com/plugins/gitiles/test-project"}]' + + '}' + + '}'), + }; + mockFetch.mockResolvedValueOnce(mockResponse as any); + + const result = await getGerritReposFromConfig(config, 1, mockDb); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + name: 'test-project', + id: 'test%2Dproject', + state: 'ACTIVE', + web_links: [ + { + name: 'browse', + url: 'https://gerrit.example.com/plugins/gitiles/test-project' + } + ] + }); +}); + +test('getGerritReposFromConfig handles authentication credential retrieval errors', async () => { + const config: GerritConnectionConfig = { + type: 'gerrit', + url: 'https://gerrit.example.com', + projects: ['test-project'], + auth: { + username: 'testuser', + password: { env: 'MISSING_ENV_VAR' } + } + }; + + // Mock getTokenFromConfig to throw an error + const { getTokenFromConfig } = await import('./utils.js'); + vi.mocked(getTokenFromConfig).mockRejectedValueOnce(new Error('Environment variable not found')); + + await expect(getGerritReposFromConfig(config, 1, mockDb)).rejects.toThrow('Environment variable not found'); +}); + +test('getGerritReposFromConfig handles empty projects response', async () => { + const config: GerritConnectionConfig = { + type: 'gerrit', + url: 'https://gerrit.example.com' + }; + + const mockResponse = { + ok: true, + text: () => Promise.resolve(')]}\'\n{}'), + }; + mockFetch.mockResolvedValueOnce(mockResponse as any); + + const result = await getGerritReposFromConfig(config, 1, mockDb); + + expect(result).toHaveLength(0); +}); + +test('getGerritReposFromConfig handles response without XSSI prefix', async () => { + const config: GerritConnectionConfig = { + type: 'gerrit', + url: 'https://gerrit.example.com', + projects: ['test-project'] + }; + + // Response without XSSI prefix (some Gerrit instances might not include it) + const mockResponse = { + ok: true, + text: () => Promise.resolve('{"test-project": {"id": "test%2Dproject", "state": "ACTIVE"}}'), + }; + mockFetch.mockResolvedValueOnce(mockResponse as any); + + const result = await getGerritReposFromConfig(config, 1, mockDb); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + name: 'test-project', + id: 'test%2Dproject', + state: 'ACTIVE' + }); +}); + +test('getGerritReposFromConfig validates Basic Auth header format', async () => { + const config: GerritConnectionConfig = { + type: 'gerrit', + url: 'https://gerrit.example.com', + projects: ['test-project'], + auth: { + username: 'user@example.com', + password: 'complex-password-123!' + } + }; + + const mockResponse = { + ok: true, + text: () => Promise.resolve(')]}\'\n{"test-project": {"id": "test%2Dproject"}}'), + }; + mockFetch.mockResolvedValueOnce(mockResponse as any); + + await getGerritReposFromConfig(config, 1, mockDb); + + const [, options] = mockFetch.mock.calls[0]; + const headers = options?.headers as Record; + const authHeader = headers?.Authorization; + + // Verify Basic Auth format + expect(authHeader).toMatch(/^Basic [A-Za-z0-9+/]+=*$/); + + // Verify credentials can be decoded and contain the username + const encodedCredentials = authHeader?.replace('Basic ', ''); + const decodedCredentials = Buffer.from(encodedCredentials || '', 'base64').toString(); + expect(decodedCredentials).toContain('user@example.com:'); +}); + +test('getGerritReposFromConfig handles special characters in project names', async () => { + const config: GerritConnectionConfig = { + type: 'gerrit', + url: 'https://gerrit.example.com' + }; + + const mockResponse = { + ok: true, + text: () => Promise.resolve(')]}\'\n{' + + '"project/with-dashes": {"id": "project%2Fwith-dashes"}, ' + + '"project_with_underscores": {"id": "project_with_underscores"}, ' + + '"project.with.dots": {"id": "project.with.dots"}, ' + + '"project with spaces": {"id": "project%20with%20spaces"}' + + '}'), + }; + mockFetch.mockResolvedValueOnce(mockResponse as any); + + const result = await getGerritReposFromConfig(config, 1, mockDb); + + expect(result).toHaveLength(4); + expect(result.map(p => p.name)).toEqual([ + 'project/with-dashes', + 'project_with_underscores', + 'project.with.dots', + 'project with spaces' + ]); +}); + +test('getGerritReposFromConfig handles large project responses', async () => { + const config: GerritConnectionConfig = { + type: 'gerrit', + url: 'https://gerrit.example.com' + }; + + // Generate a large response with many projects + const projects: Record = {}; + for (let i = 0; i < 100; i++) { + projects[`project-${i}`] = { + id: `project%2D${i}`, + state: 'ACTIVE' + }; + } + + const mockResponse = { + ok: true, + text: () => Promise.resolve(')]}\'\n' + JSON.stringify(projects)), + }; + mockFetch.mockResolvedValueOnce(mockResponse as any); + + const result = await getGerritReposFromConfig(config, 1, mockDb); + + expect(result).toHaveLength(100); + expect(result[0].name).toBe('project-0'); + expect(result[99].name).toBe('project-99'); +}); + +test('getGerritReposFromConfig handles mixed authentication scenarios', async () => { + // Test that the function correctly chooses authenticated vs public endpoints + const publicConfig: GerritConnectionConfig = { + type: 'gerrit', + url: 'https://gerrit.example.com', + projects: ['public-project'] + }; + + const authenticatedConfig: GerritConnectionConfig = { + type: 'gerrit', + url: 'https://gerrit.example.com', + projects: ['private-project'], + auth: { + username: 'testuser', + password: 'test-password' + } + }; + + const mockResponse = { + ok: true, + text: () => Promise.resolve(')]}\'\n{"test-project": {"id": "test%2Dproject"}}'), + }; + + // Test public access + mockFetch.mockResolvedValueOnce(mockResponse as any); + await getGerritReposFromConfig(publicConfig, 1, mockDb); + + expect(mockFetch).toHaveBeenLastCalledWith( + 'https://gerrit.example.com/projects/?S=0', + expect.objectContaining({ + headers: expect.not.objectContaining({ + Authorization: expect.any(String) + }) + }) + ); + + // Test authenticated access + mockFetch.mockResolvedValueOnce(mockResponse as any); + await getGerritReposFromConfig(authenticatedConfig, 1, mockDb); + + expect(mockFetch).toHaveBeenLastCalledWith( + 'https://gerrit.example.com/a/projects/?S=0', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: expect.stringMatching(/^Basic /) + }) + }) + ); +}); + +test('getGerritReposFromConfig handles passwords with special characters', async () => { + const config: GerritConnectionConfig = { + type: 'gerrit', + url: 'https://gerrit.example.com', + projects: ['test-project'], + auth: { + username: 'user@example.com', + password: { env: 'GERRIT_SPECIAL_PASSWORD' } + } + }; + + // Mock getTokenFromConfig to return password with special characters + const { getTokenFromConfig } = await import('./utils.js'); + vi.mocked(getTokenFromConfig).mockResolvedValueOnce('pass/with+special=chars'); + + const mockResponse = { + ok: true, + text: () => Promise.resolve(')]}\'\n{"test-project": {"id": "test%2Dproject"}}'), + }; + mockFetch.mockResolvedValueOnce(mockResponse as any); + + await getGerritReposFromConfig(config, 1, mockDb); + + const [, options] = mockFetch.mock.calls[0]; + const headers = options?.headers as Record; + const authHeader = headers?.Authorization; + + // Verify Basic Auth format + expect(authHeader).toMatch(/^Basic [A-Za-z0-9+/]+=*$/); + + // Verify credentials can be decoded and contain the special characters + const encodedCredentials = authHeader?.replace('Basic ', ''); + const decodedCredentials = Buffer.from(encodedCredentials || '', 'base64').toString(); + expect(decodedCredentials).toContain('user@example.com:pass/with+special=chars'); + + // Verify that getTokenFromConfig was called for the password with special characters + expect(getTokenFromConfig).toHaveBeenCalledWith( + { env: 'GERRIT_SPECIAL_PASSWORD' }, + 1, + mockDb, + expect.any(Object) + ); +}); + +test('getGerritReposFromConfig handles concurrent authentication requests', async () => { + const config: GerritConnectionConfig = { + type: 'gerrit', + url: 'https://gerrit.example.com', + projects: ['test-project'], + auth: { + username: 'testuser', + password: { env: 'GERRIT_HTTP_PASSWORD' } + } + }; + + const mockResponse = { + ok: true, + text: () => Promise.resolve(')]}\'\n{"test-project": {"id": "test%2Dproject"}}'), + }; + + // Mock multiple concurrent calls + mockFetch.mockResolvedValue(mockResponse as any); + + const promises = Array(5).fill(null).map(() => + getGerritReposFromConfig(config, 1, mockDb) + ); + + const results = await Promise.all(promises); + + // All should succeed + expect(results).toHaveLength(5); + results.forEach(result => { + expect(result).toHaveLength(1); + expect(result[0].name).toBe('test-project'); + }); + + // Verify getTokenFromConfig was called for each request + const { getTokenFromConfig } = await import('./utils.js'); + expect(getTokenFromConfig).toHaveBeenCalledTimes(5); +}); + +test('getGerritReposFromConfig rejects invalid token formats (security)', async () => { + const configWithStringToken: any = { + type: 'gerrit', + url: 'https://gerrit.example.com', + projects: ['test-project'], + auth: { + username: 'testuser', + password: 'direct-string-password' // This should be rejected + } + }; + + await expect(getGerritReposFromConfig(configWithStringToken, 1, mockDb)) + .rejects.toThrow('CONNECTION_SYNC_FAILED_TO_FETCH_GERRIT_PROJECTS'); + + const configWithMalformedToken: any = { + type: 'gerrit', + url: 'https://gerrit.example.com', + projects: ['test-project'], + auth: { + username: 'testuser', + password: { invalid: 'format' } // This should be rejected + } + }; + + await expect(getGerritReposFromConfig(configWithMalformedToken, 1, mockDb)) + .rejects.toThrow('CONNECTION_SYNC_FAILED_TO_FETCH_GERRIT_PROJECTS'); +}); + +test('getGerritReposFromConfig handles responses with and without XSSI prefix', async () => { + const config: GerritConnectionConfig = { + type: 'gerrit', + url: 'https://gerrit.example.com', + projects: ['test-project'] + }; + + // Test with XSSI prefix + const responseWithXSSI = { + ok: true, + text: () => Promise.resolve(')]}\'\n{"test-project": {"id": "test%2Dproject"}}'), + }; + mockFetch.mockResolvedValueOnce(responseWithXSSI as any); + + const result1 = await getGerritReposFromConfig(config, 1, mockDb); + expect(result1).toHaveLength(1); + expect(result1[0].name).toBe('test-project'); + + // Test without XSSI prefix + const responseWithoutXSSI = { + ok: true, + text: () => Promise.resolve('{"test-project": {"id": "test%2Dproject"}}'), + }; + mockFetch.mockResolvedValueOnce(responseWithoutXSSI as any); + + const result2 = await getGerritReposFromConfig(config, 1, mockDb); + expect(result2).toHaveLength(1); + expect(result2[0].name).toBe('test-project'); +}); \ No newline at end of file diff --git a/packages/backend/src/gerrit.ts b/packages/backend/src/gerrit.ts index 25e3cfa7..c8e7f1f4 100644 --- a/packages/backend/src/gerrit.ts +++ b/packages/backend/src/gerrit.ts @@ -1,10 +1,11 @@ import fetch from 'cross-fetch'; import { GerritConnectionConfig } from "@sourcebot/schemas/v3/index.type" -import { createLogger } from '@sourcebot/logger'; +import { createLogger } from "@sourcebot/logger"; import micromatch from "micromatch"; -import { measure, fetchWithRetry } from './utils.js'; +import { measure, fetchWithRetry, getTokenFromConfig } from './utils.js'; import { BackendError } from '@sourcebot/error'; import { BackendException } from '@sourcebot/error'; +import { PrismaClient } from "@sourcebot/db"; import * as Sentry from "@sentry/node"; // https://gerrit-review.googlesource.com/Documentation/rest-api.html @@ -21,7 +22,7 @@ interface GerritProjectInfo { web_links?: GerritWebLink[]; } -interface GerritProject { +export interface GerritProject { name: string; id: string; state?: GerritProjectState; @@ -33,15 +34,40 @@ interface GerritWebLink { url: string; } +interface GerritAuthConfig { + username: string; + password: string; +} + const logger = createLogger('gerrit'); -export const getGerritReposFromConfig = async (config: GerritConnectionConfig): Promise => { +export const getGerritReposFromConfig = async ( + config: GerritConnectionConfig, + orgId: number, + db: PrismaClient +): Promise => { const url = config.url.endsWith('/') ? config.url : `${config.url}/`; const hostname = new URL(config.url).hostname; + // Get authentication credentials if provided + let auth: GerritAuthConfig | undefined; + if (config.auth) { + try { + const password = await getTokenFromConfig(config.auth.password, orgId, db, logger); + auth = { + username: config.auth.username, + password: password + }; + logger.debug(`Using authentication for Gerrit instance ${hostname} with username: ${auth.username}`); + } catch (error) { + logger.error(`Failed to retrieve Gerrit authentication credentials: ${error}`); + throw error; + } + } + let { durationMs, data: projects } = await measure(async () => { try { - const fetchFn = () => fetchAllProjects(url); + const fetchFn = () => fetchAllProjects(url, auth); return fetchWithRetry(fetchFn, `projects from ${url}`, logger); } catch (err) { Sentry.captureException(err); @@ -81,23 +107,44 @@ export const getGerritReposFromConfig = async (config: GerritConnectionConfig): return projects; }; -const fetchAllProjects = async (url: string): Promise => { - const projectsEndpoint = `${url}projects/`; +const fetchAllProjects = async (url: string, auth?: GerritAuthConfig): Promise => { + // Use authenticated endpoint if auth is provided, otherwise use public endpoint + // See: https://gerrit-review.googlesource.com/Documentation/rest-api.html#:~:text=Protocol%20Details-,Authentication,-By%20default%20all + const projectsEndpoint = auth ? `${url}a/projects/` : `${url}projects/`; let allProjects: GerritProject[] = []; let start = 0; // Start offset for pagination let hasMoreProjects = true; + // Prepare authentication headers if credentials are provided + const headers: Record = { + 'Accept': 'application/json', + 'User-Agent': 'Sourcebot-Gerrit-Client/1.0' + }; + + if (auth) { + const authString = Buffer.from(`${auth.username}:${auth.password}`).toString('base64'); + headers['Authorization'] = `Basic ${authString}`; + logger.debug(`Using HTTP Basic authentication for user: ${auth.username}`); + } + while (hasMoreProjects) { const endpointWithParams = `${projectsEndpoint}?S=${start}`; - logger.debug(`Fetching projects from Gerrit at ${endpointWithParams}`); + logger.debug(`Fetching projects from Gerrit at ${endpointWithParams} ${auth ? '(authenticated)' : '(public)'}`); let response: Response; try { - response = await fetch(endpointWithParams); + response = await fetch(endpointWithParams, { + method: 'GET', + headers + }); + if (!response.ok) { - logger.error(`Failed to fetch projects from Gerrit at ${endpointWithParams} with status ${response.status}`); + const errorText = await response.text().catch(() => 'Unknown error'); + logger.error(`Failed to fetch projects from Gerrit at ${endpointWithParams} with status ${response.status}: ${errorText}`); const e = new BackendException(BackendError.CONNECTION_SYNC_FAILED_TO_FETCH_GERRIT_PROJECTS, { status: response.status, + url: endpointWithParams, + authenticated: !!auth }); Sentry.captureException(e); throw e; @@ -112,11 +159,16 @@ const fetchAllProjects = async (url: string): Promise => { logger.error(`Failed to fetch projects from Gerrit at ${endpointWithParams} with status ${status}`); throw new BackendException(BackendError.CONNECTION_SYNC_FAILED_TO_FETCH_GERRIT_PROJECTS, { status: status, + url: endpointWithParams, + authenticated: !!auth }); } const text = await response.text(); - const jsonText = text.replace(")]}'\n", ''); // Remove XSSI protection prefix + // Remove XSSI protection prefix that Gerrit adds to JSON responses + // The regex /^\)\]\}'\n/ matches the literal string ")]}'" at the start of the response + // followed by a newline character, which Gerrit adds to prevent JSON hijacking + const jsonText = text.startsWith(")]}'") ? text.replace(/^\)\]\}'\n/, '') : text; const data: GerritProjects = JSON.parse(jsonText); // Add fetched projects to allProjects @@ -138,10 +190,11 @@ const fetchAllProjects = async (url: string): Promise => { start += Object.keys(data).length; } + logger.debug(`Successfully fetched ${allProjects.length} projects ${auth ? '(authenticated)' : '(public)'}`); return allProjects; }; -const shouldExcludeProject = ({ +export const shouldExcludeProject = ({ project, exclude, }: { diff --git a/packages/backend/src/github.ts b/packages/backend/src/github.ts index 376ed039..02fb742d 100644 --- a/packages/backend/src/github.ts +++ b/packages/backend/src/github.ts @@ -66,7 +66,7 @@ export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, o if (isHttpError(error, 401)) { const e = new BackendException(BackendError.CONNECTION_SYNC_INVALID_TOKEN, { - ...(config.token && 'secret' in config.token ? { + ...(config.token && typeof config.token === 'object' && 'secret' in config.token ? { secretKey: config.token.secret, } : {}), }); diff --git a/packages/backend/src/repoCompileUtils.ts b/packages/backend/src/repoCompileUtils.ts index 0013cd89..e6d36da0 100644 --- a/packages/backend/src/repoCompileUtils.ts +++ b/packages/backend/src/repoCompileUtils.ts @@ -246,16 +246,21 @@ export const compileGiteaConfig = async ( export const compileGerritConfig = async ( config: GerritConnectionConfig, connectionId: number, - orgId: number) => { + orgId: number, + db: PrismaClient) => { - const gerritRepos = await getGerritReposFromConfig(config); + const gerritRepos = await getGerritReposFromConfig(config, orgId, db); const hostUrl = config.url; const repoNameRoot = new URL(hostUrl) .toString() .replace(/^https?:\/\//, ''); const repos = gerritRepos.map((project) => { - const cloneUrl = new URL(path.join(hostUrl, encodeURIComponent(project.name))); + // Use authenticated clone URL (/a/) if auth is configured, otherwise use public URL + const cloneUrlPath = config.auth ? + path.join(hostUrl, 'a', encodeURIComponent(project.name)) : + path.join(hostUrl, encodeURIComponent(project.name)); + const cloneUrl = new URL(cloneUrlPath); const repoDisplayName = project.name; const repoName = path.join(repoNameRoot, repoDisplayName); diff --git a/packages/backend/src/repoManager.ts b/packages/backend/src/repoManager.ts index 491e9d1d..de3260ce 100644 --- a/packages/backend/src/repoManager.ts +++ b/packages/backend/src/repoManager.ts @@ -2,7 +2,7 @@ import { Job, Queue, Worker } from 'bullmq'; import { Redis } from 'ioredis'; import { createLogger } from "@sourcebot/logger"; import { Connection, PrismaClient, Repo, RepoToConnection, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db"; -import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, BitbucketConnectionConfig } from '@sourcebot/schemas/v3/connection.type'; +import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, BitbucketConnectionConfig, GerritConnectionConfig } from '@sourcebot/schemas/v3/connection.type'; import { AppContext, Settings, repoMetadataSchema } from "./types.js"; import { getRepoPath, getTokenFromConfig, measure, getShardPrefix } from "./utils.js"; import { cloneRepository, fetchRepository, upsertGitConfig } from "./git.js"; @@ -220,6 +220,17 @@ export class RepoManager implements IRepoManager { } } } + + else if (connection.connectionType === 'gerrit') { + const config = connection.config as unknown as GerritConnectionConfig; + if (config.auth) { + const password = await getTokenFromConfig(config.auth.password, connection.orgId, db, logger); + return { + username: config.auth.username, + password: password, + } + } + } } return undefined; @@ -260,10 +271,10 @@ export class RepoManager implements IRepoManager { // we only have a password, we set the username to the password. // @see: https://www.typescriptlang.org/play/?#code/MYewdgzgLgBArgJwDYwLwzAUwO4wKoBKAMgBQBEAFlFAA4QBcA9I5gB4CGAtjUpgHShOZADQBKANwAoREj412ECNhAIAJmhhl5i5WrJTQkELz5IQAcxIy+UEAGUoCAJZhLo0UA if (!auth.username) { - cloneUrl.username = auth.password; + cloneUrl.username = encodeURIComponent(auth.password); } else { - cloneUrl.username = auth.username; - cloneUrl.password = auth.password; + cloneUrl.username = encodeURIComponent(auth.username); + cloneUrl.password = encodeURIComponent(auth.password); } } diff --git a/packages/backend/src/utils.ts b/packages/backend/src/utils.ts index 3245828d..d98f4fb4 100644 --- a/packages/backend/src/utils.ts +++ b/packages/backend/src/utils.ts @@ -3,6 +3,7 @@ import { AppContext } from "./types.js"; import path from 'path'; import { PrismaClient, Repo } from "@sourcebot/db"; import { getTokenFromConfig as getTokenFromConfigBase } from "@sourcebot/crypto"; +import { Token } from "@sourcebot/schemas/v3/shared.type"; import { BackendException, BackendError } from "@sourcebot/error"; import * as Sentry from "@sentry/node"; @@ -20,7 +21,8 @@ export const marshalBool = (value?: boolean) => { return !!value ? '1' : '0'; } -export const getTokenFromConfig = async (token: any, orgId: number, db: PrismaClient, logger?: Logger) => { + +export const getTokenFromConfig = async (token: Token, orgId: number, db: PrismaClient, logger?: Logger) => { try { return await getTokenFromConfigBase(token, orgId, db); } catch (error: unknown) { diff --git a/packages/crypto/package.json b/packages/crypto/package.json index abccd406..212c12b1 100644 --- a/packages/crypto/package.json +++ b/packages/crypto/package.json @@ -5,7 +5,8 @@ "private": true, "scripts": { "build": "tsc", - "postinstall": "yarn build" + "postinstall": "yarn build", + "test": "cross-env SKIP_ENV_VALIDATION=1 vitest --config ./vitest.config.ts" }, "dependencies": { "@sourcebot/db": "*", @@ -14,6 +15,8 @@ }, "devDependencies": { "@types/node": "^22.7.5", - "typescript": "^5.7.3" + "cross-env": "^7.0.3", + "typescript": "^5.7.3", + "vitest": "^2.1.9" } } diff --git a/packages/crypto/src/tokenUtils.test.ts b/packages/crypto/src/tokenUtils.test.ts new file mode 100644 index 00000000..8249ebe5 --- /dev/null +++ b/packages/crypto/src/tokenUtils.test.ts @@ -0,0 +1,96 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { PrismaClient } from '@sourcebot/db'; +import { getTokenFromConfig } from './tokenUtils'; + +// Mock the decrypt function +vi.mock('./index.js', () => ({ + decrypt: vi.fn().mockReturnValue('decrypted-secret-value') +})); + +describe('tokenUtils', () => { + let mockPrisma: any; + const testOrgId = 1; + + beforeEach(() => { + mockPrisma = { + secret: { + findUnique: vi.fn(), + }, + }; + + vi.clearAllMocks(); + process.env.TEST_TOKEN = undefined; + process.env.EMPTY_TOKEN = undefined; + }); + + describe('getTokenFromConfig', () => { + test('handles secret-based tokens', async () => { + const mockSecret = { + iv: 'test-iv', + encryptedValue: 'encrypted-value' + }; + mockPrisma.secret.findUnique.mockResolvedValue(mockSecret); + + const config = { secret: 'my-secret' }; + const result = await getTokenFromConfig(config, testOrgId, mockPrisma); + + expect(result).toBe('decrypted-secret-value'); + expect(mockPrisma.secret.findUnique).toHaveBeenCalledWith({ + where: { + orgId_key: { + key: 'my-secret', + orgId: testOrgId + } + } + }); + }); + + test('handles environment variable tokens', async () => { + process.env.TEST_TOKEN = 'env-token-value'; + + const config = { env: 'TEST_TOKEN' }; + const result = await getTokenFromConfig(config, testOrgId, mockPrisma); + + expect(result).toBe('env-token-value'); + }); + + test('throws error for string tokens (security)', async () => { + const config = 'direct-string-token'; + + await expect(getTokenFromConfig(config as any, testOrgId, mockPrisma)) + .rejects.toThrow('Invalid token configuration'); + }); + + test('throws error for malformed token objects', async () => { + const config = { invalid: 'format' }; + + await expect(getTokenFromConfig(config as any, testOrgId, mockPrisma)) + .rejects.toThrow('Invalid token configuration'); + }); + + test('throws error for missing secret', async () => { + mockPrisma.secret.findUnique.mockResolvedValue(null); + + const config = { secret: 'non-existent-secret' }; + + await expect(getTokenFromConfig(config, testOrgId, mockPrisma)) + .rejects.toThrow('Secret with key non-existent-secret not found for org 1'); + }); + + test('throws error for missing environment variable', async () => { + const config = { env: 'NON_EXISTENT_VAR' }; + + await expect(getTokenFromConfig(config, testOrgId, mockPrisma)) + .rejects.toThrow('Environment variable NON_EXISTENT_VAR not found.'); + }); + + test('handles empty environment variable', async () => { + process.env.EMPTY_TOKEN = ''; + + const config = { env: 'EMPTY_TOKEN' }; + + await expect(getTokenFromConfig(config, testOrgId, mockPrisma)) + .rejects.toThrow('Environment variable EMPTY_TOKEN not found.'); + }); + }); +}); \ No newline at end of file diff --git a/packages/crypto/src/tokenUtils.ts b/packages/crypto/src/tokenUtils.ts index be5a064d..5fcfe90b 100644 --- a/packages/crypto/src/tokenUtils.ts +++ b/packages/crypto/src/tokenUtils.ts @@ -3,6 +3,10 @@ import { Token } from "@sourcebot/schemas/v3/shared.type"; import { decrypt } from "./index.js"; export const getTokenFromConfig = async (token: Token, orgId: number, db: PrismaClient) => { + if (typeof token !== 'object' || token === null) { + throw new Error('Invalid token configuration'); + } + if ('secret' in token) { const secretKey = token.secret; const secret = await db.secret.findUnique({ diff --git a/packages/crypto/vitest.config.ts b/packages/crypto/vitest.config.ts new file mode 100644 index 00000000..7c052526 --- /dev/null +++ b/packages/crypto/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + watch: false, + } +}); \ No newline at end of file diff --git a/packages/schemas/package.json b/packages/schemas/package.json index 632361fd..8e85cdca 100644 --- a/packages/schemas/package.json +++ b/packages/schemas/package.json @@ -6,15 +6,19 @@ "build": "yarn generate && tsc", "generate": "tsx tools/generate.ts", "watch": "nodemon --watch ../../schemas -e json -x 'yarn generate'", - "postinstall": "yarn build" + "postinstall": "yarn build", + "test": "cross-env SKIP_ENV_VALIDATION=1 vitest --config ./vitest.config.ts" }, "devDependencies": { "@apidevtools/json-schema-ref-parser": "^11.7.3", + "ajv": "^8.12.0", + "cross-env": "^7.0.3", "glob": "^11.0.1", "json-schema-to-typescript": "^15.0.4", "nodemon": "^3.1.10", "tsx": "^4.19.2", - "typescript": "^5.7.3" + "typescript": "^5.7.3", + "vitest": "^2.1.9" }, "exports": { "./v2/*": "./dist/v2/*.js", diff --git a/packages/schemas/src/v3/bitbucket.schema.ts b/packages/schemas/src/v3/bitbucket.schema.ts index a7c857ce..ebd27898 100644 --- a/packages/schemas/src/v3/bitbucket.schema.ts +++ b/packages/schemas/src/v3/bitbucket.schema.ts @@ -25,6 +25,7 @@ const schema = { "properties": { "secret": { "type": "string", + "minLength": 1, "description": "The name of the secret that contains the token." } }, @@ -38,6 +39,7 @@ const schema = { "properties": { "env": { "type": "string", + "minLength": 1, "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." } }, diff --git a/packages/schemas/src/v3/connection.schema.ts b/packages/schemas/src/v3/connection.schema.ts index d0a72c72..49f2159f 100644 --- a/packages/schemas/src/v3/connection.schema.ts +++ b/packages/schemas/src/v3/connection.schema.ts @@ -25,6 +25,7 @@ const schema = { "properties": { "secret": { "type": "string", + "minLength": 1, "description": "The name of the secret that contains the token." } }, @@ -38,6 +39,7 @@ const schema = { "properties": { "env": { "type": "string", + "minLength": 1, "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." } }, @@ -238,6 +240,7 @@ const schema = { "properties": { "secret": { "type": "string", + "minLength": 1, "description": "The name of the secret that contains the token." } }, @@ -251,6 +254,7 @@ const schema = { "properties": { "env": { "type": "string", + "minLength": 1, "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." } }, @@ -440,6 +444,7 @@ const schema = { "properties": { "secret": { "type": "string", + "minLength": 1, "description": "The name of the secret that contains the token." } }, @@ -453,6 +458,7 @@ const schema = { "properties": { "env": { "type": "string", + "minLength": 1, "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." } }, @@ -596,6 +602,65 @@ const schema = { ], "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" }, + "auth": { + "type": "object", + "description": "Authentication configuration for Gerrit", + "properties": { + "username": { + "type": "string", + "description": "Gerrit username for authentication", + "examples": [ + "john.doe" + ] + }, + "password": { + "description": "Gerrit HTTP password (not your account password). Generate this in Gerrit → Settings → HTTP Credentials → Generate Password. Note: HTTP password authentication requires Gerrit's auth.gitBasicAuthPolicy to be set to HTTP or HTTP_LDAP.", + "examples": [ + { + "env": "GERRIT_HTTP_PASSWORD" + }, + { + "secret": "GERRIT_PASSWORD_SECRET" + } + ], + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "minLength": 1, + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "minLength": 1, + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "username", + "password" + ], + "additionalProperties": false + }, "projects": { "type": "array", "items": { @@ -671,6 +736,7 @@ const schema = { "properties": { "secret": { "type": "string", + "minLength": 1, "description": "The name of the secret that contains the token." } }, @@ -684,6 +750,7 @@ const schema = { "properties": { "env": { "type": "string", + "minLength": 1, "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." } }, diff --git a/packages/schemas/src/v3/connection.type.ts b/packages/schemas/src/v3/connection.type.ts index d1d2bc18..65e1058f 100644 --- a/packages/schemas/src/v3/connection.type.ts +++ b/packages/schemas/src/v3/connection.type.ts @@ -226,6 +226,31 @@ export interface GerritConnectionConfig { * The URL of the Gerrit host. */ url: string; + /** + * Authentication configuration for Gerrit + */ + auth?: { + /** + * Gerrit username for authentication + */ + username: string; + /** + * Gerrit HTTP password (not your account password). Generate this in Gerrit → Settings → HTTP Credentials → Generate Password. Note: HTTP password authentication requires Gerrit's auth.gitBasicAuthPolicy to be set to HTTP or HTTP_LDAP. + */ + password: + | { + /** + * The name of the secret that contains the token. + */ + secret: string; + } + | { + /** + * The name of the environment variable that contains the token. Only supported in declarative connection configs. + */ + env: string; + }; + }; /** * List of specific projects to sync. If not specified, all projects will be synced. Glob patterns are supported */ diff --git a/packages/schemas/src/v3/gerrit.schema.ts b/packages/schemas/src/v3/gerrit.schema.ts index b8b99e76..13aab92c 100644 --- a/packages/schemas/src/v3/gerrit.schema.ts +++ b/packages/schemas/src/v3/gerrit.schema.ts @@ -17,6 +17,65 @@ const schema = { ], "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" }, + "auth": { + "type": "object", + "description": "Authentication configuration for Gerrit", + "properties": { + "username": { + "type": "string", + "description": "Gerrit username for authentication", + "examples": [ + "john.doe" + ] + }, + "password": { + "description": "Gerrit HTTP password (not your account password). Generate this in Gerrit → Settings → HTTP Credentials → Generate Password. Note: HTTP password authentication requires Gerrit's auth.gitBasicAuthPolicy to be set to HTTP or HTTP_LDAP.", + "examples": [ + { + "env": "GERRIT_HTTP_PASSWORD" + }, + { + "secret": "GERRIT_PASSWORD_SECRET" + } + ], + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "minLength": 1, + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "minLength": 1, + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "username", + "password" + ], + "additionalProperties": false + }, "projects": { "type": "array", "items": { diff --git a/packages/schemas/src/v3/gerrit.type.ts b/packages/schemas/src/v3/gerrit.type.ts index 752a63b3..6b4e56d5 100644 --- a/packages/schemas/src/v3/gerrit.type.ts +++ b/packages/schemas/src/v3/gerrit.type.ts @@ -9,6 +9,31 @@ export interface GerritConnectionConfig { * The URL of the Gerrit host. */ url: string; + /** + * Authentication configuration for Gerrit + */ + auth?: { + /** + * Gerrit username for authentication + */ + username: string; + /** + * Gerrit HTTP password (not your account password). Generate this in Gerrit → Settings → HTTP Credentials → Generate Password. Note: HTTP password authentication requires Gerrit's auth.gitBasicAuthPolicy to be set to HTTP or HTTP_LDAP. + */ + password: + | { + /** + * The name of the secret that contains the token. + */ + secret: string; + } + | { + /** + * The name of the environment variable that contains the token. Only supported in declarative connection configs. + */ + env: string; + }; + }; /** * List of specific projects to sync. If not specified, all projects will be synced. Glob patterns are supported */ diff --git a/packages/schemas/src/v3/gitea.schema.ts b/packages/schemas/src/v3/gitea.schema.ts index 1e1283ee..51accbd1 100644 --- a/packages/schemas/src/v3/gitea.schema.ts +++ b/packages/schemas/src/v3/gitea.schema.ts @@ -21,6 +21,7 @@ const schema = { "properties": { "secret": { "type": "string", + "minLength": 1, "description": "The name of the secret that contains the token." } }, @@ -34,6 +35,7 @@ const schema = { "properties": { "env": { "type": "string", + "minLength": 1, "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." } }, diff --git a/packages/schemas/src/v3/github.schema.ts b/packages/schemas/src/v3/github.schema.ts index c29e1c08..45635f13 100644 --- a/packages/schemas/src/v3/github.schema.ts +++ b/packages/schemas/src/v3/github.schema.ts @@ -21,6 +21,7 @@ const schema = { "properties": { "secret": { "type": "string", + "minLength": 1, "description": "The name of the secret that contains the token." } }, @@ -34,6 +35,7 @@ const schema = { "properties": { "env": { "type": "string", + "minLength": 1, "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." } }, diff --git a/packages/schemas/src/v3/gitlab.schema.ts b/packages/schemas/src/v3/gitlab.schema.ts index 891ca4eb..0a50dea5 100644 --- a/packages/schemas/src/v3/gitlab.schema.ts +++ b/packages/schemas/src/v3/gitlab.schema.ts @@ -21,6 +21,7 @@ const schema = { "properties": { "secret": { "type": "string", + "minLength": 1, "description": "The name of the secret that contains the token." } }, @@ -34,6 +35,7 @@ const schema = { "properties": { "env": { "type": "string", + "minLength": 1, "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." } }, diff --git a/packages/schemas/src/v3/index.schema.ts b/packages/schemas/src/v3/index.schema.ts index 35e7a4fe..f4fda70c 100644 --- a/packages/schemas/src/v3/index.schema.ts +++ b/packages/schemas/src/v3/index.schema.ts @@ -264,6 +264,7 @@ const schema = { "properties": { "secret": { "type": "string", + "minLength": 1, "description": "The name of the secret that contains the token." } }, @@ -277,6 +278,7 @@ const schema = { "properties": { "env": { "type": "string", + "minLength": 1, "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." } }, @@ -477,6 +479,7 @@ const schema = { "properties": { "secret": { "type": "string", + "minLength": 1, "description": "The name of the secret that contains the token." } }, @@ -490,6 +493,7 @@ const schema = { "properties": { "env": { "type": "string", + "minLength": 1, "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." } }, @@ -679,6 +683,7 @@ const schema = { "properties": { "secret": { "type": "string", + "minLength": 1, "description": "The name of the secret that contains the token." } }, @@ -692,6 +697,7 @@ const schema = { "properties": { "env": { "type": "string", + "minLength": 1, "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." } }, @@ -835,6 +841,65 @@ const schema = { ], "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" }, + "auth": { + "type": "object", + "description": "Authentication configuration for Gerrit", + "properties": { + "username": { + "type": "string", + "description": "Gerrit username for authentication", + "examples": [ + "john.doe" + ] + }, + "password": { + "description": "Gerrit HTTP password (not your account password). Generate this in Gerrit → Settings → HTTP Credentials → Generate Password. Note: HTTP password authentication requires Gerrit's auth.gitBasicAuthPolicy to be set to HTTP or HTTP_LDAP.", + "examples": [ + { + "env": "GERRIT_HTTP_PASSWORD" + }, + { + "secret": "GERRIT_PASSWORD_SECRET" + } + ], + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "minLength": 1, + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "minLength": 1, + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "username", + "password" + ], + "additionalProperties": false + }, "projects": { "type": "array", "items": { @@ -910,6 +975,7 @@ const schema = { "properties": { "secret": { "type": "string", + "minLength": 1, "description": "The name of the secret that contains the token." } }, @@ -923,6 +989,7 @@ const schema = { "properties": { "env": { "type": "string", + "minLength": 1, "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." } }, diff --git a/packages/schemas/src/v3/index.type.ts b/packages/schemas/src/v3/index.type.ts index d239245f..84b328af 100644 --- a/packages/schemas/src/v3/index.type.ts +++ b/packages/schemas/src/v3/index.type.ts @@ -325,6 +325,31 @@ export interface GerritConnectionConfig { * The URL of the Gerrit host. */ url: string; + /** + * Authentication configuration for Gerrit + */ + auth?: { + /** + * Gerrit username for authentication + */ + username: string; + /** + * Gerrit HTTP password (not your account password). Generate this in Gerrit → Settings → HTTP Credentials → Generate Password. Note: HTTP password authentication requires Gerrit's auth.gitBasicAuthPolicy to be set to HTTP or HTTP_LDAP. + */ + password: + | { + /** + * The name of the secret that contains the token. + */ + secret: string; + } + | { + /** + * The name of the environment variable that contains the token. Only supported in declarative connection configs. + */ + env: string; + }; + }; /** * List of specific projects to sync. If not specified, all projects will be synced. Glob patterns are supported */ diff --git a/packages/schemas/src/v3/shared.schema.test.ts b/packages/schemas/src/v3/shared.schema.test.ts new file mode 100644 index 00000000..f3006e3a --- /dev/null +++ b/packages/schemas/src/v3/shared.schema.test.ts @@ -0,0 +1,155 @@ +import { describe, test, expect, beforeEach } from 'vitest'; +import Ajv from 'ajv'; +import { sharedSchema } from './shared.schema'; + +describe('shared schema validation', () => { + let ajv: Ajv; + + beforeEach(() => { + ajv = new Ajv({ strict: false }); + }); + + describe('Token validation', () => { + test('accepts valid secret token format', () => { + const tokenSchema = sharedSchema.definitions!.Token; + const validate = ajv.compile(tokenSchema); + + const validToken = { secret: 'my-secret-name' }; + const isValid = validate(validToken); + + expect(isValid).toBe(true); + expect(validate.errors).toBeNull(); + }); + + test('accepts valid environment variable token format', () => { + const tokenSchema = sharedSchema.definitions!.Token; + const validate = ajv.compile(tokenSchema); + + const validToken = { env: 'MY_TOKEN_VAR' }; + const isValid = validate(validToken); + + expect(isValid).toBe(true); + expect(validate.errors).toBeNull(); + }); + + test('rejects string tokens (security measure)', () => { + const tokenSchema = sharedSchema.definitions!.Token; + const validate = ajv.compile(tokenSchema); + + const stringToken = 'direct-string-token'; + const isValid = validate(stringToken); + + expect(isValid).toBe(false); + expect(validate.errors).toBeTruthy(); + expect(validate.errors![0].message).toContain('must be object'); + }); + + test('rejects empty string tokens', () => { + const tokenSchema = sharedSchema.definitions!.Token; + const validate = ajv.compile(tokenSchema); + + const emptyStringToken = ''; + const isValid = validate(emptyStringToken); + + expect(isValid).toBe(false); + expect(validate.errors).toBeTruthy(); + }); + + test('rejects malformed token objects', () => { + const tokenSchema = sharedSchema.definitions!.Token; + const validate = ajv.compile(tokenSchema); + + const malformedToken = { invalid: 'format' }; + const isValid = validate(malformedToken); + + expect(isValid).toBe(false); + expect(validate.errors).toBeTruthy(); + }); + + test('rejects token objects with both secret and env', () => { + const tokenSchema = sharedSchema.definitions!.Token; + const validate = ajv.compile(tokenSchema); + + const invalidToken = { secret: 'my-secret', env: 'MY_VAR' }; + const isValid = validate(invalidToken); + + expect(isValid).toBe(false); + expect(validate.errors).toBeTruthy(); + }); + + test('rejects empty secret name (security measure)', () => { + const tokenSchema = sharedSchema.definitions!.Token; + const validate = ajv.compile(tokenSchema); + + const tokenWithEmptySecret = { secret: '' }; + const isValid = validate(tokenWithEmptySecret); + + expect(isValid).toBe(false); + expect(validate.errors).toBeTruthy(); + }); + + test('rejects empty environment variable name (security measure)', () => { + const tokenSchema = sharedSchema.definitions!.Token; + const validate = ajv.compile(tokenSchema); + + const tokenWithEmptyEnv = { env: '' }; + const isValid = validate(tokenWithEmptyEnv); + + expect(isValid).toBe(false); + expect(validate.errors).toBeTruthy(); + }); + + test('rejects token objects with additional properties', () => { + const tokenSchema = sharedSchema.definitions!.Token; + const validate = ajv.compile(tokenSchema); + + const invalidToken = { secret: 'my-secret', extra: 'property' }; + const isValid = validate(invalidToken); + + expect(isValid).toBe(false); + expect(validate.errors).toBeTruthy(); + }); + }); + + describe('GitRevisions validation', () => { + test('accepts valid GitRevisions object', () => { + const revisionsSchema = sharedSchema.definitions!.GitRevisions; + const validate = ajv.compile(revisionsSchema); + + const validRevisions = { + branches: ['main', 'develop'], + tags: ['v1.0.0', 'latest'] + }; + const isValid = validate(validRevisions); + + expect(isValid).toBe(true); + expect(validate.errors).toBeNull(); + }); + + test('accepts empty GitRevisions object', () => { + const revisionsSchema = sharedSchema.definitions!.GitRevisions; + const validate = ajv.compile(revisionsSchema); + + const emptyRevisions = {}; + const isValid = validate(emptyRevisions); + + expect(isValid).toBe(true); + expect(validate.errors).toBeNull(); + }); + + test('rejects GitRevisions with additional properties', () => { + const revisionsSchema = sharedSchema.definitions!.GitRevisions; + const validate = ajv.compile(revisionsSchema); + + const invalidRevisions = { + branches: ['main'], + tags: ['v1.0.0'], + invalid: 'property' + }; + const isValid = validate(invalidRevisions); + + expect(isValid).toBe(false); + expect(validate.errors).toBeTruthy(); + }); + }); +}); \ No newline at end of file diff --git a/packages/schemas/src/v3/shared.schema.ts b/packages/schemas/src/v3/shared.schema.ts index 0c1792ae..120e2149 100644 --- a/packages/schemas/src/v3/shared.schema.ts +++ b/packages/schemas/src/v3/shared.schema.ts @@ -10,6 +10,7 @@ const schema = { "properties": { "secret": { "type": "string", + "minLength": 1, "description": "The name of the secret that contains the token." } }, @@ -23,6 +24,7 @@ const schema = { "properties": { "env": { "type": "string", + "minLength": 1, "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." } }, diff --git a/packages/schemas/vitest.config.ts b/packages/schemas/vitest.config.ts new file mode 100644 index 00000000..7c052526 --- /dev/null +++ b/packages/schemas/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + watch: false, + } +}); \ No newline at end of file diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 73dbdcf3..07bc7ecc 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -1986,7 +1986,7 @@ const parseConnectionConfig = (config: string) => { } satisfies ServiceError; } - if ('token' in parsedConfig && parsedConfig.token && 'env' in parsedConfig.token) { + if ('token' in parsedConfig && parsedConfig.token && typeof parsedConfig.token === 'object' && 'env' in parsedConfig.token) { return { statusCode: StatusCodes.BAD_REQUEST, errorCode: ErrorCode.INVALID_REQUEST_BODY, diff --git a/schemas/v3/gerrit.json b/schemas/v3/gerrit.json index dccb4e10..5420ecb5 100644 --- a/schemas/v3/gerrit.json +++ b/schemas/v3/gerrit.json @@ -16,6 +16,36 @@ ], "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" }, + "auth": { + "type": "object", + "description": "Authentication configuration for Gerrit", + "properties": { + "username": { + "type": "string", + "description": "Gerrit username for authentication", + "examples": [ + "john.doe" + ] + }, + "password": { + "$ref": "./shared.json#/definitions/Token", + "description": "Gerrit HTTP password (not your account password). Generate this in Gerrit → Settings → HTTP Credentials → Generate Password. Note: HTTP password authentication requires Gerrit's auth.gitBasicAuthPolicy to be set to HTTP or HTTP_LDAP.", + "examples": [ + { + "env": "GERRIT_HTTP_PASSWORD" + }, + { + "secret": "GERRIT_PASSWORD_SECRET" + } + ] + } + }, + "required": [ + "username", + "password" + ], + "additionalProperties": false + }, "projects": { "type": "array", "items": { diff --git a/schemas/v3/shared.json b/schemas/v3/shared.json index 8af4cbdc..a21725ac 100644 --- a/schemas/v3/shared.json +++ b/schemas/v3/shared.json @@ -9,6 +9,7 @@ "properties": { "secret": { "type": "string", + "minLength": 1, "description": "The name of the secret that contains the token." } }, @@ -22,6 +23,7 @@ "properties": { "env": { "type": "string", + "minLength": 1, "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." } }, diff --git a/yarn.lock b/yarn.lock index ecac7374..30210c38 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5908,8 +5908,10 @@ __metadata: "@sourcebot/db": "npm:*" "@sourcebot/schemas": "npm:*" "@types/node": "npm:^22.7.5" + cross-env: "npm:^7.0.3" dotenv: "npm:^16.4.5" typescript: "npm:^5.7.3" + vitest: "npm:^2.1.9" languageName: unknown linkType: soft @@ -5978,11 +5980,14 @@ __metadata: resolution: "@sourcebot/schemas@workspace:packages/schemas" dependencies: "@apidevtools/json-schema-ref-parser": "npm:^11.7.3" + ajv: "npm:^8.12.0" + cross-env: "npm:^7.0.3" glob: "npm:^11.0.1" json-schema-to-typescript: "npm:^15.0.4" nodemon: "npm:^3.1.10" tsx: "npm:^4.19.2" typescript: "npm:^5.7.3" + vitest: "npm:^2.1.9" languageName: unknown linkType: soft @@ -7395,7 +7400,7 @@ __metadata: languageName: node linkType: hard -"ajv@npm:^8.17.1": +"ajv@npm:^8.12.0, ajv@npm:^8.17.1": version: 8.17.1 resolution: "ajv@npm:8.17.1" dependencies: