From a0a642bc14583eb810b3edc126a86922d4d7d878 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C4=86ori=C4=87?= Date: Mon, 11 May 2026 16:13:21 +0200 Subject: [PATCH 01/23] chore(db): scaffold src/db/postgres directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stub modules for the upcoming Postgres sink adapter. No behavior yet — each adapter file is a placeholder so subsequent commits can land each concern (helper, pushes, repos, users) in isolation. Refs #1497 --- src/db/postgres/helper.ts | 18 ++++++++++++++++++ src/db/postgres/index.ts | 21 +++++++++++++++++++++ src/db/postgres/pushes.ts | 18 ++++++++++++++++++ src/db/postgres/repo.ts | 18 ++++++++++++++++++ src/db/postgres/users.ts | 18 ++++++++++++++++++ 5 files changed, 93 insertions(+) create mode 100644 src/db/postgres/helper.ts create mode 100644 src/db/postgres/index.ts create mode 100644 src/db/postgres/pushes.ts create mode 100644 src/db/postgres/repo.ts create mode 100644 src/db/postgres/users.ts diff --git a/src/db/postgres/helper.ts b/src/db/postgres/helper.ts new file mode 100644 index 000000000..64d93f9d3 --- /dev/null +++ b/src/db/postgres/helper.ts @@ -0,0 +1,18 @@ +/** + * 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. + */ + +// Stub — implemented in a subsequent commit. +export const getSessionStore = (): undefined => undefined; diff --git a/src/db/postgres/index.ts b/src/db/postgres/index.ts new file mode 100644 index 000000000..c10d716ee --- /dev/null +++ b/src/db/postgres/index.ts @@ -0,0 +1,21 @@ +/** + * 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'; + +export const { getSessionStore } = helper; + +// pushes / repo / users adapters are wired in subsequent commits. diff --git a/src/db/postgres/pushes.ts b/src/db/postgres/pushes.ts new file mode 100644 index 000000000..0686f7998 --- /dev/null +++ b/src/db/postgres/pushes.ts @@ -0,0 +1,18 @@ +/** + * 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. + */ + +// Stub — implemented in a subsequent commit. +export {}; diff --git a/src/db/postgres/repo.ts b/src/db/postgres/repo.ts new file mode 100644 index 000000000..0686f7998 --- /dev/null +++ b/src/db/postgres/repo.ts @@ -0,0 +1,18 @@ +/** + * 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. + */ + +// Stub — implemented in a subsequent commit. +export {}; diff --git a/src/db/postgres/users.ts b/src/db/postgres/users.ts new file mode 100644 index 000000000..0686f7998 --- /dev/null +++ b/src/db/postgres/users.ts @@ -0,0 +1,18 @@ +/** + * 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. + */ + +// Stub — implemented in a subsequent commit. +export {}; From b7ff225101d847fc3832279a9f564dafdd475c1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C4=86ori=C4=87?= Date: Mon, 11 May 2026 16:14:07 +0200 Subject: [PATCH 02/23] feat(config): add postgres variant to sink schema Adds a third `oneOf` entry for the `database` definition in the JSON schema and regenerates the TypeScript config types. `connectionString` is optional at the schema level so an env-var fallback can supply it at runtime (added in a follow-up commit). Refs #1497 --- config.schema.json | 14 ++++++++++++++ src/config/generated/config.ts | 10 +++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) 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/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'], }; From 52db0b22bbbc4ef5f2e7c1af83ec8fd6cb8812c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C4=86ori=C4=87?= Date: Mon, 11 May 2026 16:16:24 +0200 Subject: [PATCH 03/23] feat(config): env fallback for postgres connection string Adds `GIT_PROXY_POSTGRES_CONNECTION_STRING` to `serverConfig` and wires the postgres branch of `getDatabase()` to populate `connectionString` from it when the user config omits one. Mirrors the existing pattern used for `GIT_PROXY_MONGO_CONNECTION_STRING`. Refs #1497 --- src/config/env.ts | 2 ++ src/config/index.ts | 4 ++++ src/config/types.ts | 1 + 3 files changed, 7 insertions(+) diff --git a/src/config/env.ts b/src/config/env.ts index 1534dad96..83e81bd2b 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -23,6 +23,7 @@ const { GIT_PROXY_UI_PORT = 8080, 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 = { @@ -32,4 +33,5 @@ export const serverConfig: ServerConfig = { GIT_PROXY_UI_PORT, GIT_PROXY_COOKIE_SECRET, GIT_PROXY_MONGO_CONNECTION_STRING, + GIT_PROXY_POSTGRES_CONNECTION_STRING, }; 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 300deb4cf..8d6d90b29 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -23,6 +23,7 @@ export type ServerConfig = { GIT_PROXY_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 { From ba5c752d1a4fd0c1144ae1b1d58c862507e1edd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C4=86ori=C4=87?= Date: Mon, 11 May 2026 16:19:34 +0200 Subject: [PATCH 04/23] feat(config): add disabled postgres entry to default sink list Documents the new sink type in the shipped default config. Disabled by default so the `fs` backend continues to be selected unless an operator explicitly enables postgres. Refs #1497 --- proxy.config.json | 5 +++++ 1 file changed, 5 insertions(+) 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": [ From 88fb6328c4fd84fc136678abc4f0f37e0c5e017a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C4=86ori=C4=87?= Date: Mon, 11 May 2026 16:20:39 +0200 Subject: [PATCH 05/23] chore(deps): add pg and connect-pg-simple MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runtime deps for the new PostgreSQL sink adapter: - `pg` — node-postgres client + Pool, used by the adapter modules. - `connect-pg-simple` — express-session store backed by Postgres, used to persist UI sessions when the postgres sink is active. - `@types/pg`, `@types/connect-pg-simple` — TypeScript definitions. Refs #1497 --- package-lock.json | 178 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 4 ++ 2 files changed, 181 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index b99ef2897..13f3200d2 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", @@ -4218,6 +4222,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, @@ -4439,6 +4455,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" @@ -6508,6 +6536,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", @@ -11800,6 +11840,95 @@ "dev": true, "license": "MIT" }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "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.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "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, @@ -11944,6 +12073,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": { @@ -13140,7 +13308,6 @@ }, "node_modules/split2": { "version": "4.2.0", - "dev": true, "license": "ISC", "engines": { "node": ">= 10.x" @@ -14953,6 +15120,15 @@ "typedarray-to-buffer": "^3.1.5" } }, + "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 23682c71a..b46a2b2bf 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,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 +132,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 +151,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 +163,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", From d95a04f7ab26c537d5f91b736502164a1644e5a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C4=86ori=C4=87?= Date: Mon, 11 May 2026 16:22:19 +0200 Subject: [PATCH 06/23] feat(db/postgres): pool and schema bootstrap helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the foundation shared by the postgres adapter modules: - `connect()` lazily constructs a `pg.Pool` from the configured connection string and runs an idempotent `CREATE TABLE IF NOT EXISTS` bootstrap exactly once per process. All adapter modules acquire the pool through this function, so the schema is in place before any query is executed against `users` / `repos` / `pushes`. - `query()` is a thin convenience wrapper that awaits `connect()` and delegates to `pool.query`. - `resetConnection()` tears down the pool and bootstrap latch — used by the integration test harness between suites. - `getSessionStore()` returns a `connect-pg-simple` store bound to the same pool. Per issue #1497 it MUST NOT silently return undefined when postgres is the active sink (express-session would silently fall back to MemoryStore), so a missing connection string throws instead. The schema covers the three application tables plus the indexes used by `getPushes` (timestamp DESC) and `getRepo` (name lookup). The session table is left to `connect-pg-simple` via `createTableIfMissing: true`. Refs #1497 --- src/db/postgres/helper.ts | 128 +++++++++++++++++++++++++++++++++++++- 1 file changed, 126 insertions(+), 2 deletions(-) diff --git a/src/db/postgres/helper.ts b/src/db/postgres/helper.ts index 64d93f9d3..193cc772c 100644 --- a/src/db/postgres/helper.ts +++ b/src/db/postgres/helper.ts @@ -14,5 +14,129 @@ * limitations under the License. */ -// Stub — implemented in a subsequent commit. -export const getSessionStore = (): undefined => undefined; +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, + }); +}; From 189b20daf133bbc4fa84e749d97ae68d020d0333 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C4=86ori=C4=87?= Date: Mon, 11 May 2026 16:23:19 +0200 Subject: [PATCH 07/23] feat(db/postgres): pushes adapter Implements the `Sink` push methods against the `pushes` table: - `getPushes`: filters by the same keys the mongo backend supports (error/blocked/allowPush/authorised/canceled/rejected/type) via a small allow-list mapping, then sorts `ORDER BY timestamp DESC` to preserve current backend ordering (issue #1497 must-fix). - `getPush` / `deletePush`: lookups by `id` PK. - `writeAudit`: upsert on `id` with the full Action serialized into the `data` JSONB column and the projection columns kept in sync. Throws `Invalid id` to match mongo behaviour. - `authorise` / `cancel` / `reject`: read-modify-write through `getPush` + `writeAudit`, identical to the mongo flow. `reject` assigns `action.rejection = rejection` so the persisted payload shape (reason / reviewer / timestamp) matches the existing backends. The Action class is reconstructed from the `data` JSONB via the existing `toClass` helper. Refs #1497 --- src/db/postgres/pushes.ts | 142 +++++++++++++++++++++++++++++++++++++- 1 file changed, 140 insertions(+), 2 deletions(-) diff --git a/src/db/postgres/pushes.ts b/src/db/postgres/pushes.ts index 0686f7998..6ad5840fd 100644 --- a/src/db/postgres/pushes.ts +++ b/src/db/postgres/pushes.ts @@ -14,5 +14,143 @@ * limitations under the License. */ -// Stub — implemented in a subsequent commit. -export {}; +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}` }; +}; From 379dc6713c5ae0235a6bff329e06a52827ea7ace Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C4=86ori=C4=87?= Date: Mon, 11 May 2026 16:26:06 +0200 Subject: [PATCH 08/23] feat(db/postgres): users adapter Implements the `Sink` user methods against the `users` table: - `findUser` / `findUserByEmail` / `findUserByOIDC`: lower-case the lookup keys to match the mongo and fs case-insensitivity behaviour. - `getUsers`: optional username / email filters with the same lower-casing; SELECT projects `password` away (matching mongo's `.project({ password: 0 })`). - `createUser`: insert with lower-cased username / email. - `deleteUser`: delete by lower-cased username. - `updateUser`: dynamic SET-builder that mirrors mongo's partial upsert. Identity is by `_id` when supplied, otherwise by `username`; if no matching row exists when keyed on username, a new row is inserted so callers can patch-or-create without two round trips. `_id` is exposed as an opaque string (UUID rendered as text) so the HTTP/UI contract is unchanged versus the mongo backend (which renders ObjectId via `.toString()`). Refs #1497 --- src/db/postgres/users.ts | 161 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 159 insertions(+), 2 deletions(-) diff --git a/src/db/postgres/users.ts b/src/db/postgres/users.ts index 0686f7998..be06a3840 100644 --- a/src/db/postgres/users.ts +++ b/src/db/postgres/users.ts @@ -14,5 +14,162 @@ * limitations under the License. */ -// Stub — implemented in a subsequent commit. -export {}; +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. + 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, + ], + ); +}; From 98ae5a62fef811220254e84a276c2fdea1ad5c14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C4=86ori=C4=87?= Date: Mon, 11 May 2026 16:28:08 +0200 Subject: [PATCH 09/23] feat(db/postgres): repo adapter with jsonb permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the `Sink` repo methods against the `repos` table. Permissions (`canPush` / `canAuthorise`) are stored as a single JSONB column matching the existing mongo/fs shape, with a TODO marker pointing at a future migration to a normalized `repo_users` join table (open question called out in issue #1497). Notable details: - `addUser*` use `jsonb_set` + a DISTINCT subquery so re-adding an existing user is a no-op, matching the fs adapter's `includes` guard. - `removeUser*` use `coalesce(..., '[]'::jsonb)` around the `array_agg` filter so that removing the last user leaves the array as `[]`, not `null` — issue #1497 explicitly requires this and the reader path additionally defaults `null` arrays to `[]` for belt-and-braces resilience against legacy rows. - `getRepos` accepts the same query keys as the mongo backend (name / project / url) with the same lower-casing on `name`. - `createRepo` returns the row with `_id` populated (UUID rendered as text), matching the mongo backend's contract. Refs #1497 --- src/db/postgres/repo.ts | 161 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 159 insertions(+), 2 deletions(-) diff --git a/src/db/postgres/repo.ts b/src/db/postgres/repo.ts index 0686f7998..045333554 100644 --- a/src/db/postgres/repo.ts +++ b/src/db/postgres/repo.ts @@ -14,5 +14,162 @@ * limitations under the License. */ -// Stub — implemented in a subsequent commit. -export {}; +// 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]); +}; From cf62feac59fa0821e37456be8a12fee79cd9770a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C4=86ori=C4=87?= Date: Mon, 11 May 2026 16:28:51 +0200 Subject: [PATCH 10/23] feat(db): wire postgres adapter into sink dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the `postgres` branch to the runtime `start()` selector so a sink config of `type: 'postgres'` resolves to the new adapter modules, and re-exports the full `Sink` surface from `src/db/postgres/index.ts`. The `getSessionStore` return type on the `Sink` interface and on the top-level `src/db/index.ts` re-export is widened from `MongoDBStore | undefined` to `MongoDBStore | Store | undefined`, where `Store` is the express-session base class — `connect-pg-simple` extends it. This keeps the existing mongo / fs callers type-compatible. Refs #1497 --- src/db/index.ts | 7 ++++++- src/db/postgres/index.ts | 28 +++++++++++++++++++++++++++- src/db/types.ts | 3 ++- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/db/index.ts b/src/db/index.ts index f9048fb3b..e67fdbf0a 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,7 @@ 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 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/index.ts b/src/db/postgres/index.ts index c10d716ee..d4626e577 100644 --- a/src/db/postgres/index.ts +++ b/src/db/postgres/index.ts @@ -15,7 +15,33 @@ */ import * as helper from './helper'; +import * as pushes from './pushes'; +import * as repo from './repo'; +import * as users from './users'; export const { getSessionStore } = helper; -// pushes / repo / users adapters are wired in subsequent commits. +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/types.ts b/src/db/types.ts index 74ead38b5..fda28245c 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,7 @@ export interface PublicUser { } export interface Sink { - getSessionStore: () => MongoDBStore | undefined; + getSessionStore: () => MongoDBStore | Store | undefined; getPushes: (query: Partial) => Promise; writeAudit: (action: Action) => Promise; getPush: (id: string) => Promise; From 5971316ca46f8798a05eacd55e4b51ed56b04021 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C4=86ori=C4=87?= Date: Mon, 11 May 2026 16:30:19 +0200 Subject: [PATCH 11/23] fix(service): fail loudly if persistent session store is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per issue #1497 must-fix: when the active sink is one that promises a persistent session store (currently `mongo` or `postgres`), `db.getSessionStore()` returning undefined must NOT silently fall through to express-session's default `MemoryStore` — that store loses sessions on every restart and is unsafe in any multi-process deployment. `createApp` now resolves the store before registering the session middleware and throws if a persistent backend produced `undefined`. The `fs` backend is unaffected: it has always returned `undefined` deliberately, and falling back to MemoryStore there matches existing single-node-only fs semantics. --- src/service/index.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/service/index.ts b/src/service/index.ts index b8ee756b8..36b87e4f6 100644 --- a/src/service/index.ts +++ b/src/service/index.ts @@ -125,6 +125,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 @@ -134,9 +141,17 @@ async function createApp(proxy: Proxy): Promise { app.set('trust proxy', 1); app.use(limiter); + const backendType = config.getDatabase().type; + 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, From 77ee747a1f2160d451b07489eddab92cf8884ea2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C4=86ori=C4=87?= Date: Mon, 11 May 2026 16:36:02 +0200 Subject: [PATCH 12/23] test(db/postgres): unit tests for pushes adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mocks the `query` export from the postgres helper so the suite runs without a live database. Covers: - `getPushes` ordering — asserts the generated SQL contains `ORDER BY timestamp DESC` (issue #1497 must-fix). - `getPushes` column translation — `allowPush` filter maps to the `allow_push` snake_case column. - `getPushes` unknown filter keys are ignored (no spurious WHERE). - `getPush` returns null when the row is absent. - `writeAudit` throws `Invalid id` for non-string ids (mongo parity). - `writeAudit` upserts via `ON CONFLICT (id) DO UPDATE`. - `reject` writes a serialized Action into the `data` JSONB column with the `rejection` field populated — confirming the payload shape matches the existing backends. - `reject` throws when the push is missing. --- test/db/postgres/pushes.test.ts | 140 ++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 test/db/postgres/pushes.test.ts 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'); + }); + }); +}); From f9fbb3b3693cca174d8978140463b66a92ddf54c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C4=86ori=C4=87?= Date: Mon, 11 May 2026 16:36:53 +0200 Subject: [PATCH 13/23] test(db/postgres): unit tests for users adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers behaviour-critical paths: - case insensitivity: findUser / findUserByEmail / createUser / deleteUser all lower-case their lookup or stored values (parity with the mongo and fs adapters). - getUsers omits `password` from the projection (mirrors mongo's `.project({ password: 0 })`). - updateUser dispatches on `_id` vs `username`, and when the username-keyed UPDATE matches nothing it falls back to INSERT — this is the upsert semantics issue #1497 calls for. - updateUser throws when given neither `_id` nor `username`. --- test/db/postgres/users.test.ts | 111 +++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 test/db/postgres/users.test.ts diff --git a/test/db/postgres/users.test.ts b/test/db/postgres/users.test.ts new file mode 100644 index 000000000..1f25c5e4a --- /dev/null +++ b/test/db/postgres/users.test.ts @@ -0,0 +1,111 @@ +/** + * 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('throws if neither _id nor username is supplied', async () => { + await expect(updateUser({ admin: true } as never)).rejects.toThrow( + 'updateUser requires either _id or username', + ); + }); + }); +}); From 9e49a76e4bad228a3cbfe108e5d8edcdd80b0995 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C4=86ori=C4=87?= Date: Mon, 11 May 2026 16:37:56 +0200 Subject: [PATCH 14/23] test(db/postgres): unit tests for repo adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Targets the parity invariants called out in issue #1497: - `getRepoById` defaults a NULL `users` JSONB to empty arrays — guards against legacy or partial rows and matches the fs/mongo contract. - `getRepo` lower-cases the lookup name. - `createRepo` serialises the default `{canPush:[],canAuthorise:[]}` into the JSONB column and stamps the generated `_id` back onto the returned object. - `addUserCanPush` lower-cases the user value before storing. - `removeUserCanPush` and `removeUserCanAuthorise` emit a SQL fragment that wraps the filtered array in `coalesce(..., '[]'::jsonb)`, so the array remains `[]` when the last user is removed — this is the explicit must-fix from the issue, and an end-to-end check sits in the integration suite. --- test/db/postgres/repo.test.ts | 112 ++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 test/db/postgres/repo.test.ts 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"); + }); + }); +}); From 5013da6a3115d2bdd489ad578254bb785be64462 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C4=86ori=C4=87?= Date: Mon, 11 May 2026 16:39:14 +0200 Subject: [PATCH 15/23] test(db/postgres): unit tests for helper bootstrap and session store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mocks the `pg` Pool and `connect-pg-simple` constructors so the suite can exercise the helper without a real database. Covers: - `connect()` is concurrency-safe: many parallel calls share one Pool and run the bootstrap SQL exactly once. - the bootstrap SQL creates `users`, `repos`, and `pushes` (assertion via regex against the inlined statement). - bootstrap failure does not permanently latch the helper: the next `connect()` retries instead of returning the rejected promise. - `query()` surfaces the helpful error message when the configured connection string is missing. - `getSessionStore()` throws (not returns undefined) when the connection string is missing — the explicit must-fix from issue #1497 to prevent a silent MemoryStore fallback. - `getSessionStore()` constructs the `connect-pg-simple` store with `createTableIfMissing: true` and shares the helper's pool. Full suite: 791 unit tests passing (+27 new), zero regressions. --- test/db/postgres/helper.test.ts | 139 ++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 test/db/postgres/helper.test.ts diff --git a/test/db/postgres/helper.test.ts b/test/db/postgres/helper.test.ts new file mode 100644 index 000000000..f6a331443 --- /dev/null +++ b/test/db/postgres/helper.test.ts @@ -0,0 +1,139 @@ +/** + * 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); + } + }, +})); + +const getDatabaseMock = vi.fn(); +vi.mock('../../../src/config', () => ({ + getDatabase: getDatabaseMock, +})); + +describe('PostgreSQL - helper', async () => { + const { connect, query, resetConnection, getSessionStore } = + 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(); + }); + }); +}); From a17a9d15219c925acdd42ab3e79d3105b6cefb2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C4=86ori=C4=87?= Date: Mon, 11 May 2026 16:40:13 +0200 Subject: [PATCH 16/23] test(db/postgres): integration test harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the scaffolding for postgres-backed integration tests: - `vitest.config.integration.postgres.ts`: separate vitest config that scopes `include` to `test/db/postgres/**/*.integration.test.ts`, sets `RUN_POSTGRES_TESTS=true`, points `CONFIG_FILE` at the dedicated postgres test config, and uses a single-fork pool so the lazy pg.Pool is shared across the suite. Mirrors the shape of `vitest.config.integration.ts` for mongo. - `test/setup-integration-postgres.ts`: connects a `pg.Client`, truncates the app tables (and the connect-pg-simple `session` table if it exists) between tests, drops them in `afterAll`, and calls `resetConnection()` + `invalidateCache()` so each test sees a fresh helper state. - `test-integration.postgres.proxy.config.json`: minimal config with a single enabled postgres sink and local auth, so `getDatabase()` resolves to postgres without the default fs entry winning first. - `package.json`: adds `npm run test:integration:postgres`. No suites yet — added in the next commit. --- package.json | 1 + test-integration.postgres.proxy.config.json | 21 +++++ test/setup-integration-postgres.ts | 95 +++++++++++++++++++++ vitest.config.integration.postgres.ts | 40 +++++++++ 4 files changed, 157 insertions(+) create mode 100644 test-integration.postgres.proxy.config.json create mode 100644 test/setup-integration-postgres.ts create mode 100644 vitest.config.integration.postgres.ts diff --git a/package.json b/package.json index b46a2b2bf..a3e15f29a 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": "NODE_ENV=test vitest --run --config vitest.config.integration.ts", + "test:integration:postgres": "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", 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/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', + }, + }, +}); From 6f74b099976a4265d719cd7b95d11ec4e4eab8ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C4=86ori=C4=87?= Date: Mon, 11 May 2026 16:41:57 +0200 Subject: [PATCH 17/23] test(db/postgres): pushes integration suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parity with the mongo pushes integration suite, gated on RUN_POSTGRES_TESTS=true (set automatically by vitest.config.integration.postgres.ts). The suite is skipped in normal `npm test` runs and only executes against a real Postgres in the dedicated `npm run test:integration:postgres` task. The added test that goes beyond the mongo parity: - `getPushes` returns results in descending timestamp order across three rows with deliberately distinct timestamps — exercising the must-fix ordering requirement end-to-end against a real database. Otherwise the assertions mirror the existing mongo integration suite verbatim so backend parity is verifiable side-by-side. --- test/db/postgres/pushes.integration.test.ts | 269 ++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 test/db/postgres/pushes.integration.test.ts 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'); + }); + }); +}); From d8d4e313204b985f4c2be673ef281229a84aebf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C4=86ori=C4=87?= Date: Mon, 11 May 2026 16:43:39 +0200 Subject: [PATCH 18/23] test(db/postgres): users integration suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parity with the mongo users integration suite, gated on RUN_POSTGRES_TESTS=true. Mirrors the same case-insensitivity and filtering assertions, plus one additional test exercising the upsert-on-username path through `updateUser` end-to-end (the mongo adapter has this via its `upsert: true`, our postgres adapter implements it as an `UPDATE … WHERE username` fallback to `INSERT`). `getUsers` asserts `password` is `null` in list responses rather than `undefined`: the postgres SELECT projects `NULL::text AS password`, which round-trips as `null` rather than being elided from the JSON shape — semantically equivalent to mongo's omission for the API consumers. --- test/db/postgres/users.integration.test.ts | 173 +++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 test/db/postgres/users.integration.test.ts 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(); + }); + }); +}); From efcd2402a38970f0d72ee095d1779f722314cbc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C4=86ori=C4=87?= Date: Mon, 11 May 2026 16:45:06 +0200 Subject: [PATCH 19/23] test(db/postgres): repo integration suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parity with the mongo repo integration suite, gated on RUN_POSTGRES_TESTS=true. The permission-JSONB block is the centrepiece — it exercises the explicit issue #1497 must-fix end-to-end against a real database: - starts with empty arrays in the JSONB column. - adding a user is deduplicated (re-adding does not double-insert). - removing the *last* user leaves the array as `[]`, not `null`. - the invariant applies symmetrically to `canAuthorise`. - removing one user from a multi-user list keeps the rest intact. Skipped without postgres available: full unit suite is 791 passing, 90 skipped (45 mongo + 18 postgres-pushes + 14 postgres-users + 13 postgres-repo), zero failures, zero regressions. --- test/db/postgres/repo.integration.test.ts | 173 ++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 test/db/postgres/repo.integration.test.ts 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(); + }); + }); +}); From b88d6e48f8c2226a0045c8501a659c2079c920e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C4=86ori=C4=87?= Date: Mon, 11 May 2026 16:45:32 +0200 Subject: [PATCH 20/23] ci: postgres integration lane Adds a `postgres:16` service container to the `build-ubuntu` job and a new `PostgreSQL Integration Tests` step that runs `npm run test:integration:postgres` against it. The service uses the default `postgres` superuser with database `git_proxy_test`, matching the connection string our adapter and test harness default to. Per the issue's "Open Questions" section, a single Postgres version is sufficient for the initial lane; a broader matrix can follow once the backend has soaked in. Refs #1497 --- .github/workflows/ci.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5db95b114..9fe5d9fa4 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 + --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: From 93a3292625876762d1b67c2e2db81165d72ad035 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C4=86ori=C4=87?= Date: Mon, 11 May 2026 16:46:12 +0200 Subject: [PATCH 21/23] docs: configuring postgres sink Documents the new `postgres` backend in the `sink` section of the architecture reference: - Lists `postgres` as a supported sink alongside `fs` and `mongo`. - Shows the minimal config block. - Documents the `GIT_PROXY_POSTGRES_CONNECTION_STRING` env-var fallback. - Calls out the v1 limitations explicitly (no migration tooling, no AWS RDS IAM auth, JSONB permissions, no split PG env vars, fail- loudly on missing connection string). Refs #1497 --- docs/Architecture.md | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/docs/Architecture.md b/docs/Architecture.md index 7f49ebe62..32ab1f72e 100644 --- a/docs/Architecture.md +++ b/docs/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`](/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` From fcf94f17a000d28650ebec517ef554680d9784df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C4=86ori=C4=87?= Date: Tue, 12 May 2026 13:38:52 +0200 Subject: [PATCH 22/23] fix(postgres): correct user updates and session startup --- .github/workflows/ci.yml | 2 +- src/db/index.ts | 2 ++ src/db/postgres/helper.ts | 19 +++++++++++++++++++ src/db/postgres/index.ts | 2 +- src/db/postgres/users.ts | 1 + src/db/types.ts | 1 + src/service/index.ts | 3 +++ test/db/postgres/helper.test.ts | 22 +++++++++++++++++++++- test/db/postgres/users.test.ts | 10 ++++++++++ 9 files changed, 59 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9fe5d9fa4..a584b4c3a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: ports: - 5432:5432 options: >- - --health-cmd pg_isready + --health-cmd "pg_isready -U postgres" --health-interval 10s --health-timeout 5s --health-retries 5 diff --git a/src/db/index.ts b/src/db/index.ts index e67fdbf0a..1190f6256 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -182,6 +182,8 @@ export const canUserCancelPush = async (id: string, user: string) => { }; 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 index 193cc772c..010be1f78 100644 --- a/src/db/postgres/helper.ts +++ b/src/db/postgres/helper.ts @@ -140,3 +140,22 @@ export const getSessionStore = (): Store => { 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 index d4626e577..2b25baea6 100644 --- a/src/db/postgres/index.ts +++ b/src/db/postgres/index.ts @@ -19,7 +19,7 @@ import * as pushes from './pushes'; import * as repo from './repo'; import * as users from './users'; -export const { getSessionStore } = helper; +export const { getSessionStore, ensureSessionStoreReady } = helper; export const { getPushes, writeAudit, getPush, deletePush, authorise, cancel, reject } = pushes; diff --git a/src/db/postgres/users.ts b/src/db/postgres/users.ts index be06a3840..8c7928172 100644 --- a/src/db/postgres/users.ts +++ b/src/db/postgres/users.ts @@ -151,6 +151,7 @@ export const updateUser = async (user: Partial): Promise => { } // 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, diff --git a/src/db/types.ts b/src/db/types.ts index fda28245c..467bf8b13 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -110,6 +110,7 @@ export interface PublicUser { export interface Sink { 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 36b87e4f6..7513d1d88 100644 --- a/src/service/index.ts +++ b/src/service/index.ts @@ -142,6 +142,9 @@ async function createApp(proxy: Proxy): Promise { 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( diff --git a/test/db/postgres/helper.test.ts b/test/db/postgres/helper.test.ts index f6a331443..e952b1843 100644 --- a/test/db/postgres/helper.test.ts +++ b/test/db/postgres/helper.test.ts @@ -41,6 +41,13 @@ vi.mock('connect-pg-simple', () => ({ constructor(opts: unknown) { mockStoreCtor(opts); } + get(_sid: string, cb: (err: Error | null) => void) { + mockPoolQuery('SELECT 1', []); + cb(null); + } + close() { + return Promise.resolve(); + } }, })); @@ -50,7 +57,7 @@ vi.mock('../../../src/config', () => ({ })); describe('PostgreSQL - helper', async () => { - const { connect, query, resetConnection, getSessionStore } = + const { connect, query, resetConnection, getSessionStore, ensureSessionStoreReady } = await import('../../../src/db/postgres/helper'); beforeEach(async () => { @@ -135,5 +142,18 @@ describe('PostgreSQL - helper', async () => { 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/users.test.ts b/test/db/postgres/users.test.ts index 1f25c5e4a..ee92792c3 100644 --- a/test/db/postgres/users.test.ts +++ b/test/db/postgres/users.test.ts @@ -102,6 +102,16 @@ describe('PostgreSQL - Users', async () => { 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', From 00ea383fb5443828639b84222c67ace8394bb694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C4=86ori=C4=87?= Date: Fri, 15 May 2026 14:49:46 +0200 Subject: [PATCH 23/23] fix(config): recover cached git sources on detached head --- src/config/ConfigLoader.ts | 51 +++++++++++++++++++++++++++----------- test/ConfigLoader.test.ts | 43 ++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 15 deletions(-) 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/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',