diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5db95b114..a584b4c3a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,25 @@ jobs: node-version: [22.x, 24.x] mongodb-version: ['6.0', '7.0', '8.0'] + # PostgreSQL service container for the postgres integration tests added in + # issue #1497. A single version (postgres:16) is sufficient for the initial + # CI lane per the issue's "Open Questions"; a broader version matrix can + # follow later. + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: git_proxy_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: - name: Harden Runner uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 @@ -71,6 +90,12 @@ jobs: GIT_PROXY_MONGO_CONNECTION_STRING: mongodb://localhost:27017/git-proxy-test run: npm run test:integration + - name: PostgreSQL Integration Tests + env: + RUN_POSTGRES_TESTS: 'true' + GIT_PROXY_POSTGRES_CONNECTION_STRING: postgresql://postgres:postgres@localhost:5432/git_proxy_test + run: npm run test:integration:postgres + - name: Upload test coverage report uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3 with: diff --git a/config.schema.json b/config.schema.json index c72543037..bbd8e5f68 100644 --- a/config.schema.json +++ b/config.schema.json @@ -423,6 +423,20 @@ "enabled": { "type": "boolean" } }, "required": ["type", "enabled"] + }, + { + "type": "object", + "name": "PostgreSQL Config", + "description": "Connection properties for PostgreSQL. The `connectionString` may also be supplied via the `GIT_PROXY_POSTGRES_CONNECTION_STRING` environment variable.", + "properties": { + "type": { "type": "string", "const": "postgres" }, + "enabled": { "type": "boolean" }, + "connectionString": { + "type": "string", + "description": "PostgreSQL client connection string, see [https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING). If omitted, `GIT_PROXY_POSTGRES_CONNECTION_STRING` is used as a fallback." + } + }, + "required": ["type", "enabled"] } ] }, diff --git a/package-lock.json b/package-lock.json index 394673bd2..f527157bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "clsx": "^2.1.1", "concurrently": "^9.2.1", "connect-mongo": "^5.1.0", + "connect-pg-simple": "^10.0.0", "cors": "^2.8.6", "diff2html": "^3.4.56", "env-paths": "^3.0.0", @@ -47,6 +48,7 @@ "passport-activedirectory": "^1.4.0", "passport-local": "^1.0.0", "perfect-scrollbar": "^1.5.6", + "pg": "^8.20.0", "react": "^16.14.0", "react-dom": "^16.14.0", "react-html-parser": "^2.0.2", @@ -69,6 +71,7 @@ "@eslint/js": "^9.39.2", "@eslint/json": "^1.0.1", "@types/activedirectory2": "^1.2.6", + "@types/connect-pg-simple": "^7.0.3", "@types/cors": "^2.8.19", "@types/domutils": "^2.1.0", "@types/express": "^5.0.6", @@ -80,6 +83,7 @@ "@types/node": "^22.19.7", "@types/passport": "^1.0.17", "@types/passport-local": "^1.0.38", + "@types/pg": "^8.20.0", "@types/react-dom": "^17.0.26", "@types/react-html-parser": "^2.0.7", "@types/supertest": "^6.0.3", @@ -4246,6 +4250,18 @@ "@types/node": "*" } }, + "node_modules/@types/connect-pg-simple": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/connect-pg-simple/-/connect-pg-simple-7.0.3.tgz", + "integrity": "sha512-NGCy9WBlW2bw+J/QlLnFZ9WjoGs6tMo3LAut6mY4kK+XHzue//lpNVpAvYRpIwM969vBRAM2Re0izUvV6kt+NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/express-session": "*", + "@types/pg": "*" + } + }, "node_modules/@types/conventional-commits-parser": { "version": "5.0.1", "dev": true, @@ -4467,6 +4483,18 @@ "@types/passport": "*" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/prop-types": { "version": "15.7.11", "license": "MIT" @@ -6549,6 +6577,18 @@ "mongodb": ">= 5.1.0 < 7" } }, + "node_modules/connect-pg-simple": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-10.0.0.tgz", + "integrity": "sha512-pBGVazlqiMrackzCr0eKhn4LO5trJXsOX0nQoey9wCOayh80MYtThCbq8eoLsjpiWgiok/h+1/uti9/2/Una8A==", + "license": "MIT", + "dependencies": { + "pg": "^8.12.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=22.0.0" + } + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -11859,6 +11899,95 @@ "dev": true, "license": "MIT" }, + "node_modules/pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "dev": true, @@ -12003,6 +12132,45 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/precond": { "version": "0.2.3", "engines": { @@ -13201,7 +13369,6 @@ }, "node_modules/split2": { "version": "4.2.0", - "dev": true, "license": "ISC", "engines": { "node": ">= 10.x" @@ -15029,6 +15196,15 @@ "node": ">=16.0.0" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "license": "ISC", diff --git a/package.json b/package.json index 35c20e11c..116b717ae 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "test-coverage": "cross-env NODE_ENV=test vitest --run --dir ./test --coverage", "test-coverage-ci": "cross-env NODE_ENV=test vitest --run --dir ./test --coverage.enabled=true --coverage.reporter=lcovonly --coverage.reporter=text", "test:integration": "cross-env NODE_ENV=test vitest --run --config vitest.config.integration.ts", + "test:integration:postgres": "cross-env NODE_ENV=test vitest --run --config vitest.config.integration.postgres.ts", "test:watch": "cross-env NODE_ENV=test vitest --dir ./test --watch", "prepare": "node ./scripts/prepare.js", "lint": "eslint", @@ -107,6 +108,7 @@ "clsx": "^2.1.1", "concurrently": "^9.2.1", "connect-mongo": "^5.1.0", + "connect-pg-simple": "^10.0.0", "cors": "^2.8.6", "diff2html": "^3.4.56", "env-paths": "^3.0.0", @@ -131,6 +133,7 @@ "passport-activedirectory": "^1.4.0", "passport-local": "^1.0.0", "perfect-scrollbar": "^1.5.6", + "pg": "^8.20.0", "react": "^16.14.0", "react-dom": "^16.14.0", "react-html-parser": "^2.0.2", @@ -149,6 +152,7 @@ "@eslint/js": "^9.39.2", "@eslint/json": "^1.0.1", "@types/activedirectory2": "^1.2.6", + "@types/connect-pg-simple": "^7.0.3", "@types/cors": "^2.8.19", "@types/domutils": "^2.1.0", "@types/express": "^5.0.6", @@ -160,6 +164,7 @@ "@types/node": "^22.19.7", "@types/passport": "^1.0.17", "@types/passport-local": "^1.0.38", + "@types/pg": "^8.20.0", "@types/react-dom": "^17.0.26", "@types/react-html-parser": "^2.0.7", "@types/supertest": "^6.0.3", diff --git a/proxy.config.json b/proxy.config.json index 715c38f48..2ad322120 100644 --- a/proxy.config.json +++ b/proxy.config.json @@ -31,6 +31,11 @@ "ssl": true }, "enabled": false + }, + { + "type": "postgres", + "connectionString": "postgresql://localhost:5432/gitproxy", + "enabled": false } ], "authentication": [ diff --git a/src/config/ConfigLoader.ts b/src/config/ConfigLoader.ts index 7d4b8de8f..126028e8f 100644 --- a/src/config/ConfigLoader.ts +++ b/src/config/ConfigLoader.ts @@ -293,22 +293,24 @@ export class ConfigLoader extends EventEmitter { console.log(`Using repository directory: ${repoDir}`); + const gitEnv = { + // dont wait for credentials; the command should be sufficiently authed + // https://git-scm.com/docs/git#Documentation/git.txt-codeGITTERMINALPROMPTcode + GIT_TERMINAL_PROMPT: 'false', + ...process.env, + ...(source.auth?.type === 'ssh' + ? { + GIT_SSH_COMMAND: `ssh -i ${source.auth.privateKeyPath}`, + } + : {}), + }; + // Clone or pull repository if (!fs.existsSync(repoDir)) { console.log(`Cloning repository ${source.repository} to ${repoDir}`); const execOptions = { cwd: process.cwd(), - env: { - // dont wait for credentials; the command should be sufficiently authed - // https://git-scm.com/docs/git#Documentation/git.txt-codeGITTERMINALPROMPTcode - GIT_TERMINAL_PROMPT: 'false', - ...process.env, - ...(source.auth?.type === 'ssh' - ? { - GIT_SSH_COMMAND: `ssh -i ${source.auth.privateKeyPath}`, - } - : {}), - }, + env: gitEnv, }; try { @@ -318,12 +320,20 @@ export class ConfigLoader extends EventEmitter { handleErrorAndThrow(error, 'Failed to clone repository'); } } else { - console.log(`Pulling latest changes from ${source.repository}`); + console.log(`Fetching latest changes from ${source.repository}`); try { - await execFileAsync('git', ['pull'], { cwd: repoDir }); - console.log('Repository pulled successfully'); + if (source.branch) { + await execFileAsync('git', ['fetch', '--all', '--prune'], { cwd: repoDir, env: gitEnv }); + console.log('Repository fetched successfully'); + } else { + await execFileAsync('git', ['pull'], { cwd: repoDir, env: gitEnv }); + console.log('Repository pulled successfully'); + } } catch (error: unknown) { - handleErrorAndThrow(error, 'Failed to pull repository'); + handleErrorAndThrow( + error, + source.branch ? 'Failed to fetch repository' : 'Failed to pull repository', + ); } } @@ -336,6 +346,17 @@ export class ConfigLoader extends EventEmitter { } catch (error: unknown) { handleErrorAndThrow(error, `Failed to checkout branch ${source.branch}`); } + + console.log(`Pulling latest changes from ${source.branch}`); + try { + await execFileAsync('git', ['pull', '--ff-only', 'origin', source.branch], { + cwd: repoDir, + env: gitEnv, + }); + console.log('Repository pulled successfully'); + } catch (error: unknown) { + handleErrorAndThrow(error, 'Failed to pull repository'); + } } // Read and parse config file diff --git a/src/config/env.ts b/src/config/env.ts index 91b5b6b61..9d614b98a 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -24,6 +24,7 @@ const { GIT_PROXY_HTTPS_UI_PORT = 8444, GIT_PROXY_COOKIE_SECRET, GIT_PROXY_MONGO_CONNECTION_STRING = 'mongodb://localhost:27017/git-proxy', + GIT_PROXY_POSTGRES_CONNECTION_STRING, } = process.env; export const serverConfig: ServerConfig = { @@ -34,4 +35,5 @@ export const serverConfig: ServerConfig = { GIT_PROXY_HTTPS_UI_PORT, GIT_PROXY_COOKIE_SECRET, GIT_PROXY_MONGO_CONNECTION_STRING, + GIT_PROXY_POSTGRES_CONNECTION_STRING, }; diff --git a/src/config/generated/config.ts b/src/config/generated/config.ts index 0a85e8e70..8caace215 100644 --- a/src/config/generated/config.ts +++ b/src/config/generated/config.ts @@ -480,11 +480,18 @@ export interface RateLimit { * or broken out in the options object * * Connection properties for an neDB file-based database + * + * Connection properties for PostgreSQL. The `connectionString` may also be supplied via the + * `GIT_PROXY_POSTGRES_CONNECTION_STRING` environment variable. */ export interface Database { /** * mongoDB Client connection string, see * [https://www.mongodb.com/docs/manual/reference/connection-string/](https://www.mongodb.com/docs/manual/reference/connection-string/) + * + * PostgreSQL client connection string, see + * [https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING). + * If omitted, `GIT_PROXY_POSTGRES_CONNECTION_STRING` is used as a fallback. */ connectionString?: string; enabled: boolean; @@ -522,6 +529,7 @@ export interface AuthMechanismProperties { export enum DatabaseType { FS = 'fs', Mongo = 'mongo', + Postgres = 'postgres', } /** @@ -982,5 +990,5 @@ const typeMap: any = { 'any', ), AuthenticationElementType: ['ActiveDirectory', 'jwt', 'local', 'openidconnect'], - DatabaseType: ['fs', 'mongo'], + DatabaseType: ['fs', 'mongo', 'postgres'], }; diff --git a/src/config/index.ts b/src/config/index.ts index 0d4591300..1b717d970 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -172,6 +172,10 @@ export const getDatabase = () => { if (db.type === 'mongo' && !db.connectionString) { db.connectionString = serverConfig.GIT_PROXY_MONGO_CONNECTION_STRING; } + // same fallback for postgres + if (db.type === 'postgres' && !db.connectionString) { + db.connectionString = serverConfig.GIT_PROXY_POSTGRES_CONNECTION_STRING; + } return db; } } diff --git a/src/config/types.ts b/src/config/types.ts index 534415a5c..a54b2e541 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -24,6 +24,7 @@ export type ServerConfig = { GIT_PROXY_HTTPS_UI_PORT: string | number; GIT_PROXY_COOKIE_SECRET: string | undefined; GIT_PROXY_MONGO_CONNECTION_STRING: string; + GIT_PROXY_POSTGRES_CONNECTION_STRING: string | undefined; }; interface GitAuth { diff --git a/src/db/index.ts b/src/db/index.ts index f9048fb3b..1190f6256 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -20,8 +20,10 @@ import * as bcrypt from 'bcryptjs'; import * as config from '../config'; import * as mongo from './mongo'; import * as neDb from './file'; +import * as postgres from './postgres'; import { Action } from '../proxy/actions/Action'; import MongoDBStore from 'connect-mongo'; +import { Store } from 'express-session'; import { CompletedAttestation, Rejection } from '../proxy/processors/types'; import { processGitUrl } from '../proxy/routes/helper'; import { initializeFolders } from './file/helper'; @@ -41,6 +43,9 @@ const start = () => { console.log('Loading neDB database adaptor'); initializeFolders(); _sink = neDb; + } else if (config.getDatabase().type === 'postgres') { + console.log('Loading PostgreSQL database adaptor'); + _sink = postgres; } else { console.error(`Unsupported database type: ${config.getDatabase().type}`); process.exit(1); @@ -176,7 +181,9 @@ export const canUserCancelPush = async (id: string, user: string) => { } }; -export const getSessionStore = (): MongoDBStore | undefined => start().getSessionStore(); +export const getSessionStore = (): MongoDBStore | Store | undefined => start().getSessionStore(); +export const ensureSessionStoreReady = (): Promise => + start().ensureSessionStoreReady?.() ?? Promise.resolve(); export const getPushes = (query: Partial): Promise => start().getPushes(query); export const writeAudit = (action: Action): Promise => start().writeAudit(action); export const getPush = (id: string): Promise => start().getPush(id); diff --git a/src/db/postgres/helper.ts b/src/db/postgres/helper.ts new file mode 100644 index 000000000..010be1f78 --- /dev/null +++ b/src/db/postgres/helper.ts @@ -0,0 +1,161 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Pool, QueryResult, QueryResultRow } from 'pg'; +import session, { Store } from 'express-session'; +import connectPgSimple from 'connect-pg-simple'; + +import { getDatabase } from '../../config'; + +let _pool: Pool | null = null; +let _bootstrapPromise: Promise | null = null; + +const ensurePool = (): Pool => { + if (_pool) return _pool; + + const connectionString = getDatabase().connectionString; + if (!connectionString) { + throw new Error('Postgres connection string is not provided'); + } + + _pool = new Pool({ connectionString }); + return _pool; +}; + +const APP_SCHEMA_SQL = ` + CREATE TABLE IF NOT EXISTS users ( + _id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username TEXT NOT NULL UNIQUE, + email TEXT NOT NULL UNIQUE, + password TEXT, + git_account TEXT NOT NULL, + admin BOOLEAN NOT NULL DEFAULT FALSE, + oidc_id TEXT UNIQUE, + display_name TEXT, + title TEXT + ); + + CREATE TABLE IF NOT EXISTS repos ( + _id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL, + url TEXT NOT NULL UNIQUE, + users JSONB NOT NULL DEFAULT '{"canPush":[],"canAuthorise":[]}'::jsonb + ); + CREATE INDEX IF NOT EXISTS repos_name_idx ON repos (name); + + CREATE TABLE IF NOT EXISTS pushes ( + id TEXT PRIMARY KEY, + timestamp BIGINT NOT NULL, + type TEXT, + error BOOLEAN NOT NULL DEFAULT FALSE, + blocked BOOLEAN NOT NULL DEFAULT FALSE, + allow_push BOOLEAN NOT NULL DEFAULT FALSE, + authorised BOOLEAN NOT NULL DEFAULT FALSE, + canceled BOOLEAN NOT NULL DEFAULT FALSE, + rejected BOOLEAN NOT NULL DEFAULT FALSE, + data JSONB NOT NULL + ); + CREATE INDEX IF NOT EXISTS pushes_timestamp_idx ON pushes (timestamp DESC); +`; + +const bootstrapAppSchema = async (pool: Pool): Promise => { + await pool.query(APP_SCHEMA_SQL); +}; + +/** + * Lazily resolves the pg Pool and runs the app schema bootstrap exactly once + * per process. All adapter modules acquire the pool through this function so + * the bootstrap completes before any query against `users` / `repos` / `pushes` + * is executed. + */ +export const connect = async (): Promise => { + const pool = ensurePool(); + if (!_bootstrapPromise) { + _bootstrapPromise = bootstrapAppSchema(pool).catch((err) => { + // Reset so the next caller retries instead of being permanently latched + // onto a rejected promise. + _bootstrapPromise = null; + throw err; + }); + } + await _bootstrapPromise; + return pool; +}; + +export const query = async ( + text: string, + params?: ReadonlyArray, +): Promise> => { + const pool = await connect(); + return pool.query(text, params as unknown[] | undefined); +}; + +/** + * Reset the pool and bootstrap latch — exported for test cleanup. + */ +export const resetConnection = async (): Promise => { + if (_pool) { + await _pool.end(); + _pool = null; + } + _bootstrapPromise = null; +}; + +/** + * Build an express-session Store backed by Postgres via `connect-pg-simple`. + * + * IMPORTANT: this function MUST NOT silently return undefined when Postgres is + * the active sink — that would cause express-session to fall back to its + * default in-memory store, which loses sessions on every restart and is unsafe + * in any multi-process deployment. Issue #1497 calls this out as a must-fix + * requirement, so we throw loudly instead. + */ +export const getSessionStore = (): Store => { + const connectionString = getDatabase().connectionString; + if (!connectionString) { + throw new Error( + 'Postgres connection string is required for session storage (set it in `sink[].connectionString` or via GIT_PROXY_POSTGRES_CONNECTION_STRING)', + ); + } + + const pool = ensurePool(); + const PgStore = connectPgSimple(session); + return new PgStore({ + pool, + tableName: 'session', + createTableIfMissing: true, + }); +}; + +export const ensureSessionStoreReady = async (): Promise => { + const store = getSessionStore(); + + await new Promise((resolve, reject) => { + store.get('__git_proxy_session_startup_probe__', (err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + + const maybeClosableStore = store as Store & { close?: () => Promise }; + if (maybeClosableStore.close) { + await maybeClosableStore.close(); + } +}; diff --git a/src/db/postgres/index.ts b/src/db/postgres/index.ts new file mode 100644 index 000000000..2b25baea6 --- /dev/null +++ b/src/db/postgres/index.ts @@ -0,0 +1,47 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as helper from './helper'; +import * as pushes from './pushes'; +import * as repo from './repo'; +import * as users from './users'; + +export const { getSessionStore, ensureSessionStoreReady } = helper; + +export const { getPushes, writeAudit, getPush, deletePush, authorise, cancel, reject } = pushes; + +export const { + getRepos, + getRepo, + getRepoByUrl, + getRepoById, + createRepo, + addUserCanPush, + addUserCanAuthorise, + removeUserCanPush, + removeUserCanAuthorise, + deleteRepo, +} = repo; + +export const { + findUser, + findUserByEmail, + findUserByOIDC, + getUsers, + createUser, + deleteUser, + updateUser, +} = users; diff --git a/src/db/postgres/pushes.ts b/src/db/postgres/pushes.ts new file mode 100644 index 000000000..6ad5840fd --- /dev/null +++ b/src/db/postgres/pushes.ts @@ -0,0 +1,156 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Action } from '../../proxy/actions'; +import { CompletedAttestation, Rejection } from '../../proxy/processors/types'; +import { toClass } from '../helper'; +import { PushQuery } from '../types'; +import { query } from './helper'; + +const defaultPushQuery: Partial = { + error: false, + blocked: true, + allowPush: false, + authorised: false, + type: 'push', +}; + +// Columns that mirror Action fields used to filter `getPushes` results. +// Anything not in this map is ignored — the API only filters by these. +const FILTER_COLUMNS: Record = { + error: 'error', + blocked: 'blocked', + allowPush: 'allow_push', + authorised: 'authorised', + canceled: 'canceled', + rejected: 'rejected', + type: 'type', +}; + +const rowToAction = (row: { data: unknown }): Action => + toClass(row.data, Action.prototype) as Action; + +export const getPushes = async (q: Partial = defaultPushQuery): Promise => { + const clauses: string[] = []; + const values: unknown[] = []; + for (const [key, value] of Object.entries(q)) { + const column = FILTER_COLUMNS[key]; + if (!column || value === undefined) continue; + values.push(value); + clauses.push(`${column} = $${values.length}`); + } + + const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : ''; + const result = await query<{ data: unknown }>( + `SELECT data FROM pushes ${where} ORDER BY timestamp DESC`, + values, + ); + return result.rows.map(rowToAction); +}; + +export const getPush = async (id: string): Promise => { + const result = await query<{ data: unknown }>(`SELECT data FROM pushes WHERE id = $1`, [id]); + if (result.rowCount === 0) return null; + return rowToAction(result.rows[0]); +}; + +export const deletePush = async (id: string): Promise => { + await query(`DELETE FROM pushes WHERE id = $1`, [id]); +}; + +export const writeAudit = async (action: Action): Promise => { + if (typeof action.id !== 'string') { + throw new Error('Invalid id'); + } + + // Round-trip through JSON to drop class identity / mongo-specific _id fields + // before persisting (mirrors mongo's `JSON.parse(JSON.stringify(action))`). + const data = JSON.parse(JSON.stringify(action)); + delete data._id; + + await query( + `INSERT INTO pushes ( + id, timestamp, type, error, blocked, allow_push, + authorised, canceled, rejected, data + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb) + ON CONFLICT (id) DO UPDATE SET + timestamp = EXCLUDED.timestamp, + type = EXCLUDED.type, + error = EXCLUDED.error, + blocked = EXCLUDED.blocked, + allow_push = EXCLUDED.allow_push, + authorised = EXCLUDED.authorised, + canceled = EXCLUDED.canceled, + rejected = EXCLUDED.rejected, + data = EXCLUDED.data`, + [ + action.id, + action.timestamp ?? Date.now(), + action.type ?? null, + action.error ?? false, + action.blocked ?? false, + action.allowPush ?? false, + action.authorised ?? false, + action.canceled ?? false, + action.rejected ?? false, + JSON.stringify(data), + ], + ); +}; + +export const authorise = async ( + id: string, + attestation?: CompletedAttestation, +): Promise<{ message: string }> => { + const action = await getPush(id); + if (!action) { + throw new Error(`push ${id} not found`); + } + action.authorised = true; + action.canceled = false; + action.rejected = false; + action.attestation = attestation; + await writeAudit(action); + return { message: `authorised ${id}` }; +}; + +export const reject = async (id: string, rejection: Rejection): Promise<{ message: string }> => { + const action = await getPush(id); + if (!action) { + throw new Error(`push ${id} not found`); + } + action.authorised = false; + action.canceled = false; + action.rejected = true; + // Preserve the existing rejection-payload shape used by the fs/mongo + // backends — the issue calls this out explicitly as a must-fix. + action.rejection = rejection; + await writeAudit(action); + return { message: `reject ${id}` }; +}; + +export const cancel = async (id: string): Promise<{ message: string }> => { + const action = await getPush(id); + if (!action) { + throw new Error(`push ${id} not found`); + } + action.authorised = false; + action.canceled = true; + action.rejected = false; + await writeAudit(action); + return { message: `canceled ${id}` }; +}; diff --git a/src/db/postgres/repo.ts b/src/db/postgres/repo.ts new file mode 100644 index 000000000..045333554 --- /dev/null +++ b/src/db/postgres/repo.ts @@ -0,0 +1,175 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// TODO(#1497-followup): consider normalizing repo permissions into a +// repo_users(repo_id, user, role) join table. JSONB is used for v1 to +// match the mongo/fs shape and minimize migration churn — the issue +// flags this as an open question for a follow-up PR. + +import { Repo, RepoQuery } from '../types'; +import { query } from './helper'; + +interface RepoRow { + _id: string; + project: string; + name: string; + url: string; + users: { canPush: string[]; canAuthorise: string[] } | null; +} + +const rowToRepo = (row: RepoRow): Repo => + new Repo( + row.project, + row.name, + row.url, + // Guard against null/legacy rows so callers always see arrays. + { + canPush: row.users?.canPush ?? [], + canAuthorise: row.users?.canAuthorise ?? [], + }, + row._id, + ); + +const SELECT_COLUMNS = '_id, project, name, url, users'; + +export const getRepos = async (q: Partial = {}): Promise => { + const clauses: string[] = []; + const values: unknown[] = []; + if (q.name) { + values.push(q.name.toLowerCase()); + clauses.push(`name = $${values.length}`); + } + if (q.project !== undefined) { + values.push(q.project); + clauses.push(`project = $${values.length}`); + } + if (q.url) { + values.push(q.url); + clauses.push(`url = $${values.length}`); + } + + const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : ''; + const result = await query(`SELECT ${SELECT_COLUMNS} FROM repos ${where}`, values); + return result.rows.map(rowToRepo); +}; + +export const getRepo = async (name: string): Promise => { + const result = await query(`SELECT ${SELECT_COLUMNS} FROM repos WHERE name = $1`, [ + name.toLowerCase(), + ]); + return result.rowCount === 0 ? null : rowToRepo(result.rows[0]); +}; + +export const getRepoByUrl = async (url: string): Promise => { + const result = await query(`SELECT ${SELECT_COLUMNS} FROM repos WHERE url = $1`, [url]); + return result.rowCount === 0 ? null : rowToRepo(result.rows[0]); +}; + +export const getRepoById = async (_id: string): Promise => { + const result = await query(`SELECT ${SELECT_COLUMNS} FROM repos WHERE _id = $1`, [_id]); + return result.rowCount === 0 ? null : rowToRepo(result.rows[0]); +}; + +export const createRepo = async (repo: Repo): Promise => { + const users = repo.users ?? { canPush: [], canAuthorise: [] }; + const result = await query<{ _id: string }>( + `INSERT INTO repos (project, name, url, users) + VALUES ($1, $2, $3, $4::jsonb) + RETURNING _id`, + [repo.project ?? '', repo.name, repo.url, JSON.stringify(users)], + ); + repo._id = result.rows[0]._id; + repo.users = users; + return repo; +}; + +/** + * Append a user to one of the JSONB permission arrays. The query is a + * read-modify-write that deduplicates the value, then re-serialises the array + * so the stored shape matches the existing mongo/fs backends exactly. + * + * Crucially: when the last user is later removed, the array stays `[]` rather + * than collapsing to `null` — issue #1497 explicitly requires this. + */ +const addUserToRole = async ( + _id: string, + user: string, + role: 'canPush' | 'canAuthorise', +): Promise => { + const lowered = user.toLowerCase(); + await query( + `UPDATE repos + SET users = jsonb_set( + users, + $2::text[], + ( + SELECT to_jsonb( + ARRAY( + SELECT DISTINCT v + FROM jsonb_array_elements_text(coalesce(users->$3, '[]'::jsonb)) AS v + UNION + SELECT $4 + ) + ) + ) + ) + WHERE _id = $1`, + [_id, `{${role}}`, role, lowered], + ); +}; + +const removeUserFromRole = async ( + _id: string, + user: string, + role: 'canPush' | 'canAuthorise', +): Promise => { + const lowered = user.toLowerCase(); + // The filter expression evaluates to `[]` if the last matching user is + // removed — preserving the empty-array invariant from issue #1497. + await query( + `UPDATE repos + SET users = jsonb_set( + users, + $2::text[], + coalesce( + ( + SELECT to_jsonb(array_agg(v)) + FROM jsonb_array_elements_text(coalesce(users->$3, '[]'::jsonb)) AS v + WHERE v <> $4 + ), + '[]'::jsonb + ) + ) + WHERE _id = $1`, + [_id, `{${role}}`, role, lowered], + ); +}; + +export const addUserCanPush = (_id: string, user: string): Promise => + addUserToRole(_id, user, 'canPush'); + +export const addUserCanAuthorise = (_id: string, user: string): Promise => + addUserToRole(_id, user, 'canAuthorise'); + +export const removeUserCanPush = (_id: string, user: string): Promise => + removeUserFromRole(_id, user, 'canPush'); + +export const removeUserCanAuthorise = (_id: string, user: string): Promise => + removeUserFromRole(_id, user, 'canAuthorise'); + +export const deleteRepo = async (_id: string): Promise => { + await query(`DELETE FROM repos WHERE _id = $1`, [_id]); +}; diff --git a/src/db/postgres/users.ts b/src/db/postgres/users.ts new file mode 100644 index 000000000..8c7928172 --- /dev/null +++ b/src/db/postgres/users.ts @@ -0,0 +1,176 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { User, UserQuery } from '../types'; +import { query } from './helper'; + +interface UserRow { + _id: string; + username: string; + email: string; + password: string | null; + git_account: string; + admin: boolean; + oidc_id: string | null; + display_name: string | null; + title: string | null; +} + +const rowToUser = (row: UserRow): User => { + const user = new User( + row.username, + row.password ?? '', + row.git_account, + row.email, + row.admin, + row.oidc_id, + row._id, + ); + user.password = row.password; + user.displayName = row.display_name; + user.title = row.title; + return user; +}; + +const SELECT_COLUMNS = + '_id, username, email, password, git_account, admin, oidc_id, display_name, title'; + +export const findUser = async (username: string): Promise => { + const result = await query(`SELECT ${SELECT_COLUMNS} FROM users WHERE username = $1`, [ + username.toLowerCase(), + ]); + return result.rowCount === 0 ? null : rowToUser(result.rows[0]); +}; + +export const findUserByEmail = async (email: string): Promise => { + const result = await query(`SELECT ${SELECT_COLUMNS} FROM users WHERE email = $1`, [ + email.toLowerCase(), + ]); + return result.rowCount === 0 ? null : rowToUser(result.rows[0]); +}; + +export const findUserByOIDC = async (oidcId: string): Promise => { + const result = await query(`SELECT ${SELECT_COLUMNS} FROM users WHERE oidc_id = $1`, [ + oidcId, + ]); + return result.rowCount === 0 ? null : rowToUser(result.rows[0]); +}; + +export const getUsers = async (q: Partial = {}): Promise => { + const clauses: string[] = []; + const values: unknown[] = []; + if (q.username) { + values.push(q.username.toLowerCase()); + clauses.push(`username = $${values.length}`); + } + if (q.email) { + values.push(q.email.toLowerCase()); + clauses.push(`email = $${values.length}`); + } + + const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : ''; + // Match mongo's `.project({ password: 0 })` — omit password from list results. + const result = await query( + `SELECT _id, username, email, NULL::text AS password, git_account, admin, oidc_id, display_name, title + FROM users ${where}`, + values, + ); + return result.rows.map(rowToUser); +}; + +export const createUser = async (user: User): Promise => { + await query( + `INSERT INTO users (username, email, password, git_account, admin, oidc_id, display_name, title) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + user.username.toLowerCase(), + user.email.toLowerCase(), + user.password ?? null, + user.gitAccount, + user.admin, + user.oidcId ?? null, + user.displayName ?? null, + user.title ?? null, + ], + ); +}; + +export const deleteUser = async (username: string): Promise => { + await query(`DELETE FROM users WHERE username = $1`, [username.toLowerCase()]); +}; + +/** + * Update an existing user, or insert a new one if no matching row exists. + * + * Mirrors the mongo adapter's upsert semantics: partial updates are merged + * onto an existing row (only supplied fields are written), and a missing row + * is created. Identity is by `_id` when provided, otherwise by `username`. + */ +export const updateUser = async (user: Partial): Promise => { + const username = user.username?.toLowerCase(); + const email = user.email?.toLowerCase(); + + // Build the SET fragment dynamically so callers can patch arbitrary fields. + const sets: string[] = []; + const values: unknown[] = []; + const set = (column: string, value: unknown) => { + values.push(value); + sets.push(`${column} = $${values.length}`); + }; + + if (username !== undefined) set('username', username); + if (email !== undefined) set('email', email); + if (user.password !== undefined) set('password', user.password); + if (user.gitAccount !== undefined) set('git_account', user.gitAccount); + if (user.admin !== undefined) set('admin', user.admin); + if (user.oidcId !== undefined) set('oidc_id', user.oidcId); + if (user.displayName !== undefined) set('display_name', user.displayName); + if (user.title !== undefined) set('title', user.title); + + if (user._id) { + values.push(user._id); + await query(`UPDATE users SET ${sets.join(', ')} WHERE _id = $${values.length}`, values); + return; + } + + if (!username) { + throw new Error('updateUser requires either _id or username'); + } + + // Upsert by username when no _id is supplied, matching mongo's behaviour. + values.push(username); + const result = await query( + `UPDATE users SET ${sets.join(', ')} WHERE username = $${values.length}`, + values, + ); + if (result.rowCount && result.rowCount > 0) return; + + await query( + `INSERT INTO users (username, email, password, git_account, admin, oidc_id, display_name, title) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (username) DO NOTHING`, + [ + username, + email ?? '', + user.password ?? null, + user.gitAccount ?? '', + user.admin ?? false, + user.oidcId ?? null, + user.displayName ?? null, + user.title ?? null, + ], + ); +}; diff --git a/src/db/types.ts b/src/db/types.ts index 74ead38b5..467bf8b13 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -16,6 +16,7 @@ import { Action } from '../proxy/actions/Action'; import MongoDBStore from 'connect-mongo'; +import { Store } from 'express-session'; import { CompletedAttestation, Rejection } from '../proxy/processors/types'; export type PushQuery = { @@ -108,7 +109,8 @@ export interface PublicUser { } export interface Sink { - getSessionStore: () => MongoDBStore | undefined; + getSessionStore: () => MongoDBStore | Store | undefined; + ensureSessionStoreReady?: () => Promise; getPushes: (query: Partial) => Promise; writeAudit: (action: Action) => Promise; getPush: (id: string) => Promise; diff --git a/src/service/index.ts b/src/service/index.ts index 25cfc2731..f20a33eeb 100644 --- a/src/service/index.ts +++ b/src/service/index.ts @@ -139,6 +139,13 @@ const corsOptions: cors.CorsOptions = { * @param {Proxy} proxy A reference to the proxy, used to restart it when necessary. * @return {Promise} the express application */ +// Backend sink types that promise a persistent session store. If one of these +// is active and getSessionStore() returns undefined, express-session would +// silently fall back to MemoryStore — which loses sessions on restart and is +// unsafe in any multi-process deployment. Issue #1497 calls this out as a +// must-fix requirement, so we throw loudly instead. +const PERSISTENT_SESSION_BACKENDS = new Set(['mongo', 'postgres']); + async function createApp(proxy: Proxy): Promise { // configuration of passport is async // Before we can bind the routes - we need the passport strategy @@ -148,9 +155,20 @@ async function createApp(proxy: Proxy): Promise { app.set('trust proxy', 1); app.use(limiter); + const backendType = config.getDatabase().type; + if (PERSISTENT_SESSION_BACKENDS.has(backendType)) { + await db.ensureSessionStoreReady(); + } + const sessionStore = db.getSessionStore(); + if (PERSISTENT_SESSION_BACKENDS.has(backendType) && !sessionStore) { + throw new Error( + `Session store for backend "${backendType}" failed to initialize — refusing to fall back to MemoryStore`, + ); + } + app.use( session({ - store: db.getSessionStore(), + store: sessionStore, secret: config.getCookieSecret(), resave: false, saveUninitialized: false, diff --git a/test-integration.postgres.proxy.config.json b/test-integration.postgres.proxy.config.json new file mode 100644 index 000000000..7a1a57d5e --- /dev/null +++ b/test-integration.postgres.proxy.config.json @@ -0,0 +1,21 @@ +{ + "cookieSecret": "integration-test-cookie-secret", + "sessionMaxAgeHours": 12, + "sink": [ + { + "type": "fs", + "enabled": false + }, + { + "type": "postgres", + "connectionString": "postgresql://postgres:postgres@localhost:5432/git_proxy_test", + "enabled": true + } + ], + "authentication": [ + { + "type": "local", + "enabled": true + } + ] +} diff --git a/test/ConfigLoader.test.ts b/test/ConfigLoader.test.ts index f0890a50d..051c78ec6 100644 --- a/test/ConfigLoader.test.ts +++ b/test/ConfigLoader.test.ts @@ -17,6 +17,8 @@ import { describe, it, beforeEach, afterEach, afterAll, expect, vi } from 'vitest'; import fs from 'fs'; import path from 'path'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; import { getConfigFile } from '../src/config/file'; import { ConfigLoader, @@ -33,6 +35,8 @@ import { } from '../src/config/types'; import axios from 'axios'; +const execFileAsync = promisify(execFile); + describe('ConfigLoader', () => { let configLoader: ConfigLoader; let tempDir: string; @@ -471,6 +475,45 @@ describe('ConfigLoader', () => { expect(config).toHaveProperty('cookieSecret'); }, 10000); + it('should recover a cached git repository that is on a detached HEAD', async () => { + const source: GitSource = { + type: 'git', + repository: 'https://example.com/git-proxy-config-test.git', + path: 'proxy.config.json', + branch: 'main', + enabled: true, + }; + const remoteDir = path.resolve(tempDir, 'remote.git'); + const workDir = path.resolve(tempDir, 'work'); + const envPaths = (await import('env-paths')).default; + const paths = envPaths('git-proxy', { suffix: '' }); + const repoDirName = Buffer.from(source.repository) + .toString('base64') + .replace(/[^a-zA-Z0-9]/g, '_'); + const repoDir = path.join(paths.cache, 'git-config-cache', repoDirName); + + if (fs.existsSync(repoDir)) { + fs.rmSync(repoDir, { recursive: true }); + } + + await execFileAsync('git', ['init', '--bare', remoteDir]); + await execFileAsync('git', ['init', '--initial-branch=main', workDir]); + fs.writeFileSync( + path.join(workDir, source.path), + JSON.stringify({ proxyUrl: 'https://test.com', cookieSecret: 'from-cache' }), + ); + await execFileAsync('git', ['add', source.path], { cwd: workDir }); + await execFileAsync('git', ['commit', '-m', 'add config'], { cwd: workDir }); + await execFileAsync('git', ['remote', 'add', 'origin', remoteDir], { cwd: workDir }); + await execFileAsync('git', ['push', '-u', 'origin', 'main'], { cwd: workDir }); + await execFileAsync('git', ['clone', remoteDir, repoDir]); + await execFileAsync('git', ['checkout', '--detach', 'HEAD'], { cwd: repoDir }); + + const config = await configLoader.loadFromSource(source); + + expect(config.cookieSecret).toBe('from-cache'); + }); + it('should throw error for invalid configuration file path (git)', async () => { const source: GitSource = { type: 'git', diff --git a/test/db/postgres/helper.test.ts b/test/db/postgres/helper.test.ts new file mode 100644 index 000000000..e952b1843 --- /dev/null +++ b/test/db/postgres/helper.test.ts @@ -0,0 +1,159 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +const mockPoolQuery = vi.fn(); +const mockPoolEnd = vi.fn(); +const mockPoolCtor = vi.fn(); + +vi.mock('pg', () => { + class Pool { + constructor(opts: unknown) { + mockPoolCtor(opts); + } + query = mockPoolQuery; + end = mockPoolEnd; + } + return { Pool }; +}); + +// connect-pg-simple returns a constructor that accepts options including a +// `pool` instance. We don't exercise the real store — just want to capture the +// options the helper passes. +const mockStoreCtor = vi.fn(); +vi.mock('connect-pg-simple', () => ({ + default: () => + class FakePgStore { + constructor(opts: unknown) { + mockStoreCtor(opts); + } + get(_sid: string, cb: (err: Error | null) => void) { + mockPoolQuery('SELECT 1', []); + cb(null); + } + close() { + return Promise.resolve(); + } + }, +})); + +const getDatabaseMock = vi.fn(); +vi.mock('../../../src/config', () => ({ + getDatabase: getDatabaseMock, +})); + +describe('PostgreSQL - helper', async () => { + const { connect, query, resetConnection, getSessionStore, ensureSessionStoreReady } = + await import('../../../src/db/postgres/helper'); + + beforeEach(async () => { + vi.clearAllMocks(); + await resetConnection(); + mockPoolQuery.mockResolvedValue({ rowCount: 0, rows: [] }); + }); + + describe('connect / bootstrap', () => { + it('runs the bootstrap SQL exactly once across many concurrent connects', async () => { + getDatabaseMock.mockReturnValue({ + type: 'postgres', + enabled: true, + connectionString: 'postgresql://localhost/x', + }); + + await Promise.all([connect(), connect(), connect()]); + + // Pool constructed once, bootstrap SQL run once. + expect(mockPoolCtor).toHaveBeenCalledTimes(1); + expect(mockPoolQuery).toHaveBeenCalledTimes(1); + const [sql] = mockPoolQuery.mock.calls[0]; + expect(sql).toMatch(/CREATE TABLE IF NOT EXISTS users/); + expect(sql).toMatch(/CREATE TABLE IF NOT EXISTS repos/); + expect(sql).toMatch(/CREATE TABLE IF NOT EXISTS pushes/); + }); + + it('retries bootstrap on the next call if it failed', async () => { + getDatabaseMock.mockReturnValue({ + type: 'postgres', + enabled: true, + connectionString: 'postgresql://localhost/x', + }); + + mockPoolQuery.mockRejectedValueOnce(new Error('schema kaboom')); + + await expect(connect()).rejects.toThrow('schema kaboom'); + + // Second attempt re-runs bootstrap rather than being permanently + // latched to the rejected promise. + mockPoolQuery.mockResolvedValueOnce({ rowCount: 0, rows: [] }); + await connect(); + expect(mockPoolQuery).toHaveBeenCalledTimes(2); + }); + + it('throws when the connection string is missing', async () => { + getDatabaseMock.mockReturnValue({ + type: 'postgres', + enabled: true, + connectionString: undefined, + }); + + await expect(query('SELECT 1')).rejects.toThrow('Postgres connection string is not provided'); + }); + }); + + describe('getSessionStore', () => { + it('throws when connection string is missing — no MemoryStore fallback', () => { + getDatabaseMock.mockReturnValue({ + type: 'postgres', + enabled: true, + connectionString: undefined, + }); + + expect(() => getSessionStore()).toThrow( + /Postgres connection string is required for session storage/, + ); + }); + + it('passes the shared pool to connect-pg-simple with createTableIfMissing', () => { + getDatabaseMock.mockReturnValue({ + type: 'postgres', + enabled: true, + connectionString: 'postgresql://localhost/x', + }); + + getSessionStore(); + + expect(mockStoreCtor).toHaveBeenCalledTimes(1); + const opts = mockStoreCtor.mock.calls[0][0] as Record; + expect(opts.tableName).toBe('session'); + expect(opts.createTableIfMissing).toBe(true); + expect(opts.pool).toBeDefined(); + }); + + it('touches the session store during readiness checks', async () => { + getDatabaseMock.mockReturnValue({ + type: 'postgres', + enabled: true, + connectionString: 'postgresql://localhost/x', + }); + + await ensureSessionStoreReady(); + + expect(mockStoreCtor).toHaveBeenCalledTimes(1); + expect(mockPoolQuery).toHaveBeenCalled(); + }); + }); +}); diff --git a/test/db/postgres/pushes.integration.test.ts b/test/db/postgres/pushes.integration.test.ts new file mode 100644 index 000000000..f52910f29 --- /dev/null +++ b/test/db/postgres/pushes.integration.test.ts @@ -0,0 +1,269 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { + writeAudit, + getPush, + getPushes, + deletePush, + authorise, + reject, + cancel, +} from '../../../src/db/postgres/pushes'; +import { Action } from '../../../src/proxy/actions'; + +const shouldRunPostgresTests = process.env.RUN_POSTGRES_TESTS === 'true'; + +describe.runIf(shouldRunPostgresTests)('PostgreSQL Pushes Integration Tests', () => { + const createTestAction = (overrides: Partial = {}): Action => { + const timestamp = Date.now(); + const action = new Action( + overrides.id || `test-push-${timestamp}`, + overrides.type || 'push', + overrides.method || 'POST', + overrides.timestamp || timestamp, + overrides.url || 'https://github.com/test/repo.git', + ); + + action.error = overrides.error ?? false; + action.blocked = overrides.blocked ?? true; + action.allowPush = overrides.allowPush ?? false; + action.authorised = overrides.authorised ?? false; + action.canceled = overrides.canceled ?? false; + action.rejected = overrides.rejected ?? false; + + return action; + }; + + describe('writeAudit', () => { + it('writes an action to the database', async () => { + const action = createTestAction({ id: 'write-audit-test' }); + await writeAudit(action); + + const retrieved = await getPush('write-audit-test'); + expect(retrieved).not.toBeNull(); + expect(retrieved?.id).toBe('write-audit-test'); + }); + + it('upserts an existing action', async () => { + const action = createTestAction({ id: 'upsert-test' }); + await writeAudit(action); + + action.blocked = false; + action.allowPush = true; + await writeAudit(action); + + const retrieved = await getPush('upsert-test'); + expect(retrieved?.blocked).toBe(false); + expect(retrieved?.allowPush).toBe(true); + }); + + it('throws Invalid id for non-string ids', async () => { + const action = createTestAction(); + action.id = 123 as unknown as string; + + await expect(writeAudit(action)).rejects.toThrow('Invalid id'); + }); + + it('strips _id from action before saving', async () => { + const action = createTestAction({ id: 'strip-id-test' }); + (action as any)._id = 'should-be-removed'; + + await writeAudit(action); + const retrieved = await getPush('strip-id-test'); + expect(retrieved).not.toBeNull(); + // _id should not leak back out — the action JSON contains only public fields + expect((retrieved as any)._id).toBeUndefined(); + expect(retrieved?.id).toBe('strip-id-test'); + }); + }); + + describe('getPush', () => { + it('retrieves a push by id', async () => { + const action = createTestAction({ id: 'get-push-test' }); + await writeAudit(action); + + const result = await getPush('get-push-test'); + expect(result?.id).toBe('get-push-test'); + expect(result?.type).toBe('push'); + }); + + it('returns null for a non-existent push', async () => { + expect(await getPush('non-existent')).toBeNull(); + }); + + it('returns an Action instance', async () => { + const action = createTestAction({ id: 'action-instance-test' }); + await writeAudit(action); + + const result = await getPush('action-instance-test'); + expect(Object.getPrototypeOf(result)).toBe(Action.prototype); + }); + }); + + describe('getPushes', () => { + beforeEach(async () => { + // Three pushes with deliberately increasing timestamps so we can verify + // DESC ordering deterministically. + await writeAudit( + createTestAction({ + id: 'push-a', + timestamp: 1000, + blocked: true, + authorised: false, + }), + ); + await writeAudit( + createTestAction({ + id: 'push-b', + timestamp: 2000, + blocked: true, + authorised: false, + }), + ); + await writeAudit( + createTestAction({ + id: 'push-authorised', + timestamp: 3000, + blocked: true, + authorised: true, + }), + ); + }); + + it('orders pushes by timestamp DESC (issue #1497 must-fix)', async () => { + const result = await getPushes({}); + const ids = result.map((p) => p.id); + expect(ids).toEqual(['push-authorised', 'push-b', 'push-a']); + }); + + it('filters by authorised flag', async () => { + const result = await getPushes({ authorised: true }); + const authorisedPush = result.find((p) => p.id === 'push-authorised'); + expect(authorisedPush).toBeDefined(); + expect(result.every((p) => p.authorised === true)).toBe(true); + }); + + it('does not leak _id', async () => { + const result = await getPushes({}); + result.forEach((push) => { + expect((push as any)._id).toBeUndefined(); + expect(push.id).toBeDefined(); + }); + }); + }); + + describe('deletePush', () => { + it('deletes a push by id', async () => { + const action = createTestAction({ id: 'delete-test' }); + await writeAudit(action); + await deletePush('delete-test'); + expect(await getPush('delete-test')).toBeNull(); + }); + + it('does not throw when deleting a non-existent push', async () => { + await expect(deletePush('non-existent')).resolves.not.toThrow(); + }); + }); + + describe('authorise', () => { + it('authorises a push and resets cancel/reject flags', async () => { + const action = createTestAction({ + id: 'authorise-test', + authorised: false, + canceled: true, + rejected: true, + }); + await writeAudit(action); + + const result = await authorise('authorise-test', { note: 'approved' } as never); + expect(result.message).toBe('authorised authorise-test'); + + const updated = await getPush('authorise-test'); + expect(updated?.authorised).toBe(true); + expect(updated?.canceled).toBe(false); + expect(updated?.rejected).toBe(false); + expect((updated as any)?.attestation).toEqual({ note: 'approved' }); + }); + + it('throws for a non-existent push', async () => { + await expect(authorise('non-existent', {} as never)).rejects.toThrow( + 'push non-existent not found', + ); + }); + }); + + describe('reject', () => { + it('rejects a push and persists the rejection payload', async () => { + const action = createTestAction({ + id: 'reject-test', + authorised: true, + canceled: true, + rejected: false, + }); + await writeAudit(action); + + const rejection = { + reason: 'policy violation', + timestamp: new Date('2026-05-11T00:00:00Z'), + reviewer: { username: 'r', reviewerEmail: 'r@example.com' }, + }; + + const result = await reject('reject-test', rejection as never); + expect(result.message).toBe('reject reject-test'); + + const updated = await getPush('reject-test'); + expect(updated?.authorised).toBe(false); + expect(updated?.canceled).toBe(false); + expect(updated?.rejected).toBe(true); + // Round-tripped through JSONB — `reason` and `reviewer` survive + // exactly; the `Date` round-trips as an ISO string in JSON. + expect((updated as any)?.rejection?.reason).toBe('policy violation'); + expect((updated as any)?.rejection?.reviewer).toEqual(rejection.reviewer); + }); + + it('throws for a non-existent push', async () => { + await expect(reject('non-existent', {} as never)).rejects.toThrow( + 'push non-existent not found', + ); + }); + }); + + describe('cancel', () => { + it('cancels a push and resets authorise/reject flags', async () => { + const action = createTestAction({ + id: 'cancel-test', + authorised: true, + canceled: false, + rejected: true, + }); + await writeAudit(action); + + const result = await cancel('cancel-test'); + expect(result.message).toBe('canceled cancel-test'); + + const updated = await getPush('cancel-test'); + expect(updated?.authorised).toBe(false); + expect(updated?.canceled).toBe(true); + expect(updated?.rejected).toBe(false); + }); + + it('throws for a non-existent push', async () => { + await expect(cancel('non-existent')).rejects.toThrow('push non-existent not found'); + }); + }); +}); diff --git a/test/db/postgres/pushes.test.ts b/test/db/postgres/pushes.test.ts new file mode 100644 index 000000000..9d21d2fd3 --- /dev/null +++ b/test/db/postgres/pushes.test.ts @@ -0,0 +1,140 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +const mockQuery = vi.fn(); + +vi.mock('../../../src/db/postgres/helper', () => ({ + query: mockQuery, +})); + +describe('PostgreSQL - Pushes', async () => { + const { reject, getPushes, getPush, writeAudit } = + await import('../../../src/db/postgres/pushes'); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('getPushes', () => { + it('orders results by timestamp DESC', async () => { + mockQuery.mockResolvedValue({ rowCount: 0, rows: [] }); + + await getPushes({}); + + const [sql] = mockQuery.mock.calls[0]; + expect(sql).toMatch(/ORDER BY timestamp DESC/); + }); + + it('translates allowPush to the snake_case column', async () => { + mockQuery.mockResolvedValue({ rowCount: 0, rows: [] }); + + await getPushes({ allowPush: true }); + + const [sql, params] = mockQuery.mock.calls[0]; + expect(sql).toContain('allow_push = $1'); + expect(params).toEqual([true]); + }); + + it('ignores unknown filter keys', async () => { + mockQuery.mockResolvedValue({ rowCount: 0, rows: [] }); + + await getPushes({ id: 'x' } as never); + + const [sql, params] = mockQuery.mock.calls[0]; + expect(sql).not.toContain('WHERE'); + expect(params).toEqual([]); + }); + }); + + describe('getPush', () => { + it('returns null when no row matches', async () => { + mockQuery.mockResolvedValue({ rowCount: 0, rows: [] }); + expect(await getPush('missing')).toBeNull(); + }); + }); + + describe('writeAudit', () => { + it('throws Invalid id when id is not a string', async () => { + const action = { id: 42, timestamp: 1 } as unknown as Parameters[0]; + await expect(writeAudit(action)).rejects.toThrow('Invalid id'); + expect(mockQuery).not.toHaveBeenCalled(); + }); + + it('upserts via ON CONFLICT (id)', async () => { + mockQuery.mockResolvedValue({ rowCount: 1, rows: [] }); + + const action = { + id: 'push-1', + timestamp: 1234, + type: 'push', + error: false, + blocked: true, + allowPush: false, + authorised: false, + canceled: false, + rejected: false, + } as unknown as Parameters[0]; + + await writeAudit(action); + + const [sql] = mockQuery.mock.calls[0]; + expect(sql).toContain('ON CONFLICT (id) DO UPDATE'); + }); + }); + + describe('reject', () => { + it('persists rejection payload onto data JSONB', async () => { + const rejection = { + reason: 'fails policy', + timestamp: new Date('2026-05-11T00:00:00Z'), + reviewer: { username: 'r', reviewerEmail: 'r@example.com' }, + }; + + // First call: getPush → resolves to a row whose data is the action. + // Second call: writeAudit upsert. + mockQuery + .mockResolvedValueOnce({ + rowCount: 1, + rows: [{ data: { id: 'p1', authorised: false, canceled: false, rejected: false } }], + }) + .mockResolvedValueOnce({ rowCount: 1, rows: [] }); + + const result = await reject('p1', rejection as never); + + expect(result).toEqual({ message: 'reject p1' }); + + // The upsert call serializes the action (with rejection assigned) into + // the final query parameter as JSON text. + const upsertParams = mockQuery.mock.calls[1][1] as unknown[]; + const dataJson = JSON.parse(upsertParams[9] as string); + expect(dataJson).toMatchObject({ + id: 'p1', + rejected: true, + authorised: false, + canceled: false, + rejection: { reason: 'fails policy' }, + }); + }); + + it('throws if push is not found', async () => { + mockQuery.mockResolvedValue({ rowCount: 0, rows: [] }); + + await expect(reject('missing', {} as never)).rejects.toThrow('push missing not found'); + }); + }); +}); diff --git a/test/db/postgres/repo.integration.test.ts b/test/db/postgres/repo.integration.test.ts new file mode 100644 index 000000000..04856adef --- /dev/null +++ b/test/db/postgres/repo.integration.test.ts @@ -0,0 +1,173 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect } from 'vitest'; +import { + createRepo, + getRepo, + getRepoByUrl, + getRepoById, + getRepos, + addUserCanPush, + addUserCanAuthorise, + removeUserCanPush, + removeUserCanAuthorise, + deleteRepo, +} from '../../../src/db/postgres/repo'; +import { Repo } from '../../../src/db/types'; + +const shouldRunPostgresTests = process.env.RUN_POSTGRES_TESTS === 'true'; + +const createTestRepo = (overrides: Partial = {}): Repo => { + const id = Date.now() + Math.floor(Math.random() * 10_000); + return new Repo( + overrides.project ?? 'test-project', + overrides.name ?? `repo-${id}`, + overrides.url ?? `https://github.com/test-project/repo-${id}.git`, + overrides.users ?? { canPush: [], canAuthorise: [] }, + ); +}; + +describe.runIf(shouldRunPostgresTests)('PostgreSQL Repo Integration Tests', () => { + describe('createRepo', () => { + it('persists the row and stamps a generated _id', async () => { + const repo = createTestRepo({ name: 'create-test', url: 'https://example.com/x.git' }); + const created = await createRepo(repo); + + expect(created._id).toBeDefined(); + expect(created._id).toMatch(/^[0-9a-f-]{36}$/i); + + const fromDb = await getRepoByUrl('https://example.com/x.git'); + expect(fromDb?.name).toBe('create-test'); + }); + }); + + describe('getRepo / getRepoByUrl / getRepoById', () => { + it('finds by name (lower-cased lookup)', async () => { + await createRepo(createTestRepo({ name: 'findme', url: 'https://example.com/findme.git' })); + const found = await getRepo('FINDME'); + expect(found?.name).toBe('findme'); + }); + + it('finds by url exactly', async () => { + const url = 'https://example.com/url-test.git'; + await createRepo(createTestRepo({ name: 'url-test', url })); + const found = await getRepoByUrl(url); + expect(found?.url).toBe(url); + }); + + it('finds by _id', async () => { + const created = await createRepo( + createTestRepo({ name: 'id-test', url: 'https://example.com/id-test.git' }), + ); + const fromDb = await getRepoById(created._id as string); + expect(fromDb?.url).toBe('https://example.com/id-test.git'); + }); + + it('returns null when nothing matches', async () => { + expect(await getRepo('does-not-exist')).toBeNull(); + expect(await getRepoByUrl('https://nope.example/x.git')).toBeNull(); + }); + }); + + describe('getRepos', () => { + it('returns the seeded repos', async () => { + await createRepo(createTestRepo({ name: 'list-1', url: 'https://example.com/l1.git' })); + await createRepo(createTestRepo({ name: 'list-2', url: 'https://example.com/l2.git' })); + + const all = await getRepos(); + const names = all.map((r) => r.name); + expect(names).toEqual(expect.arrayContaining(['list-1', 'list-2'])); + }); + }); + + describe('permission JSONB — issue #1497 must-fix', () => { + it('starts with empty arrays', async () => { + const created = await createRepo( + createTestRepo({ name: 'perm-start', url: 'https://example.com/ps.git' }), + ); + const fromDb = await getRepoById(created._id as string); + expect(fromDb?.users.canPush).toEqual([]); + expect(fromDb?.users.canAuthorise).toEqual([]); + }); + + it('adds a user without duplication', async () => { + const created = await createRepo( + createTestRepo({ name: 'perm-add', url: 'https://example.com/pa.git' }), + ); + const id = created._id as string; + + await addUserCanPush(id, 'Alice'); + await addUserCanPush(id, 'alice'); // duplicate (after lower-casing) + + const fromDb = await getRepoById(id); + expect(fromDb?.users.canPush).toEqual(['alice']); + }); + + it('removes the last user, leaving an empty array (NOT null)', async () => { + const created = await createRepo( + createTestRepo({ name: 'perm-remove', url: 'https://example.com/pr.git' }), + ); + const id = created._id as string; + + await addUserCanPush(id, 'bob'); + await removeUserCanPush(id, 'bob'); + + const fromDb = await getRepoById(id); + // This is the core invariant from issue #1497. + expect(fromDb?.users.canPush).toEqual([]); + expect(fromDb?.users.canPush).not.toBeNull(); + }); + + it('applies the same invariant to canAuthorise', async () => { + const created = await createRepo( + createTestRepo({ name: 'auth-remove', url: 'https://example.com/ar.git' }), + ); + const id = created._id as string; + + await addUserCanAuthorise(id, 'reviewer'); + await removeUserCanAuthorise(id, 'reviewer'); + + const fromDb = await getRepoById(id); + expect(fromDb?.users.canAuthorise).toEqual([]); + expect(fromDb?.users.canAuthorise).not.toBeNull(); + }); + + it('keeps other users intact when removing one', async () => { + const created = await createRepo( + createTestRepo({ name: 'multi-perm', url: 'https://example.com/mp.git' }), + ); + const id = created._id as string; + + await addUserCanPush(id, 'alice'); + await addUserCanPush(id, 'bob'); + await removeUserCanPush(id, 'alice'); + + const fromDb = await getRepoById(id); + expect(fromDb?.users.canPush).toEqual(['bob']); + }); + }); + + describe('deleteRepo', () => { + it('deletes by _id', async () => { + const created = await createRepo( + createTestRepo({ name: 'del', url: 'https://example.com/del.git' }), + ); + await deleteRepo(created._id as string); + expect(await getRepoById(created._id as string)).toBeNull(); + }); + }); +}); diff --git a/test/db/postgres/repo.test.ts b/test/db/postgres/repo.test.ts new file mode 100644 index 000000000..6788e046e --- /dev/null +++ b/test/db/postgres/repo.test.ts @@ -0,0 +1,112 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +const mockQuery = vi.fn(); + +vi.mock('../../../src/db/postgres/helper', () => ({ + query: mockQuery, +})); + +describe('PostgreSQL - Repo', async () => { + const { + getRepo, + getRepoById, + createRepo, + addUserCanPush, + removeUserCanPush, + removeUserCanAuthorise, + } = await import('../../../src/db/postgres/repo'); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('read normalization', () => { + it('returns empty arrays when stored users is null', async () => { + mockQuery.mockResolvedValue({ + rowCount: 1, + rows: [ + { + _id: 'r-1', + project: 'p', + name: 'n', + url: 'https://example.com/p/n', + users: null, + }, + ], + }); + + const repo = await getRepoById('r-1'); + expect(repo?.users.canPush).toEqual([]); + expect(repo?.users.canAuthorise).toEqual([]); + }); + + it('lower-cases the name on getRepo', async () => { + mockQuery.mockResolvedValue({ rowCount: 0, rows: [] }); + await getRepo('MixedCase'); + expect(mockQuery.mock.calls[0][1]).toEqual(['mixedcase']); + }); + }); + + describe('createRepo', () => { + it('serialises default users JSONB and stamps _id from RETURNING', async () => { + mockQuery.mockResolvedValue({ rowCount: 1, rows: [{ _id: 'generated-uuid' }] }); + + const created = await createRepo({ + project: 'finos', + name: 'git-proxy', + url: 'https://github.com/finos/git-proxy.git', + users: { canPush: [], canAuthorise: [] }, + } as never); + + expect(created._id).toBe('generated-uuid'); + const params = mockQuery.mock.calls[0][1] as unknown[]; + // Last param is the JSONB string for users. + expect(JSON.parse(params[3] as string)).toEqual({ canPush: [], canAuthorise: [] }); + }); + }); + + describe('add/remove user — empty array invariant (issue #1497)', () => { + it('lower-cases user on add', async () => { + mockQuery.mockResolvedValue({ rowCount: 1, rows: [] }); + await addUserCanPush('r-1', 'Bob'); + const params = mockQuery.mock.calls[0][1] as unknown[]; + expect(params).toContain('bob'); + }); + + it('removeUserCanPush coalesces filtered array to [] when last user leaves', () => { + // The whole point of the issue: the SQL fragment must coalesce a NULL + // aggregate result back to '[]'::jsonb so the array does not collapse + // to null when the last user is removed. + mockQuery.mockResolvedValue({ rowCount: 1, rows: [] }); + return removeUserCanPush('r-1', 'bob').then(() => { + const [sql] = mockQuery.mock.calls[0]; + expect(sql).toContain('coalesce('); + expect(sql).toContain("'[]'::jsonb"); + }); + }); + + it('removeUserCanAuthorise applies the same empty-array coalesce', async () => { + mockQuery.mockResolvedValue({ rowCount: 1, rows: [] }); + await removeUserCanAuthorise('r-1', 'bob'); + const [sql] = mockQuery.mock.calls[0]; + expect(sql).toContain('coalesce('); + expect(sql).toContain("'[]'::jsonb"); + }); + }); +}); diff --git a/test/db/postgres/users.integration.test.ts b/test/db/postgres/users.integration.test.ts new file mode 100644 index 000000000..a489a9a64 --- /dev/null +++ b/test/db/postgres/users.integration.test.ts @@ -0,0 +1,173 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect } from 'vitest'; +import { + createUser, + findUser, + findUserByEmail, + findUserByOIDC, + getUsers, + updateUser, + deleteUser, +} from '../../../src/db/postgres/users'; +import { User } from '../../../src/db/types'; + +const shouldRunPostgresTests = process.env.RUN_POSTGRES_TESTS === 'true'; + +describe.runIf(shouldRunPostgresTests)('PostgreSQL Users Integration Tests', () => { + const createTestUser = (overrides: Partial = {}): User => { + const timestamp = Date.now(); + return new User( + overrides.username || `testuser-${timestamp}`, + overrides.password || 'hashedpassword123', + overrides.gitAccount || `git-${timestamp}`, + overrides.email || `test-${timestamp}@example.com`, + overrides.admin ?? false, + overrides.oidcId || null, + ); + }; + + describe('createUser', () => { + it('lowercases username and email on insert', async () => { + const user = createTestUser({ username: 'CreateUser', email: 'Create@Example.COM' }); + await createUser(user); + + const found = await findUser('createuser'); + expect(found?.username).toBe('createuser'); + expect(found?.email).toBe('create@example.com'); + }); + }); + + describe('findUser', () => { + it('finds a user by username (case-insensitive)', async () => { + await createUser(createTestUser({ username: 'findme' })); + const result = await findUser('FINDME'); + expect(result?.username).toBe('findme'); + }); + + it('returns null for a non-existent user', async () => { + expect(await findUser('non-existent-user')).toBeNull(); + }); + }); + + describe('findUserByEmail', () => { + it('finds a user by email (case-insensitive)', async () => { + await createUser(createTestUser({ email: 'findbyemail@test.com' })); + const result = await findUserByEmail('FindByEmail@TEST.com'); + expect(result?.email).toBe('findbyemail@test.com'); + }); + + it('returns null for a non-existent email', async () => { + expect(await findUserByEmail('nonexistent@test.com')).toBeNull(); + }); + }); + + describe('findUserByOIDC', () => { + it('finds a user by OIDC ID', async () => { + const oidcId = `oidc-${Date.now()}`; + await createUser(createTestUser({ oidcId })); + const result = await findUserByOIDC(oidcId); + expect(result?.oidcId).toBe(oidcId); + }); + + it('returns null for a non-existent OIDC ID', async () => { + expect(await findUserByOIDC('non-existent-oidc')).toBeNull(); + }); + }); + + describe('getUsers', () => { + it('retrieves users without their password', async () => { + await createUser(createTestUser({ username: 'getusers1' })); + await createUser(createTestUser({ username: 'getusers2' })); + + const result = await getUsers(); + + expect(result.length).toBeGreaterThanOrEqual(2); + result.forEach((user) => { + // Mirrors mongo's projection — passwords are null in list responses. + expect(user.password).toBeNull(); + }); + }); + + it('filters by username (lowercased)', async () => { + await createUser(createTestUser({ username: 'filteruser', email: 'filter@test.com' })); + await createUser(createTestUser({ username: 'otheruser', email: 'other@test.com' })); + + const result = await getUsers({ username: 'FilterUser' }); + + expect(result.length).toBe(1); + expect(result[0].username).toBe('filteruser'); + }); + + it('filters by email (lowercased)', async () => { + await createUser(createTestUser({ username: 'emailfilter', email: 'unique-email@test.com' })); + + const result = await getUsers({ email: 'Unique-Email@TEST.com' }); + + expect(result.length).toBe(1); + expect(result[0].email).toBe('unique-email@test.com'); + }); + }); + + describe('updateUser', () => { + it('updates by username and lowercases new fields', async () => { + await createUser(createTestUser({ username: 'updateme', admin: false })); + + await updateUser({ username: 'UpdateMe', admin: true }); + + const updated = await findUser('updateme'); + expect(updated?.admin).toBe(true); + }); + + it('updates by _id when provided', async () => { + await createUser(createTestUser({ username: 'updatebyid' })); + const created = await findUser('updatebyid'); + await updateUser({ _id: created?._id as string, gitAccount: 'new-git-account' }); + + const updated = await findUser('updatebyid'); + expect(updated?.gitAccount).toBe('new-git-account'); + }); + + it('lowercases email during update', async () => { + await createUser(createTestUser({ username: 'lowercaseupdate' })); + await updateUser({ username: 'LowerCaseUpdate', email: 'NEW@EMAIL.COM' }); + + const updated = await findUser('lowercaseupdate'); + expect(updated?.email).toBe('new@email.com'); + }); + + it('inserts when no row matches and only username is provided', async () => { + await updateUser({ + username: 'brand-new-user', + email: 'brand-new@example.com', + gitAccount: 'brand-new-git', + }); + + const inserted = await findUser('brand-new-user'); + expect(inserted?.email).toBe('brand-new@example.com'); + expect(inserted?.gitAccount).toBe('brand-new-git'); + }); + }); + + describe('deleteUser', () => { + it('deletes a user by username (case-insensitive)', async () => { + await createUser(createTestUser({ username: 'deleteme' })); + await deleteUser('DeleteMe'); + expect(await findUser('deleteme')).toBeNull(); + }); + }); +}); diff --git a/test/db/postgres/users.test.ts b/test/db/postgres/users.test.ts new file mode 100644 index 000000000..ee92792c3 --- /dev/null +++ b/test/db/postgres/users.test.ts @@ -0,0 +1,121 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +const mockQuery = vi.fn(); + +vi.mock('../../../src/db/postgres/helper', () => ({ + query: mockQuery, +})); + +describe('PostgreSQL - Users', async () => { + const { findUser, findUserByEmail, createUser, deleteUser, getUsers, updateUser } = + await import('../../../src/db/postgres/users'); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('case insensitivity', () => { + it('lower-cases username on findUser', async () => { + mockQuery.mockResolvedValue({ rowCount: 0, rows: [] }); + await findUser('Mixed-Case'); + expect(mockQuery.mock.calls[0][1]).toEqual(['mixed-case']); + }); + + it('lower-cases email on findUserByEmail', async () => { + mockQuery.mockResolvedValue({ rowCount: 0, rows: [] }); + await findUserByEmail('USER@Example.COM'); + expect(mockQuery.mock.calls[0][1]).toEqual(['user@example.com']); + }); + + it('lower-cases username/email on createUser', async () => { + mockQuery.mockResolvedValue({ rowCount: 1, rows: [] }); + await createUser({ + username: 'Alice', + password: 'pw', + gitAccount: 'alice-git', + email: 'Alice@Example.com', + admin: false, + } as never); + + const params = mockQuery.mock.calls[0][1] as unknown[]; + expect(params[0]).toBe('alice'); + expect(params[1]).toBe('alice@example.com'); + }); + + it('lower-cases username on deleteUser', async () => { + mockQuery.mockResolvedValue({ rowCount: 1, rows: [] }); + await deleteUser('Alice'); + expect(mockQuery.mock.calls[0][1]).toEqual(['alice']); + }); + }); + + describe('getUsers', () => { + it('omits password from the SELECT projection', async () => { + mockQuery.mockResolvedValue({ rowCount: 0, rows: [] }); + await getUsers({}); + const [sql] = mockQuery.mock.calls[0]; + expect(sql).toContain('NULL::text AS password'); + }); + }); + + describe('updateUser', () => { + it('updates by _id when provided', async () => { + mockQuery.mockResolvedValue({ rowCount: 1, rows: [] }); + + await updateUser({ _id: 'abc-123', displayName: 'Alice A.' } as never); + + const [sql, params] = mockQuery.mock.calls[0]; + expect(sql).toContain('UPDATE users SET'); + expect(sql).toContain('WHERE _id = $'); + expect(params).toEqual(['Alice A.', 'abc-123']); + }); + + it('falls back to username and inserts when no row matches', async () => { + mockQuery + .mockResolvedValueOnce({ rowCount: 0, rows: [] }) // UPDATE matches nothing + .mockResolvedValueOnce({ rowCount: 1, rows: [] }); // INSERT + + await updateUser({ username: 'new-user', email: 'new@example.com', admin: true } as never); + + expect(mockQuery).toHaveBeenCalledTimes(2); + const [updateSql] = mockQuery.mock.calls[0]; + const [insertSql, insertParams] = mockQuery.mock.calls[1]; + expect(updateSql).toContain('WHERE username = $'); + expect(insertSql).toContain('INSERT INTO users'); + // username is the first INSERT param. + expect(insertParams[0]).toBe('new-user'); + }); + + it('uses a separate username parameter for the username-keyed UPDATE filter', async () => { + mockQuery.mockResolvedValue({ rowCount: 1, rows: [] }); + + await updateUser({ username: 'ExistingUser', email: 'updated@example.com' } as never); + + const [updateSql, updateParams] = mockQuery.mock.calls[0]; + expect(updateSql).toContain('UPDATE users SET username = $1, email = $2 WHERE username = $3'); + expect(updateParams).toEqual(['existinguser', 'updated@example.com', 'existinguser']); + }); + + it('throws if neither _id nor username is supplied', async () => { + await expect(updateUser({ admin: true } as never)).rejects.toThrow( + 'updateUser requires either _id or username', + ); + }); + }); +}); diff --git a/test/setup-integration-postgres.ts b/test/setup-integration-postgres.ts new file mode 100644 index 000000000..94d1befc4 --- /dev/null +++ b/test/setup-integration-postgres.ts @@ -0,0 +1,95 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { beforeAll, afterAll, afterEach } from 'vitest'; +import { Client } from 'pg'; + +import { resetConnection } from '../src/db/postgres/helper'; +import { invalidateCache } from '../src/config'; + +const DEFAULT_CONNECTION_STRING = 'postgresql://postgres:postgres@localhost:5432/git_proxy_test'; +const APP_TABLES = ['pushes', 'repos', 'users']; +const SESSION_TABLE = 'session'; + +let client: Client | null = null; + +const getConnectionString = () => + process.env.GIT_PROXY_POSTGRES_CONNECTION_STRING || DEFAULT_CONNECTION_STRING; + +const shouldRun = () => process.env.RUN_POSTGRES_TESTS === 'true'; + +beforeAll(async () => { + if (!shouldRun()) return; + + try { + client = new Client({ connectionString: getConnectionString() }); + await client.connect(); + console.log(`PostgreSQL connection established for integration tests`); + } catch (error) { + console.error('Failed to connect to PostgreSQL:', error); + throw error; + } +}); + +afterEach(async () => { + if (client) { + // Truncate app tables so each test starts from a known clean state. + // RESTART IDENTITY isn't needed (UUID PKs), but CASCADE keeps us future- + // proof in case a follow-up commit adds FK relationships. + try { + await client.query(`TRUNCATE TABLE ${APP_TABLES.join(', ')} CASCADE`); + } catch (error) { + console.warn('Failed to truncate app tables during integration test cleanup', error); + } + try { + // The session table is created lazily by connect-pg-simple; ignore the + // error if it does not yet exist. + await client.query(`TRUNCATE TABLE "${SESSION_TABLE}"`); + } catch { + // intentionally swallowed — table may not exist yet + } + } + + try { + await resetConnection(); + } catch (error) { + console.warn('Failed to reset Postgres pool during integration test cleanup', error); + } + invalidateCache(); +}); + +afterAll(async () => { + try { + await resetConnection(); + } catch (error) { + console.warn('Failed to reset Postgres pool during integration test cleanup', error); + } + + if (client) { + try { + for (const table of APP_TABLES) { + await client.query(`DROP TABLE IF EXISTS ${table} CASCADE`); + } + await client.query(`DROP TABLE IF EXISTS "${SESSION_TABLE}"`); + } catch (error) { + console.warn('Failed to drop Postgres test tables during cleanup', error); + } + await client.end(); + client = null; + } + + console.log('PostgreSQL integration test cleanup complete'); +}); diff --git a/vitest.config.integration.postgres.ts b/vitest.config.integration.postgres.ts new file mode 100644 index 000000000..aa988df37 --- /dev/null +++ b/vitest.config.integration.postgres.ts @@ -0,0 +1,40 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import path from 'path'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['test/db/postgres/**/*.integration.test.ts'], + testTimeout: 30000, + hookTimeout: 10000, + setupFiles: ['test/setup-integration-postgres.ts'], + pool: 'forks', + poolOptions: { + forks: { + singleFork: true, + }, + }, + env: { + NODE_ENV: 'test', + RUN_POSTGRES_TESTS: 'true', + CONFIG_FILE: path.resolve(__dirname, 'test-integration.postgres.proxy.config.json'), + GIT_PROXY_POSTGRES_CONNECTION_STRING: + 'postgresql://postgres:postgres@localhost:5432/git_proxy_test', + }, + }, +}); diff --git a/website/docs/architecture/architecture.md b/website/docs/architecture/architecture.md index e91a3d457..99678be5b 100644 --- a/website/docs/architecture/architecture.md +++ b/website/docs/architecture/architecture.md @@ -412,10 +412,41 @@ Sample values: #### `sink` -List of database sources. The first source with `enabled` set to `true` will be used. Currently, MongoDB and filesystem databases ([NeDB](https://www.npmjs.com/package/@seald-io/nedb)) are supported. By default, the filesystem database is used. +List of database sources. The first source with `enabled` set to `true` will be used. GitProxy supports three sink backends: + +- **`fs`** — filesystem-backed [NeDB](https://www.npmjs.com/package/@seald-io/nedb). Default. Suitable for single-process deployments. +- **`mongo`** — MongoDB via `connect-mongo` for session storage. +- **`postgres`** — PostgreSQL via [`pg`](https://node-postgres.com/) + [`connect-pg-simple`](https://github.com/voxpelli/node-connect-pg-simple) for session storage. Each entry has its own unique configuration parameters. +##### PostgreSQL configuration + +The `postgres` backend stores `users`, `repos`, `pushes`, and the `connect-pg-simple` `session` table in a single PostgreSQL database. The required tables are created on startup with `CREATE TABLE IF NOT EXISTS`, so pointing the proxy at an empty database is enough to get running — no migration tooling is required for the initial setup. + +```json +{ + "sink": [ + { + "type": "postgres", + "connectionString": "postgresql://user:pass@host:5432/gitproxy", + "enabled": true + } + ] +} +``` + +If `connectionString` is omitted on the config entry, GitProxy falls back to the `GIT_PROXY_POSTGRES_CONNECTION_STRING` environment variable. This mirrors the behaviour of the mongo backend's `GIT_PROXY_MONGO_CONNECTION_STRING`. + +Notes and current limitations (issue #1497, v1): + +- Schema is bootstrapped via `CREATE TABLE IF NOT EXISTS` at startup. No formal migration mechanism ships with this release. +- Repo permissions (`canPush` / `canAuthorise`) are stored as a JSONB column on the `repos` table. A future PR may normalise these into a `repo_users` join table. +- No data migration utility from `fs` or `mongo` to `postgres` — copy data yourself if needed. +- No AWS RDS IAM authentication helper (the mongo backend has one via `AWS_CREDENTIAL_PROVIDER`); use a standard connection string for v1. +- Only the `connectionString` form is supported; split `PGHOST`/`PGPORT`/`PGUSER`/`PGPASSWORD`/`PGDATABASE` env vars are not consulted. +- If `postgres` is selected as the active sink and the connection string cannot be resolved, GitProxy refuses to start rather than silently falling back to an in-memory session store. + Extending GitProxy to support other databases requires adding the relevant handlers and setup to the [`/src/db`](https://github.com/finos/git-proxy/blob/main/src/db/) directory. Feel free to [open an issue](https://github.com/finos/git-proxy/issues) requesting support for any specific databases - or [open a PR](https://github.com/finos/git-proxy/pulls) with the desired changes! #### `authentication`