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: