diff --git a/.gitignore b/.gitignore index 17ad0f22..6f878790 100644 --- a/.gitignore +++ b/.gitignore @@ -163,4 +163,8 @@ dist .sourcebot /bin /config.json -.DS_Store \ No newline at end of file +.DS_Store + +# Test files with real credentials (should not be tracked) +gerrit_auth_test.ts +**/gerrit_auth_test.ts \ No newline at end of file diff --git a/docs/docs/connections/gerrit-troubleshooting.mdx b/docs/docs/connections/gerrit-troubleshooting.mdx new file mode 100644 index 00000000..63d33fc6 --- /dev/null +++ b/docs/docs/connections/gerrit-troubleshooting.mdx @@ -0,0 +1,496 @@ +--- +title: Gerrit Authentication Troubleshooting Guide +sidebarTitle: Gerrit Troubleshooting +--- + +# Gerrit Authentication Troubleshooting Guide + +This guide provides detailed troubleshooting steps for Gerrit authentication issues with Sourcebot, based on extensive testing and real-world scenarios. + +## Quick Diagnosis + +### Authentication Test Checklist + +Run through this checklist to quickly identify authentication issues: + +1. **✅ API Test**: Can you access Gerrit's API? + ```bash + curl -u "username:http-password" "https://gerrit.example.com/a/projects/" + ``` + +2. **✅ Git Clone Test**: Can you clone manually? + ```bash + git clone https://username@gerrit.example.com/a/project-name + ``` + +3. **✅ Project Access**: Do you have permissions for the project? + ```bash + curl -u "username:http-password" "https://gerrit.example.com/a/projects/project-name" + ``` + +4. **✅ Environment Variable**: Is your password set correctly? + ```bash + echo $GERRIT_HTTP_PASSWORD + ``` + +## Common Error Scenarios + +### 1. Authentication Failed (401 Unauthorized) + +**Error Signs:** +- Sourcebot logs show "401 Unauthorized" +- API tests fail with authentication errors +- Git clone fails with "Authentication failed" + +**Root Causes & Solutions:** + + + + **Problem**: Using your regular Gerrit login password instead of the generated HTTP password. + + **Solution**: + 1. Generate a new HTTP password in Gerrit: + - Go to Gerrit → Settings → HTTP Credentials + - Click "Generate Password" + - Copy the generated password (NOT your login password) + 2. Test the new password: + ```bash + curl -u "username:NEW_HTTP_PASSWORD" "https://gerrit.example.com/a/projects/" + ``` + + + + **Problem**: Using display name or email instead of Gerrit username. + + **Solution**: + 1. Check your Gerrit username: + - Go to Gerrit → Settings → Profile + - Note the "Username" field (not display name) + 2. Common mistakes: + - ❌ `john.doe@company.com` (email) + - ❌ `John Doe` (display name) + - ✅ `jdoe` (username) + + + + **Problem**: Environment variable not set or incorrectly named. + + **Solution**: + 1. Check if variable is set: + ```bash + echo $GERRIT_HTTP_PASSWORD + ``` + 2. Set it correctly: + ```bash + export GERRIT_HTTP_PASSWORD="your-http-password" + ``` + 3. For Docker: + ```bash + docker run -e GERRIT_HTTP_PASSWORD="password" ... + ``` + + + +### 2. No Projects Found (0 Repos Synced) + +**Error Signs:** +- Sourcebot connects successfully but syncs 0 repositories +- Logs show "Upserted 0 repos for connection" +- No error messages, just empty results + +**Root Causes & Solutions:** + + + + **Problem**: Project names in config don't match actual Gerrit project names. + + **Solution**: + 1. List all accessible projects: + ```bash + curl -u "username:password" \ + "https://gerrit.example.com/a/projects/" | \ + jq 'keys[]' # If jq is available + ``` + 2. Use exact project names from the API response + 3. For testing, try wildcard pattern: + ```json + "projects": ["*"] + ``` + + + + **Problem**: User doesn't have clone permissions for specified projects. + + **Solution**: + 1. Check project-specific permissions in Gerrit admin + 2. Test access to a specific project: + ```bash + curl -u "username:password" \ + "https://gerrit.example.com/a/projects/project-name" + ``` + 3. Contact Gerrit admin to grant clone permissions + + + + **Problem**: Projects are hidden or read-only and excluded by default. + + **Solution**: + 1. Include hidden/read-only projects explicitly: + ```json + { + "type": "gerrit", + "url": "https://gerrit.example.com", + "projects": ["project-name"], + "exclude": { + "hidden": false, // Include hidden projects + "readOnly": false // Include read-only projects + } + } + ``` + + + +### 3. Git Clone Failures + +**Error Signs:** +- Authentication works for API but fails for git operations +- "fatal: Authentication failed for" errors +- "remote: Unauthorized" messages + +**Root Causes & Solutions:** + + + + **Problem**: Git clone URL doesn't include the `/a/` prefix required for authenticated access. + + **Solution**: + 1. Verify correct URL format: + - ❌ `https://username@gerrit.example.com/project-name` + - ✅ `https://username@gerrit.example.com/a/project-name` + 2. Test manually: + ```bash + git clone https://username@gerrit.example.com/a/project-name + ``` + + + + **Problem**: Git is using cached credentials or wrong credential helper. + + **Solution**: + 1. Clear Git credential cache: + ```bash + git credential-manager-core erase + # Or for older systems: + git credential-cache exit + ``` + 2. Test with explicit credentials: + ```bash + git -c credential.helper= clone https://username@gerrit.example.com/a/project + ``` + + + + **Problem**: HTTP passwords containing special characters (`/`, `+`, `=`) cause authentication failures. + + **Solution**: + 1. **For Sourcebot**: No action needed - URL encoding is handled automatically + 2. **For manual testing**: URL-encode the password: + ```bash + # Original password: pass/with+special=chars + # URL-encoded: pass%2Fwith%2Bspecial%3Dchars + git clone https://user:pass%2Fwith%2Bspecial%3Dchars@gerrit.example.com/a/project + ``` + 3. **For curl testing**: + ```bash + curl -u "username:pass/with+special=chars" "https://gerrit.example.com/a/projects/" + ``` + + + Sourcebot v4.5.0+ automatically handles URL encoding for git operations. Earlier versions may require manual password encoding. + + + + +### 4. Configuration Schema Errors + +**Error Signs:** +- "Config file is invalid" errors +- "must NOT have additional properties" messages +- Schema validation failures + +**Root Causes & Solutions:** + + + + **Problem**: Authentication configuration doesn't match expected schema. + + **Solution**: + 1. Use correct auth structure: + ```json + "auth": { + "username": "your-username", + "password": { + "env": "GERRIT_HTTP_PASSWORD" + } + } + ``` + 2. Common mistakes: + ```json + // ❌ Wrong - password as string + "auth": { + "username": "user", + "password": "direct-password" + } + + // ❌ Wrong - missing auth wrapper + "username": "user", + "password": {"env": "VAR"} + ``` + + + + **Problem**: Configuration includes properties not in the schema. + + **Solution**: + 1. Check allowed properties in schema + 2. Remove any extra fields not in the official schema + 3. Validate configuration: + ```bash + # Use JSON schema validator if available + jsonschema -i config.json schemas/v3/gerrit.json + ``` + + + +## Advanced Troubleshooting + +### Network and Connectivity Issues + + + + **Problem**: SSL certificate validation failures. + + **Solution**: + 1. Test with curl to verify SSL: + ```bash + curl -v "https://gerrit.example.com" + ``` + 2. For self-signed certificates (not recommended for production): + ```bash + git -c http.sslVerify=false clone https://... + ``` + 3. Install proper certificates on the system + + + + **Problem**: Corporate proxy or firewall blocking connections. + + **Solution**: + 1. Configure git proxy: + ```bash + git config --global http.proxy http://proxy.company.com:8080 + ``` + 2. Test direct connection vs proxy: + ```bash + curl --proxy http://proxy:8080 "https://gerrit.example.com/a/projects/" + ``` + + + +### Debugging Tools and Scripts + +#### Complete Authentication Test Script + +Save as `gerrit-debug.ts` and run with `ts-node`: + +```typescript +import * as https from 'https'; +import * as child_process from 'child_process'; +import * as url from 'url'; + +// Configuration +const GERRIT_URL = 'https://gerrit.example.com'; +const USERNAME = 'your-username'; +const HTTP_PASSWORD = 'your-http-password'; +const TEST_PROJECT = 'test-project-name'; + +console.log('🔍 Gerrit Authentication Debug Tool\n'); + +// Test 1: API Authentication +console.log('1️⃣ Testing API Authentication...'); +const auth = 'Basic ' + Buffer.from(`${USERNAME}:${HTTP_PASSWORD}`).toString('base64'); +const parsedUrl = new url.URL(GERRIT_URL); +const options: https.RequestOptions = { + hostname: parsedUrl.hostname, + port: parsedUrl.port || 443, + path: '/a/projects/', + method: 'GET', + headers: { + 'Authorization': auth, + 'Accept': 'application/json' + } +}; + +https.request(options, (res) => { + console.log(` Status: ${res.statusCode}`); + if (res.statusCode === 200) { + console.log(' ✅ API authentication successful'); + + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + // Remove JSON prefix that Gerrit sometimes adds + const cleanData = data.replace(/^\)\]\}'\n/, ''); + try { + const projects = JSON.parse(cleanData); + const projectCount = Object.keys(projects).length; + console.log(` 📊 Found ${projectCount} accessible projects`); + + if (projectCount > 0) { + console.log(' 📋 First 5 projects:'); + Object.keys(projects).slice(0, 5).forEach(project => { + console.log(` - ${project}`); + }); + } + } catch (e) { + console.log(' ⚠️ Could not parse project list'); + } + }); + } else { + console.log(' ❌ API authentication failed'); + } +}).on('error', (err) => { + console.log(` ❌ API request failed: ${err.message}`); +}).end(); + +// Test 2: Git Clone Test +console.log('\n2️⃣ Testing Git Clone...'); +const cloneUrl = `https://${USERNAME}@${parsedUrl.host}/a/${TEST_PROJECT}`; +console.log(` URL: ${cloneUrl}`); + +// Note: This is a simplified test - in practice you'd need proper credential handling +console.log(' ℹ️ Manual test: git clone ' + cloneUrl); + +// Test 3: Environment Variables +console.log('\n3️⃣ Checking Environment...'); +console.log(` NODE_ENV: ${process.env.NODE_ENV || 'not set'}`); +console.log(` GERRIT_HTTP_PASSWORD: ${process.env.GERRIT_HTTP_PASSWORD ? 'set (length: ' + process.env.GERRIT_HTTP_PASSWORD.length + ')' : 'not set'}`); + +console.log('\n🔍 Debug complete!'); +``` + +#### Sourcebot Log Analysis + +Look for these specific log patterns: + +```bash +# Authentication success +grep "API authentication successful" sourcebot.log + +# Project discovery +grep "Found .* accessible projects" sourcebot.log + +# Git clone operations +grep "git clone" sourcebot.log + +# Error patterns +grep -E "(401|unauthorized|authentication)" sourcebot.log -i +``` + +## Prevention and Best Practices + +### Security Best Practices + +1. **Credential Management**: + - Never commit HTTP passwords to version control + - Use environment variables or secret management systems + - Rotate HTTP passwords regularly + +2. **Least Privilege**: + - Create dedicated service accounts for Sourcebot + - Grant minimal necessary permissions + - Monitor access logs + +3. **Testing**: + - Always test authentication manually before configuring Sourcebot + - Keep backup authentication methods + - Document working configurations + +### Configuration Templates + +#### Development Environment +```json +{ + "connections": { + "dev-gerrit": { + "type": "gerrit", + "url": "https://gerrit-dev.company.com", + "projects": ["dev-*"], + "auth": { + "username": "sourcebot-dev", + "password": { + "env": "GERRIT_DEV_PASSWORD" + } + } + } + } +} +``` + +#### Production Environment +```json +{ + "connections": { + "prod-gerrit": { + "type": "gerrit", + "url": "https://gerrit.company.com", + "projects": [ + "critical-project", + "team-alpha/**" + ], + "exclude": { + "projects": ["**/archived/**"], + "hidden": true, + "readOnly": true + }, + "auth": { + "username": "sourcebot-prod", + "password": { + "env": "GERRIT_PROD_PASSWORD" + } + } + } + } +} +``` + +## Getting Help + +If you've followed this troubleshooting guide and still encounter issues: + +1. **Gather Debug Information**: + - Sourcebot logs with timestamps + - Your configuration (with credentials redacted) + - Gerrit version and authentication method + - Network topology (proxy, firewall, etc.) + +2. **Test Manually**: + - Run the debug script above + - Document exact error messages + - Note which step fails + +3. **Report Issues**: + - [Open a GitHub issue](https://github.com/sourcebot-dev/sourcebot/issues) + - Include debug information + - Describe expected vs actual behavior + +4. **Community Support**: + - [Join discussions](https://github.com/sourcebot-dev/sourcebot/discussions) + - Search existing issues for similar problems + - Share solutions that work for your environment + +--- + + +This troubleshooting guide is based on real-world testing and user reports. It will be updated as new scenarios are discovered and resolved. + \ No newline at end of file diff --git a/docs/docs/connections/gerrit.mdx b/docs/docs/connections/gerrit.mdx index b7253259..89dd4e27 100644 --- a/docs/docs/connections/gerrit.mdx +++ b/docs/docs/connections/gerrit.mdx @@ -6,72 +6,356 @@ 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", + "projects": ["public-project-name"] +} +``` + +### Authenticated Connection + +For private/authenticated Gerrit projects, you need to provide credentials: ```json { "type": "gerrit", - "url": "https://gerrit.example.com" - // .. rest of config .. + "url": "https://gerrit.example.com", + "projects": ["private-project-name"], + "auth": { + "username": "your-gerrit-username", + "password": { + "secret": "GERRIT_HTTP_PASSWORD" + } + } +} +``` + + +Use **HTTP Password**, not your Gerrit account password. Generate an HTTP password in Gerrit: **Settings → HTTP Credentials → Generate Password**. + + +### Environment Variables + +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" + } + } + ``` + + + +### Debug Steps + +1. **Enable Debug Logging**: Set log level to debug in Sourcebot configuration +2. **Test API Access**: Verify Gerrit API responds correctly +3. **Check Project Permissions**: Ensure your user has clone permissions +4. **Validate Configuration**: Use JSON schema validation tools + +### Manual Testing Script + +You can test Gerrit authentication independently: + +```typescript +// gerrit-test.ts - Test script for Gerrit authentication +import * as https from 'https'; +import * as child_process from 'child_process'; + +const GERRIT_URL = 'https://gerrit.example.com'; +const USERNAME = 'your-username'; +const HTTP_PASSWORD = 'your-http-password'; +const PROJECT = 'project-name'; + +// Test API access +const auth = 'Basic ' + Buffer.from(`${USERNAME}:${HTTP_PASSWORD}`).toString('base64'); +const options = { + hostname: new URL(GERRIT_URL).hostname, + path: '/a/projects/', + headers: { 'Authorization': auth } +}; + +https.get(options, (res) => { + console.log(`API Status: ${res.statusCode}`); + if (res.statusCode === 200) { + console.log('✅ API authentication successful'); + + // Test git clone + const cloneUrl = `https://${USERNAME}@${new URL(GERRIT_URL).host}/a/${PROJECT}`; + console.log(`Testing git clone: ${cloneUrl}`); + + // Note: This requires proper credential handling in production + } else { + console.log('❌ API authentication failed'); + } +}); +``` + +## Implementation Details + +### URL Structure + +Gerrit uses a specific URL structure for authenticated access: + +- **API Access**: `https://gerrit.example.com/a/endpoint` +- **Git Clone**: `https://username@gerrit.example.com/a/project-name` + +The `/a/` prefix is crucial for authenticated operations. + +### Credential Flow + +1. Sourcebot validates configuration and credentials +2. API call to `/a/projects/` to list accessible projects +3. For each project, git clone using `https://username@host/a/project` +4. Git authentication handled via URL-embedded username and credential helpers + +### Security Considerations + +- Store HTTP passwords in environment variables, never in config files +- Use least-privilege Gerrit accounts for Sourcebot +- Regularly rotate HTTP passwords +- Monitor access logs for unusual activity + +## Schema Reference [schemas/v3/gerrit.json](https://github.com/sourcebot-dev/sourcebot/blob/main/schemas/v3/gerrit.json) - \ No newline at end of file + + +## Additional Resources + +- [Gerrit HTTP Password Documentation](https://gerrit-review.googlesource.com/Documentation/user-upload.html#http) +- [Gerrit REST API](https://gerrit-review.googlesource.com/Documentation/rest-api.html) +- [Git Credential Helpers](https://git-scm.com/docs/gitcredentials) + + +This documentation is based on extensive testing with Gerrit authentication. If you encounter issues not covered here, please [open an issue](https://github.com/sourcebot-dev/sourcebot/issues) with your specific configuration and error details. + \ 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..54d13ff8 100644 --- a/docs/snippets/schemas/v3/bitbucket.schema.mdx +++ b/docs/snippets/schemas/v3/bitbucket.schema.mdx @@ -21,6 +21,11 @@ } ], "anyOf": [ + { + "type": "string", + "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)", + "minLength": 1 + }, { "type": "object", "properties": { diff --git a/docs/snippets/schemas/v3/connection.schema.mdx b/docs/snippets/schemas/v3/connection.schema.mdx index 9731bdeb..7d4f86d9 100644 --- a/docs/snippets/schemas/v3/connection.schema.mdx +++ b/docs/snippets/schemas/v3/connection.schema.mdx @@ -21,6 +21,11 @@ } ], "anyOf": [ + { + "type": "string", + "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)", + "minLength": 1 + }, { "type": "object", "properties": { @@ -234,6 +239,11 @@ } ], "anyOf": [ + { + "type": "string", + "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)", + "minLength": 1 + }, { "type": "object", "properties": { @@ -436,6 +446,11 @@ } ], "anyOf": [ + { + "type": "string", + "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)", + "minLength": 1 + }, { "type": "object", "properties": { @@ -597,6 +612,68 @@ ], "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.", + "examples": [ + { + "env": "GERRIT_HTTP_PASSWORD" + }, + { + "secret": "GERRIT_PASSWORD_SECRET" + } + ], + "anyOf": [ + { + "type": "string", + "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)", + "minLength": 1 + }, + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "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": { @@ -667,6 +744,11 @@ } ], "anyOf": [ + { + "type": "string", + "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)", + "minLength": 1 + }, { "type": "object", "properties": { diff --git a/docs/snippets/schemas/v3/gerrit.schema.mdx b/docs/snippets/schemas/v3/gerrit.schema.mdx index 561bda80..2d2fbb32 100644 --- a/docs/snippets/schemas/v3/gerrit.schema.mdx +++ b/docs/snippets/schemas/v3/gerrit.schema.mdx @@ -18,6 +18,68 @@ ], "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.", + "examples": [ + { + "env": "GERRIT_HTTP_PASSWORD" + }, + { + "secret": "GERRIT_PASSWORD_SECRET" + } + ], + "anyOf": [ + { + "type": "string", + "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)", + "minLength": 1 + }, + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "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..31f160c2 100644 --- a/docs/snippets/schemas/v3/gitea.schema.mdx +++ b/docs/snippets/schemas/v3/gitea.schema.mdx @@ -17,6 +17,11 @@ } ], "anyOf": [ + { + "type": "string", + "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)", + "minLength": 1 + }, { "type": "object", "properties": { diff --git a/docs/snippets/schemas/v3/github.schema.mdx b/docs/snippets/schemas/v3/github.schema.mdx index 1858eee8..5521b324 100644 --- a/docs/snippets/schemas/v3/github.schema.mdx +++ b/docs/snippets/schemas/v3/github.schema.mdx @@ -17,6 +17,11 @@ } ], "anyOf": [ + { + "type": "string", + "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)", + "minLength": 1 + }, { "type": "object", "properties": { diff --git a/docs/snippets/schemas/v3/gitlab.schema.mdx b/docs/snippets/schemas/v3/gitlab.schema.mdx index feadeaac..be0c00d3 100644 --- a/docs/snippets/schemas/v3/gitlab.schema.mdx +++ b/docs/snippets/schemas/v3/gitlab.schema.mdx @@ -17,6 +17,11 @@ } ], "anyOf": [ + { + "type": "string", + "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)", + "minLength": 1 + }, { "type": "object", "properties": { diff --git a/docs/snippets/schemas/v3/index.schema.mdx b/docs/snippets/schemas/v3/index.schema.mdx index 79bcda80..32b9b9e3 100644 --- a/docs/snippets/schemas/v3/index.schema.mdx +++ b/docs/snippets/schemas/v3/index.schema.mdx @@ -260,6 +260,11 @@ } ], "anyOf": [ + { + "type": "string", + "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)", + "minLength": 1 + }, { "type": "object", "properties": { @@ -473,6 +478,11 @@ } ], "anyOf": [ + { + "type": "string", + "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)", + "minLength": 1 + }, { "type": "object", "properties": { @@ -675,6 +685,11 @@ } ], "anyOf": [ + { + "type": "string", + "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)", + "minLength": 1 + }, { "type": "object", "properties": { @@ -836,6 +851,68 @@ ], "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.", + "examples": [ + { + "env": "GERRIT_HTTP_PASSWORD" + }, + { + "secret": "GERRIT_PASSWORD_SECRET" + } + ], + "anyOf": [ + { + "type": "string", + "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)", + "minLength": 1 + }, + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "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": { @@ -906,6 +983,11 @@ } ], "anyOf": [ + { + "type": "string", + "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)", + "minLength": 1 + }, { "type": "object", "properties": { diff --git a/docs/snippets/schemas/v3/shared.schema.mdx b/docs/snippets/schemas/v3/shared.schema.mdx index 97fdbabf..7d05dfa7 100644 --- a/docs/snippets/schemas/v3/shared.schema.mdx +++ b/docs/snippets/schemas/v3/shared.schema.mdx @@ -6,6 +6,11 @@ "definitions": { "Token": { "anyOf": [ + { + "type": "string", + "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)", + "minLength": 1 + }, { "type": "object", "properties": { 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..8bdf5a6f --- /dev/null +++ b/packages/backend/src/gerrit.test.ts @@ -0,0 +1,950 @@ +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) => { + // If token is a string, return it directly (mimicking actual behavior) + if (typeof token === 'string') { + return token; + } + // For objects (env/secret), return mock value + return 'mock-password'; + }), + }; +}); +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); +}); \ No newline at end of file diff --git a/packages/backend/src/gerrit.ts b/packages/backend/src/gerrit.ts index 25e3cfa7..cd85e49e 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,43 @@ 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 + 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 +158,14 @@ 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 + const jsonText = text.replace(/^\)\]\}'\n/, ''); const data: GerritProjects = JSON.parse(jsonText); // Add fetched projects to allProjects @@ -138,10 +187,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 8722b928..d8995b56 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 50dc0478..c76eb46f 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..32aa481f 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,16 @@ export const marshalBool = (value?: boolean) => { return !!value ? '1' : '0'; } -export const getTokenFromConfig = async (token: any, orgId: number, db: PrismaClient, logger?: Logger) => { +export const isRemotePath = (path: string) => { + return path.startsWith('https://') || path.startsWith('http://'); +} + +export const getTokenFromConfig = async (token: Token, orgId: number, db: PrismaClient, logger?: Logger) => { + // Handle direct string tokens (for backward compatibility and Gerrit auth) + if (typeof token === 'string') { + return token; + } + try { return await getTokenFromConfigBase(token, orgId, db); } catch (error: unknown) { diff --git a/packages/crypto/src/tokenUtils.ts b/packages/crypto/src/tokenUtils.ts index be5a064d..c92ce08c 100644 --- a/packages/crypto/src/tokenUtils.ts +++ b/packages/crypto/src/tokenUtils.ts @@ -3,6 +3,11 @@ import { Token } from "@sourcebot/schemas/v3/shared.type"; import { decrypt } from "./index.js"; export const getTokenFromConfig = async (token: Token, orgId: number, db: PrismaClient) => { + // Handle direct string tokens + if (typeof token === 'string') { + return token; + } + if ('secret' in token) { const secretKey = token.secret; const secret = await db.secret.findUnique({ diff --git a/packages/schemas/src/v3/bitbucket.schema.ts b/packages/schemas/src/v3/bitbucket.schema.ts index a7c857ce..39d8e526 100644 --- a/packages/schemas/src/v3/bitbucket.schema.ts +++ b/packages/schemas/src/v3/bitbucket.schema.ts @@ -20,6 +20,11 @@ const schema = { } ], "anyOf": [ + { + "type": "string", + "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)", + "minLength": 1 + }, { "type": "object", "properties": { diff --git a/packages/schemas/src/v3/bitbucket.type.ts b/packages/schemas/src/v3/bitbucket.type.ts index 260d949d..769fbfae 100644 --- a/packages/schemas/src/v3/bitbucket.type.ts +++ b/packages/schemas/src/v3/bitbucket.type.ts @@ -13,6 +13,7 @@ export interface BitbucketConnectionConfig { * An authentication token. */ token?: + | string | { /** * The name of the secret that contains the token. diff --git a/packages/schemas/src/v3/connection.schema.ts b/packages/schemas/src/v3/connection.schema.ts index d0a72c72..30b7ed3d 100644 --- a/packages/schemas/src/v3/connection.schema.ts +++ b/packages/schemas/src/v3/connection.schema.ts @@ -20,6 +20,11 @@ const schema = { } ], "anyOf": [ + { + "type": "string", + "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)", + "minLength": 1 + }, { "type": "object", "properties": { @@ -233,6 +238,11 @@ const schema = { } ], "anyOf": [ + { + "type": "string", + "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)", + "minLength": 1 + }, { "type": "object", "properties": { @@ -435,6 +445,11 @@ const schema = { } ], "anyOf": [ + { + "type": "string", + "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)", + "minLength": 1 + }, { "type": "object", "properties": { @@ -596,6 +611,68 @@ 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.", + "examples": [ + { + "env": "GERRIT_HTTP_PASSWORD" + }, + { + "secret": "GERRIT_PASSWORD_SECRET" + } + ], + "anyOf": [ + { + "type": "string", + "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)", + "minLength": 1 + }, + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "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": { @@ -666,6 +743,11 @@ const schema = { } ], "anyOf": [ + { + "type": "string", + "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)", + "minLength": 1 + }, { "type": "object", "properties": { diff --git a/packages/schemas/src/v3/connection.type.ts b/packages/schemas/src/v3/connection.type.ts index d1d2bc18..b8055c68 100644 --- a/packages/schemas/src/v3/connection.type.ts +++ b/packages/schemas/src/v3/connection.type.ts @@ -17,6 +17,7 @@ export interface GithubConnectionConfig { * A Personal Access Token (PAT). */ token?: + | string | { /** * The name of the secret that contains the token. @@ -106,6 +107,7 @@ export interface GitlabConnectionConfig { * An authentication token. */ token?: + | string | { /** * The name of the secret that contains the token. @@ -173,6 +175,7 @@ export interface GiteaConnectionConfig { * A Personal Access Token (PAT). */ token?: + | string | { /** * The name of the secret that contains the token. @@ -226,6 +229,32 @@ 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. + */ + password: + | string + | { + /** + * 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 */ @@ -258,6 +287,7 @@ export interface BitbucketConnectionConfig { * An authentication token. */ token?: + | string | { /** * The name of the secret that contains the token. diff --git a/packages/schemas/src/v3/gerrit.schema.ts b/packages/schemas/src/v3/gerrit.schema.ts index b8b99e76..86ea2e3e 100644 --- a/packages/schemas/src/v3/gerrit.schema.ts +++ b/packages/schemas/src/v3/gerrit.schema.ts @@ -17,6 +17,68 @@ 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.", + "examples": [ + { + "env": "GERRIT_HTTP_PASSWORD" + }, + { + "secret": "GERRIT_PASSWORD_SECRET" + } + ], + "anyOf": [ + { + "type": "string", + "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)", + "minLength": 1 + }, + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "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..fd5b54c1 100644 --- a/packages/schemas/src/v3/gerrit.type.ts +++ b/packages/schemas/src/v3/gerrit.type.ts @@ -9,6 +9,32 @@ 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. + */ + password: + | string + | { + /** + * 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..a2d397b7 100644 --- a/packages/schemas/src/v3/gitea.schema.ts +++ b/packages/schemas/src/v3/gitea.schema.ts @@ -16,6 +16,11 @@ const schema = { } ], "anyOf": [ + { + "type": "string", + "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)", + "minLength": 1 + }, { "type": "object", "properties": { diff --git a/packages/schemas/src/v3/gitea.type.ts b/packages/schemas/src/v3/gitea.type.ts index ec9e3046..6702906b 100644 --- a/packages/schemas/src/v3/gitea.type.ts +++ b/packages/schemas/src/v3/gitea.type.ts @@ -9,6 +9,7 @@ export interface GiteaConnectionConfig { * A Personal Access Token (PAT). */ token?: + | string | { /** * The name of the secret that contains the token. diff --git a/packages/schemas/src/v3/github.schema.ts b/packages/schemas/src/v3/github.schema.ts index c29e1c08..eda0922c 100644 --- a/packages/schemas/src/v3/github.schema.ts +++ b/packages/schemas/src/v3/github.schema.ts @@ -16,6 +16,11 @@ const schema = { } ], "anyOf": [ + { + "type": "string", + "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)", + "minLength": 1 + }, { "type": "object", "properties": { diff --git a/packages/schemas/src/v3/github.type.ts b/packages/schemas/src/v3/github.type.ts index 4cb73c9b..50b0c4e3 100644 --- a/packages/schemas/src/v3/github.type.ts +++ b/packages/schemas/src/v3/github.type.ts @@ -9,6 +9,7 @@ export interface GithubConnectionConfig { * A Personal Access Token (PAT). */ token?: + | string | { /** * The name of the secret that contains the token. diff --git a/packages/schemas/src/v3/gitlab.schema.ts b/packages/schemas/src/v3/gitlab.schema.ts index 891ca4eb..0b42db7c 100644 --- a/packages/schemas/src/v3/gitlab.schema.ts +++ b/packages/schemas/src/v3/gitlab.schema.ts @@ -16,6 +16,11 @@ const schema = { } ], "anyOf": [ + { + "type": "string", + "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)", + "minLength": 1 + }, { "type": "object", "properties": { diff --git a/packages/schemas/src/v3/gitlab.type.ts b/packages/schemas/src/v3/gitlab.type.ts index f5a293ce..0d84ea55 100644 --- a/packages/schemas/src/v3/gitlab.type.ts +++ b/packages/schemas/src/v3/gitlab.type.ts @@ -9,6 +9,7 @@ export interface GitlabConnectionConfig { * An authentication token. */ token?: + | string | { /** * The name of the secret that contains the token. diff --git a/packages/schemas/src/v3/index.schema.ts b/packages/schemas/src/v3/index.schema.ts index 35e7a4fe..d5ade248 100644 --- a/packages/schemas/src/v3/index.schema.ts +++ b/packages/schemas/src/v3/index.schema.ts @@ -259,6 +259,11 @@ const schema = { } ], "anyOf": [ + { + "type": "string", + "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)", + "minLength": 1 + }, { "type": "object", "properties": { @@ -472,6 +477,11 @@ const schema = { } ], "anyOf": [ + { + "type": "string", + "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)", + "minLength": 1 + }, { "type": "object", "properties": { @@ -674,6 +684,11 @@ const schema = { } ], "anyOf": [ + { + "type": "string", + "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)", + "minLength": 1 + }, { "type": "object", "properties": { @@ -835,6 +850,68 @@ 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.", + "examples": [ + { + "env": "GERRIT_HTTP_PASSWORD" + }, + { + "secret": "GERRIT_PASSWORD_SECRET" + } + ], + "anyOf": [ + { + "type": "string", + "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)", + "minLength": 1 + }, + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "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": { @@ -905,6 +982,11 @@ const schema = { } ], "anyOf": [ + { + "type": "string", + "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)", + "minLength": 1 + }, { "type": "object", "properties": { diff --git a/packages/schemas/src/v3/index.type.ts b/packages/schemas/src/v3/index.type.ts index d239245f..5666ed6d 100644 --- a/packages/schemas/src/v3/index.type.ts +++ b/packages/schemas/src/v3/index.type.ts @@ -116,6 +116,7 @@ export interface GithubConnectionConfig { * A Personal Access Token (PAT). */ token?: + | string | { /** * The name of the secret that contains the token. @@ -205,6 +206,7 @@ export interface GitlabConnectionConfig { * An authentication token. */ token?: + | string | { /** * The name of the secret that contains the token. @@ -272,6 +274,7 @@ export interface GiteaConnectionConfig { * A Personal Access Token (PAT). */ token?: + | string | { /** * The name of the secret that contains the token. @@ -325,6 +328,32 @@ 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. + */ + password: + | string + | { + /** + * 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 */ @@ -357,6 +386,7 @@ export interface BitbucketConnectionConfig { * An authentication token. */ token?: + | string | { /** * The name of the secret that contains the token. diff --git a/packages/schemas/src/v3/shared.schema.ts b/packages/schemas/src/v3/shared.schema.ts index 0c1792ae..34be6db7 100644 --- a/packages/schemas/src/v3/shared.schema.ts +++ b/packages/schemas/src/v3/shared.schema.ts @@ -5,6 +5,11 @@ const schema = { "definitions": { "Token": { "anyOf": [ + { + "type": "string", + "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)", + "minLength": 1 + }, { "type": "object", "properties": { diff --git a/packages/schemas/src/v3/shared.type.ts b/packages/schemas/src/v3/shared.type.ts index eeec734c..26e5e180 100644 --- a/packages/schemas/src/v3/shared.type.ts +++ b/packages/schemas/src/v3/shared.type.ts @@ -5,6 +5,7 @@ * via the `definition` "Token". */ export type Token = + | string | { /** * The name of the secret that contains the token. diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index e9adbc2a..0a039f20 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -2096,7 +2096,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..3c157e5a 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.", + "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..a27e359f 100644 --- a/schemas/v3/shared.json +++ b/schemas/v3/shared.json @@ -4,6 +4,11 @@ "definitions": { "Token": { "anyOf": [ + { + "type": "string", + "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)", + "minLength": 1 + }, { "type": "object", "properties": {