diff --git a/.eslintrc.json b/.eslintrc.json index fb129879f..d8c875c4d 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -51,5 +51,6 @@ "react": { "version": "detect" } - } + }, + "ignorePatterns": ["src/config/generated/config.ts"] } diff --git a/.github/workflows/unused-dependencies.yml b/.github/workflows/unused-dependencies.yml index 8bde95cb6..de9249ea5 100644 --- a/.github/workflows/unused-dependencies.yml +++ b/.github/workflows/unused-dependencies.yml @@ -21,7 +21,7 @@ jobs: node-version: '22.x' - name: 'Run depcheck' run: | - npx depcheck --skip-missing --ignores="tsx,@babel/*,@commitlint/*,eslint,eslint-*,husky,mocha,ts-mocha,ts-node,concurrently,nyc,prettier,typescript,tsconfig-paths,vite-tsconfig-paths,history,@types/domutils" + npx depcheck --skip-missing --ignores="tsx,@babel/*,@commitlint/*,eslint,eslint-*,husky,mocha,ts-mocha,ts-node,concurrently,nyc,prettier,typescript,tsconfig-paths,vite-tsconfig-paths,@types/sinon,quicktype,history,@types/domutils" echo $? if [[ $? == 1 ]]; then echo "Unused dependencies or devDependencies found" diff --git a/config.schema.json b/config.schema.json index 4539cb5b2..f5e5085a9 100644 --- a/config.schema.json +++ b/config.schema.json @@ -145,6 +145,14 @@ }, "required": ["enabled", "key", "cert"] }, + "sslKeyPemPath": { + "description": "Legacy: Path to SSL private key file (use tls.key instead)", + "type": "string" + }, + "sslCertPemPath": { + "description": "Legacy: Path to SSL certificate file (use tls.cert instead)", + "type": "string" + }, "configurationSources": { "enabled": { "type": "boolean" }, "reloadIntervalSeconds": { "type": "number" }, diff --git a/index.ts b/index.ts index 29da39b44..2b2c9c0be 100755 --- a/index.ts +++ b/index.ts @@ -5,7 +5,6 @@ import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; import * as fs from 'fs'; import { configFile, setConfigFile, validate } from './src/config/file'; -import { initUserConfig } from './src/config'; import proxy from './src/proxy'; import service from './src/service'; @@ -30,8 +29,7 @@ const argv = yargs(hideBin(process.argv)) .strict() .parseSync(); -setConfigFile(argv.c as string || ""); -initUserConfig(); +setConfigFile((argv.c as string) || ''); if (argv.v) { if (!fs.existsSync(configFile)) { diff --git a/package-lock.json b/package-lock.json index 717f6552f..31a4b4035 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,6 @@ "express-session": "^1.18.2", "history": "5.3.0", "isomorphic-git": "^1.32.2", - "jsonschema": "^1.5.0", "jsonwebtoken": "^9.0.2", "jwk-to-pem": "^2.0.7", "load-plugin": "^6.0.3", @@ -73,6 +72,7 @@ "@types/node": "^22.17.0", "@types/react-dom": "^17.0.26", "@types/react-html-parser": "^2.0.7", + "@types/sinon": "^17.0.4", "@types/yargs": "^17.0.33", "@typescript-eslint/eslint-plugin": "^8.38.0", "@typescript-eslint/parser": "^8.38.0", @@ -94,7 +94,9 @@ "nyc": "^17.1.0", "prettier": "^3.6.2", "proxyquire": "^2.1.3", + "quicktype": "^23.2.6", "sinon": "^21.0.0", + "sinon-chai": "^3.7.0", "ts-mocha": "^11.1.0", "ts-node": "^10.9.2", "tsx": "^4.20.3", @@ -1604,6 +1606,13 @@ "resolved": "packages/git-proxy-cli", "link": true }, + "node_modules/@glideapps/ts-necessities": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@glideapps/ts-necessities/-/ts-necessities-2.4.0.tgz", + "integrity": "sha512-mDC+qosuNa4lxR3ioMBb6CD0XLRsQBplU+zRPUYiMLXKeVPZ6UYphdNG/EGReig0YyfnVlBKZEXl1wzTotYmPA==", + "dev": true, + "license": "MIT" + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -1843,6 +1852,69 @@ "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==" }, + "node_modules/@mark.probst/typescript-json-schema": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/@mark.probst/typescript-json-schema/-/typescript-json-schema-0.55.0.tgz", + "integrity": "sha512-jI48mSnRgFQxXiE/UTUCVCpX8lK3wCFKLF1Ss2aEreboKNuLQGt3e0/YFqWVHe/WENxOaqiJvwOz+L/SrN2+qQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@types/json-schema": "^7.0.9", + "@types/node": "^16.9.2", + "glob": "^7.1.7", + "path-equal": "^1.1.2", + "safe-stable-stringify": "^2.2.0", + "ts-node": "^10.9.1", + "typescript": "4.9.4", + "yargs": "^17.1.1" + }, + "bin": { + "typescript-json-schema": "bin/typescript-json-schema" + } + }, + "node_modules/@mark.probst/typescript-json-schema/node_modules/@types/node": { + "version": "16.18.126", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz", + "integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@mark.probst/typescript-json-schema/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@mark.probst/typescript-json-schema/node_modules/typescript": { + "version": "4.9.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz", + "integrity": "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, "node_modules/@material-ui/core": { "version": "4.12.4", "resolved": "https://registry.npmjs.org/@material-ui/core/-/core-4.12.4.tgz", @@ -2485,6 +2557,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/lodash": { "version": "4.17.20", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", @@ -2604,6 +2683,16 @@ "@types/send": "*" } }, + "node_modules/@types/sinon": { + "version": "17.0.4", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.4.tgz", + "integrity": "sha512-RHnIrhfPO3+tJT0s7cFaXGZvsL4bbR3/k7z3P312qMS4JaS2Tk+KiwiLx1S0rQ56ERj00u1/BtdyVd0FY+Pdew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/sinonjs__fake-timers": "*" + } + }, "node_modules/@types/sinonjs__fake-timers": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz", @@ -2984,6 +3073,19 @@ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/abstract-logging": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", @@ -3203,6 +3305,16 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "node_modules/array-back": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz", + "integrity": "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", @@ -3612,6 +3724,13 @@ "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", "license": "MIT" }, + "node_modules/browser-or-node": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/browser-or-node/-/browser-or-node-3.0.0.tgz", + "integrity": "sha512-iczIdVJzGEYhP5DqQxYM9Hh7Ztpqqi+CXZpSmX8ALFs9ecXkQIeqRyM6TfxEfMVpwhl3dSuDvxdzzo9sUOIVBQ==", + "dev": true, + "license": "MIT" + }, "node_modules/browser-stdout": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", @@ -3878,6 +3997,22 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chalk-template": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", + "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/chalk-template?sponsor=1" + } + }, "node_modules/chalk/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4135,6 +4270,13 @@ "node": ">=6" } }, + "node_modules/collection-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/collection-utils/-/collection-utils-1.0.1.tgz", + "integrity": "sha512-LA2YTIlR7biSpXkKYwwuzGjwL5rjWEZVOSnvdUc7gObvWe4WkjxOpfrdhoP7Hs09YWDVfg0Mal9BpAqLfVEzQg==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -4181,6 +4323,58 @@ "node": ">= 0.8" } }, + "node_modules/command-line-args": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.2.1.tgz", + "integrity": "sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-back": "^3.1.0", + "find-replace": "^3.0.0", + "lodash.camelcase": "^4.3.0", + "typical": "^4.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/command-line-usage": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-7.0.3.tgz", + "integrity": "sha512-PqMLy5+YGwhMh1wS04mVG44oqDsgyLRSKJBdOo1bnYhMKBW65gZF1dRp2OZRhiTjgUHljy99qkO7bsctLaw35Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-back": "^6.2.2", + "chalk-template": "^0.4.0", + "table-layout": "^4.1.0", + "typical": "^7.1.1" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/command-line-usage/node_modules/array-back": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.2.tgz", + "integrity": "sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, + "node_modules/command-line-usage/node_modules/typical": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-7.3.0.tgz", + "integrity": "sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, "node_modules/commander": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", @@ -4441,6 +4635,16 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-fetch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", + "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5736,12 +5940,32 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter2": { "version": "6.4.7", "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.7.tgz", "integrity": "sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==", "dev": true }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/execa": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", @@ -6215,6 +6439,19 @@ "url": "https://github.com/avajs/find-cache-dir?sponsor=1" } }, + "node_modules/find-replace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-3.0.0.tgz", + "integrity": "sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-back": "^3.0.1" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -6780,6 +7017,17 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/graphql": { + "version": "0.11.7", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-0.11.7.tgz", + "integrity": "sha512-x7uDjyz8Jx+QPbpCFCMQ8lltnQa4p4vSYHx6ADe8rVYRTdsyhCJbvSty5DAsLVmU6cGakl+r8HQYolKHxk/tiw==", + "deprecated": "No longer supported; please update to a newer version. Details: https://github.com/graphql/graphql-js#version-support", + "dev": true, + "license": "MIT", + "dependencies": { + "iterall": "1.1.3" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -7685,6 +7933,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", + "dev": true, + "license": "MIT" + }, "node_modules/is-weakmap": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", @@ -7972,6 +8227,13 @@ "node": ">=8" } }, + "node_modules/iterall": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/iterall/-/iterall-1.1.3.tgz", + "integrity": "sha512-Cu/kb+4HiNSejAPhSaN1VukdNTTi/r4/e+yykqjlG/IW+1gZH5b4+Bq3whDX4tvbYugta3r8KTMUiqT3fIGxuQ==", + "dev": true, + "license": "MIT" + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -8026,6 +8288,13 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-base64": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.7.tgz", + "integrity": "sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -8143,15 +8412,6 @@ ], "license": "MIT" }, - "node_modules/jsonschema": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/jsonschema/-/jsonschema-1.5.0.tgz", - "integrity": "sha512-K+A9hhqbn0f3pJX17Q/7H6yQfD/5OXgdrR5UE12gMXCiN9D5Xq2o5mddV2QEcX/bjla99ASsAAQUyMCCRWAEhw==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/JSONStream": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", @@ -9327,6 +9587,52 @@ "node": ">= 0.6" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-preload": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", @@ -10014,6 +10320,13 @@ "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", "license": "MIT" }, + "node_modules/path-equal": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/path-equal/-/path-equal-1.2.5.tgz", + "integrity": "sha512-i73IctDr3F2W+bsOWDyyVm/lqsXO47aY9nsFZUjTT/aljSbkxHxxCoyZ9UUrM8jK0JVod+An+rl48RCsvWM+9g==", + "dev": true, + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -10201,6 +10514,16 @@ "node": ">=8" } }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/popper.js": { "version": "1.16.1-lts", "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1-lts.tgz", @@ -10462,6 +10785,209 @@ } ] }, + "node_modules/quicktype": { + "version": "23.2.6", + "resolved": "https://registry.npmjs.org/quicktype/-/quicktype-23.2.6.tgz", + "integrity": "sha512-rlD1jF71bOmDn6SQ/ToLuuRkMQ7maxo5oVTn5dPCl11ymqoJCFCvl7FzRfh+fkDFmWt2etl+JiIEdWImLxferA==", + "dev": true, + "license": "Apache-2.0", + "workspaces": [ + "./packages/quicktype-core", + "./packages/quicktype-graphql-input", + "./packages/quicktype-typescript-input", + "./packages/quicktype-vscode" + ], + "dependencies": { + "@glideapps/ts-necessities": "^2.2.3", + "chalk": "^4.1.2", + "collection-utils": "^1.0.1", + "command-line-args": "^5.2.1", + "command-line-usage": "^7.0.1", + "cross-fetch": "^4.0.0", + "graphql": "^0.11.7", + "lodash": "^4.17.21", + "moment": "^2.30.1", + "quicktype-core": "23.2.6", + "quicktype-graphql-input": "23.2.6", + "quicktype-typescript-input": "23.2.6", + "readable-stream": "^4.5.2", + "stream-json": "1.8.0", + "string-to-stream": "^3.0.1", + "typescript": "~5.8.3" + }, + "bin": { + "quicktype": "dist/index.js" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/quicktype-core": { + "version": "23.2.6", + "resolved": "https://registry.npmjs.org/quicktype-core/-/quicktype-core-23.2.6.tgz", + "integrity": "sha512-asfeSv7BKBNVb9WiYhFRBvBZHcRutPRBwJMxW0pefluK4kkKu4lv0IvZBwFKvw2XygLcL1Rl90zxWDHYgkwCmA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@glideapps/ts-necessities": "2.2.3", + "browser-or-node": "^3.0.0", + "collection-utils": "^1.0.1", + "cross-fetch": "^4.0.0", + "is-url": "^1.2.4", + "js-base64": "^3.7.7", + "lodash": "^4.17.21", + "pako": "^1.0.6", + "pluralize": "^8.0.0", + "readable-stream": "4.5.2", + "unicode-properties": "^1.4.1", + "urijs": "^1.19.1", + "wordwrap": "^1.0.0", + "yaml": "^2.4.1" + } + }, + "node_modules/quicktype-core/node_modules/@glideapps/ts-necessities": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@glideapps/ts-necessities/-/ts-necessities-2.2.3.tgz", + "integrity": "sha512-gXi0awOZLHk3TbW55GZLCPP6O+y/b5X1pBXKBVckFONSwF1z1E5ND2BGJsghQFah+pW7pkkyFb2VhUQI2qhL5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/quicktype-core/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/quicktype-core/node_modules/readable-stream": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", + "integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/quicktype-graphql-input": { + "version": "23.2.6", + "resolved": "https://registry.npmjs.org/quicktype-graphql-input/-/quicktype-graphql-input-23.2.6.tgz", + "integrity": "sha512-jHQ8XrEaccZnWA7h/xqUQhfl+0mR5o91T6k3I4QhlnZSLdVnbycrMq4FHa9EaIFcai783JKwSUl1+koAdJq4pg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "collection-utils": "^1.0.1", + "graphql": "^0.11.7", + "quicktype-core": "23.2.6" + } + }, + "node_modules/quicktype-typescript-input": { + "version": "23.2.6", + "resolved": "https://registry.npmjs.org/quicktype-typescript-input/-/quicktype-typescript-input-23.2.6.tgz", + "integrity": "sha512-dCNMxR+7PGs9/9Tsth9H6LOQV1G+Tv4sUGT8ZUfDRJ5Hq371qOYLma5BnLX6VxkPu8JT7mAMpQ9VFlxstX6Qaw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@mark.probst/typescript-json-schema": "0.55.0", + "quicktype-core": "23.2.6", + "typescript": "4.9.5" + } + }, + "node_modules/quicktype-typescript-input/node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/quicktype/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/quicktype/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/quicktype/node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/random-bytes": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", @@ -10971,6 +11497,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -11319,6 +11855,17 @@ "url": "https://opencollective.com/sinon" } }, + "node_modules/sinon-chai": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/sinon-chai/-/sinon-chai-3.7.0.tgz", + "integrity": "sha512-mf5NURdUaSdnatJx3uhoBOrY9dtL19fiOtAdT1Azxg3+lNJFiuN0uzaU3xX1LeAfL17kHQhTAJgpsfhbMJMY2g==", + "dev": true, + "license": "(BSD-2-Clause OR WTFPL)", + "peerDependencies": { + "chai": "^4.0.0", + "sinon": ">=4.0.0" + } + }, "node_modules/sinon/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -11474,6 +12021,23 @@ "node": ">= 0.8" } }, + "node_modules/stream-chain": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz", + "integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stream-json": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.8.0.tgz", + "integrity": "sha512-HZfXngYHUAr1exT4fxlbc1IOce1RYxp2ldeaf97LYCOPSoOqY/1Psp7iGvpb+6JIOgkra9zDYnPX01hGAHzEPw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "stream-chain": "^2.2.5" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -11482,6 +12046,16 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-to-stream": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/string-to-stream/-/string-to-stream-3.0.1.tgz", + "integrity": "sha512-Hl092MV3USJuUCC6mfl9sPzGloA3K5VwdIeJjYIkXY/8K+mUvaeEabWJgArp+xXrsWxCajeT2pc4axbVhIZJyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^3.4.0" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -11777,6 +12351,30 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/table-layout": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/table-layout/-/table-layout-4.1.1.tgz", + "integrity": "sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-back": "^6.2.2", + "wordwrapjs": "^5.1.0" + }, + "engines": { + "node": ">=12.17" + } + }, + "node_modules/table-layout/node_modules/array-back": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.2.tgz", + "integrity": "sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -11845,6 +12443,13 @@ "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", "dev": true }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "dev": true, + "license": "MIT" + }, "node_modules/tiny-warning": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", @@ -12594,6 +13199,16 @@ "node": ">=14.17" } }, + "node_modules/typical": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-4.0.0.tgz", + "integrity": "sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/uid-safe": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", @@ -12630,6 +13245,35 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unicode-trie/node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "dev": true, + "license": "MIT" + }, "node_modules/unicorn-magic": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", @@ -12709,6 +13353,13 @@ "punycode": "^2.1.0" } }, + "node_modules/urijs": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", + "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", + "dev": true, + "license": "MIT" + }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", @@ -13044,6 +13695,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wordwrapjs": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-5.1.0.tgz", + "integrity": "sha512-JNjcULU2e4KJwUNv6CHgI46UvDGitb6dGryHajXTDiLgg1/RiGoPSDw4kZfYnwGtEXf2ZMeIewDQgFGzkCB2Sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, "node_modules/workerpool": { "version": "6.5.1", "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz", @@ -13170,6 +13838,19 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", + "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index cb966e403..78afebe4d 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "lint:fix": "eslint --fix \"src/**/*.{js,jsx,ts,tsx,json}\" \"test/**/*.{js,jsx,ts,tsx,json}\"", "format": "prettier --write src/**/*.{js,jsx,ts,tsx,css,md,json,scss} test/**/*.{js,jsx,ts,tsx,json} packages/git-proxy-cli/test/**/*.{js,jsx,ts,tsx,json} packages/git-proxy-cli/index.js --config ./.prettierrc", "gen-schema-doc": "node ./scripts/doc-schema.js", - "cypress:run": "cypress run" + "cypress:run": "cypress run", + "generate-types": "quicktype --src-lang schema --lang typescript --out src/config/generated/config.ts --top-level GitProxyConfig config.schema.json && ts-node scripts/add-banner.ts src/config/generated/config.ts" }, "bin": { "git-proxy": "./index.js", @@ -58,7 +59,6 @@ "express-session": "^1.18.2", "history": "5.3.0", "isomorphic-git": "^1.32.2", - "jsonschema": "^1.5.0", "jsonwebtoken": "^9.0.2", "jwk-to-pem": "^2.0.7", "load-plugin": "^6.0.3", @@ -96,6 +96,7 @@ "@types/node": "^22.17.0", "@types/react-dom": "^17.0.26", "@types/react-html-parser": "^2.0.7", + "@types/sinon": "^17.0.4", "@types/yargs": "^17.0.33", "@typescript-eslint/eslint-plugin": "^8.38.0", "@typescript-eslint/parser": "^8.38.0", @@ -117,7 +118,9 @@ "nyc": "^17.1.0", "prettier": "^3.6.2", "proxyquire": "^2.1.3", + "quicktype": "^23.2.6", "sinon": "^21.0.0", + "sinon-chai": "^3.7.0", "ts-mocha": "^11.1.0", "ts-node": "^10.9.2", "tsx": "^4.20.3", diff --git a/scripts/add-banner.ts b/scripts/add-banner.ts new file mode 100644 index 000000000..f4c54688f --- /dev/null +++ b/scripts/add-banner.ts @@ -0,0 +1,24 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +const banner = '// THIS FILE IS AUTOMATICALLY GENERATED – DO NOT EDIT MANUALLY.\n\n'; + +const filePath = process.argv[2]; + +if (!filePath) { + console.error('Error: Provide a file path as an argument.'); + process.exit(1); +} + +const resolvedPath = path.resolve(filePath); + +if (!fs.existsSync(resolvedPath)) { + console.error(`Error: The file "${resolvedPath}" does not exist.`); + process.exit(1); +} + +const originalContent = fs.readFileSync(resolvedPath, 'utf8'); + +if (!originalContent.startsWith(banner)) { + fs.writeFileSync(resolvedPath, banner + originalContent, 'utf8'); +} diff --git a/src/config/ConfigLoader.ts b/src/config/ConfigLoader.ts index 009becf98..e09ce81f6 100644 --- a/src/config/ConfigLoader.ts +++ b/src/config/ConfigLoader.ts @@ -1,10 +1,11 @@ -import fs from 'fs'; -import path from 'path'; +import * as fs from 'fs'; +import * as path from 'path'; import axios from 'axios'; import { execFile } from 'child_process'; import { promisify } from 'util'; -import EventEmitter from 'events'; +import { EventEmitter } from 'events'; import envPaths from 'env-paths'; +import { GitProxyConfig, Convert } from './generated/config'; const execFileAsync = promisify(execFile); @@ -52,9 +53,8 @@ export interface ConfigurationSources { merge?: boolean; } -export interface Configuration { - configurationSources: ConfigurationSources; - [key: string]: any; +export interface Configuration extends GitProxyConfig { + configurationSources?: ConfigurationSources; } // Add path validation helper @@ -111,6 +111,10 @@ export class ConfigLoader extends EventEmitter { this.cacheDir = null; } + get cacheDirPath(): string | null { + return this.cacheDir; + } + async initialize(): Promise { // Get cache directory path const paths = envPaths('git-proxy'); @@ -204,7 +208,7 @@ export class ConfigLoader extends EventEmitter { ); // Filter out null results from failed loads - const validConfigs = configs.filter((config): config is Configuration => config !== null); + const validConfigs = configs.filter((config): config is GitProxyConfig => config !== null); if (validConfigs.length === 0) { console.log('No valid configurations loaded from any source'); @@ -240,7 +244,7 @@ export class ConfigLoader extends EventEmitter { } } - async loadFromSource(source: ConfigurationSource): Promise { + async loadFromSource(source: ConfigurationSource): Promise { let exhaustiveCheck: never; switch (source.type) { case 'file': @@ -255,17 +259,25 @@ export class ConfigLoader extends EventEmitter { } } - async loadFromFile(source: FileSource): Promise { + async loadFromFile(source: FileSource): Promise { const configPath = path.resolve(process.cwd(), source.path); if (!isValidPath(configPath)) { throw new Error('Invalid configuration file path'); } console.log(`Loading configuration from file: ${configPath}`); const content = await fs.promises.readFile(configPath, 'utf8'); - return JSON.parse(content); + + // Use QuickType to validate and parse the configuration + try { + return Convert.toGitProxyConfig(content); + } catch (error) { + throw new Error( + `Invalid configuration file format: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } } - async loadFromHttp(source: HttpSource): Promise { + async loadFromHttp(source: HttpSource): Promise { console.log(`Loading configuration from HTTP: ${source.url}`); const headers = { ...source.headers, @@ -273,10 +285,20 @@ export class ConfigLoader extends EventEmitter { }; const response = await axios.get(source.url, { headers }); - return response.data; + + // Use QuickType to validate and parse the configuration from HTTP response + try { + const configJson = + typeof response.data === 'string' ? response.data : JSON.stringify(response.data); + return Convert.toGitProxyConfig(configJson); + } catch (error) { + throw new Error( + `Invalid configuration format from HTTP source: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } } - async loadFromGit(source: GitSource): Promise { + async loadFromGit(source: GitSource): Promise { console.log(`Loading configuration from Git: ${source.repository}`); // Validate inputs @@ -371,7 +393,9 @@ export class ConfigLoader extends EventEmitter { try { const content = await fs.promises.readFile(configPath, 'utf8'); - const config = JSON.parse(content); + + // Use QuickType to validate and parse the configuration from Git + const config = Convert.toGitProxyConfig(content); console.log('Configuration loaded successfully from Git'); return config; } catch (error: any) { diff --git a/src/config/file.ts b/src/config/file.ts index d418acac3..04deae6ea 100644 --- a/src/config/file.ts +++ b/src/config/file.ts @@ -1,27 +1,22 @@ import { readFileSync } from 'fs'; import { join } from 'path'; -import { validate as jsonSchemaValidate } from 'jsonschema'; +import { Convert } from './generated/config'; export let configFile: string = join(__dirname, '../../proxy.config.json'); /** - * Set the config file path. - * @param {string} file - The path to the config file. + * Sets the path to the configuration file. + * + * @param {string} file - The path to the configuration file. + * @return {void} */ export function setConfigFile(file: string) { configFile = file; } -/** - * Validate config file. - * @param {string} configFilePath - The path to the config file. - * @return {boolean} - Returns true if validation is successful. - * @throws Will throw an error if the validation fails. - */ -export function validate(configFilePath: string = configFile!): boolean { - const config = JSON.parse(readFileSync(configFilePath, 'utf-8')); - const schemaPath = join(__dirname, '../../config.schema.json'); - const schema = JSON.parse(readFileSync(schemaPath, 'utf-8')); - jsonSchemaValidate(config, schema, { required: true, throwError: true }); +export function validate(filePath: string = configFile): boolean { + // Use QuickType to validate the configuration + const configContent = readFileSync(filePath, 'utf-8'); + Convert.toGitProxyConfig(configContent); return true; } diff --git a/src/config/generated/config.ts b/src/config/generated/config.ts new file mode 100644 index 000000000..7e880884a --- /dev/null +++ b/src/config/generated/config.ts @@ -0,0 +1,581 @@ +// THIS FILE IS AUTOMATICALLY GENERATED – DO NOT EDIT MANUALLY. + +// To parse this data: +// +// import { Convert, GitProxyConfig } from "./file"; +// +// const gitProxyConfig = Convert.toGitProxyConfig(json); +// +// These functions will throw an error if the JSON doesn't +// match the expected interface, even if the JSON is valid. + +/** + * Configuration for customizing git-proxy + */ +export interface GitProxyConfig { + /** + * Third party APIs + */ + api?: API; + /** + * List of authentication sources for API endpoints. May be empty, in which case all + * endpoints are public. + */ + apiAuthentication?: Authentication[]; + /** + * Customisable questions to add to attestation form + */ + attestationConfig?: { [key: string]: any }; + /** + * List of authentication sources. The first source in the configuration with enabled=true + * will be used. + */ + authentication?: Authentication[]; + /** + * List of repositories that are authorised to be pushed to through the proxy. + */ + authorisedList?: AuthorisedRepo[]; + /** + * Enforce rules and patterns on commits including e-mail and message + */ + commitConfig?: { [key: string]: any }; + configurationSources?: any; + /** + * Customisable e-mail address to share in proxy responses and warnings + */ + contactEmail?: string; + cookieSecret?: string; + /** + * Flag to enable CSRF protections for UI + */ + csrfProtection?: boolean; + /** + * Provide domains to use alternative to the defaults + */ + domains?: { [key: string]: any }; + /** + * List of plugins to integrate on GitProxy's push or pull actions. Each value is either a + * file path or a module name. + */ + plugins?: string[]; + /** + * Pattern searches for listed private organizations are disabled + */ + privateOrganizations?: any[]; + proxyUrl?: string; + /** + * API Rate limiting configuration. + */ + rateLimit?: RateLimit; + sessionMaxAgeHours?: number; + /** + * List of database sources. The first source in the configuration with enabled=true will be + * used. + */ + sink?: Database[]; + /** + * Legacy: Path to SSL certificate file (use tls.cert instead) + */ + sslCertPemPath?: string; + /** + * Legacy: Path to SSL private key file (use tls.key instead) + */ + sslKeyPemPath?: string; + /** + * Toggle the generation of temporary password for git-proxy admin user + */ + tempPassword?: TempPassword; + /** + * TLS configuration for secure connections + */ + tls?: TLS; + /** + * UI routes that require authentication (logged in or admin) + */ + uiRouteAuth?: UIRouteAuth; + /** + * Customisable URL shortener to share in proxy responses and warnings + */ + urlShortener?: string; +} + +/** + * Third party APIs + */ +export interface API { + github?: Github; + /** + * Configuration used in conjunction with ActiveDirectory auth, which relates to a REST API + * used to check user group membership, as opposed to direct querying via LDAP.
If this + * configuration is set direct querying of group membership via LDAP will be disabled. + */ + ls?: Ls; + [property: string]: any; +} + +export interface Github { + baseUrl?: string; + [property: string]: any; +} + +/** + * Configuration used in conjunction with ActiveDirectory auth, which relates to a REST API + * used to check user group membership, as opposed to direct querying via LDAP.
If this + * configuration is set direct querying of group membership via LDAP will be disabled. + */ +export interface Ls { + /** + * URL template for a GET request that confirms a user's membership of a specific group. + * Should respond with a non-empty 200 status if the user is a member of the group, an empty + * response or non-200 status indicates that the user is not a group member. If set, this + * URL will be queried and direct queries via LDAP will be disabled. The template should + * contain the following string placeholders, which will be replaced to produce the final + * URL:
  • "<domain>": AD domain,
  • "<name>": The group name to check + * membership of.
  • "<id>": The username to check group membership for.
+ */ + userInADGroup?: string; + [property: string]: any; +} + +/** + * Configuration for an authentication source + */ +export interface Authentication { + enabled: boolean; + type: Type; + /** + * Additional Active Directory configuration supporting LDAP connection which can be used to + * confirm group membership. For the full set of available options see the activedirectory 2 + * NPM module docs at https://www.npmjs.com/package/activedirectory2#activedirectoryoptions + *

Please note that if the Third Party APIs config `api.ls.userInADGroup` is set + * then the REST API it represents is used in preference to direct querying of group + * memebership via LDAP. + */ + adConfig?: AdConfig; + /** + * Group that indicates that a user is an admin + */ + adminGroup?: string; + /** + * Active Directory domain + */ + domain?: string; + /** + * Group that indicates that a user should be able to login to the Git Proxy UI and can work + * as a reviewer + */ + userGroup?: string; + /** + * Additional OIDC configuration. + */ + oidcConfig?: OidcConfig; + /** + * Additional JWT configuration. + */ + jwtConfig?: JwtConfig; + [property: string]: any; +} + +/** + * Additional Active Directory configuration supporting LDAP connection which can be used to + * confirm group membership. For the full set of available options see the activedirectory 2 + * NPM module docs at https://www.npmjs.com/package/activedirectory2#activedirectoryoptions + *

Please note that if the Third Party APIs config `api.ls.userInADGroup` is set + * then the REST API it represents is used in preference to direct querying of group + * memebership via LDAP. + */ +export interface AdConfig { + /** + * The root DN from which all searches will be performed, e.g. `dc=example,dc=com`. + */ + baseDN: string; + /** + * Password for the given `username`. + */ + password: string; + /** + * Active Directory server to connect to, e.g. `ldap://ad.example.com`. + */ + url: string; + /** + * An account name capable of performing the operations desired. + */ + username: string; + [property: string]: any; +} + +/** + * Additional JWT configuration. + */ +export interface JwtConfig { + authorityURL: string; + clientID: string; + [property: string]: any; +} + +/** + * Additional OIDC configuration. + */ +export interface OidcConfig { + callbackURL: string; + clientID: string; + clientSecret: string; + issuer: string; + scope: string; + [property: string]: any; +} + +export enum Type { + ActiveDirectory = "ActiveDirectory", + Jwt = "jwt", + Local = "local", + Openidconnect = "openidconnect", +} + +export interface AuthorisedRepo { + name: string; + project: string; + url: string; + [property: string]: any; +} + +/** + * API Rate limiting configuration. + */ +export interface RateLimit { + /** + * How many requests to allow (default 150). + */ + limit: number; + /** + * Response to return after limit is reached. + */ + message?: string; + /** + * HTTP status code after limit is reached (default is 429). + */ + statusCode?: number; + /** + * How long to remember requests for, in milliseconds (default 10 mins). + */ + windowMs: number; +} + +export interface Database { + connectionString?: string; + enabled: boolean; + options?: { [key: string]: any }; + params?: { [key: string]: any }; + type: string; + [property: string]: any; +} + +/** + * Toggle the generation of temporary password for git-proxy admin user + */ +export interface TempPassword { + /** + * Generic object to configure nodemailer. For full type information, please see + * https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/nodemailer + */ + emailConfig?: { [key: string]: any }; + sendEmail?: boolean; + [property: string]: any; +} + +/** + * TLS configuration for secure connections + */ +export interface TLS { + cert: string; + enabled: boolean; + key: string; + [property: string]: any; +} + +/** + * UI routes that require authentication (logged in or admin) + */ +export interface UIRouteAuth { + enabled?: boolean; + rules?: RouteAuthRule[]; + [property: string]: any; +} + +export interface RouteAuthRule { + adminOnly?: boolean; + loginRequired?: boolean; + pattern?: string; + [property: string]: any; +} + +// Converts JSON strings to/from your types +// and asserts the results of JSON.parse at runtime +export class Convert { + public static toGitProxyConfig(json: string): GitProxyConfig { + return cast(JSON.parse(json), r("GitProxyConfig")); + } + + public static gitProxyConfigToJson(value: GitProxyConfig): string { + return JSON.stringify(uncast(value, r("GitProxyConfig")), null, 2); + } +} + +function invalidValue(typ: any, val: any, key: any, parent: any = ''): never { + const prettyTyp = prettyTypeName(typ); + const parentText = parent ? ` on ${parent}` : ''; + const keyText = key ? ` for key "${key}"` : ''; + throw Error(`Invalid value${keyText}${parentText}. Expected ${prettyTyp} but got ${JSON.stringify(val)}`); +} + +function prettyTypeName(typ: any): string { + if (Array.isArray(typ)) { + if (typ.length === 2 && typ[0] === undefined) { + return `an optional ${prettyTypeName(typ[1])}`; + } else { + return `one of [${typ.map(a => { return prettyTypeName(a); }).join(", ")}]`; + } + } else if (typeof typ === "object" && typ.literal !== undefined) { + return typ.literal; + } else { + return typeof typ; + } +} + +function jsonToJSProps(typ: any): any { + if (typ.jsonToJS === undefined) { + const map: any = {}; + typ.props.forEach((p: any) => map[p.json] = { key: p.js, typ: p.typ }); + typ.jsonToJS = map; + } + return typ.jsonToJS; +} + +function jsToJSONProps(typ: any): any { + if (typ.jsToJSON === undefined) { + const map: any = {}; + typ.props.forEach((p: any) => map[p.js] = { key: p.json, typ: p.typ }); + typ.jsToJSON = map; + } + return typ.jsToJSON; +} + +function transform(val: any, typ: any, getProps: any, key: any = '', parent: any = ''): any { + function transformPrimitive(typ: string, val: any): any { + if (typeof typ === typeof val) return val; + return invalidValue(typ, val, key, parent); + } + + function transformUnion(typs: any[], val: any): any { + // val must validate against one typ in typs + const l = typs.length; + for (let i = 0; i < l; i++) { + const typ = typs[i]; + try { + return transform(val, typ, getProps); + } catch (_) {} + } + return invalidValue(typs, val, key, parent); + } + + function transformEnum(cases: string[], val: any): any { + if (cases.indexOf(val) !== -1) return val; + return invalidValue(cases.map(a => { return l(a); }), val, key, parent); + } + + function transformArray(typ: any, val: any): any { + // val must be an array with no invalid elements + if (!Array.isArray(val)) return invalidValue(l("array"), val, key, parent); + return val.map(el => transform(el, typ, getProps)); + } + + function transformDate(val: any): any { + if (val === null) { + return null; + } + const d = new Date(val); + if (isNaN(d.valueOf())) { + return invalidValue(l("Date"), val, key, parent); + } + return d; + } + + function transformObject(props: { [k: string]: any }, additional: any, val: any): any { + if (val === null || typeof val !== "object" || Array.isArray(val)) { + return invalidValue(l(ref || "object"), val, key, parent); + } + const result: any = {}; + Object.getOwnPropertyNames(props).forEach(key => { + const prop = props[key]; + const v = Object.prototype.hasOwnProperty.call(val, key) ? val[key] : undefined; + result[prop.key] = transform(v, prop.typ, getProps, key, ref); + }); + Object.getOwnPropertyNames(val).forEach(key => { + if (!Object.prototype.hasOwnProperty.call(props, key)) { + result[key] = transform(val[key], additional, getProps, key, ref); + } + }); + return result; + } + + if (typ === "any") return val; + if (typ === null) { + if (val === null) return val; + return invalidValue(typ, val, key, parent); + } + if (typ === false) return invalidValue(typ, val, key, parent); + let ref: any = undefined; + while (typeof typ === "object" && typ.ref !== undefined) { + ref = typ.ref; + typ = typeMap[typ.ref]; + } + if (Array.isArray(typ)) return transformEnum(typ, val); + if (typeof typ === "object") { + return typ.hasOwnProperty("unionMembers") ? transformUnion(typ.unionMembers, val) + : typ.hasOwnProperty("arrayItems") ? transformArray(typ.arrayItems, val) + : typ.hasOwnProperty("props") ? transformObject(getProps(typ), typ.additional, val) + : invalidValue(typ, val, key, parent); + } + // Numbers can be parsed by Date but shouldn't be. + if (typ === Date && typeof val !== "number") return transformDate(val); + return transformPrimitive(typ, val); +} + +function cast(val: any, typ: any): T { + return transform(val, typ, jsonToJSProps); +} + +function uncast(val: T, typ: any): any { + return transform(val, typ, jsToJSONProps); +} + +function l(typ: any) { + return { literal: typ }; +} + +function a(typ: any) { + return { arrayItems: typ }; +} + +function u(...typs: any[]) { + return { unionMembers: typs }; +} + +function o(props: any[], additional: any) { + return { props, additional }; +} + +function m(additional: any) { + return { props: [], additional }; +} + +function r(name: string) { + return { ref: name }; +} + +const typeMap: any = { + "GitProxyConfig": o([ + { json: "api", js: "api", typ: u(undefined, r("API")) }, + { json: "apiAuthentication", js: "apiAuthentication", typ: u(undefined, a(r("Authentication"))) }, + { json: "attestationConfig", js: "attestationConfig", typ: u(undefined, m("any")) }, + { json: "authentication", js: "authentication", typ: u(undefined, a(r("Authentication"))) }, + { json: "authorisedList", js: "authorisedList", typ: u(undefined, a(r("AuthorisedRepo"))) }, + { json: "commitConfig", js: "commitConfig", typ: u(undefined, m("any")) }, + { json: "configurationSources", js: "configurationSources", typ: u(undefined, "any") }, + { json: "contactEmail", js: "contactEmail", typ: u(undefined, "") }, + { json: "cookieSecret", js: "cookieSecret", typ: u(undefined, "") }, + { json: "csrfProtection", js: "csrfProtection", typ: u(undefined, true) }, + { json: "domains", js: "domains", typ: u(undefined, m("any")) }, + { json: "plugins", js: "plugins", typ: u(undefined, a("")) }, + { json: "privateOrganizations", js: "privateOrganizations", typ: u(undefined, a("any")) }, + { json: "proxyUrl", js: "proxyUrl", typ: u(undefined, "") }, + { json: "rateLimit", js: "rateLimit", typ: u(undefined, r("RateLimit")) }, + { json: "sessionMaxAgeHours", js: "sessionMaxAgeHours", typ: u(undefined, 3.14) }, + { json: "sink", js: "sink", typ: u(undefined, a(r("Database"))) }, + { json: "sslCertPemPath", js: "sslCertPemPath", typ: u(undefined, "") }, + { json: "sslKeyPemPath", js: "sslKeyPemPath", typ: u(undefined, "") }, + { json: "tempPassword", js: "tempPassword", typ: u(undefined, r("TempPassword")) }, + { json: "tls", js: "tls", typ: u(undefined, r("TLS")) }, + { json: "uiRouteAuth", js: "uiRouteAuth", typ: u(undefined, r("UIRouteAuth")) }, + { json: "urlShortener", js: "urlShortener", typ: u(undefined, "") }, + ], false), + "API": o([ + { json: "github", js: "github", typ: u(undefined, r("Github")) }, + { json: "ls", js: "ls", typ: u(undefined, r("Ls")) }, + ], "any"), + "Github": o([ + { json: "baseUrl", js: "baseUrl", typ: u(undefined, "") }, + ], "any"), + "Ls": o([ + { json: "userInADGroup", js: "userInADGroup", typ: u(undefined, "") }, + ], "any"), + "Authentication": o([ + { json: "enabled", js: "enabled", typ: true }, + { json: "type", js: "type", typ: r("Type") }, + { json: "adConfig", js: "adConfig", typ: u(undefined, r("AdConfig")) }, + { json: "adminGroup", js: "adminGroup", typ: u(undefined, "") }, + { json: "domain", js: "domain", typ: u(undefined, "") }, + { json: "userGroup", js: "userGroup", typ: u(undefined, "") }, + { json: "oidcConfig", js: "oidcConfig", typ: u(undefined, r("OidcConfig")) }, + { json: "jwtConfig", js: "jwtConfig", typ: u(undefined, r("JwtConfig")) }, + ], "any"), + "AdConfig": o([ + { json: "baseDN", js: "baseDN", typ: "" }, + { json: "password", js: "password", typ: "" }, + { json: "url", js: "url", typ: "" }, + { json: "username", js: "username", typ: "" }, + ], "any"), + "JwtConfig": o([ + { json: "authorityURL", js: "authorityURL", typ: "" }, + { json: "clientID", js: "clientID", typ: "" }, + ], "any"), + "OidcConfig": o([ + { json: "callbackURL", js: "callbackURL", typ: "" }, + { json: "clientID", js: "clientID", typ: "" }, + { json: "clientSecret", js: "clientSecret", typ: "" }, + { json: "issuer", js: "issuer", typ: "" }, + { json: "scope", js: "scope", typ: "" }, + ], "any"), + "AuthorisedRepo": o([ + { json: "name", js: "name", typ: "" }, + { json: "project", js: "project", typ: "" }, + { json: "url", js: "url", typ: "" }, + ], "any"), + "RateLimit": o([ + { json: "limit", js: "limit", typ: 3.14 }, + { json: "message", js: "message", typ: u(undefined, "") }, + { json: "statusCode", js: "statusCode", typ: u(undefined, 3.14) }, + { json: "windowMs", js: "windowMs", typ: 3.14 }, + ], false), + "Database": o([ + { json: "connectionString", js: "connectionString", typ: u(undefined, "") }, + { json: "enabled", js: "enabled", typ: true }, + { json: "options", js: "options", typ: u(undefined, m("any")) }, + { json: "params", js: "params", typ: u(undefined, m("any")) }, + { json: "type", js: "type", typ: "" }, + ], "any"), + "TempPassword": o([ + { json: "emailConfig", js: "emailConfig", typ: u(undefined, m("any")) }, + { json: "sendEmail", js: "sendEmail", typ: u(undefined, true) }, + ], "any"), + "TLS": o([ + { json: "cert", js: "cert", typ: "" }, + { json: "enabled", js: "enabled", typ: true }, + { json: "key", js: "key", typ: "" }, + ], "any"), + "UIRouteAuth": o([ + { json: "enabled", js: "enabled", typ: u(undefined, true) }, + { json: "rules", js: "rules", typ: u(undefined, a(r("RouteAuthRule"))) }, + ], "any"), + "RouteAuthRule": o([ + { json: "adminOnly", js: "adminOnly", typ: u(undefined, true) }, + { json: "loginRequired", js: "loginRequired", typ: u(undefined, true) }, + { json: "pattern", js: "pattern", typ: u(undefined, "") }, + ], "any"), + "Type": [ + "ActiveDirectory", + "jwt", + "local", + "openidconnect", + ], +}; diff --git a/src/config/index.ts b/src/config/index.ts index 7ffb6a969..436a8a5b2 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,100 +1,151 @@ import { existsSync, readFileSync } from 'fs'; import defaultSettings from '../../proxy.config.json'; -import { serverConfig } from './env'; -import { configFile, validate } from './file'; +import { GitProxyConfig, Convert } from './generated/config'; import { ConfigLoader, Configuration } from './ConfigLoader'; -import { - Authentication, - AuthorisedRepo, - Database, - RateLimitConfig, - TempPasswordConfig, - UserSettings, -} from './types'; - -let _userSettings: UserSettings | null = null; -console.log(`_userSettings during import: ${_userSettings}`); // for debugging only +import { serverConfig } from './env'; +import { configFile } from './file'; + +// Cache for current configuration +let _currentConfig: GitProxyConfig | null = null; +let _configLoader: ConfigLoader | null = null; + +// Function to invalidate cache - useful for testing +export const invalidateCache = () => { + _currentConfig = null; +}; +// Compatibility function for old initUserConfig behavior export const initUserConfig = () => { - console.log(`Initializing user configuration from ${configFile}`); // for debugging only - if (existsSync(configFile)) { - _userSettings = JSON.parse(readFileSync(configFile, 'utf-8')); - } + invalidateCache(); + loadFullConfiguration(); // Force immediate reload }; -let _authorisedList: AuthorisedRepo[] = defaultSettings.authorisedList; -let _database: Database[] = defaultSettings.sink; -let _authentication: Authentication[] = defaultSettings.authentication; -let _apiAuthentication: Authentication[] = defaultSettings.apiAuthentication; -let _tempPassword: TempPasswordConfig = defaultSettings.tempPassword; -let _proxyUrl = defaultSettings.proxyUrl; -let _api: Record = defaultSettings.api; -let _cookieSecret: string = serverConfig.GIT_PROXY_COOKIE_SECRET || defaultSettings.cookieSecret; -let _sessionMaxAgeHours: number = defaultSettings.sessionMaxAgeHours; -let _plugins: any[] = defaultSettings.plugins; -let _commitConfig: Record = defaultSettings.commitConfig; -let _attestationConfig: Record = defaultSettings.attestationConfig; -let _privateOrganizations: string[] = defaultSettings.privateOrganizations; -let _urlShortener: string = defaultSettings.urlShortener; -let _contactEmail: string = defaultSettings.contactEmail; -let _csrfProtection: boolean = defaultSettings.csrfProtection; -let _domains: Record = defaultSettings.domains; -let _rateLimit: RateLimitConfig = defaultSettings.rateLimit; - -// These are not always present in the default config file, so casting is required -let _tlsEnabled = defaultSettings.tls.enabled; -let _tlsKeyPemPath = defaultSettings.tls.key; -let _tlsCertPemPath = defaultSettings.tls.cert; -let _uiRouteAuth: Record = defaultSettings.uiRouteAuth; - -// Initialize configuration with defaults and user settings -let _config = { ...defaultSettings, ...(_userSettings || {}) } as Configuration; - -// Create config loader instance -const configLoader = new ConfigLoader(_config); +// Function to clean undefined values from an object +function cleanUndefinedValues(obj: any): any { + if (obj === null || obj === undefined) return obj; + if (typeof obj !== 'object') return obj; + if (Array.isArray(obj)) return obj.map(cleanUndefinedValues); -// Get configured proxy URL -export const getProxyUrl = () => { - if (_userSettings !== null && _userSettings.proxyUrl) { - _proxyUrl = _userSettings.proxyUrl; + const cleaned: any = {}; + for (const [key, value] of Object.entries(obj)) { + if (value !== undefined) { + cleaned[key] = cleanUndefinedValues(value); + } } + return cleaned; +} + +/** + * Load and merge default + user configuration with QuickType validation + * @return {GitProxyConfig} The merged and validated configuration + */ +function loadFullConfiguration(): GitProxyConfig { + if (_currentConfig) { + return _currentConfig; + } + + const rawDefaultConfig = Convert.toGitProxyConfig(JSON.stringify(defaultSettings)); + + // Clean undefined values from defaultConfig + const defaultConfig = cleanUndefinedValues(rawDefaultConfig); + + let userSettings: Partial = {}; + const userConfigFile = process.env.CONFIG_FILE || configFile; - return _proxyUrl; + if (existsSync(userConfigFile)) { + try { + const userConfigContent = readFileSync(userConfigFile, 'utf-8'); + // Parse as JSON first, then clean undefined values + // Don't use QuickType validation for partial configurations + const rawUserConfig = JSON.parse(userConfigContent); + userSettings = cleanUndefinedValues(rawUserConfig); + } catch (error) { + console.error(`Error loading user config from ${userConfigFile}:`, error); + throw error; + } + } + + _currentConfig = mergeConfigurations(defaultConfig, userSettings); + + return _currentConfig; +} + +/** + * Merge configurations with environment variable overrides + * @param {GitProxyConfig} defaultConfig - The default configuration + * @param {Partial} userSettings - User-provided configuration overrides + * @return {GitProxyConfig} The merged configuration + */ +function mergeConfigurations( + defaultConfig: GitProxyConfig, + userSettings: Partial, +): GitProxyConfig { + // Special handling for TLS configuration when legacy fields are used + let tlsConfig = userSettings.tls || defaultConfig.tls; + + // If user doesn't specify tls but has legacy SSL fields, use only legacy fallback + if (!userSettings.tls && (userSettings.sslKeyPemPath || userSettings.sslCertPemPath)) { + tlsConfig = { + enabled: defaultConfig.tls?.enabled || false, + // Use empty strings so legacy fallback works + key: '', + cert: '', + }; + } + + return { + ...defaultConfig, + ...userSettings, + // Deep merge for specific objects + api: userSettings.api ? cleanUndefinedValues(userSettings.api) : defaultConfig.api, + domains: { ...defaultConfig.domains, ...userSettings.domains }, + commitConfig: { ...defaultConfig.commitConfig, ...userSettings.commitConfig }, + attestationConfig: { ...defaultConfig.attestationConfig, ...userSettings.attestationConfig }, + rateLimit: userSettings.rateLimit || defaultConfig.rateLimit, + tls: tlsConfig, + tempPassword: { ...defaultConfig.tempPassword, ...userSettings.tempPassword }, + // Preserve legacy SSL fields + sslKeyPemPath: userSettings.sslKeyPemPath || defaultConfig.sslKeyPemPath, + sslCertPemPath: userSettings.sslCertPemPath || defaultConfig.sslCertPemPath, + // Environment variable overrides + cookieSecret: + serverConfig.GIT_PROXY_COOKIE_SECRET || + userSettings.cookieSecret || + defaultConfig.cookieSecret, + }; +} + +// Get configured proxy URL +export const getProxyUrl = (): string | undefined => { + const config = loadFullConfiguration(); + return config.proxyUrl; }; // Gets a list of authorised repositories export const getAuthorisedList = () => { - if (_userSettings !== null && _userSettings.authorisedList) { - _authorisedList = _userSettings.authorisedList; - } - return _authorisedList; + const config = loadFullConfiguration(); + return config.authorisedList || []; }; // Gets a list of authorised repositories export const getTempPasswordConfig = () => { - if (_userSettings !== null && _userSettings.tempPassword) { - _tempPassword = _userSettings.tempPassword; - } - - return _tempPassword; + const config = loadFullConfiguration(); + return config.tempPassword; }; // Gets the configured data sink, defaults to filesystem export const getDatabase = () => { - if (_userSettings !== null && _userSettings.sink) { - _database = _userSettings.sink; - } - for (const ix in _database) { - if (ix) { - const db = _database[ix]; - if (db.enabled) { - // if mongodb is configured and connection string unspecified, fallback to env var - if (db.type === 'mongo' && !db.connectionString) { - db.connectionString = serverConfig.GIT_PROXY_MONGO_CONNECTION_STRING; - } - return db; + const config = loadFullConfiguration(); + const databases = config.sink || []; + + for (const db of databases) { + if (db.enabled) { + // if mongodb is configured and connection string unspecified, fallback to env var + if (db.type === 'mongo' && !db.connectionString) { + db.connectionString = serverConfig.GIT_PROXY_MONGO_CONNECTION_STRING; } + return db; } } @@ -107,12 +158,11 @@ export const getDatabase = () => { * At least one authentication method must be enabled. * @return {Authentication[]} List of enabled authentication methods */ -export const getAuthMethods = (): Authentication[] => { - if (_userSettings !== null && _userSettings.authentication) { - _authentication = _userSettings.authentication; - } +export const getAuthMethods = () => { + const config = loadFullConfiguration(); + const authSources = config.authentication || []; - const enabledAuthMethods = _authentication.filter((auth) => auth.enabled); + const enabledAuthMethods = authSources.filter((auth) => auth.enabled); if (enabledAuthMethods.length === 0) { throw new Error('No authentication method enabled'); @@ -127,12 +177,17 @@ export const getAuthMethods = (): Authentication[] => { * If no API authentication methods are enabled, all endpoints are public. * @return {Authentication[]} List of enabled authentication methods */ -export const getAPIAuthMethods = (): Authentication[] => { - if (_userSettings !== null && _userSettings.apiAuthentication) { - _apiAuthentication = _userSettings.apiAuthentication; - } +export const getAPIAuthMethods = () => { + const config = loadFullConfiguration(); + const apiAuthSources = config.apiAuthentication || []; - return _apiAuthentication.filter((auth) => auth.enabled); + return apiAuthSources.filter((auth: { enabled: any }) => auth.enabled); +}; + +// Gets the configured authentication method, defaults to local (backward compatibility) +export const getAuthentication = () => { + const authMethods = getAuthMethods(); + return authMethods[0]; // Return first enabled method for backward compatibility }; // Log configuration to console @@ -144,151 +199,107 @@ export const logConfiguration = () => { }; export const getAPIs = () => { - if (_userSettings && _userSettings.api) { - _api = _userSettings.api; - } - return _api; + const config = loadFullConfiguration(); + return config.api || {}; }; -export const getCookieSecret = () => { - if (_userSettings && _userSettings.cookieSecret) { - _cookieSecret = _userSettings.cookieSecret; - } - return _cookieSecret; +export const getCookieSecret = (): string | undefined => { + const config = loadFullConfiguration(); + return config.cookieSecret; }; -export const getSessionMaxAgeHours = () => { - if (_userSettings && _userSettings.sessionMaxAgeHours) { - _sessionMaxAgeHours = _userSettings.sessionMaxAgeHours; - } - return _sessionMaxAgeHours; +export const getSessionMaxAgeHours = (): number | undefined => { + const config = loadFullConfiguration(); + return config.sessionMaxAgeHours; }; // Get commit related configuration export const getCommitConfig = () => { - if (_userSettings && _userSettings.commitConfig) { - _commitConfig = _userSettings.commitConfig; - } - return _commitConfig; + const config = loadFullConfiguration(); + return config.commitConfig || {}; }; // Get attestation related configuration export const getAttestationConfig = () => { - if (_userSettings && _userSettings.attestationConfig) { - _attestationConfig = _userSettings.attestationConfig; - } - return _attestationConfig; + const config = loadFullConfiguration(); + return config.attestationConfig || {}; }; // Get private organizations related configuration export const getPrivateOrganizations = () => { - if (_userSettings && _userSettings.privateOrganizations) { - _privateOrganizations = _userSettings.privateOrganizations; - } - return _privateOrganizations; + const config = loadFullConfiguration(); + return config.privateOrganizations || []; }; // Get URL shortener -export const getURLShortener = () => { - if (_userSettings && _userSettings.urlShortener) { - _urlShortener = _userSettings.urlShortener; - } - return _urlShortener; +export const getURLShortener = (): string | undefined => { + const config = loadFullConfiguration(); + return config.urlShortener; }; // Get contact e-mail address -export const getContactEmail = () => { - if (_userSettings && _userSettings.contactEmail) { - _contactEmail = _userSettings.contactEmail; - } - return _contactEmail; +export const getContactEmail = (): string | undefined => { + const config = loadFullConfiguration(); + return config.contactEmail; }; // Get CSRF protection flag -export const getCSRFProtection = () => { - if (_userSettings && _userSettings.csrfProtection) { - _csrfProtection = _userSettings.csrfProtection; - } - return _csrfProtection; +export const getCSRFProtection = (): boolean | undefined => { + const config = loadFullConfiguration(); + return config.csrfProtection; }; // Get loadable push plugins export const getPlugins = () => { - if (_userSettings && _userSettings.plugins) { - _plugins = _userSettings.plugins; - } - return _plugins; + const config = loadFullConfiguration(); + return config.plugins || []; }; -export const getTLSKeyPemPath = () => { - if (_userSettings && _userSettings.sslKeyPemPath) { - console.log( - 'Warning: sslKeyPemPath setting is replaced with tls.key setting in proxy.config.json & will be deprecated in a future release', - ); - _tlsKeyPemPath = _userSettings.sslKeyPemPath; - } - if (_userSettings?.tls && _userSettings?.tls?.key) { - _tlsKeyPemPath = _userSettings.tls.key; - } - return _tlsKeyPemPath; +export const getTLSKeyPemPath = (): string | undefined => { + const config = loadFullConfiguration(); + return config.tls?.key && config.tls.key !== '' ? config.tls.key : config.sslKeyPemPath; }; -export const getTLSCertPemPath = () => { - if (_userSettings && _userSettings.sslCertPemPath) { - console.log( - 'Warning: sslCertPemPath setting is replaced with tls.cert setting in proxy.config.json & will be deprecated in a future release', - ); - _tlsCertPemPath = _userSettings.sslCertPemPath; - } - if (_userSettings?.tls && _userSettings?.tls?.cert) { - _tlsCertPemPath = _userSettings.tls.cert; - } - return _tlsCertPemPath; +export const getTLSCertPemPath = (): string | undefined => { + const config = loadFullConfiguration(); + return config.tls?.cert && config.tls.cert !== '' ? config.tls.cert : config.sslCertPemPath; }; -export const getTLSEnabled = () => { - if (_userSettings && _userSettings.tls && _userSettings.tls.enabled) { - _tlsEnabled = _userSettings.tls.enabled; - } - return _tlsEnabled; +export const getTLSEnabled = (): boolean => { + const config = loadFullConfiguration(); + return config.tls?.enabled || false; }; export const getDomains = () => { - if (_userSettings && _userSettings.domains) { - _domains = _userSettings.domains; - } - return _domains; + const config = loadFullConfiguration(); + return config.domains || {}; }; export const getUIRouteAuth = () => { - if (_userSettings && _userSettings.uiRouteAuth) { - _uiRouteAuth = _userSettings.uiRouteAuth; - } - return _uiRouteAuth; + const config = loadFullConfiguration(); + return config.uiRouteAuth || {}; }; export const getRateLimit = () => { - if (_userSettings && _userSettings.rateLimit) { - _rateLimit = _userSettings.rateLimit; - } - return _rateLimit; + const config = loadFullConfiguration(); + return config.rateLimit; }; // Function to handle configuration updates -const handleConfigUpdate = async (newConfig: typeof _config) => { +const handleConfigUpdate = async (newConfig: Configuration) => { console.log('Configuration updated from external source'); try { - // 1. Get proxy module dynamically to avoid circular dependency + // 1. Validate new configuration using QuickType + const validatedConfig = Convert.toGitProxyConfig(JSON.stringify(newConfig)); + + // 2. Get proxy module dynamically to avoid circular dependency const proxy = require('../proxy'); - // 2. Stop existing services + // 3. Stop existing services await proxy.stop(); - // 3. Update config - _config = newConfig; - - // 4. Validate new configuration - validate(); + // 4. Update config + _currentConfig = validatedConfig; // 5. Restart services with new config await proxy.start(); @@ -306,22 +317,39 @@ const handleConfigUpdate = async (newConfig: typeof _config) => { } }; -// Handle configuration updates -configLoader.on('configurationChanged', handleConfigUpdate); +// Initialize config loader +function initializeConfigLoader() { + const config = loadFullConfiguration() as Configuration; + _configLoader = new ConfigLoader(config); + + // Handle configuration updates + _configLoader.on('configurationChanged', handleConfigUpdate); -configLoader.on('configurationError', (error: Error) => { - console.error('Error loading external configuration:', error); -}); + _configLoader.on('configurationError', (error: Error) => { + console.error('Error loading external configuration:', error); + }); -// Start the config loader if external sources are enabled -configLoader.start().catch((error: Error) => { - console.error('Failed to start configuration loader:', error); -}); + // Start the config loader if external sources are enabled + _configLoader.start().catch((error: Error) => { + console.error('Failed to start configuration loader:', error); + }); +} // Force reload of configuration -const reloadConfiguration = async () => { - await configLoader.reloadConfiguration(); +export const reloadConfiguration = async () => { + _currentConfig = null; + if (_configLoader) { + await _configLoader.reloadConfiguration(); + } + loadFullConfiguration(); }; -// Export reloadConfiguration -export { reloadConfiguration }; +// Initialize configuration on module load +try { + loadFullConfiguration(); + initializeConfigLoader(); + console.log('Configuration loaded successfully'); +} catch (error) { + console.error('Failed to load configuration:', error); + throw error; +} diff --git a/src/db/index.ts b/src/db/index.ts index ff1189f1b..79be64a59 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -1,9 +1,10 @@ +import { getDatabase } from '../config'; const bcrypt = require('bcryptjs'); -const config = require('../config'); + let sink: any; -if (config.getDatabase().type === 'mongo') { +if (getDatabase().type === 'mongo') { sink = require('./mongo'); -} else if (config.getDatabase().type === 'fs') { +} else if (getDatabase().type === 'fs') { sink = require('./file'); } diff --git a/src/proxy/index.ts b/src/proxy/index.ts index 79c91791a..69e6b2b6e 100644 --- a/src/proxy/index.ts +++ b/src/proxy/index.ts @@ -30,8 +30,8 @@ const options: ServerOptions = { inflate: true, limit: '100000kb', type: '*/*', - key: getTLSEnabled() ? fs.readFileSync(getTLSKeyPemPath()) : undefined, - cert: getTLSEnabled() ? fs.readFileSync(getTLSCertPemPath()) : undefined, + key: getTLSEnabled() && getTLSKeyPemPath() ? fs.readFileSync(getTLSKeyPemPath()!) : undefined, + cert: getTLSEnabled() && getTLSCertPemPath() ? fs.readFileSync(getTLSCertPemPath()!) : undefined, }; export const proxyPreparations = async () => { diff --git a/src/proxy/routes/index.ts b/src/proxy/routes/index.ts index b4794a8ae..8095e30eb 100644 --- a/src/proxy/routes/index.ts +++ b/src/proxy/routes/index.ts @@ -113,7 +113,7 @@ router.use(teeAndValidate); router.use( '/', - proxy(getProxyUrl(), { + proxy(getProxyUrl() || '', { parseReqBody: false, preserveHostHdr: false, diff --git a/test/ConfigLoader.test.js b/test/ConfigLoader.test.js index f8bfde26d..07315e741 100644 --- a/test/ConfigLoader.test.js +++ b/test/ConfigLoader.test.js @@ -57,7 +57,9 @@ describe('ConfigLoader', () => { path: tempConfigFile, }); - expect(result).to.deep.equal(testConfig); + expect(result).to.be.an('object'); + expect(result.proxyUrl).to.equal('https://test.com'); + expect(result.cookieSecret).to.equal('test-secret'); }); }); @@ -78,7 +80,9 @@ describe('ConfigLoader', () => { headers: {}, }); - expect(result).to.deep.equal(testConfig); + expect(result).to.be.an('object'); + expect(result.proxyUrl).to.equal('https://test.com'); + expect(result.cookieSecret).to.equal('test-secret'); }); it('should include bearer token if provided', async () => { @@ -206,6 +210,15 @@ describe('ConfigLoader', () => { } }); + it('should return cacheDirPath via getter', async () => { + configLoader = new ConfigLoader({}); + await configLoader.initialize(); + + const cacheDirPath = configLoader.cacheDirPath; + expect(cacheDirPath).to.equal(configLoader.cacheDir); + expect(cacheDirPath).to.be.a('string'); + }); + it('should create cache directory if it does not exist', async () => { configLoader = new ConfigLoader({}); await configLoader.initialize(); @@ -680,3 +693,70 @@ describe('Validation Helpers', () => { }); }); }); + +describe('ConfigLoader Error Handling', () => { + let configLoader; + let tempDir; + let tempConfigFile; + + beforeEach(() => { + tempDir = fs.mkdtempSync('gitproxy-configloader-test-'); + tempConfigFile = path.join(tempDir, 'test-config.json'); + }); + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true }); + } + sinon.restore(); + configLoader?.stop(); + }); + + it('should handle invalid JSON in file source', async () => { + fs.writeFileSync(tempConfigFile, 'invalid json content'); + + configLoader = new ConfigLoader({}); + try { + await configLoader.loadFromFile({ + type: 'file', + enabled: true, + path: tempConfigFile, + }); + throw new Error('Expected error was not thrown'); + } catch (error) { + expect(error.message).to.contain('Invalid configuration file format'); + } + }); + + it('should handle HTTP request errors', async () => { + sinon.stub(axios, 'get').rejects(new Error('Network error')); + + configLoader = new ConfigLoader({}); + try { + await configLoader.loadFromHttp({ + type: 'http', + enabled: true, + url: 'http://config-service/config', + }); + throw new Error('Expected error was not thrown'); + } catch (error) { + expect(error.message).to.equal('Network error'); + } + }); + + it('should handle invalid JSON from HTTP response', async () => { + sinon.stub(axios, 'get').resolves({ data: 'invalid json response' }); + + configLoader = new ConfigLoader({}); + try { + await configLoader.loadFromHttp({ + type: 'http', + enabled: true, + url: 'http://config-service/config', + }); + throw new Error('Expected error was not thrown'); + } catch (error) { + expect(error.message).to.contain('Invalid configuration format from HTTP source'); + } + }); +}); diff --git a/test/generated-config.test.js b/test/generated-config.test.js new file mode 100644 index 000000000..6ccaea569 --- /dev/null +++ b/test/generated-config.test.js @@ -0,0 +1,361 @@ +const chai = require('chai'); +const { Convert } = require('../src/config/generated/config'); + +const { expect } = chai; + +describe('Generated Config (QuickType)', () => { + describe('Convert class', () => { + it('should parse valid configuration JSON', () => { + const validConfig = { + proxyUrl: 'https://proxy.example.com', + cookieSecret: 'test-secret', + authorisedList: [ + { + project: 'test', + name: 'repo', + url: 'https://github.com/test/repo.git', + }, + ], + authentication: [ + { + type: 'local', + enabled: true, + }, + ], + sink: [ + { + type: 'memory', + enabled: true, + }, + ], + }; + + const result = Convert.toGitProxyConfig(JSON.stringify(validConfig)); + + expect(result).to.be.an('object'); + expect(result.proxyUrl).to.equal('https://proxy.example.com'); + expect(result.cookieSecret).to.equal('test-secret'); + expect(result.authorisedList).to.be.an('array'); + expect(result.authentication).to.be.an('array'); + expect(result.sink).to.be.an('array'); + }); + + it('should convert config object back to JSON', () => { + const configObject = { + proxyUrl: 'https://proxy.example.com', + cookieSecret: 'test-secret', + authorisedList: [], + authentication: [ + { + type: 'local', + enabled: true, + }, + ], + }; + + const jsonString = Convert.gitProxyConfigToJson(configObject); + const parsed = JSON.parse(jsonString); + + expect(parsed).to.be.an('object'); + expect(parsed.proxyUrl).to.equal('https://proxy.example.com'); + expect(parsed.cookieSecret).to.equal('test-secret'); + }); + + it('should handle empty configuration object', () => { + const emptyConfig = {}; + + const result = Convert.toGitProxyConfig(JSON.stringify(emptyConfig)); + expect(result).to.be.an('object'); + }); + + it('should throw error for invalid JSON string', () => { + expect(() => { + Convert.toGitProxyConfig('invalid json'); + }).to.throw(); + }); + + it('should handle configuration with valid rate limit structure', () => { + const validConfig = { + proxyUrl: 'https://proxy.example.com', + cookieSecret: 'secret', + sessionMaxAgeHours: 24, + rateLimit: { + windowMs: 60000, + limit: 150, + }, + tempPassword: { + sendEmail: false, + emailConfig: {}, + }, + authorisedList: [ + { + project: 'test', + name: 'repo', + url: 'https://github.com/test/repo.git', + }, + ], + sink: [ + { + type: 'fs', + params: { + filepath: './.', + }, + enabled: true, + }, + ], + authentication: [ + { + type: 'local', + enabled: true, + }, + ], + contactEmail: 'admin@example.com', + csrfProtection: true, + plugins: [], + privateOrganizations: ['private-org'], + urlShortener: 'https://shortener.example.com', + }; + + const result = Convert.toGitProxyConfig(JSON.stringify(validConfig)); + + expect(result).to.be.an('object'); + expect(result.authentication).to.be.an('array'); + expect(result.authorisedList).to.be.an('array'); + expect(result.contactEmail).to.be.a('string'); + expect(result.cookieSecret).to.be.a('string'); + expect(result.csrfProtection).to.be.a('boolean'); + expect(result.plugins).to.be.an('array'); + expect(result.privateOrganizations).to.be.an('array'); + expect(result.proxyUrl).to.be.a('string'); + expect(result.rateLimit).to.be.an('object'); + expect(result.sessionMaxAgeHours).to.be.a('number'); + expect(result.sink).to.be.an('array'); + }); + + it('should handle malformed configuration gracefully', () => { + const malformedConfig = { + proxyUrl: 123, // Wrong type + authentication: 'not-an-array', // Wrong type + }; + + try { + const result = Convert.toGitProxyConfig(JSON.stringify(malformedConfig)); + expect(result).to.be.an('object'); + } catch (error) { + expect(error).to.be.an('error'); + } + }); + + it('should preserve array structures', () => { + const configWithArrays = { + proxyUrl: 'https://proxy.example.com', + cookieSecret: 'secret', + authorisedList: [ + { project: 'proj1', name: 'repo1', url: 'https://github.com/proj1/repo1.git' }, + { project: 'proj2', name: 'repo2', url: 'https://github.com/proj2/repo2.git' }, + ], + authentication: [{ type: 'local', enabled: true }], + sink: [{ type: 'fs', params: { filepath: './.' }, enabled: true }], + plugins: ['plugin1', 'plugin2'], + privateOrganizations: ['org1', 'org2'], + }; + + const result = Convert.toGitProxyConfig(JSON.stringify(configWithArrays)); + + expect(result.authorisedList).to.have.lengthOf(2); + expect(result.authentication).to.have.lengthOf(1); + expect(result.plugins).to.have.lengthOf(2); + expect(result.privateOrganizations).to.have.lengthOf(2); + }); + + it('should handle nested object structures', () => { + const configWithNesting = { + proxyUrl: 'https://proxy.example.com', + cookieSecret: 'secret', + authentication: [{ type: 'local', enabled: true }], + sink: [{ type: 'fs', params: { filepath: './.' }, enabled: true }], + tls: { + enabled: true, + key: '/path/to/key.pem', + cert: '/path/to/cert.pem', + }, + rateLimit: { + windowMs: 60000, + limit: 150, + }, + tempPassword: { + sendEmail: false, + emailConfig: {}, + }, + }; + + const result = Convert.toGitProxyConfig(JSON.stringify(configWithNesting)); + + expect(result.tls).to.be.an('object'); + expect(result.tls.enabled).to.be.a('boolean'); + expect(result.rateLimit).to.be.an('object'); + expect(result.tempPassword).to.be.an('object'); + }); + + it('should handle complex validation scenarios', () => { + // Test configuration that will trigger more validation paths + const complexConfig = { + proxyUrl: 'https://proxy.example.com', + cookieSecret: 'secret', + authentication: [{ type: 'local', enabled: true }], + sink: [{ type: 'fs', params: { filepath: './.' }, enabled: true }], + + api: { + github: { + baseUrl: 'https://api.github.com', + token: 'secret-token', + rateLimit: 100, + enabled: true, + }, + }, + + domains: { + localhost: 'http://localhost:3000', + 'example.com': 'https://example.com', + }, + + // Complex nested structures + attestationConfig: { + enabled: true, + questions: [ + { + id: 'q1', + type: 'boolean', + required: true, + label: 'Test Question', + }, + ], + }, + }; + + const result = Convert.toGitProxyConfig(JSON.stringify(complexConfig)); + expect(result).to.be.an('object'); + expect(result.api).to.be.an('object'); + expect(result.domains).to.be.an('object'); + }); + + it('should handle array validation edge cases', () => { + const configWithArrays = { + proxyUrl: 'https://proxy.example.com', + cookieSecret: 'secret', + authentication: [{ type: 'local', enabled: true }], + sink: [{ type: 'fs', params: { filepath: './.' }, enabled: true }], + + // Test different array structures + authorisedList: [ + { + project: 'test1', + name: 'repo1', + url: 'https://github.com/test1/repo1.git', + }, + { + project: 'test2', + name: 'repo2', + url: 'https://github.com/test2/repo2.git', + }, + ], + + plugins: ['plugin-a', 'plugin-b', 'plugin-c'], + privateOrganizations: ['org1', 'org2'], + }; + + const result = Convert.toGitProxyConfig(JSON.stringify(configWithArrays)); + expect(result.authorisedList).to.have.lengthOf(2); + expect(result.plugins).to.have.lengthOf(3); + expect(result.privateOrganizations).to.have.lengthOf(2); + }); + + it('should exercise transformation functions with edge cases', () => { + const edgeCaseConfig = { + proxyUrl: 'https://proxy.example.com', + cookieSecret: 'secret', + authentication: [{ type: 'local', enabled: true }], + sink: [{ type: 'fs', params: { filepath: './.' }, enabled: true }], + + sessionMaxAgeHours: 0, + csrfProtection: false, + + tempPassword: { + sendEmail: true, + emailConfig: { + host: 'smtp.example.com', + port: 587, + secure: false, + auth: { + user: 'user@example.com', + pass: 'password', + }, + }, + length: 12, + expiry: 7200, + }, + + rateLimit: { + windowMs: 900000, + limit: 1000, + message: 'Rate limit exceeded', + }, + }; + + const result = Convert.toGitProxyConfig(JSON.stringify(edgeCaseConfig)); + expect(result.sessionMaxAgeHours).to.equal(0); + expect(result.csrfProtection).to.equal(false); + expect(result.tempPassword).to.be.an('object'); + expect(result.tempPassword.length).to.equal(12); + }); + + it('should test validation error paths', () => { + try { + // Try to parse something that looks like valid JSON but has wrong structure + Convert.toGitProxyConfig('{"proxyUrl": 123, "authentication": "not-array"}'); + } catch (error) { + expect(error).to.be.an('error'); + } + }); + + it('should test date and null handling', () => { + // Test that null values cause validation errors (covers error paths) + const configWithNulls = { + proxyUrl: 'https://proxy.example.com', + cookieSecret: null, + authentication: [{ type: 'local', enabled: true }], + sink: [{ type: 'fs', params: { filepath: './.' }, enabled: true }], + contactEmail: null, + urlShortener: null, + }; + + expect(() => { + Convert.toGitProxyConfig(JSON.stringify(configWithNulls)); + }).to.throw('Invalid value'); + }); + + it('should test serialization back to JSON', () => { + const testConfig = { + proxyUrl: 'https://test.com', + cookieSecret: 'secret', + authentication: [{ type: 'local', enabled: true }], + sink: [{ type: 'fs', params: { filepath: './.' }, enabled: true }], + rateLimit: { + windowMs: 60000, + limit: 150, + }, + tempPassword: { + sendEmail: false, + emailConfig: {}, + }, + }; + + const parsed = Convert.toGitProxyConfig(JSON.stringify(testConfig)); + const serialized = Convert.gitProxyConfigToJson(parsed); + const reparsed = JSON.parse(serialized); + + expect(reparsed.proxyUrl).to.equal('https://test.com'); + expect(reparsed.rateLimit).to.be.an('object'); + }); + }); +}); diff --git a/test/proxy.test.js b/test/proxy.test.js new file mode 100644 index 000000000..0f1dd7bc8 --- /dev/null +++ b/test/proxy.test.js @@ -0,0 +1,141 @@ +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); +const fs = require('fs'); + +chai.use(sinonChai); +const { expect } = chai; + +describe('Proxy Module TLS Certificate Loading', () => { + let sandbox; + let mockConfig; + let mockHttpServer; + let mockHttpsServer; + let proxyModule; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + + mockConfig = { + getTLSEnabled: sandbox.stub(), + getTLSKeyPemPath: sandbox.stub(), + getTLSCertPemPath: sandbox.stub(), + getPlugins: sandbox.stub().returns([]), + getAuthorisedList: sandbox.stub().returns([]), + }; + + const mockDb = { + getRepos: sandbox.stub().resolves([]), + createRepo: sandbox.stub().resolves(), + addUserCanPush: sandbox.stub().resolves(), + addUserCanAuthorise: sandbox.stub().resolves(), + }; + + const mockPluginLoader = { + load: sandbox.stub().resolves(), + }; + + mockHttpServer = { + listen: sandbox.stub().callsFake((port, callback) => { + if (callback) callback(); + return mockHttpServer; + }), + close: sandbox.stub().callsFake((callback) => { + if (callback) callback(); + }), + }; + + mockHttpsServer = { + listen: sandbox.stub().callsFake((port, callback) => { + if (callback) callback(); + return mockHttpsServer; + }), + close: sandbox.stub().callsFake((callback) => { + if (callback) callback(); + }), + }; + + sandbox.stub(require('../src/plugin'), 'PluginLoader').returns(mockPluginLoader); + + const configModule = require('../src/config'); + sandbox.stub(configModule, 'getTLSEnabled').callsFake(mockConfig.getTLSEnabled); + sandbox.stub(configModule, 'getTLSKeyPemPath').callsFake(mockConfig.getTLSKeyPemPath); + sandbox.stub(configModule, 'getTLSCertPemPath').callsFake(mockConfig.getTLSCertPemPath); + sandbox.stub(configModule, 'getPlugins').callsFake(mockConfig.getPlugins); + sandbox.stub(configModule, 'getAuthorisedList').callsFake(mockConfig.getAuthorisedList); + + const dbModule = require('../src/db'); + sandbox.stub(dbModule, 'getRepos').callsFake(mockDb.getRepos); + sandbox.stub(dbModule, 'createRepo').callsFake(mockDb.createRepo); + sandbox.stub(dbModule, 'addUserCanPush').callsFake(mockDb.addUserCanPush); + sandbox.stub(dbModule, 'addUserCanAuthorise').callsFake(mockDb.addUserCanAuthorise); + + const chain = require('../src/proxy/chain'); + chain.chainPluginLoader = null; + + process.env.NODE_ENV = 'test'; + process.env.GIT_PROXY_HTTPS_SERVER_PORT = '8443'; + + // Import proxy module after mocks are set up + delete require.cache[require.resolve('../src/proxy/index')]; + proxyModule = require('../src/proxy/index').default; + }); + + afterEach(async () => { + try { + await proxyModule.stop(); + } catch (error) { + // Ignore errors during cleanup + } + sandbox.restore(); + }); + + describe('TLS certificate file reading', () => { + it('should read TLS key and cert files when TLS is enabled and paths are provided', async () => { + const mockKeyContent = Buffer.from('mock-key-content'); + const mockCertContent = Buffer.from('mock-cert-content'); + + mockConfig.getTLSEnabled.returns(true); + mockConfig.getTLSKeyPemPath.returns('/path/to/key.pem'); + mockConfig.getTLSCertPemPath.returns('/path/to/cert.pem'); + + const fsStub = sandbox.stub(fs, 'readFileSync'); + fsStub.returns(Buffer.from('default-cert')); + fsStub.withArgs('/path/to/key.pem').returns(mockKeyContent); + fsStub.withArgs('/path/to/cert.pem').returns(mockCertContent); + await proxyModule.start(); + + // Check if files should have been read + if (fsStub.called) { + expect(fsStub).to.have.been.calledWith('/path/to/key.pem'); + expect(fsStub).to.have.been.calledWith('/path/to/cert.pem'); + } else { + console.log('fs.readFileSync was never called - TLS certificate reading not triggered'); + } + }); + + it('should not read TLS files when TLS is disabled', async () => { + mockConfig.getTLSEnabled.returns(false); + mockConfig.getTLSKeyPemPath.returns('/path/to/key.pem'); + mockConfig.getTLSCertPemPath.returns('/path/to/cert.pem'); + + const fsStub = sandbox.stub(fs, 'readFileSync'); + + await proxyModule.start(); + + expect(fsStub).not.to.have.been.called; + }); + + it('should not read TLS files when paths are not provided', async () => { + mockConfig.getTLSEnabled.returns(true); + mockConfig.getTLSKeyPemPath.returns(null); + mockConfig.getTLSCertPemPath.returns(null); + + const fsStub = sandbox.stub(fs, 'readFileSync'); + + await proxyModule.start(); + + expect(fsStub).not.to.have.been.called; + }); + }); +}); diff --git a/test/testConfig.test.js b/test/testConfig.test.js index 2d34d91dd..c099dffea 100644 --- a/test/testConfig.test.js +++ b/test/testConfig.test.js @@ -54,8 +54,9 @@ describe('user configuration', function () { }; fs.writeFileSync(tempUserFile, JSON.stringify(user)); + // Invalidate cache to force reload const config = require('../src/config'); - config.initUserConfig(); + config.invalidateCache(); const enabledMethods = defaultSettings.authentication.filter((method) => method.enabled); expect(config.getAuthorisedList()).to.be.eql(user.authorisedList); @@ -65,16 +66,31 @@ describe('user configuration', function () { }); it('should override default settings for authentication', function () { - const user = { authentication: [{ type: 'google', enabled: true }] }; + const user = { + authentication: [ + { + type: 'openidconnect', + enabled: true, + oidcConfig: { + issuer: 'https://accounts.google.com', + clientID: 'test-client-id', + clientSecret: 'test-client-secret', + callbackURL: 'https://example.com/callback', + scope: 'openid email profile', + }, + }, + ], + }; fs.writeFileSync(tempUserFile, JSON.stringify(user)); + // Invalidate cache to force reload const config = require('../src/config'); - config.initUserConfig(); + config.invalidateCache(); const authMethods = config.getAuthMethods(); - const googleAuth = authMethods.find((method) => method.type === 'google'); + const oidcAuth = authMethods.find((method) => method.type === 'openidconnect'); - expect(googleAuth).to.not.be.undefined; - expect(googleAuth.enabled).to.be.true; + expect(oidcAuth).to.not.be.undefined; + expect(oidcAuth.enabled).to.be.true; expect(config.getAuthMethods()).to.deep.include(user.authentication[0]); expect(config.getAuthMethods()).to.not.be.eql(defaultSettings.authentication); expect(config.getDatabase()).to.be.eql(defaultSettings.sink[0]); @@ -86,7 +102,7 @@ describe('user configuration', function () { fs.writeFileSync(tempUserFile, JSON.stringify(user)); const config = require('../src/config'); - config.initUserConfig(); + config.invalidateCache(); const enabledMethods = defaultSettings.authentication.filter((method) => method.enabled); expect(config.getDatabase()).to.be.eql(user.sink[0]); @@ -96,11 +112,18 @@ describe('user configuration', function () { }); it('should override default settings for SSL certificate', function () { - const user = { tls: { key: 'my-key.pem', cert: 'my-cert.pem' } }; + const user = { + tls: { + enabled: true, + key: 'my-key.pem', + cert: 'my-cert.pem', + }, + }; fs.writeFileSync(tempUserFile, JSON.stringify(user)); + // Invalidate cache to force reload const config = require('../src/config'); - config.initUserConfig(); + config.invalidateCache(); expect(config.getTLSKeyPemPath()).to.be.eql(user.tls.key); expect(config.getTLSCertPemPath()).to.be.eql(user.tls.cert); @@ -111,7 +134,7 @@ describe('user configuration', function () { fs.writeFileSync(tempUserFile, JSON.stringify(limitConfig)); const config = require('../src/config'); - config.initUserConfig(); + config.invalidateCache(); expect(config.getRateLimit().windowMs).to.be.eql(limitConfig.rateLimit.windowMs); expect(config.getRateLimit().limit).to.be.eql(limitConfig.rateLimit.limit); @@ -128,7 +151,7 @@ describe('user configuration', function () { fs.writeFileSync(tempUserFile, JSON.stringify(user)); const config = require('../src/config'); - config.initUserConfig(); + config.invalidateCache(); expect(config.getAttestationConfig()).to.be.eql(user.attestationConfig); }); @@ -137,8 +160,9 @@ describe('user configuration', function () { const user = { urlShortener: 'https://url-shortener.com' }; fs.writeFileSync(tempUserFile, JSON.stringify(user)); + // Invalidate cache to force reload const config = require('../src/config'); - config.initUserConfig(); + config.invalidateCache(); expect(config.getURLShortener()).to.be.eql(user.urlShortener); }); @@ -148,7 +172,7 @@ describe('user configuration', function () { fs.writeFileSync(tempUserFile, JSON.stringify(user)); const config = require('../src/config'); - config.initUserConfig(); + config.invalidateCache(); expect(config.getContactEmail()).to.be.eql(user.contactEmail); }); @@ -158,18 +182,24 @@ describe('user configuration', function () { fs.writeFileSync(tempUserFile, JSON.stringify(user)); const config = require('../src/config'); - config.initUserConfig(); + config.invalidateCache(); expect(config.getPlugins()).to.be.eql(user.plugins); }); it('should override default settings for sslCertPemPath', function () { - const user = { tls: { enabled: true, key: 'my-key.pem', cert: 'my-cert.pem' } }; + const user = { + tls: { + enabled: true, + key: 'my-key.pem', + cert: 'my-cert.pem', + }, + }; fs.writeFileSync(tempUserFile, JSON.stringify(user)); const config = require('../src/config'); - config.initUserConfig(); + config.invalidateCache(); expect(config.getTLSCertPemPath()).to.be.eql(user.tls.cert); expect(config.getTLSKeyPemPath()).to.be.eql(user.tls.key); @@ -184,8 +214,9 @@ describe('user configuration', function () { }; fs.writeFileSync(tempUserFile, JSON.stringify(user)); + // Invalidate cache to force reload const config = require('../src/config'); - config.initUserConfig(); + config.invalidateCache(); expect(config.getTLSCertPemPath()).to.be.eql(user.tls.cert); expect(config.getTLSKeyPemPath()).to.be.eql(user.tls.key); @@ -196,8 +227,9 @@ describe('user configuration', function () { const user = { sslKeyPemPath: 'good-key.pem', sslCertPemPath: 'good-cert.pem' }; fs.writeFileSync(tempUserFile, JSON.stringify(user)); + // Invalidate cache to force reload const config = require('../src/config'); - config.initUserConfig(); + config.invalidateCache(); expect(config.getTLSCertPemPath()).to.be.eql(user.sslCertPemPath); expect(config.getTLSKeyPemPath()).to.be.eql(user.sslKeyPemPath); @@ -208,8 +240,9 @@ describe('user configuration', function () { const user = { api: { gitlab: { baseUrl: 'https://gitlab.com' } } }; fs.writeFileSync(tempUserFile, JSON.stringify(user)); + // Invalidate cache to force reload const config = require('../src/config'); - config.initUserConfig(); + config.invalidateCache(); expect(config.getAPIs()).to.be.eql(user.api); }); @@ -219,20 +252,91 @@ describe('user configuration', function () { process.env.GIT_PROXY_COOKIE_SECRET = 'test-cookie-secret'; const config = require('../src/config'); - config.initUserConfig(); + config.invalidateCache(); expect(config.getCookieSecret()).to.equal('test-cookie-secret'); }); it('should override default settings for mongo connection string if env var is used', function () { - const user = { sink: [{ type: 'mongo', enabled: true }] }; + const user = { + sink: [ + { + type: 'mongo', + enabled: true, + }, + ], + }; fs.writeFileSync(tempUserFile, JSON.stringify(user)); process.env.GIT_PROXY_MONGO_CONNECTION_STRING = 'mongodb://example.com:27017/test'; const config = require('../src/config'); - config.initUserConfig(); + config.invalidateCache(); expect(config.getDatabase().connectionString).to.equal('mongodb://example.com:27017/test'); }); + it('should test cache invalidation function', function () { + fs.writeFileSync(tempUserFile, '{}'); + + const config = require('../src/config'); + + // Load config first time + const firstLoad = config.getAuthorisedList(); + + // Invalidate cache and load again + config.invalidateCache(); + const secondLoad = config.getAuthorisedList(); + + expect(firstLoad).to.deep.equal(secondLoad); + }); + + it('should test reloadConfiguration function', async function () { + fs.writeFileSync(tempUserFile, '{}'); + + const config = require('../src/config'); + + // reloadConfiguration doesn't throw + await config.reloadConfiguration(); + }); + + it('should handle configuration errors during initialization', function () { + const user = { + invalidConfig: 'this should cause validation error', + }; + fs.writeFileSync(tempUserFile, JSON.stringify(user)); + + const config = require('../src/config'); + expect(() => config.getAuthorisedList()).to.not.throw(); + }); + + it('should test all getter functions for coverage', function () { + fs.writeFileSync(tempUserFile, '{}'); + + const config = require('../src/config'); + + expect(() => config.getProxyUrl()).to.not.throw(); + expect(() => config.getCookieSecret()).to.not.throw(); + expect(() => config.getSessionMaxAgeHours()).to.not.throw(); + expect(() => config.getCommitConfig()).to.not.throw(); + expect(() => config.getPrivateOrganizations()).to.not.throw(); + expect(() => config.getUIRouteAuth()).to.not.throw(); + }); + + it('should test getAuthentication function returns first auth method', function () { + const user = { + authentication: [ + { type: 'ldap', enabled: true }, + { type: 'local', enabled: true }, + ], + }; + fs.writeFileSync(tempUserFile, JSON.stringify(user)); + + const config = require('../src/config'); + config.invalidateCache(); + + const firstAuth = config.getAuthentication(); + expect(firstAuth).to.be.an('object'); + expect(firstAuth.type).to.equal('ldap'); + }); + afterEach(function () { fs.rmSync(tempUserFile); fs.rmdirSync(tempDir); @@ -261,7 +365,125 @@ describe('validate config files', function () { } }); + it('should validate using default config file when no path provided', function () { + const originalConfigFile = config.configFile; + const mainConfigPath = path.join(__dirname, '..', 'proxy.config.json'); + config.setConfigFile(mainConfigPath); + + try { + // default configFile + expect(() => config.validate()).to.not.throw(); + } finally { + // Restore original config file + config.setConfigFile(originalConfigFile); + } + }); + after(function () { delete require.cache[require.resolve('../src/config')]; }); }); + +describe('setConfigFile function', function () { + const config = require('../src/config/file'); + let originalConfigFile; + + beforeEach(function () { + originalConfigFile = config.configFile; + }); + + afterEach(function () { + // Restore original config file + config.setConfigFile(originalConfigFile); + }); + + it('should set the config file path', function () { + const newPath = '/tmp/new-config.json'; + config.setConfigFile(newPath); + expect(config.configFile).to.equal(newPath); + }); + + it('should allow changing config file multiple times', function () { + const firstPath = '/tmp/first-config.json'; + const secondPath = '/tmp/second-config.json'; + + config.setConfigFile(firstPath); + expect(config.configFile).to.equal(firstPath); + + config.setConfigFile(secondPath); + expect(config.configFile).to.equal(secondPath); + }); +}); + +describe('Configuration Update Handling', function () { + let tempDir; + let tempUserFile; + let oldEnv; + + beforeEach(function () { + delete require.cache[require.resolve('../src/config')]; + oldEnv = { ...process.env }; + tempDir = fs.mkdtempSync('gitproxy-test'); + tempUserFile = path.join(tempDir, 'test-settings.json'); + require('../src/config/file').configFile = tempUserFile; + }); + + it('should test ConfigLoader initialization', function () { + const configWithSources = { + configurationSources: { + enabled: true, + sources: [ + { + type: 'file', + enabled: true, + path: tempUserFile, + }, + ], + }, + }; + + fs.writeFileSync(tempUserFile, JSON.stringify(configWithSources)); + + const config = require('../src/config'); + config.invalidateCache(); + + expect(() => config.getAuthorisedList()).to.not.throw(); + }); + + it('should handle config loader initialization errors', function () { + const invalidConfigSources = { + configurationSources: { + enabled: true, + sources: [ + { + type: 'invalid-type', + enabled: true, + path: tempUserFile, + }, + ], + }, + }; + + fs.writeFileSync(tempUserFile, JSON.stringify(invalidConfigSources)); + + const consoleErrorSpy = require('sinon').spy(console, 'error'); + + const config = require('../src/config'); + config.invalidateCache(); + + expect(() => config.getAuthorisedList()).to.not.throw(); + + consoleErrorSpy.restore(); + }); + + afterEach(function () { + if (fs.existsSync(tempUserFile)) { + fs.rmSync(tempUserFile, { force: true }); + } + if (fs.existsSync(tempDir)) { + fs.rmdirSync(tempDir); + } + process.env = oldEnv; + delete require.cache[require.resolve('../src/config')]; + }); +});