diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 0ed90732d..4735f3fb0 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -21,6 +21,6 @@ jobs: with: comment-summary-in-pr: always fail-on-severity: high - allow-licenses: MIT, MIT-0, Apache-2.0, BSD-3-Clause, BSD-3-Clause-Clear, ISC, BSD-2-Clause, Unlicense, CC0-1.0, 0BSD, X11, MPL-2.0, MPL-1.0, MPL-1.1, MPL-2.0, OFL-1.1, Zlib + allow-licenses: MIT, MIT-0, Apache-2.0, BSD-3-Clause, BSD-3-Clause-Clear, ISC, BSD-2-Clause, Unlicense, CC0-1.0, 0BSD, X11, MPL-2.0, MPL-1.0, MPL-1.1, MPL-2.0, OFL-1.1, Zlib, BlueOak-1.0.0 fail-on-scopes: development, runtime allow-dependencies-licenses: 'pkg:npm/caniuse-lite' diff --git a/.github/workflows/unused-dependencies.yml b/.github/workflows/unused-dependencies.yml index 8b48b6fc7..6af85a852 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,@types/sinon,quicktype,history,@types/domutils" + npx depcheck --skip-missing --ignores="tsx,@babel/*,@commitlint/*,eslint,eslint-*,husky,ts-node,concurrently,nyc,prettier,typescript,tsconfig-paths,vite-tsconfig-paths,quicktype,history,@types/domutils,@vitest/coverage-v8" echo $? if [[ $? == 1 ]]; then echo "Unused dependencies or devDependencies found" diff --git a/config.schema.json b/config.schema.json index dafb93c3f..97d3d1dcb 100644 --- a/config.schema.json +++ b/config.schema.json @@ -427,6 +427,10 @@ "password": { "type": "string", "description": "Password for the given `username`." + }, + "searchBase": { + "type": "string", + "description": "Override baseDN to query for users in other OUs or sub-trees." } }, "required": ["url", "baseDN", "username", "password"] @@ -466,7 +470,9 @@ "description": "Additional JWT configuration.", "properties": { "clientID": { "type": "string" }, - "authorityURL": { "type": "string" } + "authorityURL": { "type": "string" }, + "expectedAudience": { "type": "string" }, + "roleMapping": { "$ref": "#/definitions/roleMapping" } }, "required": ["clientID", "authorityURL"] } @@ -482,6 +488,14 @@ "adminOnly": { "type": "boolean" }, "loginRequired": { "type": "boolean" } } + }, + "roleMapping": { + "type": "object", + "description": "Mapping of application roles to JWT claims. Each key is a role name, and its value is an object mapping claim names to expected values.", + "additionalProperties": { + "type": "object", + "additionalProperties": { "type": "string" } + } } }, "additionalProperties": false diff --git a/eslint.config.mjs b/eslint.config.mjs index 284e94b91..38c953971 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -149,6 +149,7 @@ export default defineConfig( ], // allow for chai `expect().to.xyz` '@typescript-eslint/no-unused-expressions': 'off', + 'new-cap': ['error', { capIsNewExceptionPattern: '^express\\..*' }], }, }, diff --git a/package-lock.json b/package-lock.json index bbb0085e4..95cb574b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,7 @@ "react-html-parser": "^2.0.2", "react-router-dom": "6.30.1", "simple-git": "^3.28.0", + "supertest": "^7.1.4", "uuid": "^11.1.0", "validator": "^13.15.15", "yargs": "^17.7.2" @@ -76,18 +77,16 @@ "@types/jwk-to-pem": "^2.0.3", "@types/lodash": "^4.17.20", "@types/lusca": "^1.7.5", - "@types/mocha": "^10.0.10", "@types/node": "^22.18.10", "@types/passport": "^1.0.17", "@types/passport-local": "^1.0.38", "@types/react-dom": "^17.0.26", "@types/react-html-parser": "^2.0.7", - "@types/sinon": "^17.0.4", + "@types/supertest": "^6.0.3", "@types/validator": "^13.15.3", "@types/yargs": "^17.0.33", "@vitejs/plugin-react": "^4.7.0", - "chai": "^4.5.0", - "chai-http": "^4.4.0", + "@vitest/coverage-v8": "^3.2.4", "cypress": "^15.4.0", "eslint": "^9.37.0", "eslint-config-prettier": "^10.1.8", @@ -97,20 +96,16 @@ "globals": "^16.4.0", "husky": "^9.1.7", "lint-staged": "^16.2.4", - "mocha": "^10.8.2", "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.6", "typescript": "^5.9.3", "typescript-eslint": "^8.46.1", - "vite": "^4.5.14", - "vite-tsconfig-paths": "^5.1.4" + "vite": "^7.1.9", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^3.2.4" }, "engines": { "node": ">=20.19.2" @@ -130,6 +125,20 @@ "node": ">=0.10.0" } }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "dev": true, @@ -499,6 +508,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@commitlint/cli": { "version": "19.8.1", "dev": true, @@ -935,9 +954,9 @@ "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", - "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz", + "integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==", "cpu": [ "ppc64" ], @@ -952,9 +971,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", - "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.11.tgz", + "integrity": "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==", "cpu": [ "arm" ], @@ -965,13 +984,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", - "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz", + "integrity": "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==", "cpu": [ "arm64" ], @@ -982,13 +1001,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", - "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.11.tgz", + "integrity": "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==", "cpu": [ "x64" ], @@ -999,7 +1018,7 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { @@ -1035,9 +1054,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", - "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz", + "integrity": "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==", "cpu": [ "arm64" ], @@ -1048,13 +1067,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", - "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz", + "integrity": "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==", "cpu": [ "x64" ], @@ -1065,13 +1084,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", - "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz", + "integrity": "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==", "cpu": [ "arm" ], @@ -1082,13 +1101,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", - "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz", + "integrity": "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==", "cpu": [ "arm64" ], @@ -1099,13 +1118,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", - "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz", + "integrity": "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==", "cpu": [ "ia32" ], @@ -1116,13 +1135,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", - "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz", + "integrity": "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==", "cpu": [ "loong64" ], @@ -1133,13 +1152,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", - "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz", + "integrity": "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==", "cpu": [ "mips64el" ], @@ -1150,13 +1169,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", - "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz", + "integrity": "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==", "cpu": [ "ppc64" ], @@ -1167,13 +1186,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", - "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz", + "integrity": "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==", "cpu": [ "riscv64" ], @@ -1184,13 +1203,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", - "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz", + "integrity": "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==", "cpu": [ "s390x" ], @@ -1201,7 +1220,7 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { @@ -1221,9 +1240,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz", - "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz", + "integrity": "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==", "cpu": [ "arm64" ], @@ -1238,9 +1257,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", - "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz", + "integrity": "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==", "cpu": [ "x64" ], @@ -1251,13 +1270,13 @@ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz", - "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz", + "integrity": "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==", "cpu": [ "arm64" ], @@ -1272,9 +1291,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", - "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz", + "integrity": "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==", "cpu": [ "x64" ], @@ -1285,13 +1304,13 @@ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz", - "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz", + "integrity": "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==", "cpu": [ "arm64" ], @@ -1306,9 +1325,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", - "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz", + "integrity": "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==", "cpu": [ "x64" ], @@ -1319,13 +1338,13 @@ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", - "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz", + "integrity": "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==", "cpu": [ "arm64" ], @@ -1336,13 +1355,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", - "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz", + "integrity": "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==", "cpu": [ "ia32" ], @@ -1353,7 +1372,7 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { @@ -1819,12 +1838,16 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.4", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.29", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", "dependencies": { @@ -2080,7 +2103,6 @@ }, "node_modules/@noble/hashes": { "version": "1.8.0", - "dev": true, "license": "MIT", "engines": { "node": "^14.21.3 || >=16" @@ -2233,7 +2255,6 @@ }, "node_modules/@paralleldrive/cuid2": { "version": "2.2.2", - "dev": true, "license": "MIT", "dependencies": { "@noble/hashes": "^1.1.5" @@ -2271,132 +2292,401 @@ "dev": true, "license": "MIT" }, - "node_modules/@seald-io/binary-search-tree": { - "version": "1.0.3" - }, - "node_modules/@seald-io/nedb": { - "version": "4.1.2", + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz", + "integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@seald-io/binary-search-tree": "^1.0.3", - "localforage": "^1.10.0", - "util": "^0.12.5" - } + "optional": true, + "os": [ + "android" + ] }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz", + "integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "type-detect": "4.0.8" - } + "license": "MIT", + "optional": true, + "os": [ + "android" + ] }, - "node_modules/@sinonjs/commons/node_modules/type-detect": { - "version": "4.0.8", + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz", + "integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "engines": { - "node": ">=4" - } + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/@sinonjs/fake-timers": { - "version": "13.0.5", + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz", + "integrity": "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.1" - } + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/@sinonjs/samsam": { - "version": "8.0.2", + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz", + "integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.1", - "lodash.get": "^4.4.2", - "type-detect": "^4.1.0" - } + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/@tsconfig/node10": { - "version": "1.0.11", + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz", + "integrity": "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz", + "integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz", + "integrity": "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@tsconfig/node16": { - "version": "1.0.4", + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz", + "integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@types/activedirectory2": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@types/activedirectory2/-/activedirectory2-1.2.6.tgz", - "integrity": "sha512-mJsoOWf9LRpYBkExOWstWe6g6TQnZyZjVULNrX8otcCJgVliesk9T/+W+1ahrx2zaevxsp28sSKOwo/b7TOnSg==", + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz", + "integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@types/ldapjs": "*" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@types/babel__core": { - "version": "7.20.5", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz", + "integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==", + "cpu": [ + "loong64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@types/babel__generator": { - "version": "7.6.8", + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz", + "integrity": "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@types/babel__template": { - "version": "7.4.4", + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz", + "integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==", + "cpu": [ + "riscv64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@types/babel__traverse": { - "version": "7.20.5", + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz", + "integrity": "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==", + "cpu": [ + "riscv64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/types": "^7.20.7" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@types/body-parser": { - "version": "1.19.5", + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz", + "integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==", + "cpu": [ + "s390x" + ], "dev": true, "license": "MIT", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@types/chai": { - "version": "4.3.20", - "dev": true, - "license": "MIT" + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz", + "integrity": "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz", + "integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz", + "integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz", + "integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz", + "integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz", + "integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz", + "integrity": "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@seald-io/binary-search-tree": { + "version": "1.0.3" + }, + "node_modules/@seald-io/nedb": { + "version": "4.1.2", + "license": "MIT", + "dependencies": { + "@seald-io/binary-search-tree": "^1.0.3", + "localforage": "^1.10.0", + "util": "^0.12.5" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/activedirectory2": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@types/activedirectory2/-/activedirectory2-1.2.6.tgz", + "integrity": "sha512-mJsoOWf9LRpYBkExOWstWe6g6TQnZyZjVULNrX8otcCJgVliesk9T/+W+1ahrx2zaevxsp28sSKOwo/b7TOnSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ldapjs": "*" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.6.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } }, "node_modules/@types/connect": { "version": "3.4.38", @@ -2429,6 +2719,13 @@ "@types/node": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/domhandler": { "version": "2.4.5", "dev": true, @@ -2550,13 +2847,15 @@ "@types/express": "*" } }, - "node_modules/@types/mime": { - "version": "1.3.5", + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", "dev": true, "license": "MIT" }, - "node_modules/@types/mocha": { - "version": "10.0.10", + "node_modules/@types/mime": { + "version": "1.3.5", "dev": true, "license": "MIT" }, @@ -2683,16 +2982,6 @@ "@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", "dev": true, @@ -2703,13 +2992,28 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/superagent": { - "version": "4.1.13", + "node_modules/@types/supertest": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", + "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", "dev": true, "license": "MIT", "dependencies": { - "@types/cookiejar": "*", - "@types/node": "*" + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, + "node_modules/@types/supertest/node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" } }, "node_modules/@types/tmp": { @@ -3048,83 +3352,342 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, - "node_modules/abbrev": { - "version": "1.1.1", - "license": "ISC" - }, - "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==", + "node_modules/@vitest/coverage-v8": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", "dev": true, "license": "MIT", "dependencies": { - "event-target-shim": "^5.0.0" + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" }, - "engines": { - "node": ">=6.5" + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.4", + "vitest": "3.2.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } } }, - "node_modules/abstract-logging": { - "version": "2.0.1", - "license": "MIT" - }, - "node_modules/accepts": { - "version": "1.3.8", + "node_modules/@vitest/coverage-v8/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, "license": "MIT", "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" + "balanced-match": "^1.0.0" } }, - "node_modules/acorn": { - "version": "8.15.0", + "node_modules/@vitest/coverage-v8/node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" }, "engines": { - "node": ">=0.4.0" + "node": ">=10" } }, - "node_modules/acorn-jsx": { - "version": "5.3.2", + "node_modules/@vitest/coverage-v8/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/acorn-walk": { - "version": "8.3.4", + "node_modules/@vitest/coverage-v8/node_modules/test-exclude": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "acorn": "^8.11.0" + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^9.0.4" }, "engines": { - "node": ">=0.4.0" + "node": ">=18" } }, - "node_modules/activedirectory2": { - "version": "2.2.0", + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, "license": "MIT", "dependencies": { - "abstract-logging": "^2.0.0", - "async": "^3.1.0", - "ldapjs": "^2.3.3", - "merge-options": "^2.0.0" + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" }, - "engines": { - "node": ">=4.0" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/aggregate-error": { - "version": "3.1.0", + "node_modules/@vitest/expect/node_modules/@types/chai": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", + "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*" + } + }, + "node_modules/@vitest/expect/node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/@vitest/expect/node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/expect/node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/@vitest/expect/node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@vitest/expect/node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect/node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/abbrev": { + "version": "1.1.1", + "license": "ISC" + }, + "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", + "license": "MIT" + }, + "node_modules/accepts": { + "version": "1.3.8", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/activedirectory2": { + "version": "2.2.0", + "license": "MIT", + "dependencies": { + "abstract-logging": "^2.0.0", + "async": "^3.1.0", + "ldapjs": "^2.3.3", + "merge-options": "^2.0.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", "dev": true, "license": "MIT", "dependencies": { @@ -3207,6 +3770,7 @@ "version": "3.1.3", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -3405,7 +3969,6 @@ }, "node_modules/asap": { "version": "2.0.6", - "dev": true, "license": "MIT" }, "node_modules/asn1": { @@ -3440,6 +4003,25 @@ "node": "*" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.7.tgz", + "integrity": "sha512-kr1Hy6YRZBkGQSb6puP+D6FQ59Cx4m0siYhAxygMCAgadiWQ6oxAxQXHOMvJx67SJ63jRoVIIg5eXzUbbct1ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^9.0.1" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/astral-regex": { "version": "2.0.0", "dev": true, @@ -3565,6 +4147,7 @@ "version": "2.2.0", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -3617,7 +4200,8 @@ "node_modules/browser-stdout": { "version": "1.3.1", "dev": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/browserslist": { "version": "4.25.1", @@ -3699,6 +4283,16 @@ "node": ">= 0.8" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/cachedir": { "version": "2.4.0", "dev": true, @@ -3819,24 +4413,6 @@ "node": ">=4" } }, - "node_modules/chai-http": { - "version": "4.4.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "4", - "@types/superagent": "4.1.13", - "charset": "^1.0.1", - "cookiejar": "^2.1.4", - "is-ip": "^2.0.0", - "methods": "^1.1.2", - "qs": "^6.11.2", - "superagent": "^8.0.9" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/chalk": { "version": "4.1.2", "license": "MIT", @@ -3877,14 +4453,6 @@ "node": ">=8" } }, - "node_modules/charset": { - "version": "1.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/check-error": { "version": "1.0.3", "dev": true, @@ -3906,6 +4474,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -3926,6 +4495,7 @@ "version": "5.1.2", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "is-glob": "^4.0.1" }, @@ -4213,7 +4783,6 @@ }, "node_modules/component-emitter": { "version": "1.3.1", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -4335,7 +4904,6 @@ }, "node_modules/cookiejar": { "version": "2.1.4", - "dev": true, "license": "MIT" }, "node_modules/core-util-is": { @@ -4736,7 +5304,6 @@ }, "node_modules/dezalgo": { "version": "1.0.4", - "dev": true, "license": "ISC", "dependencies": { "asap": "^2.0.0", @@ -5073,6 +5640,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "license": "MIT", @@ -5133,7 +5707,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.18.20", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz", + "integrity": "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -5141,97 +5717,35 @@ "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/android-arm": "0.18.20", - "@esbuild/android-arm64": "0.18.20", - "@esbuild/android-x64": "0.18.20", - "@esbuild/darwin-arm64": "0.18.20", - "@esbuild/darwin-x64": "0.18.20", - "@esbuild/freebsd-arm64": "0.18.20", - "@esbuild/freebsd-x64": "0.18.20", - "@esbuild/linux-arm": "0.18.20", - "@esbuild/linux-arm64": "0.18.20", - "@esbuild/linux-ia32": "0.18.20", - "@esbuild/linux-loong64": "0.18.20", - "@esbuild/linux-mips64el": "0.18.20", - "@esbuild/linux-ppc64": "0.18.20", - "@esbuild/linux-riscv64": "0.18.20", - "@esbuild/linux-s390x": "0.18.20", - "@esbuild/linux-x64": "0.18.20", - "@esbuild/netbsd-x64": "0.18.20", - "@esbuild/openbsd-x64": "0.18.20", - "@esbuild/sunos-x64": "0.18.20", - "@esbuild/win32-arm64": "0.18.20", - "@esbuild/win32-ia32": "0.18.20", - "@esbuild/win32-x64": "0.18.20" - } - }, - "node_modules/esbuild/node_modules/@esbuild/darwin-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", - "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild/node_modules/@esbuild/darwin-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", - "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild/node_modules/@esbuild/linux-x64": { - "version": "0.18.20", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild/node_modules/@esbuild/win32-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", - "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" + "@esbuild/aix-ppc64": "0.25.11", + "@esbuild/android-arm": "0.25.11", + "@esbuild/android-arm64": "0.25.11", + "@esbuild/android-x64": "0.25.11", + "@esbuild/darwin-arm64": "0.25.11", + "@esbuild/darwin-x64": "0.25.11", + "@esbuild/freebsd-arm64": "0.25.11", + "@esbuild/freebsd-x64": "0.25.11", + "@esbuild/linux-arm": "0.25.11", + "@esbuild/linux-arm64": "0.25.11", + "@esbuild/linux-ia32": "0.25.11", + "@esbuild/linux-loong64": "0.25.11", + "@esbuild/linux-mips64el": "0.25.11", + "@esbuild/linux-ppc64": "0.25.11", + "@esbuild/linux-riscv64": "0.25.11", + "@esbuild/linux-s390x": "0.25.11", + "@esbuild/linux-x64": "0.25.11", + "@esbuild/netbsd-arm64": "0.25.11", + "@esbuild/netbsd-x64": "0.25.11", + "@esbuild/openbsd-arm64": "0.25.11", + "@esbuild/openbsd-x64": "0.25.11", + "@esbuild/openharmony-arm64": "0.25.11", + "@esbuild/sunos-x64": "0.25.11", + "@esbuild/win32-arm64": "0.25.11", + "@esbuild/win32-ia32": "0.25.11", + "@esbuild/win32-x64": "0.25.11" } }, "node_modules/escalade": { @@ -5526,7 +6040,17 @@ "node": ">=4.0" } }, - "node_modules/esutils": { + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { "version": "2.0.3", "dev": true, "license": "BSD-2-Clause", @@ -5604,6 +6128,16 @@ "node": ">=4" } }, + "node_modules/expect-type": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", + "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "4.21.2", "license": "MIT", @@ -5871,7 +6405,6 @@ }, "node_modules/fast-safe-stringify": { "version": "2.1.1", - "dev": true, "license": "MIT" }, "node_modules/fast-uri": { @@ -5940,18 +6473,6 @@ "node": ">=16.0.0" } }, - "node_modules/fill-keys": { - "version": "1.0.2", - "dev": true, - "license": "MIT", - "dependencies": { - "is-object": "~1.0.1", - "merge-descriptors": "~1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/fill-range": { "version": "7.1.1", "dev": true, @@ -6038,6 +6559,7 @@ "version": "5.0.2", "dev": true, "license": "BSD-3-Clause", + "peer": true, "bin": { "flat": "cli.js" } @@ -6136,20 +6658,6 @@ "node": ">= 6" } }, - "node_modules/formidable": { - "version": "2.1.5", - "dev": true, - "license": "MIT", - "dependencies": { - "@paralleldrive/cuid2": "^2.2.2", - "dezalgo": "^1.0.4", - "once": "^1.4.0", - "qs": "^6.11.0" - }, - "funding": { - "url": "https://ko-fi.com/tunnckoCore/commissions" - } - }, "node_modules/forwarded": { "version": "0.2.0", "license": "MIT", @@ -6394,21 +6902,21 @@ } }, "node_modules/glob": { - "version": "10.3.10", + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", - "jackspeak": "^2.3.5", - "minimatch": "^9.0.1", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", - "path-scurry": "^1.10.1" + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, "funding": { "url": "https://github.com/sponsors/isaacs" } @@ -6432,7 +6940,9 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "9.0.3", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -6654,6 +7164,7 @@ "version": "1.2.0", "dev": true, "license": "MIT", + "peer": true, "bin": { "he": "bin/he" } @@ -6912,14 +7423,6 @@ "version": "1.1.3", "license": "BSD-3-Clause" }, - "node_modules/ip-regex": { - "version": "2.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/ipaddr.js": { "version": "1.9.1", "license": "MIT", @@ -6998,6 +7501,7 @@ "version": "2.1.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "binary-extensions": "^2.0.0" }, @@ -7156,17 +7660,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-ip": { - "version": "2.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "ip-regex": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/is-map": { "version": "2.0.3", "dev": true, @@ -7220,14 +7713,6 @@ "node": ">=8" } }, - "node_modules/is-object": { - "version": "1.0.2", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-path-inside": { "version": "3.0.3", "dev": true, @@ -7465,7 +7950,9 @@ "license": "MIT" }, "node_modules/istanbul-lib-coverage": { - "version": "3.2.0", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -7615,7 +8102,9 @@ } }, "node_modules/istanbul-reports": { - "version": "3.1.6", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -7650,14 +8139,13 @@ } }, "node_modules/jackspeak": { - "version": "2.3.6", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" }, - "engines": { - "node": ">=14" - }, "funding": { "url": "https://github.com/sponsors/isaacs" }, @@ -8469,11 +8957,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.get": { - "version": "4.4.2", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.includes": { "version": "4.3.0", "license": "MIT" @@ -8651,6 +9134,28 @@ "node": ">=0.8.x" } }, + "node_modules/magic-string": { + "version": "0.30.19", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", + "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, "node_modules/make-dir": { "version": "3.1.0", "dev": true, @@ -8848,7 +9353,9 @@ } }, "node_modules/minipass": { - "version": "7.0.4", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" @@ -8865,6 +9372,7 @@ "version": "10.8.2", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-colors": "^4.1.3", "browser-stdout": "^1.3.1", @@ -8899,6 +9407,7 @@ "version": "2.0.2", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "balanced-match": "^1.0.0" } @@ -8907,6 +9416,7 @@ "version": "7.0.4", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", @@ -8917,6 +9427,7 @@ "version": "5.2.0", "dev": true, "license": "BSD-3-Clause", + "peer": true, "engines": { "node": ">=0.3.1" } @@ -8924,12 +9435,14 @@ "node_modules/mocha/node_modules/emoji-regex": { "version": "8.0.0", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/mocha/node_modules/escape-string-regexp": { "version": "4.0.0", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -8941,6 +9454,7 @@ "version": "8.1.0", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -8959,6 +9473,7 @@ "version": "5.1.6", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "brace-expansion": "^2.0.1" }, @@ -8970,6 +9485,7 @@ "version": "4.2.3", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -8983,6 +9499,7 @@ "version": "7.0.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -8999,6 +9516,7 @@ "version": "16.2.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", @@ -9012,11 +9530,6 @@ "node": ">=10" } }, - "node_modules/module-not-found-error": { - "version": "1.0.1", - "dev": true, - "license": "MIT" - }, "node_modules/moment": { "version": "2.30.1", "license": "MIT", @@ -9089,7 +9602,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.9", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, "funding": [ { @@ -9193,6 +9708,7 @@ "version": "3.0.0", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -9656,6 +10172,12 @@ "node": ">=8" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -9796,30 +10318,38 @@ "license": "MIT" }, "node_modules/path-scurry": { - "version": "1.10.1", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^9.1.1 || ^10.0.0", + "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=16 || 14 >=14.18" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.1.0", - "license": "ISC", - "engines": { - "node": "14 || >=16.14" - } + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" }, "node_modules/path-to-regexp": { "version": "0.1.12", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pathval": { "version": "1.1.1", "dev": true, @@ -9961,7 +10491,9 @@ } }, "node_modules/postcss": { - "version": "8.4.33", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, "funding": [ { @@ -9979,9 +10511,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -10080,35 +10612,6 @@ "version": "1.1.0", "license": "MIT" }, - "node_modules/proxyquire": { - "version": "2.1.3", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-keys": "^1.0.2", - "module-not-found-error": "^1.0.1", - "resolve": "^1.11.1" - } - }, - "node_modules/proxyquire/node_modules/resolve": { - "version": "1.22.10", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/pump": { "version": "3.0.0", "dev": true, @@ -10388,6 +10891,7 @@ "version": "2.1.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "^5.1.0" } @@ -10535,6 +11039,7 @@ "version": "3.6.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "picomatch": "^2.2.1" }, @@ -10719,17 +11224,44 @@ } }, "node_modules/rollup": { - "version": "3.29.5", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz", + "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", "dev": true, "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, "bin": { "rollup": "dist/bin/rollup" }, "engines": { - "node": ">=14.18.0", + "node": ">=18.0.0", "npm": ">=8.0.0" }, "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.52.5", + "@rollup/rollup-android-arm64": "4.52.5", + "@rollup/rollup-darwin-arm64": "4.52.5", + "@rollup/rollup-darwin-x64": "4.52.5", + "@rollup/rollup-freebsd-arm64": "4.52.5", + "@rollup/rollup-freebsd-x64": "4.52.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", + "@rollup/rollup-linux-arm-musleabihf": "4.52.5", + "@rollup/rollup-linux-arm64-gnu": "4.52.5", + "@rollup/rollup-linux-arm64-musl": "4.52.5", + "@rollup/rollup-linux-loong64-gnu": "4.52.5", + "@rollup/rollup-linux-ppc64-gnu": "4.52.5", + "@rollup/rollup-linux-riscv64-gnu": "4.52.5", + "@rollup/rollup-linux-riscv64-musl": "4.52.5", + "@rollup/rollup-linux-s390x-gnu": "4.52.5", + "@rollup/rollup-linux-x64-gnu": "4.52.5", + "@rollup/rollup-linux-x64-musl": "4.52.5", + "@rollup/rollup-openharmony-arm64": "4.52.5", + "@rollup/rollup-win32-arm64-msvc": "4.52.5", + "@rollup/rollup-win32-ia32-msvc": "4.52.5", + "@rollup/rollup-win32-x64-gnu": "4.52.5", + "@rollup/rollup-win32-x64-msvc": "4.52.5", "fsevents": "~2.3.2" } }, @@ -10905,6 +11437,7 @@ "version": "6.0.2", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "randombytes": "^2.1.0" } @@ -11082,6 +11615,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "dev": true, @@ -11141,44 +11681,6 @@ "url": "https://github.com/steveukx/git-js?sponsor=1" } }, - "node_modules/sinon": { - "version": "21.0.0", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.1", - "@sinonjs/fake-timers": "^13.0.5", - "@sinonjs/samsam": "^8.0.1", - "diff": "^7.0.0", - "supports-color": "^7.2.0" - }, - "funding": { - "type": "opencollective", - "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", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/slice-ansi": { "version": "3.0.0", "dev": true, @@ -11221,7 +11723,9 @@ } }, "node_modules/source-map-js": { - "version": "1.0.2", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -11301,6 +11805,13 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.1", "license": "MIT", @@ -11308,6 +11819,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "dev": true, @@ -11545,29 +12063,60 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/superagent": { - "version": "8.1.2", + "node_modules/strip-literal": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.0.0.tgz", + "integrity": "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==", "dev": true, "license": "MIT", "dependencies": { - "component-emitter": "^1.3.0", - "cookiejar": "^2.1.4", - "debug": "^4.3.4", - "fast-safe-stringify": "^2.1.1", - "form-data": "^4.0.0", - "formidable": "^2.1.2", + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.1.4.tgz", + "integrity": "sha512-tjLPs7dVyqgItVFirHYqe2T+MfWc2VOBQ8QFKKbWTA3PU7liZR8zoSpAi/C1k1ilm9RsXIKYf197oap9wXGVYg==", + "license": "MIT", + "dependencies": { "methods": "^1.1.2", - "mime": "2.6.0", - "qs": "^6.11.0", - "semver": "^7.3.8" + "superagent": "^10.2.3" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" }, "engines": { - "node": ">=6.4.0 <13 || >=14" + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" } }, - "node_modules/superagent/node_modules/mime": { + "node_modules/supertest/node_modules/mime": { "version": "2.6.0", - "dev": true, + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", "license": "MIT", "bin": { "mime": "cli.js" @@ -11576,15 +12125,24 @@ "node": ">=4.0.0" } }, - "node_modules/superagent/node_modules/semver": { - "version": "7.7.2", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "node_modules/supertest/node_modules/superagent": { + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.3.tgz", + "integrity": "sha512-y/hkYGeXAj7wUMjxRbB21g/l6aAEituGXM9Rwl4o20+SX3e8YOSV6BxFXl+dL3Uk0mjSL3kCbNkwURm8/gEDig==", + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.4", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.11.2" }, "engines": { - "node": ">=10" + "node": ">=14.18.0" } }, "node_modules/supports-color": { @@ -11729,52 +12287,137 @@ "version": "1.0.3", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.1", "dev": true, "license": "MIT" }, - "node_modules/tldts": { - "version": "6.1.86", + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^6.1.86" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, - "bin": { - "tldts": "bin/cli.js" + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tldts-core": { - "version": "6.1.86", + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } }, - "node_modules/tmp": { - "version": "0.2.5", + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=14.14" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/to-buffer": { - "version": "1.2.1", + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, "license": "MIT", - "dependencies": { - "isarray": "^2.0.5", - "safe-buffer": "^5.2.1", - "typed-array-buffer": "^1.0.3" - }, "engines": { - "node": ">= 0.4" + "node": "^18.0.0 || >=20.0.0" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", "dev": true, "license": "MIT", - "dependencies": { + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.3.tgz", + "integrity": "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "dev": true, + "license": "MIT" + }, + "node_modules/tmp": { + "version": "0.2.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/to-buffer": { + "version": "1.2.1", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "dependencies": { "is-number": "^7.0.0" }, "engines": { @@ -11870,500 +12513,84 @@ "yn": "3.1.1" }, "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } - }, - "node_modules/ts-node/node_modules/diff": { - "version": "4.0.2", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/tsconfck": { - "version": "3.1.5", - "dev": true, - "license": "MIT", - "bin": { - "tsconfck": "bin/tsconfck.js" - }, - "engines": { - "node": "^18 || >=20" - }, - "peerDependencies": { - "typescript": "^5.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/tslib": { - "version": "2.6.2", - "license": "0BSD" - }, - "node_modules/tsscmp": { - "version": "1.0.6", - "license": "MIT", - "engines": { - "node": ">=0.6.x" - } - }, - "node_modules/tsx": { - "version": "4.20.6", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", - "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.25.0", - "get-tsconfig": "^4.7.5" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz", - "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz", - "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz", - "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz", - "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz", - "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz", - "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz", - "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz", - "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz", - "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz", - "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz", - "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz", - "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz", - "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz", - "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz", - "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz", - "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz", - "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz", - "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } } }, - "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz", - "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==", - "cpu": [ - "x64" - ], + "node_modules/ts-node/node_modules/diff": { + "version": "4.0.2", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], + "license": "BSD-3-Clause", "engines": { - "node": ">=18" + "node": ">=0.3.1" } }, - "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz", - "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==", - "cpu": [ - "arm64" - ], + "node_modules/tsconfck": { + "version": "3.1.5", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "bin": { + "tsconfck": "bin/tsconfck.js" + }, "engines": { - "node": ">=18" + "node": "^18 || >=20" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz", - "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } + "node_modules/tslib": { + "version": "2.6.2", + "license": "0BSD" }, - "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz", - "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/tsscmp": { + "version": "1.0.6", "license": "MIT", - "optional": true, - "os": [ - "win32" - ], "engines": { - "node": ">=18" + "node": ">=0.6.x" } }, - "node_modules/tsx/node_modules/esbuild": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", - "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", + "node_modules/tsx": { + "version": "4.20.6", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", + "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", "dev": true, - "hasInstallScript": true, "license": "MIT", + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, "bin": { - "esbuild": "bin/esbuild" + "tsx": "dist/cli.mjs" }, "engines": { - "node": ">=18" + "node": ">=18.0.0" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.10", - "@esbuild/android-arm": "0.25.10", - "@esbuild/android-arm64": "0.25.10", - "@esbuild/android-x64": "0.25.10", - "@esbuild/darwin-arm64": "0.25.10", - "@esbuild/darwin-x64": "0.25.10", - "@esbuild/freebsd-arm64": "0.25.10", - "@esbuild/freebsd-x64": "0.25.10", - "@esbuild/linux-arm": "0.25.10", - "@esbuild/linux-arm64": "0.25.10", - "@esbuild/linux-ia32": "0.25.10", - "@esbuild/linux-loong64": "0.25.10", - "@esbuild/linux-mips64el": "0.25.10", - "@esbuild/linux-ppc64": "0.25.10", - "@esbuild/linux-riscv64": "0.25.10", - "@esbuild/linux-s390x": "0.25.10", - "@esbuild/linux-x64": "0.25.10", - "@esbuild/netbsd-arm64": "0.25.10", - "@esbuild/netbsd-x64": "0.25.10", - "@esbuild/openbsd-arm64": "0.25.10", - "@esbuild/openbsd-x64": "0.25.10", - "@esbuild/openharmony-arm64": "0.25.10", - "@esbuild/sunos-x64": "0.25.10", - "@esbuild/win32-arm64": "0.25.10", - "@esbuild/win32-ia32": "0.25.10", - "@esbuild/win32-x64": "0.25.10" + "fsevents": "~2.3.3" } }, "node_modules/tunnel-agent": { @@ -12762,39 +12989,51 @@ } }, "node_modules/vite": { - "version": "4.5.14", + "version": "7.1.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz", + "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.18.10", - "postcss": "^8.4.27", - "rollup": "^3.27.1" + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" }, "optionalDependencies": { - "fsevents": "~2.3.2" + "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": ">= 14", - "less": "*", + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", "lightningcss": "^1.21.0", - "sass": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, + "jiti": { + "optional": true + }, "less": { "optional": true }, @@ -12804,6 +13043,9 @@ "sass": { "optional": true }, + "sass-embedded": { + "optional": true + }, "stylus": { "optional": true }, @@ -12812,9 +13054,38 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vite-tsconfig-paths": { "version": "5.1.4", "dev": true, @@ -12833,6 +13104,231 @@ } } }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@types/chai": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", + "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/vitest/node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/vitest/node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest/node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/walk-up-path": { "version": "3.0.1", "license": "ISC" @@ -12953,6 +13449,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", @@ -12973,7 +13486,8 @@ "node_modules/workerpool": { "version": "6.5.1", "dev": true, - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/wrap-ansi": { "version": "8.1.0", @@ -13113,6 +13627,7 @@ "version": "20.2.9", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": ">=10" } @@ -13121,6 +13636,7 @@ "version": "2.0.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "camelcase": "^6.0.0", "decamelize": "^4.0.0", @@ -13135,6 +13651,7 @@ "version": "6.3.0", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -13146,6 +13663,7 @@ "version": "4.0.0", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -13157,6 +13675,7 @@ "version": "2.1.0", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -13225,7 +13744,8 @@ "git-proxy-cli": "dist/index.js" }, "devDependencies": { - "chai": "^4.5.0" + "chai": "^4.5.0", + "ts-mocha": "^11.1.0" } } } diff --git a/package.json b/package.json index 56c5679dd..bdb3a046b 100644 --- a/package.json +++ b/package.json @@ -48,9 +48,9 @@ "build-ui": "vite build", "check-types": "tsc", "check-types:server": "tsc --project tsconfig.publish.json --noEmit", - "test": "NODE_ENV=test ts-mocha './test/**/*.test.js' --exit", - "test-coverage": "nyc npm run test", - "test-coverage-ci": "nyc --reporter=lcovonly --reporter=text npm run test", + "test": "NODE_ENV=test vitest --run --dir ./test", + "test-coverage": "NODE_ENV=test vitest --run --dir ./test --coverage", + "test-coverage-ci": "NODE_ENV=test vitest --run --dir ./test --coverage.enabled=true --coverage.reporter=lcovonly --coverage.reporter=text", "prepare": "node ./scripts/prepare.js", "lint": "eslint", "lint:fix": "eslint --fix", @@ -113,6 +113,7 @@ "react-html-parser": "^2.0.2", "react-router-dom": "6.30.1", "simple-git": "^3.28.0", + "supertest": "^7.1.4", "uuid": "^11.1.0", "validator": "^13.15.15", "yargs": "^17.7.2" @@ -135,18 +136,16 @@ "@types/jwk-to-pem": "^2.0.3", "@types/lodash": "^4.17.20", "@types/lusca": "^1.7.5", - "@types/mocha": "^10.0.10", "@types/node": "^22.18.10", "@types/passport": "^1.0.17", "@types/passport-local": "^1.0.38", "@types/react-dom": "^17.0.26", "@types/react-html-parser": "^2.0.7", - "@types/sinon": "^17.0.4", + "@types/supertest": "^6.0.3", "@types/validator": "^13.15.3", "@types/yargs": "^17.0.33", "@vitejs/plugin-react": "^4.7.0", - "chai": "^4.5.0", - "chai-http": "^4.4.0", + "@vitest/coverage-v8": "^3.2.4", "cypress": "^15.4.0", "eslint": "^9.37.0", "eslint-config-prettier": "^10.1.8", @@ -156,20 +155,16 @@ "globals": "^16.4.0", "husky": "^9.1.7", "lint-staged": "^16.2.4", - "mocha": "^10.8.2", "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.6", "typescript": "^5.9.3", "typescript-eslint": "^8.46.1", - "vite": "^4.5.14", - "vite-tsconfig-paths": "^5.1.4" + "vite": "^7.1.9", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^3.2.4" }, "optionalDependencies": { "@esbuild/darwin-arm64": "^0.25.11", diff --git a/packages/git-proxy-cli/package.json b/packages/git-proxy-cli/package.json index fd8543dfe..e08826fc1 100644 --- a/packages/git-proxy-cli/package.json +++ b/packages/git-proxy-cli/package.json @@ -11,7 +11,8 @@ "@finos/git-proxy": "file:../.." }, "devDependencies": { - "chai": "^4.5.0" + "chai": "^4.5.0", + "ts-mocha": "^11.1.0" }, "scripts": { "build": "tsc", diff --git a/src/config/ConfigLoader.ts b/src/config/ConfigLoader.ts index e09ce81f6..2253f6adb 100644 --- a/src/config/ConfigLoader.ts +++ b/src/config/ConfigLoader.ts @@ -24,19 +24,19 @@ interface BaseSource { enabled: boolean; } -interface FileSource extends BaseSource { +export interface FileSource extends BaseSource { type: 'file'; path: string; } -interface HttpSource extends BaseSource { +export interface HttpSource extends BaseSource { type: 'http'; url: string; headers?: Record; auth?: HttpAuth; } -interface GitSource extends BaseSource { +export interface GitSource extends BaseSource { type: 'git'; repository: string; branch?: string; @@ -44,7 +44,7 @@ interface GitSource extends BaseSource { auth?: GitAuth; } -type ConfigurationSource = FileSource | HttpSource | GitSource; +export type ConfigurationSource = FileSource | HttpSource | GitSource; export interface ConfigurationSources { enabled: boolean; diff --git a/src/config/generated/config.ts b/src/config/generated/config.ts index 4d3493e1a..8071acd55 100644 --- a/src/config/generated/config.ts +++ b/src/config/generated/config.ts @@ -208,6 +208,10 @@ export interface AdConfig { * Password for the given `username`. */ password: string; + /** + * Override baseDN to query for users in other OUs or sub-trees. + */ + searchBase?: string; /** * Active Directory server to connect to, e.g. `ldap://ad.example.com`. */ @@ -225,6 +229,8 @@ export interface AdConfig { export interface JwtConfig { authorityURL: string; clientID: string; + expectedAudience?: string; + roleMapping?: { [key: string]: { [key: string]: string } }; [property: string]: any; } @@ -745,6 +751,7 @@ const typeMap: any = { [ { json: 'baseDN', js: 'baseDN', typ: '' }, { json: 'password', js: 'password', typ: '' }, + { json: 'searchBase', js: 'searchBase', typ: u(undefined, '') }, { json: 'url', js: 'url', typ: '' }, { json: 'username', js: 'username', typ: '' }, ], @@ -754,6 +761,8 @@ const typeMap: any = { [ { json: 'authorityURL', js: 'authorityURL', typ: '' }, { json: 'clientID', js: 'clientID', typ: '' }, + { json: 'expectedAudience', js: 'expectedAudience', typ: u(undefined, '') }, + { json: 'roleMapping', js: 'roleMapping', typ: u(undefined, m(m(''))) }, ], 'any', ), diff --git a/src/config/index.ts b/src/config/index.ts index 6c108d3fc..4e59e89d9 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -203,14 +203,19 @@ export const getAPIs = () => { return config.api || {}; }; -export const getCookieSecret = (): string | undefined => { +export const getCookieSecret = (): string => { const config = loadFullConfiguration(); + + if (!config.cookieSecret) { + throw new Error('cookieSecret is not set!'); + } + return config.cookieSecret; }; -export const getSessionMaxAgeHours = (): number | undefined => { +export const getSessionMaxAgeHours = (): number => { const config = loadFullConfiguration(); - return config.sessionMaxAgeHours; + return config.sessionMaxAgeHours || 24; }; // Get commit related configuration diff --git a/src/proxy/index.ts b/src/proxy/index.ts index 5ba9bbf00..0264e6c93 100644 --- a/src/proxy/index.ts +++ b/src/proxy/index.ts @@ -14,9 +14,10 @@ import { addUserCanAuthorise, addUserCanPush, createRepo, getRepos } from '../db import { PluginLoader } from '../plugin'; import chain from './chain'; import { Repo } from '../db/types'; +import { serverConfig } from '../config/env'; const { GIT_PROXY_SERVER_PORT: proxyHttpPort, GIT_PROXY_HTTPS_SERVER_PORT: proxyHttpsPort } = - require('../config/env').serverConfig; + serverConfig; interface ServerOptions { inflate: boolean; @@ -26,13 +27,13 @@ interface ServerOptions { cert: Buffer | undefined; } -const options: ServerOptions = { +const getServerOptions = (): ServerOptions => ({ inflate: true, limit: '100000kb', type: '*/*', key: getTLSEnabled() && getTLSKeyPemPath() ? fs.readFileSync(getTLSKeyPemPath()!) : undefined, cert: getTLSEnabled() && getTLSCertPemPath() ? fs.readFileSync(getTLSCertPemPath()!) : undefined, -}; +}); export default class Proxy { private httpServer: http.Server | null = null; @@ -71,15 +72,17 @@ export default class Proxy { await this.proxyPreparations(); this.expressApp = await this.createApp(); this.httpServer = http - .createServer(options as any, this.expressApp) + .createServer(getServerOptions() as any, this.expressApp) .listen(proxyHttpPort, () => { console.log(`HTTP Proxy Listening on ${proxyHttpPort}`); }); // Start HTTPS server only if TLS is enabled if (getTLSEnabled()) { - this.httpsServer = https.createServer(options, this.expressApp).listen(proxyHttpsPort, () => { - console.log(`HTTPS Proxy Listening on ${proxyHttpsPort}`); - }); + this.httpsServer = https + .createServer(getServerOptions(), this.expressApp) + .listen(proxyHttpsPort, () => { + console.log(`HTTPS Proxy Listening on ${proxyHttpsPort}`); + }); } } diff --git a/src/proxy/processors/push-action/checkAuthorEmails.ts b/src/proxy/processors/push-action/checkAuthorEmails.ts index 3c7cbb89c..850bde02e 100644 --- a/src/proxy/processors/push-action/checkAuthorEmails.ts +++ b/src/proxy/processors/push-action/checkAuthorEmails.ts @@ -3,9 +3,9 @@ import { getCommitConfig } from '../../../config'; import { Commit } from '../../actions/Action'; import { isEmail } from 'validator'; -const commitConfig = getCommitConfig(); - const isEmailAllowed = (email: string): boolean => { + const commitConfig = getCommitConfig(); + if (!email || !isEmail(email)) { return false; } diff --git a/src/proxy/processors/push-action/checkCommitMessages.ts b/src/proxy/processors/push-action/checkCommitMessages.ts index 79a259d8b..913803e0e 100644 --- a/src/proxy/processors/push-action/checkCommitMessages.ts +++ b/src/proxy/processors/push-action/checkCommitMessages.ts @@ -1,48 +1,53 @@ import { Action, Step } from '../../actions'; import { getCommitConfig } from '../../../config'; -const commitConfig = getCommitConfig(); - const isMessageAllowed = (commitMessage: string): boolean => { - console.log(`isMessageAllowed(${commitMessage})`); + try { + const commitConfig = getCommitConfig(); - // Commit message is empty, i.e. '', null or undefined - if (!commitMessage) { - console.log('No commit message included...'); - return false; - } + console.log(`isMessageAllowed(${commitMessage})`); - // Validation for configured block pattern(s) check... - if (typeof commitMessage !== 'string') { - console.log('A non-string value has been captured for the commit message...'); - return false; - } + // Commit message is empty, i.e. '', null or undefined + if (!commitMessage) { + console.log('No commit message included...'); + return false; + } + + // Validation for configured block pattern(s) check... + if (typeof commitMessage !== 'string') { + console.log('A non-string value has been captured for the commit message...'); + return false; + } - // Configured blocked literals - const blockedLiterals: string[] = commitConfig?.message?.block?.literals ?? []; + // Configured blocked literals + const blockedLiterals: string[] = commitConfig.message?.block?.literals ?? []; - // Configured blocked patterns - const blockedPatterns: string[] = commitConfig?.message?.block?.patterns ?? []; + // Configured blocked patterns + const blockedPatterns: string[] = commitConfig.message?.block?.patterns ?? []; - // Find all instances of blocked literals in commit message... - const positiveLiterals = blockedLiterals.map((literal: string) => - commitMessage.toLowerCase().includes(literal.toLowerCase()), - ); + // Find all instances of blocked literals in commit message... + const positiveLiterals = blockedLiterals.map((literal: string) => + commitMessage.toLowerCase().includes(literal.toLowerCase()), + ); - // Find all instances of blocked patterns in commit message... - const positivePatterns = blockedPatterns.map((pattern: string) => - commitMessage.match(new RegExp(pattern, 'gi')), - ); + // Find all instances of blocked patterns in commit message... + const positivePatterns = blockedPatterns.map((pattern: string) => + commitMessage.match(new RegExp(pattern, 'gi')), + ); - // Flatten any positive literal results into a 1D array... - const literalMatches = positiveLiterals.flat().filter((result) => !!result); + // Flatten any positive literal results into a 1D array... + const literalMatches = positiveLiterals.flat().filter((result) => !!result); - // Flatten any positive pattern results into a 1D array... - const patternMatches = positivePatterns.flat().filter((result) => !!result); + // Flatten any positive pattern results into a 1D array... + const patternMatches = positivePatterns.flat().filter((result) => !!result); - // Commit message matches configured block pattern(s) - if (literalMatches.length || patternMatches.length) { - console.log('Commit message is blocked via configured literals/patterns...'); + // Commit message matches configured block pattern(s) + if (literalMatches.length || patternMatches.length) { + console.log('Commit message is blocked via configured literals/patterns...'); + return false; + } + } catch (error) { + console.log('Invalid regex pattern...'); return false; } diff --git a/src/service/index.ts b/src/service/index.ts index 15c86307a..c13e79314 100644 --- a/src/service/index.ts +++ b/src/service/index.ts @@ -44,7 +44,7 @@ async function createApp(proxy: Proxy): Promise { app.use( session({ store: db.getSessionStore(), - secret: config.getCookieSecret() as string, + secret: config.getCookieSecret(), resave: false, saveUninitialized: false, cookie: { diff --git a/src/service/passport/oidc.ts b/src/service/passport/oidc.ts index 9afe379b8..ebab568ce 100644 --- a/src/service/passport/oidc.ts +++ b/src/service/passport/oidc.ts @@ -77,7 +77,7 @@ export const configure = async (passport: PassportStatic): Promise} - A promise that resolves when the user authentication is complete */ -const handleUserAuthentication = async ( +export const handleUserAuthentication = async ( userInfo: UserInfoResponse, done: (err: any, user?: any) => void, ): Promise => { diff --git a/src/service/routes/push.ts b/src/service/routes/push.ts index 4a69fd355..766d9b191 100644 --- a/src/service/routes/push.ts +++ b/src/service/routes/push.ts @@ -103,8 +103,16 @@ router.post('/:id/authorise', async (req: Request, res: Response) => { const push = await db.getPush(id); console.log({ push }); + if (!push) { + res.status(404).send({ + message: 'Push request not found', + }); + return; + } + // Get the committer of the push via their email address - const committerEmail = push?.userEmail; + const committerEmail = push.userEmail; + const list = await db.getUsers({ email: committerEmail }); console.log({ list }); diff --git a/src/service/routes/users.ts b/src/service/routes/users.ts index 40b2ead5d..2e689817e 100644 --- a/src/service/routes/users.ts +++ b/src/service/routes/users.ts @@ -3,7 +3,6 @@ const router = express.Router(); import * as db from '../../db'; import { toPublicUser } from './publicApi'; -import { UserQuery } from '../../db/types'; router.get('/', async (req: Request, res: Response) => { console.log('fetching users'); diff --git a/test/1.test.js b/test/1.test.js deleted file mode 100644 index 46eab9b9b..000000000 --- a/test/1.test.js +++ /dev/null @@ -1,98 +0,0 @@ -/* - Template test file. Demonstrates how to: - - Use chai-http to test the server - - Initialize the server - - Stub dependencies with sinon sandbox - - Reset stubs after each test - - Use proxyquire to replace modules - - Clear module cache after a test -*/ - -const chai = require('chai'); -const chaiHttp = require('chai-http'); -const sinon = require('sinon'); -const proxyquire = require('proxyquire'); - -const service = require('../src/service').default; -const db = require('../src/db'); - -const expect = chai.expect; - -chai.use(chaiHttp); - -const TEST_REPO = { - project: 'finos', - name: 'db-test-repo', - url: 'https://github.com/finos/db-test-repo.git', -}; - -describe('init', () => { - let app; - let sandbox; - - // Runs before all tests - before(async function () { - // Start the service (can also pass config if testing proxy routes) - app = await service.start(); - }); - - // Runs before each test - beforeEach(function () { - // Create a sandbox for stubbing - sandbox = sinon.createSandbox(); - - // Example: stub a DB method - sandbox.stub(db, 'getRepo').resolves(TEST_REPO); - }); - - // Example test: check server is running - it('should return 401 if not logged in', async function () { - const res = await chai.request(app).get('/api/auth/profile'); - expect(res).to.have.status(401); - }); - - // Example test: check db stub is working - it('should get the repo from stubbed db', async function () { - const repo = await db.getRepo('finos/db-test-repo'); - expect(repo).to.deep.equal(TEST_REPO); - }); - - // Example test: use proxyquire to override the config module - it('should return an array of enabled auth methods when overridden', async function () { - const fsStub = { - readFileSync: sandbox.stub().returns( - JSON.stringify({ - authentication: [ - { type: 'local', enabled: true }, - { type: 'ActiveDirectory', enabled: true }, - { type: 'openidconnect', enabled: true }, - ], - }), - ), - }; - - const config = proxyquire('../src/config', { - fs: fsStub, - }); - config.initUserConfig(); - const authMethods = config.getAuthMethods(); - expect(authMethods).to.have.lengthOf(3); - expect(authMethods[0].type).to.equal('local'); - expect(authMethods[1].type).to.equal('ActiveDirectory'); - expect(authMethods[2].type).to.equal('openidconnect'); - - // Clear config module cache so other tests don't use the stubbed config - delete require.cache[require.resolve('../src/config')]; - }); - - // Runs after each test - afterEach(function () { - // Restore all stubs in this sandbox - sandbox.restore(); - }); - - // Runs after all tests - after(async function () { - await service.httpServer.close(); - }); -}); diff --git a/test/1.test.ts b/test/1.test.ts new file mode 100644 index 000000000..886b22307 --- /dev/null +++ b/test/1.test.ts @@ -0,0 +1,95 @@ +/* + Template test file. Demonstrates how to: + - Initialize the server + - Stub dependencies with vi.spyOn + - Use supertest to make requests to the server + - Reset stubs after each test + - Use vi.doMock to replace modules + - Reset module cache after a test +*/ + +import { describe, it, beforeAll, afterAll, beforeEach, afterEach, expect, vi } from 'vitest'; +import request from 'supertest'; +import service from '../src/service'; +import * as db from '../src/db'; +import Proxy from '../src/proxy'; + +const TEST_REPO = { + project: 'finos', + name: 'db-test-repo', + url: 'https://github.com/finos/db-test-repo.git', + users: { canPush: [], canAuthorise: [] }, +}; + +describe('init', () => { + let app: any; + + // Runs before all tests + beforeAll(async function () { + // Starts the service and returns the express app + const proxy = new Proxy(); + app = await service.start(proxy); + }); + + // Runs before each test + beforeEach(async function () { + // Example: stub a DB method + vi.spyOn(db, 'getRepo').mockResolvedValue(TEST_REPO); + }); + + // Example test: check server is running + it('should return 401 if not logged in', async function () { + const res = await request(app).get('/api/auth/profile'); + expect(res.status).toBe(401); + }); + + // Example test: check db stub is working + it('should get the repo from stubbed db', async function () { + const repo = await db.getRepo('finos/db-test-repo'); + expect(repo).toEqual(TEST_REPO); + }); + + // Example test: use vi.doMock to override the config module + it('should return an array of enabled auth methods when overridden', async () => { + vi.resetModules(); // Clear module cache + + // fs must be mocked BEFORE importing the config module + // We also mock existsSync to ensure the file "exists" + vi.doMock('fs', async (importOriginal) => { + const actual: any = await importOriginal(); + return { + ...actual, + readFileSync: vi.fn().mockReturnValue( + JSON.stringify({ + authentication: [ + { type: 'local', enabled: true }, + { type: 'ActiveDirectory', enabled: true }, + { type: 'openidconnect', enabled: true }, + ], + }), + ), + existsSync: vi.fn().mockReturnValue(true), + }; + }); + + // Then we inline import the config module to use the mocked fs + // Top-level imports don't work here (they resolve to the original fs module) + const config = await import('../src/config'); + config.initUserConfig(); + + const authMethods = config.getAuthMethods(); + expect(authMethods).toHaveLength(3); + expect(authMethods[0].type).toBe('local'); + }); + + // Runs after each test + afterEach(function () { + // Restore all stubs + vi.restoreAllMocks(); + }); + + // Runs after all tests + afterAll(function () { + service.httpServer.close(); + }); +}); diff --git a/test/ConfigLoader.test.js b/test/ConfigLoader.test.ts similarity index 60% rename from test/ConfigLoader.test.js rename to test/ConfigLoader.test.ts index 76c659855..f5c04494a 100644 --- a/test/ConfigLoader.test.js +++ b/test/ConfigLoader.test.ts @@ -1,16 +1,21 @@ +import { describe, it, beforeEach, afterEach, afterAll, expect, vi } from 'vitest'; import fs from 'fs'; import path from 'path'; import { configFile } from '../src/config/file'; -import { expect } from 'chai'; -import { ConfigLoader } from '../src/config/ConfigLoader'; +import { + ConfigLoader, + Configuration, + FileSource, + GitSource, + HttpSource, +} from '../src/config/ConfigLoader'; import { isValidGitUrl, isValidPath, isValidBranchName } from '../src/config/ConfigLoader'; -import sinon from 'sinon'; import axios from 'axios'; describe('ConfigLoader', () => { - let configLoader; - let tempDir; - let tempConfigFile; + let configLoader: ConfigLoader; + let tempDir: string; + let tempConfigFile: string; beforeEach(() => { // Create temp directory for test files @@ -23,11 +28,11 @@ describe('ConfigLoader', () => { if (fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true }); } - sinon.restore(); + vi.restoreAllMocks(); configLoader?.stop(); }); - after(async () => { + afterAll(async () => { // reset config to default after all tests have run console.log(`Restoring config to defaults from file ${configFile}`); configLoader = new ConfigLoader({}); @@ -38,10 +43,6 @@ describe('ConfigLoader', () => { }); }); - after(() => { - // restore default config - }); - describe('loadFromFile', () => { it('should load configuration from file', async () => { const testConfig = { @@ -57,9 +58,9 @@ describe('ConfigLoader', () => { path: tempConfigFile, }); - expect(result).to.be.an('object'); - expect(result.proxyUrl).to.equal('https://test.com'); - expect(result.cookieSecret).to.equal('test-secret'); + expect(result).toBeTypeOf('object'); + expect(result.proxyUrl).toBe('https://test.com'); + expect(result.cookieSecret).toBe('test-secret'); }); }); @@ -70,7 +71,7 @@ describe('ConfigLoader', () => { cookieSecret: 'test-secret', }; - sinon.stub(axios, 'get').resolves({ data: testConfig }); + vi.spyOn(axios, 'get').mockResolvedValue({ data: testConfig }); configLoader = new ConfigLoader({}); const result = await configLoader.loadFromHttp({ @@ -80,13 +81,13 @@ describe('ConfigLoader', () => { headers: {}, }); - expect(result).to.be.an('object'); - expect(result.proxyUrl).to.equal('https://test.com'); - expect(result.cookieSecret).to.equal('test-secret'); + expect(result).toBeTypeOf('object'); + expect(result.proxyUrl).toBe('https://test.com'); + expect(result.cookieSecret).toBe('test-secret'); }); it('should include bearer token if provided', async () => { - const axiosStub = sinon.stub(axios, 'get').resolves({ data: {} }); + const axiosStub = vi.spyOn(axios, 'get').mockResolvedValue({ data: {} }); configLoader = new ConfigLoader({}); await configLoader.loadFromHttp({ @@ -99,11 +100,9 @@ describe('ConfigLoader', () => { }, }); - expect( - axiosStub.calledWith('http://config-service/config', { - headers: { Authorization: 'Bearer test-token' }, - }), - ).to.be.true; + expect(axiosStub).toHaveBeenCalledWith('http://config-service/config', { + headers: { Authorization: 'Bearer test-token' }, + }); }); }); @@ -129,14 +128,14 @@ describe('ConfigLoader', () => { fs.writeFileSync(tempConfigFile, JSON.stringify(newConfig)); - configLoader = new ConfigLoader(initialConfig); - const spy = sinon.spy(); + configLoader = new ConfigLoader(initialConfig as Configuration); + const spy = vi.fn(); configLoader.on('configurationChanged', spy); await configLoader.reloadConfiguration(); - expect(spy.calledOnce).to.be.true; - expect(spy.firstCall.args[0]).to.deep.include(newConfig); + expect(spy).toHaveBeenCalledOnce(); + expect(spy.mock.calls[0][0]).toMatchObject(newConfig); }); it('should not emit event if config has not changed', async () => { @@ -160,14 +159,14 @@ describe('ConfigLoader', () => { fs.writeFileSync(tempConfigFile, JSON.stringify(testConfig)); - configLoader = new ConfigLoader(config); - const spy = sinon.spy(); + configLoader = new ConfigLoader(config as Configuration); + const spy = vi.fn(); configLoader.on('configurationChanged', spy); await configLoader.reloadConfiguration(); // First reload should emit await configLoader.reloadConfiguration(); // Second reload should not emit since config hasn't changed - expect(spy.calledOnce).to.be.true; // Should only emit once + expect(spy).toHaveBeenCalledOnce(); // Should only emit once }); it('should not emit event if configurationSources is disabled', async () => { @@ -177,13 +176,13 @@ describe('ConfigLoader', () => { }, }; - configLoader = new ConfigLoader(config); - const spy = sinon.spy(); + configLoader = new ConfigLoader(config as Configuration); + const spy = vi.fn(); configLoader.on('configurationChanged', spy); await configLoader.reloadConfiguration(); - expect(spy.called).to.be.false; + expect(spy).not.toHaveBeenCalled(); }); }); @@ -193,38 +192,29 @@ describe('ConfigLoader', () => { await configLoader.initialize(); // Check that cacheDir is set and is a string - expect(configLoader.cacheDir).to.be.a('string'); + expect(configLoader.cacheDirPath).toBeTypeOf('string'); // Check that it contains 'git-proxy' in the path - expect(configLoader.cacheDir).to.include('git-proxy'); + expect(configLoader.cacheDirPath).toContain('git-proxy'); // On macOS, it should be in the Library/Caches directory // On Linux, it should be in the ~/.cache directory // On Windows, it should be in the AppData/Local directory if (process.platform === 'darwin') { - expect(configLoader.cacheDir).to.include('Library/Caches'); + expect(configLoader.cacheDirPath).toContain('Library/Caches'); } else if (process.platform === 'linux') { - expect(configLoader.cacheDir).to.include('.cache'); + expect(configLoader.cacheDirPath).toContain('.cache'); } else if (process.platform === 'win32') { - expect(configLoader.cacheDir).to.include('AppData/Local'); + expect(configLoader.cacheDirPath).toContain('AppData/Local'); } }); - 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(); // Check if directory exists - expect(fs.existsSync(configLoader.cacheDir)).to.be.true; + expect(fs.existsSync(configLoader.cacheDirPath!)).toBe(true); }); }); @@ -244,11 +234,11 @@ describe('ConfigLoader', () => { }, }; - configLoader = new ConfigLoader(mockConfig); - const spy = sinon.spy(configLoader, 'reloadConfiguration'); + configLoader = new ConfigLoader(mockConfig as Configuration); + const spy = vi.spyOn(configLoader, 'reloadConfiguration'); await configLoader.start(); - expect(spy.calledOnce).to.be.true; + expect(spy).toHaveBeenCalledOnce(); }); it('should clear an existing reload interval if it exists', async () => { @@ -265,10 +255,10 @@ describe('ConfigLoader', () => { }, }; - configLoader = new ConfigLoader(mockConfig); - configLoader.reloadTimer = setInterval(() => {}, 1000); + configLoader = new ConfigLoader(mockConfig as Configuration); + (configLoader as any).reloadTimer = setInterval(() => {}, 1000); await configLoader.start(); - expect(configLoader.reloadTimer).to.be.null; + expect((configLoader as any).reloadTimer).toBe(null); }); it('should run reloadConfiguration multiple times on short reload interval', async () => { @@ -286,14 +276,14 @@ describe('ConfigLoader', () => { }, }; - configLoader = new ConfigLoader(mockConfig); - const spy = sinon.spy(configLoader, 'reloadConfiguration'); + configLoader = new ConfigLoader(mockConfig as Configuration); + const spy = vi.spyOn(configLoader, 'reloadConfiguration'); await configLoader.start(); // Make sure the reload interval is triggered await new Promise((resolve) => setTimeout(resolve, 50)); - expect(spy.callCount).to.greaterThan(1); + expect(spy.mock.calls.length).toBeGreaterThan(1); }); it('should clear the interval when stop is called', async () => { @@ -310,11 +300,11 @@ describe('ConfigLoader', () => { }, }; - configLoader = new ConfigLoader(mockConfig); - configLoader.reloadTimer = setInterval(() => {}, 1000); - expect(configLoader.reloadTimer).to.not.be.null; + configLoader = new ConfigLoader(mockConfig as Configuration); + (configLoader as any).reloadTimer = setInterval(() => {}, 1000); + expect((configLoader as any).reloadTimer).not.toBe(null); await configLoader.stop(); - expect(configLoader.reloadTimer).to.be.null; + expect((configLoader as any).reloadTimer).toBe(null); }); }); @@ -329,193 +319,162 @@ describe('ConfigLoader', () => { }); it('should load configuration from git repository', async function () { - this.timeout(10000); - const source = { type: 'git', repository: 'https://github.com/finos/git-proxy.git', path: 'proxy.config.json', branch: 'main', enabled: true, - }; + } as GitSource; const config = await configLoader.loadFromSource(source); // Verify the loaded config has expected structure - expect(config).to.be.an('object'); - expect(config).to.have.property('cookieSecret'); - }); + expect(config).toBeTypeOf('object'); + expect(config).toHaveProperty('cookieSecret'); + }, 10000); - it('should throw error for invalid configuration file path (git)', async function () { + it('should throw error for invalid configuration file path (git)', async () => { const source = { type: 'git', repository: 'https://github.com/finos/git-proxy.git', path: '\0', // Invalid path branch: 'main', enabled: true, - }; + } as GitSource; - try { - await configLoader.loadFromSource(source); - throw new Error('Expected error was not thrown'); - } catch (error) { - expect(error.message).to.equal('Invalid configuration file path in repository'); - } + await expect(configLoader.loadFromSource(source)).rejects.toThrow( + 'Invalid configuration file path in repository', + ); }); - it('should throw error for invalid configuration file path (file)', async function () { + it('should throw error for invalid configuration file path (file)', async () => { const source = { type: 'file', path: '\0', // Invalid path enabled: true, - }; + } as FileSource; - try { - await configLoader.loadFromSource(source); - throw new Error('Expected error was not thrown'); - } catch (error) { - expect(error.message).to.equal('Invalid configuration file path'); - } + await expect(configLoader.loadFromSource(source)).rejects.toThrow( + 'Invalid configuration file path', + ); }); it('should load configuration from http', async function () { - this.timeout(10000); - const source = { type: 'http', url: 'https://raw.githubusercontent.com/finos/git-proxy/refs/heads/main/proxy.config.json', enabled: true, - }; + } as HttpSource; const config = await configLoader.loadFromSource(source); // Verify the loaded config has expected structure - expect(config).to.be.an('object'); - expect(config).to.have.property('cookieSecret'); - }); + expect(config).toBeTypeOf('object'); + expect(config).toHaveProperty('cookieSecret'); + }, 10000); - it('should throw error if repository is invalid', async function () { + it('should throw error if repository is invalid', async () => { const source = { type: 'git', repository: 'invalid-repository', path: 'proxy.config.json', branch: 'main', enabled: true, - }; + } as GitSource; - try { - await configLoader.loadFromSource(source); - throw new Error('Expected error was not thrown'); - } catch (error) { - expect(error.message).to.equal('Invalid repository URL format'); - } + await expect(configLoader.loadFromSource(source)).rejects.toThrow( + 'Invalid repository URL format', + ); }); - it('should throw error if branch name is invalid', async function () { + it('should throw error if branch name is invalid', async () => { const source = { type: 'git', repository: 'https://github.com/finos/git-proxy.git', path: 'proxy.config.json', branch: '..', // invalid branch pattern enabled: true, - }; + } as GitSource; - try { - await configLoader.loadFromSource(source); - throw new Error('Expected error was not thrown'); - } catch (error) { - expect(error.message).to.equal('Invalid branch name format'); - } + await expect(configLoader.loadFromSource(source)).rejects.toThrow( + 'Invalid branch name format', + ); }); - it('should throw error if configuration source is invalid', async function () { + it('should throw error if configuration source is invalid', async () => { const source = { type: 'invalid', repository: 'https://github.com/finos/git-proxy.git', path: 'proxy.config.json', branch: 'main', enabled: true, - }; + } as any; - try { - await configLoader.loadFromSource(source); - throw new Error('Expected error was not thrown'); - } catch (error) { - expect(error.message).to.contain('Unsupported configuration source type'); - } + await expect(configLoader.loadFromSource(source)).rejects.toThrow( + /Unsupported configuration source type/, + ); }); - it('should throw error if repository is a valid URL but not a git repository', async function () { + it('should throw error if repository is a valid URL but not a git repository', async () => { const source = { type: 'git', repository: 'https://github.com/finos/made-up-test-repo.git', path: 'proxy.config.json', branch: 'main', enabled: true, - }; + } as GitSource; - try { - await configLoader.loadFromSource(source); - throw new Error('Expected error was not thrown'); - } catch (error) { - expect(error.message).to.contain('Failed to clone repository'); - } + await expect(configLoader.loadFromSource(source)).rejects.toThrow( + /Failed to clone repository/, + ); }); - it('should throw error if repository is a valid git repo but the branch does not exist', async function () { + it('should throw error if repository is a valid git repo but the branch does not exist', async () => { const source = { type: 'git', repository: 'https://github.com/finos/git-proxy.git', path: 'proxy.config.json', branch: 'branch-does-not-exist', enabled: true, - }; + } as GitSource; - try { - await configLoader.loadFromSource(source); - throw new Error('Expected error was not thrown'); - } catch (error) { - expect(error.message).to.contain('Failed to checkout branch'); - } + await expect(configLoader.loadFromSource(source)).rejects.toThrow( + /Failed to checkout branch/, + ); }); - it('should throw error if config path was not found', async function () { + it('should throw error if config path was not found', async () => { const source = { type: 'git', repository: 'https://github.com/finos/git-proxy.git', path: 'path-not-found.json', branch: 'main', enabled: true, - }; + } as GitSource; - try { - await configLoader.loadFromSource(source); - throw new Error('Expected error was not thrown'); - } catch (error) { - expect(error.message).to.contain('Configuration file not found at'); - } + await expect(configLoader.loadFromSource(source)).rejects.toThrow( + /Configuration file not found at/, + ); }); - it('should throw error if config file is not valid JSON', async function () { + it('should throw error if config file is not valid JSON', async () => { const source = { type: 'git', repository: 'https://github.com/finos/git-proxy.git', path: 'test/fixtures/baz.js', branch: 'main', enabled: true, - }; + } as GitSource; - try { - await configLoader.loadFromSource(source); - throw new Error('Expected error was not thrown'); - } catch (error) { - expect(error.message).to.contain('Failed to read or parse configuration file'); - } + await expect(configLoader.loadFromSource(source)).rejects.toThrow( + /Failed to read or parse configuration file/, + ); }); }); describe('deepMerge', () => { - let configLoader; + let configLoader: ConfigLoader; beforeEach(() => { configLoader = new ConfigLoader({}); @@ -527,7 +486,7 @@ describe('ConfigLoader', () => { const result = configLoader.deepMerge(target, source); - expect(result).to.deep.equal({ a: 1, b: 3, c: 4 }); + expect(result).toEqual({ a: 1, b: 3, c: 4 }); }); it('should merge nested objects', () => { @@ -543,7 +502,7 @@ describe('ConfigLoader', () => { const result = configLoader.deepMerge(target, source); - expect(result).to.deep.equal({ + expect(result).toEqual({ a: 1, b: { x: 1, y: 4, w: 5 }, c: { z: 6 }, @@ -562,7 +521,7 @@ describe('ConfigLoader', () => { const result = configLoader.deepMerge(target, source); - expect(result).to.deep.equal({ + expect(result).toEqual({ a: [7, 8], b: { items: [9] }, }); @@ -582,7 +541,7 @@ describe('ConfigLoader', () => { const result = configLoader.deepMerge(target, source); - expect(result).to.deep.equal({ + expect(result).toEqual({ a: null, b: 2, c: 3, @@ -595,7 +554,7 @@ describe('ConfigLoader', () => { const result = configLoader.deepMerge(target, source); - expect(result).to.deep.equal({ a: 1, b: { c: 2 } }); + expect(result).toEqual({ a: 1, b: { c: 2 } }); }); it('should not modify the original objects', () => { @@ -606,8 +565,8 @@ describe('ConfigLoader', () => { configLoader.deepMerge(target, source); - expect(target).to.deep.equal(originalTarget); - expect(source).to.deep.equal(originalSource); + expect(target).toEqual(originalTarget); + expect(source).toEqual(originalSource); }); }); }); @@ -616,18 +575,18 @@ describe('Validation Helpers', () => { describe('isValidGitUrl', () => { it('should validate git URLs correctly', () => { // Valid URLs - expect(isValidGitUrl('git://github.com/user/repo.git')).to.be.true; - expect(isValidGitUrl('https://github.com/user/repo.git')).to.be.true; - expect(isValidGitUrl('ssh://git@github.com/user/repo.git')).to.be.true; - expect(isValidGitUrl('user@github.com:user/repo.git')).to.be.true; + expect(isValidGitUrl('git://github.com/user/repo.git')).toBe(true); + expect(isValidGitUrl('https://github.com/user/repo.git')).toBe(true); + expect(isValidGitUrl('ssh://git@github.com/user/repo.git')).toBe(true); + expect(isValidGitUrl('user@github.com:user/repo.git')).toBe(true); // Invalid URLs - expect(isValidGitUrl('not-a-git-url')).to.be.false; - expect(isValidGitUrl('http://github.com/user/repo')).to.be.false; - expect(isValidGitUrl('')).to.be.false; - expect(isValidGitUrl(null)).to.be.false; - expect(isValidGitUrl(undefined)).to.be.false; - expect(isValidGitUrl(123)).to.be.false; + expect(isValidGitUrl('not-a-git-url')).toBe(false); + expect(isValidGitUrl('http://github.com/user/repo')).toBe(false); + expect(isValidGitUrl('')).toBe(false); + expect(isValidGitUrl(null as any)).toBe(false); + expect(isValidGitUrl(undefined as any)).toBe(false); + expect(isValidGitUrl(123 as any)).toBe(false); }); }); @@ -636,64 +595,51 @@ describe('Validation Helpers', () => { const cwd = process.cwd(); // Valid paths - expect(isValidPath(path.join(cwd, 'config.json'))).to.be.true; - expect(isValidPath(path.join(cwd, 'subfolder/config.json'))).to.be.true; - expect(isValidPath('/etc/passwd')).to.be.true; - expect(isValidPath('../config.json')).to.be.true; + expect(isValidPath(path.join(cwd, 'config.json'))).toBe(true); + expect(isValidPath(path.join(cwd, 'subfolder/config.json'))).toBe(true); + expect(isValidPath('/etc/passwd')).toBe(true); + expect(isValidPath('../config.json')).toBe(true); // Invalid paths - expect(isValidPath('')).to.be.false; - expect(isValidPath(null)).to.be.false; - expect(isValidPath(undefined)).to.be.false; + expect(isValidPath('')).toBe(false); + expect(isValidPath(null as any)).toBe(false); + expect(isValidPath(undefined as any)).toBe(false); // Additional edge cases - expect(isValidPath({})).to.be.false; - expect(isValidPath([])).to.be.false; - expect(isValidPath(123)).to.be.false; - expect(isValidPath(true)).to.be.false; - expect(isValidPath('\0invalid')).to.be.false; - expect(isValidPath('\u0000')).to.be.false; - }); - - it('should handle path resolution errors', () => { - // Mock path.resolve to throw an error - const originalResolve = path.resolve; - path.resolve = () => { - throw new Error('Mock path resolution error'); - }; - - expect(isValidPath('some/path')).to.be.false; - - // Restore original path.resolve - path.resolve = originalResolve; + expect(isValidPath({} as any)).toBe(false); + expect(isValidPath([] as any)).toBe(false); + expect(isValidPath(123 as any)).toBe(false); + expect(isValidPath(true as any)).toBe(false); + expect(isValidPath('\0invalid')).toBe(false); + expect(isValidPath('\u0000')).toBe(false); }); }); describe('isValidBranchName', () => { it('should validate git branch names correctly', () => { // Valid branch names - expect(isValidBranchName('main')).to.be.true; - expect(isValidBranchName('feature/new-feature')).to.be.true; - expect(isValidBranchName('release-1.0')).to.be.true; - expect(isValidBranchName('fix_123')).to.be.true; - expect(isValidBranchName('user/feature/branch')).to.be.true; + expect(isValidBranchName('main')).toBe(true); + expect(isValidBranchName('feature/new-feature')).toBe(true); + expect(isValidBranchName('release-1.0')).toBe(true); + expect(isValidBranchName('fix_123')).toBe(true); + expect(isValidBranchName('user/feature/branch')).toBe(true); // Invalid branch names - expect(isValidBranchName('.invalid')).to.be.false; - expect(isValidBranchName('-invalid')).to.be.false; - expect(isValidBranchName('branch with spaces')).to.be.false; - expect(isValidBranchName('')).to.be.false; - expect(isValidBranchName(null)).to.be.false; - expect(isValidBranchName(undefined)).to.be.false; - expect(isValidBranchName('branch..name')).to.be.false; + expect(isValidBranchName('.invalid')).toBe(false); + expect(isValidBranchName('-invalid')).toBe(false); + expect(isValidBranchName('branch with spaces')).toBe(false); + expect(isValidBranchName('')).toBe(false); + expect(isValidBranchName(null as any)).toBe(false); + expect(isValidBranchName(undefined as any)).toBe(false); + expect(isValidBranchName('branch..name')).toBe(false); }); }); }); describe('ConfigLoader Error Handling', () => { - let configLoader; - let tempDir; - let tempConfigFile; + let configLoader: ConfigLoader; + let tempDir: string; + let tempConfigFile: string; beforeEach(() => { tempDir = fs.mkdtempSync('gitproxy-configloader-test-'); @@ -704,7 +650,7 @@ describe('ConfigLoader Error Handling', () => { if (fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true }); } - sinon.restore(); + vi.restoreAllMocks(); configLoader?.stop(); }); @@ -712,47 +658,38 @@ describe('ConfigLoader Error Handling', () => { fs.writeFileSync(tempConfigFile, 'invalid json content'); configLoader = new ConfigLoader({}); - try { - await configLoader.loadFromFile({ + await expect( + 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'); - } + }), + ).rejects.toThrow(/Invalid configuration file format/); }); it('should handle HTTP request errors', async () => { - sinon.stub(axios, 'get').rejects(new Error('Network error')); + vi.spyOn(axios, 'get').mockRejectedValue(new Error('Network error')); configLoader = new ConfigLoader({}); - try { - await configLoader.loadFromHttp({ + await expect( + 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'); - } + }), + ).rejects.toThrow('Network error'); }); it('should handle invalid JSON from HTTP response', async () => { - sinon.stub(axios, 'get').resolves({ data: 'invalid json response' }); + vi.spyOn(axios, 'get').mockResolvedValue({ data: 'invalid json response' }); configLoader = new ConfigLoader({}); - try { - await configLoader.loadFromHttp({ + await expect( + 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'); - } + }), + ).rejects.toThrow(/Invalid configuration format from HTTP source/); }); }); diff --git a/test/chain.test.js b/test/chain.test.js deleted file mode 100644 index 0db86ac91..000000000 --- a/test/chain.test.js +++ /dev/null @@ -1,480 +0,0 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const { PluginLoader } = require('../src/plugin'); -const db = require('../src/db'); - -chai.should(); -const expect = chai.expect; - -const mockLoader = { - pushPlugins: [ - { exec: Object.assign(async () => console.log('foo'), { displayName: 'foo.exec' }) }, - ], - pullPlugins: [ - { exec: Object.assign(async () => console.log('foo'), { displayName: 'bar.exec' }) }, - ], -}; - -const initMockPushProcessors = (sinon) => { - const mockPushProcessors = { - parsePush: sinon.stub(), - checkEmptyBranch: sinon.stub(), - audit: sinon.stub(), - checkRepoInAuthorisedList: sinon.stub(), - checkCommitMessages: sinon.stub(), - checkAuthorEmails: sinon.stub(), - checkUserPushPermission: sinon.stub(), - checkIfWaitingAuth: sinon.stub(), - checkHiddenCommits: sinon.stub(), - pullRemote: sinon.stub(), - writePack: sinon.stub(), - preReceive: sinon.stub(), - getDiff: sinon.stub(), - gitleaks: sinon.stub(), - clearBareClone: sinon.stub(), - scanDiff: sinon.stub(), - blockForAuth: sinon.stub(), - }; - mockPushProcessors.parsePush.displayName = 'parsePush'; - mockPushProcessors.checkEmptyBranch.displayName = 'checkEmptyBranch'; - mockPushProcessors.audit.displayName = 'audit'; - mockPushProcessors.checkRepoInAuthorisedList.displayName = 'checkRepoInAuthorisedList'; - mockPushProcessors.checkCommitMessages.displayName = 'checkCommitMessages'; - mockPushProcessors.checkAuthorEmails.displayName = 'checkAuthorEmails'; - mockPushProcessors.checkUserPushPermission.displayName = 'checkUserPushPermission'; - mockPushProcessors.checkIfWaitingAuth.displayName = 'checkIfWaitingAuth'; - mockPushProcessors.checkHiddenCommits.displayName = 'checkHiddenCommits'; - mockPushProcessors.pullRemote.displayName = 'pullRemote'; - mockPushProcessors.writePack.displayName = 'writePack'; - mockPushProcessors.preReceive.displayName = 'preReceive'; - mockPushProcessors.getDiff.displayName = 'getDiff'; - mockPushProcessors.gitleaks.displayName = 'gitleaks'; - mockPushProcessors.clearBareClone.displayName = 'clearBareClone'; - mockPushProcessors.scanDiff.displayName = 'scanDiff'; - mockPushProcessors.blockForAuth.displayName = 'blockForAuth'; - return mockPushProcessors; -}; -const mockPreProcessors = { - parseAction: sinon.stub(), -}; - -const clearCache = (sandbox) => { - delete require.cache[require.resolve('../src/proxy/processors')]; - delete require.cache[require.resolve('../src/proxy/chain')]; - sandbox.restore(); -}; - -describe('proxy chain', function () { - let processors; - let chain; - let mockPushProcessors; - let sandboxSinon; - - beforeEach(async () => { - // Create a new sandbox for each test - sandboxSinon = sinon.createSandbox(); - // Initialize the mock push processors - mockPushProcessors = initMockPushProcessors(sandboxSinon); - - // Re-import the processors module after clearing the cache - processors = await import('../src/proxy/processors'); - - // Mock the processors module - sandboxSinon.stub(processors, 'pre').value(mockPreProcessors); - - sandboxSinon.stub(processors, 'push').value(mockPushProcessors); - - // Re-import the chain module after stubbing processors - chain = require('../src/proxy/chain').default; - - chain.chainPluginLoader = new PluginLoader([]); - }); - - afterEach(() => { - // Clear the module from the cache after each test - clearCache(sandboxSinon); - }); - - it('getChain should set pluginLoaded if loader is undefined', async function () { - chain.chainPluginLoader = undefined; - const actual = await chain.getChain({ type: 'push' }); - expect(actual).to.deep.equal(chain.pushActionChain); - expect(chain.chainPluginLoader).to.be.undefined; - expect(chain.pluginsInserted).to.be.true; - }); - - it('getChain should load plugins from an initialized PluginLoader', async function () { - chain.chainPluginLoader = mockLoader; - const initialChain = [...chain.pushActionChain]; - const actual = await chain.getChain({ type: 'push' }); - expect(actual.length).to.be.greaterThan(initialChain.length); - expect(chain.pluginsInserted).to.be.true; - }); - - it('getChain should load pull plugins from an initialized PluginLoader', async function () { - chain.chainPluginLoader = mockLoader; - const initialChain = [...chain.pullActionChain]; - const actual = await chain.getChain({ type: 'pull' }); - expect(actual.length).to.be.greaterThan(initialChain.length); - expect(chain.pluginsInserted).to.be.true; - }); - - it('executeChain should stop executing if action has continue returns false', async function () { - const req = {}; - const continuingAction = { type: 'push', continue: () => true, allowPush: false }; - mockPreProcessors.parseAction.resolves({ type: 'push' }); - mockPushProcessors.parsePush.resolves(continuingAction); - mockPushProcessors.checkEmptyBranch.resolves(continuingAction); - mockPushProcessors.checkRepoInAuthorisedList.resolves(continuingAction); - mockPushProcessors.checkCommitMessages.resolves(continuingAction); - mockPushProcessors.checkAuthorEmails.resolves(continuingAction); - mockPushProcessors.checkUserPushPermission.resolves(continuingAction); - mockPushProcessors.checkHiddenCommits.resolves(continuingAction); - mockPushProcessors.pullRemote.resolves(continuingAction); - mockPushProcessors.writePack.resolves(continuingAction); - // this stops the chain from further execution - mockPushProcessors.checkIfWaitingAuth.resolves({ - type: 'push', - continue: () => false, - allowPush: false, - }); - const result = await chain.executeChain(req); - - expect(mockPreProcessors.parseAction.called).to.be.true; - expect(mockPushProcessors.parsePush.called).to.be.true; - expect(mockPushProcessors.checkRepoInAuthorisedList.called).to.be.true; - expect(mockPushProcessors.checkCommitMessages.called).to.be.true; - expect(mockPushProcessors.checkAuthorEmails.called).to.be.true; - expect(mockPushProcessors.checkUserPushPermission.called).to.be.true; - expect(mockPushProcessors.checkIfWaitingAuth.called).to.be.true; - expect(mockPushProcessors.pullRemote.called).to.be.true; - expect(mockPushProcessors.checkHiddenCommits.called).to.be.true; - expect(mockPushProcessors.writePack.called).to.be.true; - expect(mockPushProcessors.checkEmptyBranch.called).to.be.true; - expect(mockPushProcessors.audit.called).to.be.true; - - expect(result.type).to.equal('push'); - expect(result.allowPush).to.be.false; - expect(result.continue).to.be.a('function'); - }); - - it('executeChain should stop executing if action has allowPush is set to true', async function () { - const req = {}; - const continuingAction = { type: 'push', continue: () => true, allowPush: false }; - mockPreProcessors.parseAction.resolves({ type: 'push' }); - mockPushProcessors.parsePush.resolves(continuingAction); - mockPushProcessors.checkEmptyBranch.resolves(continuingAction); - mockPushProcessors.checkRepoInAuthorisedList.resolves(continuingAction); - mockPushProcessors.checkCommitMessages.resolves(continuingAction); - mockPushProcessors.checkAuthorEmails.resolves(continuingAction); - mockPushProcessors.checkUserPushPermission.resolves(continuingAction); - mockPushProcessors.checkHiddenCommits.resolves(continuingAction); - mockPushProcessors.pullRemote.resolves(continuingAction); - mockPushProcessors.writePack.resolves(continuingAction); - // this stops the chain from further execution - mockPushProcessors.checkIfWaitingAuth.resolves({ - type: 'push', - continue: () => true, - allowPush: true, - }); - const result = await chain.executeChain(req); - - expect(mockPreProcessors.parseAction.called).to.be.true; - expect(mockPushProcessors.parsePush.called).to.be.true; - expect(mockPushProcessors.checkEmptyBranch.called).to.be.true; - expect(mockPushProcessors.checkRepoInAuthorisedList.called).to.be.true; - expect(mockPushProcessors.checkCommitMessages.called).to.be.true; - expect(mockPushProcessors.checkAuthorEmails.called).to.be.true; - expect(mockPushProcessors.checkUserPushPermission.called).to.be.true; - expect(mockPushProcessors.checkIfWaitingAuth.called).to.be.true; - expect(mockPushProcessors.pullRemote.called).to.be.true; - expect(mockPushProcessors.checkHiddenCommits.called).to.be.true; - expect(mockPushProcessors.writePack.called).to.be.true; - expect(mockPushProcessors.audit.called).to.be.true; - - expect(result.type).to.equal('push'); - expect(result.allowPush).to.be.true; - expect(result.continue).to.be.a('function'); - }); - - it('executeChain should execute all steps if all actions succeed', async function () { - const req = {}; - const continuingAction = { type: 'push', continue: () => true, allowPush: false }; - mockPreProcessors.parseAction.resolves({ type: 'push' }); - mockPushProcessors.parsePush.resolves(continuingAction); - mockPushProcessors.checkEmptyBranch.resolves(continuingAction); - mockPushProcessors.checkRepoInAuthorisedList.resolves(continuingAction); - mockPushProcessors.checkCommitMessages.resolves(continuingAction); - mockPushProcessors.checkAuthorEmails.resolves(continuingAction); - mockPushProcessors.checkUserPushPermission.resolves(continuingAction); - mockPushProcessors.checkIfWaitingAuth.resolves(continuingAction); - mockPushProcessors.pullRemote.resolves(continuingAction); - mockPushProcessors.writePack.resolves(continuingAction); - mockPushProcessors.checkHiddenCommits.resolves(continuingAction); - mockPushProcessors.preReceive.resolves(continuingAction); - mockPushProcessors.getDiff.resolves(continuingAction); - mockPushProcessors.gitleaks.resolves(continuingAction); - mockPushProcessors.clearBareClone.resolves(continuingAction); - mockPushProcessors.scanDiff.resolves(continuingAction); - mockPushProcessors.blockForAuth.resolves(continuingAction); - - const result = await chain.executeChain(req); - - expect(mockPreProcessors.parseAction.called).to.be.true; - expect(mockPushProcessors.parsePush.called).to.be.true; - expect(mockPushProcessors.checkEmptyBranch.called).to.be.true; - expect(mockPushProcessors.checkRepoInAuthorisedList.called).to.be.true; - expect(mockPushProcessors.checkCommitMessages.called).to.be.true; - expect(mockPushProcessors.checkAuthorEmails.called).to.be.true; - expect(mockPushProcessors.checkUserPushPermission.called).to.be.true; - expect(mockPushProcessors.checkIfWaitingAuth.called).to.be.true; - expect(mockPushProcessors.pullRemote.called).to.be.true; - expect(mockPushProcessors.checkHiddenCommits.called).to.be.true; - expect(mockPushProcessors.writePack.called).to.be.true; - expect(mockPushProcessors.preReceive.called).to.be.true; - expect(mockPushProcessors.getDiff.called).to.be.true; - expect(mockPushProcessors.gitleaks.called).to.be.true; - expect(mockPushProcessors.clearBareClone.called).to.be.true; - expect(mockPushProcessors.scanDiff.called).to.be.true; - expect(mockPushProcessors.blockForAuth.called).to.be.true; - expect(mockPushProcessors.audit.called).to.be.true; - - expect(result.type).to.equal('push'); - expect(result.allowPush).to.be.false; - expect(result.continue).to.be.a('function'); - }); - - it('executeChain should run the expected steps for pulls', async function () { - const req = {}; - const continuingAction = { type: 'pull', continue: () => true, allowPush: false }; - mockPreProcessors.parseAction.resolves({ type: 'pull' }); - mockPushProcessors.checkRepoInAuthorisedList.resolves(continuingAction); - const result = await chain.executeChain(req); - - expect(mockPushProcessors.checkRepoInAuthorisedList.called).to.be.true; - expect(mockPushProcessors.parsePush.called).to.be.false; - expect(result.type).to.equal('pull'); - }); - - it('executeChain should handle errors and still call audit', async function () { - const req = {}; - const action = { type: 'push', continue: () => true, allowPush: true }; - - processors.pre.parseAction.resolves(action); - mockPushProcessors.parsePush.rejects(new Error('Audit error')); - - try { - await chain.executeChain(req); - } catch { - // Ignore the error - } - - expect(mockPushProcessors.audit.called).to.be.true; - }); - - it('executeChain should always run at least checkRepoInAuthList', async function () { - const req = {}; - const action = { type: 'foo', continue: () => true, allowPush: true }; - - mockPreProcessors.parseAction.resolves(action); - mockPushProcessors.checkRepoInAuthorisedList.resolves(action); - - await chain.executeChain(req); - expect(mockPushProcessors.checkRepoInAuthorisedList.called).to.be.true; - }); - - it('should approve push automatically and record in the database', async function () { - const req = {}; - const action = { - type: 'push', - continue: () => true, - allowPush: false, - setAutoApproval: sinon.stub(), - repoName: 'test-repo', - commitTo: 'newCommitHash', - }; - - mockPreProcessors.parseAction.resolves(action); - mockPushProcessors.parsePush.resolves(action); - mockPushProcessors.checkEmptyBranch.resolves(action); - mockPushProcessors.checkRepoInAuthorisedList.resolves(action); - mockPushProcessors.checkCommitMessages.resolves(action); - mockPushProcessors.checkAuthorEmails.resolves(action); - mockPushProcessors.checkUserPushPermission.resolves(action); - mockPushProcessors.checkIfWaitingAuth.resolves(action); - mockPushProcessors.pullRemote.resolves(action); - mockPushProcessors.writePack.resolves(action); - mockPushProcessors.checkHiddenCommits.resolves(action); - - mockPushProcessors.preReceive.resolves({ - ...action, - steps: [{ error: false, logs: ['Push automatically approved by pre-receive hook.'] }], - allowPush: true, - autoApproved: true, - }); - - mockPushProcessors.getDiff.resolves(action); - mockPushProcessors.gitleaks.resolves(action); - mockPushProcessors.clearBareClone.resolves(action); - mockPushProcessors.scanDiff.resolves(action); - mockPushProcessors.blockForAuth.resolves(action); - const dbStub = sinon.stub(db, 'authorise').resolves(true); - - const result = await chain.executeChain(req); - - expect(result.type).to.equal('push'); - expect(result.allowPush).to.be.true; - expect(result.continue).to.be.a('function'); - - expect(dbStub.calledOnce).to.be.true; - - dbStub.restore(); - }); - - it('should reject push automatically and record in the database', async function () { - const req = {}; - const action = { - type: 'push', - continue: () => true, - allowPush: false, - setAutoRejection: sinon.stub(), - repoName: 'test-repo', - commitTo: 'newCommitHash', - }; - - mockPreProcessors.parseAction.resolves(action); - mockPushProcessors.parsePush.resolves(action); - mockPushProcessors.checkEmptyBranch.resolves(action); - mockPushProcessors.checkRepoInAuthorisedList.resolves(action); - mockPushProcessors.checkCommitMessages.resolves(action); - mockPushProcessors.checkAuthorEmails.resolves(action); - mockPushProcessors.checkUserPushPermission.resolves(action); - mockPushProcessors.checkIfWaitingAuth.resolves(action); - mockPushProcessors.pullRemote.resolves(action); - mockPushProcessors.writePack.resolves(action); - mockPushProcessors.checkHiddenCommits.resolves(action); - - mockPushProcessors.preReceive.resolves({ - ...action, - steps: [{ error: false, logs: ['Push automatically rejected by pre-receive hook.'] }], - allowPush: true, - autoRejected: true, - }); - - mockPushProcessors.getDiff.resolves(action); - mockPushProcessors.gitleaks.resolves(action); - mockPushProcessors.clearBareClone.resolves(action); - mockPushProcessors.scanDiff.resolves(action); - mockPushProcessors.blockForAuth.resolves(action); - - const dbStub = sinon.stub(db, 'reject').resolves(true); - - const result = await chain.executeChain(req); - - expect(result.type).to.equal('push'); - expect(result.allowPush).to.be.true; - expect(result.continue).to.be.a('function'); - - expect(dbStub.calledOnce).to.be.true; - - dbStub.restore(); - }); - - it('executeChain should handle exceptions in attemptAutoApproval', async function () { - const req = {}; - const action = { - type: 'push', - continue: () => true, - allowPush: false, - setAutoApproval: sinon.stub(), - repoName: 'test-repo', - commitTo: 'newCommitHash', - }; - - mockPreProcessors.parseAction.resolves(action); - mockPushProcessors.parsePush.resolves(action); - mockPushProcessors.checkEmptyBranch.resolves(action); - mockPushProcessors.checkRepoInAuthorisedList.resolves(action); - mockPushProcessors.checkCommitMessages.resolves(action); - mockPushProcessors.checkAuthorEmails.resolves(action); - mockPushProcessors.checkUserPushPermission.resolves(action); - mockPushProcessors.checkIfWaitingAuth.resolves(action); - mockPushProcessors.pullRemote.resolves(action); - mockPushProcessors.writePack.resolves(action); - mockPushProcessors.checkHiddenCommits.resolves(action); - - mockPushProcessors.preReceive.resolves({ - ...action, - steps: [{ error: false, logs: ['Push automatically approved by pre-receive hook.'] }], - allowPush: true, - autoApproved: true, - }); - - mockPushProcessors.getDiff.resolves(action); - mockPushProcessors.gitleaks.resolves(action); - mockPushProcessors.clearBareClone.resolves(action); - mockPushProcessors.scanDiff.resolves(action); - mockPushProcessors.blockForAuth.resolves(action); - - const error = new Error('Database error'); - - const consoleErrorStub = sinon.stub(console, 'error'); - sinon.stub(db, 'authorise').rejects(error); - await chain.executeChain(req); - expect(consoleErrorStub.calledOnceWith('Error during auto-approval:', error.message)).to.be - .true; - db.authorise.restore(); - consoleErrorStub.restore(); - }); - - it('executeChain should handle exceptions in attemptAutoRejection', async function () { - const req = {}; - const action = { - type: 'push', - continue: () => true, - allowPush: false, - setAutoRejection: sinon.stub(), - repoName: 'test-repo', - commitTo: 'newCommitHash', - autoRejected: true, - }; - - mockPreProcessors.parseAction.resolves(action); - mockPushProcessors.parsePush.resolves(action); - mockPushProcessors.checkEmptyBranch.resolves(action); - mockPushProcessors.checkRepoInAuthorisedList.resolves(action); - mockPushProcessors.checkCommitMessages.resolves(action); - mockPushProcessors.checkAuthorEmails.resolves(action); - mockPushProcessors.checkUserPushPermission.resolves(action); - mockPushProcessors.checkIfWaitingAuth.resolves(action); - mockPushProcessors.pullRemote.resolves(action); - mockPushProcessors.writePack.resolves(action); - mockPushProcessors.checkHiddenCommits.resolves(action); - - mockPushProcessors.preReceive.resolves({ - ...action, - steps: [{ error: false, logs: ['Push automatically rejected by pre-receive hook.'] }], - allowPush: false, - autoRejected: true, - }); - - mockPushProcessors.getDiff.resolves(action); - mockPushProcessors.gitleaks.resolves(action); - mockPushProcessors.clearBareClone.resolves(action); - mockPushProcessors.scanDiff.resolves(action); - mockPushProcessors.blockForAuth.resolves(action); - - const error = new Error('Database error'); - - const consoleErrorStub = sinon.stub(console, 'error'); - sinon.stub(db, 'reject').rejects(error); - - await chain.executeChain(req); - - expect(consoleErrorStub.calledOnceWith('Error during auto-rejection:', error.message)).to.be - .true; - - db.reject.restore(); - consoleErrorStub.restore(); - }); -}); diff --git a/test/chain.test.ts b/test/chain.test.ts new file mode 100644 index 000000000..e9bc3fb0a --- /dev/null +++ b/test/chain.test.ts @@ -0,0 +1,456 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; +import { PluginLoader } from '../src/plugin'; + +const mockLoader = { + pushPlugins: [ + { exec: Object.assign(async () => console.log('foo'), { displayName: 'foo.exec' }) }, + ], + pullPlugins: [ + { exec: Object.assign(async () => console.log('foo'), { displayName: 'bar.exec' }) }, + ], +}; + +const initMockPushProcessors = () => { + const mockPushProcessors = { + parsePush: vi.fn(), + checkEmptyBranch: vi.fn(), + audit: vi.fn(), + checkRepoInAuthorisedList: vi.fn(), + checkCommitMessages: vi.fn(), + checkAuthorEmails: vi.fn(), + checkUserPushPermission: vi.fn(), + checkIfWaitingAuth: vi.fn(), + checkHiddenCommits: vi.fn(), + pullRemote: vi.fn(), + writePack: vi.fn(), + preReceive: vi.fn(), + getDiff: vi.fn(), + gitleaks: vi.fn(), + clearBareClone: vi.fn(), + scanDiff: vi.fn(), + blockForAuth: vi.fn(), + }; + return mockPushProcessors; +}; + +const mockPreProcessors = { + parseAction: vi.fn(), +}; + +describe('proxy chain', function () { + let processors: any; + let chain: any; + let db: any; + let mockPushProcessors: any; + + beforeEach(async () => { + vi.resetModules(); + + // Initialize the mocks + mockPushProcessors = initMockPushProcessors(); + + // Mock the processors module + vi.doMock('../src/proxy/processors', async () => ({ + pre: mockPreProcessors, + push: mockPushProcessors, + })); + + vi.doMock('../src/db', async () => ({ + authorise: vi.fn(), + reject: vi.fn(), + })); + + // Import the mocked modules + processors = await import('../src/proxy/processors'); + db = await import('../src/db'); + const chainModule = await import('../src/proxy/chain'); + chain = chainModule.default; + + chain.chainPluginLoader = new PluginLoader([]); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + }); + + it('getChain should set pluginLoaded if loader is undefined', async () => { + chain.chainPluginLoader = undefined; + const actual = await chain.getChain({ type: 'push' }); + expect(actual).toEqual(chain.pushActionChain); + expect(chain.chainPluginLoader).toBeUndefined(); + expect(chain.pluginsInserted).toBe(true); + }); + + it('getChain should load plugins from an initialized PluginLoader', async () => { + chain.chainPluginLoader = mockLoader; + const initialChain = [...chain.pushActionChain]; + const actual = await chain.getChain({ type: 'push' }); + expect(actual.length).toBeGreaterThan(initialChain.length); + expect(chain.pluginsInserted).toBe(true); + }); + + it('getChain should load pull plugins from an initialized PluginLoader', async () => { + chain.chainPluginLoader = mockLoader; + const initialChain = [...chain.pullActionChain]; + const actual = await chain.getChain({ type: 'pull' }); + expect(actual.length).toBeGreaterThan(initialChain.length); + expect(chain.pluginsInserted).toBe(true); + }); + + it('executeChain should stop executing if action has continue returns false', async () => { + const req = {}; + const continuingAction = { type: 'push', continue: () => true, allowPush: false }; + mockPreProcessors.parseAction.mockResolvedValue({ type: 'push' }); + mockPushProcessors.parsePush.mockResolvedValue(continuingAction); + mockPushProcessors.checkEmptyBranch.mockResolvedValue(continuingAction); + mockPushProcessors.checkRepoInAuthorisedList.mockResolvedValue(continuingAction); + mockPushProcessors.checkCommitMessages.mockResolvedValue(continuingAction); + mockPushProcessors.checkAuthorEmails.mockResolvedValue(continuingAction); + mockPushProcessors.checkUserPushPermission.mockResolvedValue(continuingAction); + mockPushProcessors.checkHiddenCommits.mockResolvedValue(continuingAction); + mockPushProcessors.pullRemote.mockResolvedValue(continuingAction); + mockPushProcessors.writePack.mockResolvedValue(continuingAction); + // this stops the chain from further execution + mockPushProcessors.checkIfWaitingAuth.mockResolvedValue({ + type: 'push', + continue: () => false, + allowPush: false, + }); + + const result = await chain.executeChain(req); + + expect(mockPreProcessors.parseAction).toHaveBeenCalled(); + expect(mockPushProcessors.parsePush).toHaveBeenCalled(); + expect(mockPushProcessors.checkRepoInAuthorisedList).toHaveBeenCalled(); + expect(mockPushProcessors.checkCommitMessages).toHaveBeenCalled(); + expect(mockPushProcessors.checkAuthorEmails).toHaveBeenCalled(); + expect(mockPushProcessors.checkUserPushPermission).toHaveBeenCalled(); + expect(mockPushProcessors.checkIfWaitingAuth).toHaveBeenCalled(); + expect(mockPushProcessors.pullRemote).toHaveBeenCalled(); + expect(mockPushProcessors.checkHiddenCommits).toHaveBeenCalled(); + expect(mockPushProcessors.writePack).toHaveBeenCalled(); + expect(mockPushProcessors.checkEmptyBranch).toHaveBeenCalled(); + expect(mockPushProcessors.audit).toHaveBeenCalled(); + + expect(result.type).toBe('push'); + expect(result.allowPush).toBe(false); + expect(result.continue).toBeTypeOf('function'); + }); + + it('executeChain should stop executing if action has allowPush is set to true', async () => { + const req = {}; + const continuingAction = { type: 'push', continue: () => true, allowPush: false }; + mockPreProcessors.parseAction.mockResolvedValue({ type: 'push' }); + mockPushProcessors.parsePush.mockResolvedValue(continuingAction); + mockPushProcessors.checkEmptyBranch.mockResolvedValue(continuingAction); + mockPushProcessors.checkRepoInAuthorisedList.mockResolvedValue(continuingAction); + mockPushProcessors.checkCommitMessages.mockResolvedValue(continuingAction); + mockPushProcessors.checkAuthorEmails.mockResolvedValue(continuingAction); + mockPushProcessors.checkUserPushPermission.mockResolvedValue(continuingAction); + mockPushProcessors.checkHiddenCommits.mockResolvedValue(continuingAction); + mockPushProcessors.pullRemote.mockResolvedValue(continuingAction); + mockPushProcessors.writePack.mockResolvedValue(continuingAction); + // this stops the chain from further execution + mockPushProcessors.checkIfWaitingAuth.mockResolvedValue({ + type: 'push', + continue: () => true, + allowPush: true, + }); + + const result = await chain.executeChain(req); + + expect(mockPreProcessors.parseAction).toHaveBeenCalled(); + expect(mockPushProcessors.parsePush).toHaveBeenCalled(); + expect(mockPushProcessors.checkEmptyBranch).toHaveBeenCalled(); + expect(mockPushProcessors.checkRepoInAuthorisedList).toHaveBeenCalled(); + expect(mockPushProcessors.checkCommitMessages).toHaveBeenCalled(); + expect(mockPushProcessors.checkAuthorEmails).toHaveBeenCalled(); + expect(mockPushProcessors.checkUserPushPermission).toHaveBeenCalled(); + expect(mockPushProcessors.checkIfWaitingAuth).toHaveBeenCalled(); + expect(mockPushProcessors.pullRemote).toHaveBeenCalled(); + expect(mockPushProcessors.checkHiddenCommits).toHaveBeenCalled(); + expect(mockPushProcessors.writePack).toHaveBeenCalled(); + expect(mockPushProcessors.audit).toHaveBeenCalled(); + + expect(result.type).toBe('push'); + expect(result.allowPush).toBe(true); + expect(result.continue).toBeTypeOf('function'); + }); + + it('executeChain should execute all steps if all actions succeed', async () => { + const req = {}; + const continuingAction = { type: 'push', continue: () => true, allowPush: false }; + mockPreProcessors.parseAction.mockResolvedValue({ type: 'push' }); + mockPushProcessors.parsePush.mockResolvedValue(continuingAction); + mockPushProcessors.checkEmptyBranch.mockResolvedValue(continuingAction); + mockPushProcessors.checkRepoInAuthorisedList.mockResolvedValue(continuingAction); + mockPushProcessors.checkCommitMessages.mockResolvedValue(continuingAction); + mockPushProcessors.checkAuthorEmails.mockResolvedValue(continuingAction); + mockPushProcessors.checkUserPushPermission.mockResolvedValue(continuingAction); + mockPushProcessors.checkIfWaitingAuth.mockResolvedValue(continuingAction); + mockPushProcessors.pullRemote.mockResolvedValue(continuingAction); + mockPushProcessors.writePack.mockResolvedValue(continuingAction); + mockPushProcessors.checkHiddenCommits.mockResolvedValue(continuingAction); + mockPushProcessors.preReceive.mockResolvedValue(continuingAction); + mockPushProcessors.getDiff.mockResolvedValue(continuingAction); + mockPushProcessors.gitleaks.mockResolvedValue(continuingAction); + mockPushProcessors.clearBareClone.mockResolvedValue(continuingAction); + mockPushProcessors.scanDiff.mockResolvedValue(continuingAction); + mockPushProcessors.blockForAuth.mockResolvedValue(continuingAction); + + const result = await chain.executeChain(req); + + expect(mockPreProcessors.parseAction).toHaveBeenCalled(); + expect(mockPushProcessors.parsePush).toHaveBeenCalled(); + expect(mockPushProcessors.checkEmptyBranch).toHaveBeenCalled(); + expect(mockPushProcessors.checkRepoInAuthorisedList).toHaveBeenCalled(); + expect(mockPushProcessors.checkCommitMessages).toHaveBeenCalled(); + expect(mockPushProcessors.checkAuthorEmails).toHaveBeenCalled(); + expect(mockPushProcessors.checkUserPushPermission).toHaveBeenCalled(); + expect(mockPushProcessors.checkIfWaitingAuth).toHaveBeenCalled(); + expect(mockPushProcessors.pullRemote).toHaveBeenCalled(); + expect(mockPushProcessors.checkHiddenCommits).toHaveBeenCalled(); + expect(mockPushProcessors.writePack).toHaveBeenCalled(); + expect(mockPushProcessors.preReceive).toHaveBeenCalled(); + expect(mockPushProcessors.getDiff).toHaveBeenCalled(); + expect(mockPushProcessors.gitleaks).toHaveBeenCalled(); + expect(mockPushProcessors.clearBareClone).toHaveBeenCalled(); + expect(mockPushProcessors.scanDiff).toHaveBeenCalled(); + expect(mockPushProcessors.blockForAuth).toHaveBeenCalled(); + expect(mockPushProcessors.audit).toHaveBeenCalled(); + + expect(result.type).toBe('push'); + expect(result.allowPush).toBe(false); + expect(result.continue).toBeTypeOf('function'); + }); + + it('executeChain should run the expected steps for pulls', async () => { + const req = {}; + const continuingAction = { type: 'pull', continue: () => true, allowPush: false }; + mockPreProcessors.parseAction.mockResolvedValue({ type: 'pull' }); + mockPushProcessors.checkRepoInAuthorisedList.mockResolvedValue(continuingAction); + + const result = await chain.executeChain(req); + + expect(mockPushProcessors.checkRepoInAuthorisedList).toHaveBeenCalled(); + expect(mockPushProcessors.parsePush).not.toHaveBeenCalled(); + expect(result.type).toBe('pull'); + }); + + it('executeChain should handle errors and still call audit', async () => { + const req = {}; + const action = { type: 'push', continue: () => true, allowPush: true }; + + processors.pre.parseAction.mockResolvedValue(action); + mockPushProcessors.parsePush.mockRejectedValue(new Error('Audit error')); + + try { + await chain.executeChain(req); + } catch { + // Ignore the error + } + + expect(mockPushProcessors.audit).toHaveBeenCalled(); + }); + + it('executeChain should always run at least checkRepoInAuthList', async () => { + const req = {}; + const action = { type: 'foo', continue: () => true, allowPush: true }; + + mockPreProcessors.parseAction.mockResolvedValue(action); + mockPushProcessors.checkRepoInAuthorisedList.mockResolvedValue(action); + + await chain.executeChain(req); + expect(mockPushProcessors.checkRepoInAuthorisedList).toHaveBeenCalled(); + }); + + it('should approve push automatically and record in the database', async () => { + const req = {}; + const action = { + id: '123', + type: 'push', + continue: () => true, + allowPush: false, + setAutoApproval: vi.fn(), + repoName: 'test-repo', + commitTo: 'newCommitHash', + }; + + mockPreProcessors.parseAction.mockResolvedValue(action); + mockPushProcessors.parsePush.mockResolvedValue(action); + mockPushProcessors.checkEmptyBranch.mockResolvedValue(action); + mockPushProcessors.checkRepoInAuthorisedList.mockResolvedValue(action); + mockPushProcessors.checkCommitMessages.mockResolvedValue(action); + mockPushProcessors.checkAuthorEmails.mockResolvedValue(action); + mockPushProcessors.checkUserPushPermission.mockResolvedValue(action); + mockPushProcessors.checkIfWaitingAuth.mockResolvedValue(action); + mockPushProcessors.pullRemote.mockResolvedValue(action); + mockPushProcessors.writePack.mockResolvedValue(action); + mockPushProcessors.checkHiddenCommits.mockResolvedValue(action); + + mockPushProcessors.preReceive.mockResolvedValue({ + ...action, + steps: [{ error: false, logs: ['Push automatically approved by pre-receive hook.'] }], + allowPush: true, + autoApproved: true, + }); + + mockPushProcessors.getDiff.mockResolvedValue(action); + mockPushProcessors.gitleaks.mockResolvedValue(action); + mockPushProcessors.clearBareClone.mockResolvedValue(action); + mockPushProcessors.scanDiff.mockResolvedValue(action); + mockPushProcessors.blockForAuth.mockResolvedValue(action); + + const dbSpy = vi.spyOn(db, 'authorise').mockResolvedValue({ + message: `authorised ${action.id}`, + }); + + const result = await chain.executeChain(req); + + expect(result.type).toBe('push'); + expect(result.allowPush).toBe(true); + expect(result.continue).toBeTypeOf('function'); + expect(dbSpy).toHaveBeenCalledOnce(); + }); + + it('should reject push automatically and record in the database', async () => { + const req = {}; + const action = { + id: '123', + type: 'push', + continue: () => true, + allowPush: false, + setAutoRejection: vi.fn(), + repoName: 'test-repo', + commitTo: 'newCommitHash', + }; + + mockPreProcessors.parseAction.mockResolvedValue(action); + mockPushProcessors.parsePush.mockResolvedValue(action); + mockPushProcessors.checkEmptyBranch.mockResolvedValue(action); + mockPushProcessors.checkRepoInAuthorisedList.mockResolvedValue(action); + mockPushProcessors.checkCommitMessages.mockResolvedValue(action); + mockPushProcessors.checkAuthorEmails.mockResolvedValue(action); + mockPushProcessors.checkUserPushPermission.mockResolvedValue(action); + mockPushProcessors.checkIfWaitingAuth.mockResolvedValue(action); + mockPushProcessors.pullRemote.mockResolvedValue(action); + mockPushProcessors.writePack.mockResolvedValue(action); + mockPushProcessors.checkHiddenCommits.mockResolvedValue(action); + + mockPushProcessors.preReceive.mockResolvedValue({ + ...action, + steps: [{ error: false, logs: ['Push automatically rejected by pre-receive hook.'] }], + allowPush: true, + autoRejected: true, + }); + + mockPushProcessors.getDiff.mockResolvedValue(action); + mockPushProcessors.gitleaks.mockResolvedValue(action); + mockPushProcessors.clearBareClone.mockResolvedValue(action); + mockPushProcessors.scanDiff.mockResolvedValue(action); + mockPushProcessors.blockForAuth.mockResolvedValue(action); + + const dbSpy = vi.spyOn(db, 'reject').mockResolvedValue({ + message: `reject ${action.id}`, + }); + + const result = await chain.executeChain(req); + + expect(result.type).toBe('push'); + expect(result.allowPush).toBe(true); + expect(result.continue).toBeTypeOf('function'); + expect(dbSpy).toHaveBeenCalledOnce(); + }); + + it('executeChain should handle exceptions in attemptAutoApproval', async () => { + const req = {}; + const action = { + type: 'push', + continue: () => true, + allowPush: false, + setAutoApproval: vi.fn(), + repoName: 'test-repo', + commitTo: 'newCommitHash', + }; + + mockPreProcessors.parseAction.mockResolvedValue(action); + mockPushProcessors.parsePush.mockResolvedValue(action); + mockPushProcessors.checkEmptyBranch.mockResolvedValue(action); + mockPushProcessors.checkRepoInAuthorisedList.mockResolvedValue(action); + mockPushProcessors.checkCommitMessages.mockResolvedValue(action); + mockPushProcessors.checkAuthorEmails.mockResolvedValue(action); + mockPushProcessors.checkUserPushPermission.mockResolvedValue(action); + mockPushProcessors.checkIfWaitingAuth.mockResolvedValue(action); + mockPushProcessors.pullRemote.mockResolvedValue(action); + mockPushProcessors.writePack.mockResolvedValue(action); + mockPushProcessors.checkHiddenCommits.mockResolvedValue(action); + + mockPushProcessors.preReceive.mockResolvedValue({ + ...action, + steps: [{ error: false, logs: ['Push automatically approved by pre-receive hook.'] }], + allowPush: true, + autoApproved: true, + }); + + mockPushProcessors.getDiff.mockResolvedValue(action); + mockPushProcessors.gitleaks.mockResolvedValue(action); + mockPushProcessors.clearBareClone.mockResolvedValue(action); + mockPushProcessors.scanDiff.mockResolvedValue(action); + mockPushProcessors.blockForAuth.mockResolvedValue(action); + + const error = new Error('Database error'); + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(db, 'authorise').mockRejectedValue(error); + + await chain.executeChain(req); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error during auto-approval:', error.message); + }); + + it('executeChain should handle exceptions in attemptAutoRejection', async () => { + const req = {}; + const action = { + type: 'push', + continue: () => true, + allowPush: false, + setAutoRejection: vi.fn(), + repoName: 'test-repo', + commitTo: 'newCommitHash', + autoRejected: true, + }; + + mockPreProcessors.parseAction.mockResolvedValue(action); + mockPushProcessors.parsePush.mockResolvedValue(action); + mockPushProcessors.checkEmptyBranch.mockResolvedValue(action); + mockPushProcessors.checkRepoInAuthorisedList.mockResolvedValue(action); + mockPushProcessors.checkCommitMessages.mockResolvedValue(action); + mockPushProcessors.checkAuthorEmails.mockResolvedValue(action); + mockPushProcessors.checkUserPushPermission.mockResolvedValue(action); + mockPushProcessors.checkIfWaitingAuth.mockResolvedValue(action); + mockPushProcessors.pullRemote.mockResolvedValue(action); + mockPushProcessors.writePack.mockResolvedValue(action); + mockPushProcessors.checkHiddenCommits.mockResolvedValue(action); + + mockPushProcessors.preReceive.mockResolvedValue({ + ...action, + steps: [{ error: false, logs: ['Push automatically rejected by pre-receive hook.'] }], + allowPush: false, + autoRejected: true, + }); + + mockPushProcessors.getDiff.mockResolvedValue(action); + mockPushProcessors.gitleaks.mockResolvedValue(action); + mockPushProcessors.clearBareClone.mockResolvedValue(action); + mockPushProcessors.scanDiff.mockResolvedValue(action); + mockPushProcessors.blockForAuth.mockResolvedValue(action); + + const error = new Error('Database error'); + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(db, 'reject').mockRejectedValue(error); + + await chain.executeChain(req); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error during auto-rejection:', error.message); + }); +}); diff --git a/test/checkHiddenCommit.test.js b/test/checkHiddenCommit.test.ts similarity index 51% rename from test/checkHiddenCommit.test.js rename to test/checkHiddenCommit.test.ts index b4013fb8e..3d07946f4 100644 --- a/test/checkHiddenCommit.test.js +++ b/test/checkHiddenCommit.test.ts @@ -1,23 +1,33 @@ -const fs = require('fs'); -const childProcess = require('child_process'); -const sinon = require('sinon'); -const { expect } = require('chai'); +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; +import { exec as checkHidden } from '../src/proxy/processors/push-action/checkHiddenCommits'; +import { Action } from '../src/proxy/actions'; + +// must hoist these before mocking the modules +const mockSpawnSync = vi.hoisted(() => vi.fn()); +const mockReaddirSync = vi.hoisted(() => vi.fn()); + +vi.mock('child_process', async (importOriginal) => { + const actual: any = await importOriginal(); + return { + ...actual, + spawnSync: mockSpawnSync, + }; +}); -const { exec: checkHidden } = require('../src/proxy/processors/push-action/checkHiddenCommits'); -const { Action } = require('../src/proxy/actions'); +vi.mock('fs', async (importOriginal) => { + const actual: any = await importOriginal(); + return { + ...actual, + readdirSync: mockReaddirSync, + }; +}); describe('checkHiddenCommits.exec', () => { - let action; - let sandbox; - let spawnSyncStub; - let readdirSyncStub; + let action: Action; beforeEach(() => { - sandbox = sinon.createSandbox(); - - // stub spawnSync and fs.readdirSync - spawnSyncStub = sandbox.stub(childProcess, 'spawnSync'); - readdirSyncStub = sandbox.stub(fs, 'readdirSync'); + // reset all mocks before each test + vi.clearAllMocks(); // prepare a fresh Action action = new Action('some-id', 'push', 'POST', Date.now(), 'repo.git'); @@ -28,7 +38,7 @@ describe('checkHiddenCommits.exec', () => { }); afterEach(() => { - sandbox.restore(); + vi.clearAllMocks(); }); it('reports all commits unreferenced and sets error=true', async () => { @@ -37,86 +47,75 @@ describe('checkHiddenCommits.exec', () => { // 1) rev-list → no introduced commits // 2) verify-pack → two commits in pack - spawnSyncStub - .onFirstCall() - .returns({ stdout: '' }) - .onSecondCall() - .returns({ - stdout: `${COMMIT_1} commit 100 1\n${COMMIT_2} commit 100 2\n`, - }); + mockSpawnSync.mockReturnValueOnce({ stdout: '' }).mockReturnValueOnce({ + stdout: `${COMMIT_1} commit 100 1\n${COMMIT_2} commit 100 2\n`, + }); - readdirSyncStub.returns(['pack-test.idx']); + mockReaddirSync.mockReturnValue(['pack-test.idx']); await checkHidden({ body: '' }, action); const step = action.steps.find((s) => s.stepName === 'checkHiddenCommits'); - expect(step.logs).to.include(`checkHiddenCommits - Referenced commits: 0`); - expect(step.logs).to.include(`checkHiddenCommits - Unreferenced commits: 2`); - expect(step.logs).to.include( + expect(step?.logs).toContain(`checkHiddenCommits - Referenced commits: 0`); + expect(step?.logs).toContain(`checkHiddenCommits - Unreferenced commits: 2`); + expect(step?.logs).toContain( `checkHiddenCommits - Unreferenced commits in pack (2): ${COMMIT_1}, ${COMMIT_2}.\n` + `This usually happens when a branch was made from a commit that hasn't been approved and pushed to the remote.\n` + `Please get approval on the commits, push them and try again.`, ); - expect(action.error).to.be.true; + expect(action.error).toBe(true); }); it('mixes referenced & unreferenced correctly', async () => { const COMMIT_1 = 'deadbeef'; const COMMIT_2 = 'cafebabe'; - // 1) git rev-list → introduces one commit “deadbeef” + // 1) git rev-list → introduces one commit "deadbeef" // 2) git verify-pack → the pack contains two commits - spawnSyncStub - .onFirstCall() - .returns({ stdout: `${COMMIT_1}\n` }) - .onSecondCall() - .returns({ - stdout: `${COMMIT_1} commit 100 1\n${COMMIT_2} commit 100 2\n`, - }); + mockSpawnSync.mockReturnValueOnce({ stdout: `${COMMIT_1}\n` }).mockReturnValueOnce({ + stdout: `${COMMIT_1} commit 100 1\n${COMMIT_2} commit 100 2\n`, + }); - readdirSyncStub.returns(['pack-test.idx']); + mockReaddirSync.mockReturnValue(['pack-test.idx']); await checkHidden({ body: '' }, action); const step = action.steps.find((s) => s.stepName === 'checkHiddenCommits'); - expect(step.logs).to.include('checkHiddenCommits - Referenced commits: 1'); - expect(step.logs).to.include('checkHiddenCommits - Unreferenced commits: 1'); - expect(step.logs).to.include( + expect(step?.logs).toContain('checkHiddenCommits - Referenced commits: 1'); + expect(step?.logs).toContain('checkHiddenCommits - Unreferenced commits: 1'); + expect(step?.logs).toContain( `checkHiddenCommits - Unreferenced commits in pack (1): ${COMMIT_2}.\n` + `This usually happens when a branch was made from a commit that hasn't been approved and pushed to the remote.\n` + `Please get approval on the commits, push them and try again.`, ); - expect(action.error).to.be.true; + expect(action.error).toBe(true); }); it('reports all commits referenced and sets error=false', async () => { // 1) rev-list → introduces both commits // 2) verify-pack → the pack contains the same two commits - spawnSyncStub.onFirstCall().returns({ stdout: 'deadbeef\ncafebabe\n' }).onSecondCall().returns({ + mockSpawnSync.mockReturnValueOnce({ stdout: 'deadbeef\ncafebabe\n' }).mockReturnValueOnce({ stdout: 'deadbeef commit 100 1\ncafebabe commit 100 2\n', }); - readdirSyncStub.returns(['pack-test.idx']); + mockReaddirSync.mockReturnValue(['pack-test.idx']); await checkHidden({ body: '' }, action); const step = action.steps.find((s) => s.stepName === 'checkHiddenCommits'); - expect(step.logs).to.include('checkHiddenCommits - Total introduced commits: 2'); - expect(step.logs).to.include('checkHiddenCommits - Total commits in the pack: 2'); - expect(step.logs).to.include( + expect(step?.logs).toContain('checkHiddenCommits - Total introduced commits: 2'); + expect(step?.logs).toContain('checkHiddenCommits - Total commits in the pack: 2'); + expect(step?.logs).toContain( 'checkHiddenCommits - All pack commits are referenced in the introduced range.', ); - expect(action.error).to.be.false; + expect(action.error).toBe(false); }); it('throws if commitFrom or commitTo is missing', async () => { - delete action.commitFrom; - - try { - await checkHidden({ body: '' }, action); - throw new Error('Expected checkHidden to throw'); - } catch (err) { - expect(err.message).to.match(/Both action.commitFrom and action.commitTo must be defined/); - } + delete (action as any).commitFrom; + + await expect(checkHidden({ body: '' }, action)).rejects.toThrow( + /Both action.commitFrom and action.commitTo must be defined/, + ); }); }); diff --git a/test/db-helper.test.js b/test/db-helper.test.ts similarity index 69% rename from test/db-helper.test.js rename to test/db-helper.test.ts index 6b973f2c2..ed2bede3a 100644 --- a/test/db-helper.test.js +++ b/test/db-helper.test.ts @@ -1,63 +1,63 @@ -const { expect } = require('chai'); -const { trimPrefixRefsHeads, trimTrailingDotGit } = require('../src/db/helper'); +import { describe, it, expect } from 'vitest'; +import { trimPrefixRefsHeads, trimTrailingDotGit } from '../src/db/helper'; describe('db helpers', () => { describe('trimPrefixRefsHeads', () => { it('removes `refs/heads/`', () => { const res = trimPrefixRefsHeads('refs/heads/test'); - expect(res).to.equal('test'); + expect(res).toBe('test'); }); it('removes only one `refs/heads/`', () => { const res = trimPrefixRefsHeads('refs/heads/refs/heads/'); - expect(res).to.equal('refs/heads/'); + expect(res).toBe('refs/heads/'); }); it('removes only the first `refs/heads/`', () => { const res = trimPrefixRefsHeads('refs/heads/middle/refs/heads/end/refs/heads/'); - expect(res).to.equal('middle/refs/heads/end/refs/heads/'); + expect(res).toBe('middle/refs/heads/end/refs/heads/'); }); it('handles empty string', () => { const res = trimPrefixRefsHeads(''); - expect(res).to.equal(''); + expect(res).toBe(''); }); it("doesn't remove `refs/heads`", () => { const res = trimPrefixRefsHeads('refs/headstest'); - expect(res).to.equal('refs/headstest'); + expect(res).toBe('refs/headstest'); }); it("doesn't remove `/refs/heads/`", () => { const res = trimPrefixRefsHeads('/refs/heads/test'); - expect(res).to.equal('/refs/heads/test'); + expect(res).toBe('/refs/heads/test'); }); }); describe('trimTrailingDotGit', () => { it('removes `.git`', () => { const res = trimTrailingDotGit('test.git'); - expect(res).to.equal('test'); + expect(res).toBe('test'); }); it('removes only one `.git`', () => { const res = trimTrailingDotGit('.git.git'); - expect(res).to.equal('.git'); + expect(res).toBe('.git'); }); it('removes only the last `.git`', () => { const res = trimTrailingDotGit('.git-middle.git-end.git'); - expect(res).to.equal('.git-middle.git-end'); + expect(res).toBe('.git-middle.git-end'); }); it('handles empty string', () => { const res = trimTrailingDotGit(''); - expect(res).to.equal(''); + expect(res).toBe(''); }); it("doesn't remove just `git`", () => { const res = trimTrailingDotGit('testgit'); - expect(res).to.equal('testgit'); + expect(res).toBe('testgit'); }); }); }); diff --git a/test/db/db.test.js b/test/db/db.test.ts similarity index 50% rename from test/db/db.test.js rename to test/db/db.test.ts index 0a54c22b6..bea72d574 100644 --- a/test/db/db.test.js +++ b/test/db/db.test.ts @@ -1,52 +1,71 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const db = require('../../src/db'); +import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest'; -const { expect } = chai; +vi.mock('../../src/db/mongo', () => ({ + getRepoByUrl: vi.fn(), +})); + +vi.mock('../../src/db/file', () => ({ + getRepoByUrl: vi.fn(), +})); + +vi.mock('../../src/config', () => ({ + getDatabase: vi.fn(() => ({ type: 'mongo' })), +})); + +import * as db from '../../src/db'; +import * as mongo from '../../src/db/mongo'; describe('db', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + afterEach(() => { - sinon.restore(); + vi.restoreAllMocks(); }); describe('isUserPushAllowed', () => { it('returns true if user is in canPush', async () => { - sinon.stub(db, 'getRepoByUrl').resolves({ + vi.mocked(mongo.getRepoByUrl).mockResolvedValue({ users: { canPush: ['alice'], canAuthorise: [], }, - }); + } as any); + const result = await db.isUserPushAllowed('myrepo', 'alice'); - expect(result).to.be.true; + expect(result).toBe(true); }); it('returns true if user is in canAuthorise', async () => { - sinon.stub(db, 'getRepoByUrl').resolves({ + vi.mocked(mongo.getRepoByUrl).mockResolvedValue({ users: { canPush: [], canAuthorise: ['bob'], }, - }); + } as any); + const result = await db.isUserPushAllowed('myrepo', 'bob'); - expect(result).to.be.true; + expect(result).toBe(true); }); it('returns false if user is in neither', async () => { - sinon.stub(db, 'getRepoByUrl').resolves({ + vi.mocked(mongo.getRepoByUrl).mockResolvedValue({ users: { canPush: [], canAuthorise: [], }, - }); + } as any); + const result = await db.isUserPushAllowed('myrepo', 'charlie'); - expect(result).to.be.false; + expect(result).toBe(false); }); it('returns false if repo is not registered', async () => { - sinon.stub(db, 'getRepoByUrl').resolves(null); + vi.mocked(mongo.getRepoByUrl).mockResolvedValue(null); + const result = await db.isUserPushAllowed('myrepo', 'charlie'); - expect(result).to.be.false; + expect(result).toBe(false); }); }); }); diff --git a/test/db/file/repo.test.js b/test/db/file/repo.test.js deleted file mode 100644 index f55ff35d7..000000000 --- a/test/db/file/repo.test.js +++ /dev/null @@ -1,67 +0,0 @@ -const { expect } = require('chai'); -const sinon = require('sinon'); -const repoModule = require('../../../src/db/file/repo'); - -describe('File DB', () => { - let sandbox; - - beforeEach(() => { - sandbox = sinon.createSandbox(); - }); - - afterEach(() => { - sandbox.restore(); - }); - - describe('getRepo', () => { - it('should get the repo using the name', async () => { - const repoData = { - name: 'sample', - users: { canPush: [] }, - url: 'http://example.com/sample-repo.git', - }; - - sandbox.stub(repoModule.db, 'findOne').callsFake((query, cb) => cb(null, repoData)); - - const result = await repoModule.getRepo('Sample'); - expect(result).to.deep.equal(repoData); - }); - }); - - describe('getRepoByUrl', () => { - it('should get the repo using the url', async () => { - const repoData = { - name: 'sample', - users: { canPush: [] }, - url: 'https://github.com/finos/git-proxy.git', - }; - - sandbox.stub(repoModule.db, 'findOne').callsFake((query, cb) => cb(null, repoData)); - - const result = await repoModule.getRepoByUrl('https://github.com/finos/git-proxy.git'); - expect(result).to.deep.equal(repoData); - }); - it('should return null if the repo is not found', async () => { - sandbox.stub(repoModule.db, 'findOne').callsFake((query, cb) => cb(null, null)); - - const result = await repoModule.getRepoByUrl('https://github.com/finos/missing-repo.git'); - expect(result).to.be.null; - expect( - repoModule.db.findOne.calledWith( - sinon.match({ url: 'https://github.com/finos/missing-repo.git' }), - ), - ).to.be.true; - }); - - it('should reject if the database returns an error', async () => { - sandbox.stub(repoModule.db, 'findOne').callsFake((query, cb) => cb(new Error('DB error'))); - - try { - await repoModule.getRepoByUrl('https://github.com/finos/git-proxy.git'); - expect.fail('Expected promise to be rejected'); - } catch (err) { - expect(err.message).to.equal('DB error'); - } - }); - }); -}); diff --git a/test/db/file/repo.test.ts b/test/db/file/repo.test.ts new file mode 100644 index 000000000..1a583bc5a --- /dev/null +++ b/test/db/file/repo.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as repoModule from '../../../src/db/file/repo'; +import { Repo } from '../../../src/db/types'; + +describe('File DB', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('getRepo', () => { + it('should get the repo using the name', async () => { + const repoData: Partial = { + name: 'sample', + users: { canPush: [], canAuthorise: [] }, + url: 'http://example.com/sample-repo.git', + }; + + vi.spyOn(repoModule.db, 'findOne').mockImplementation((query: any, cb: any) => + cb(null, repoData), + ); + + const result = await repoModule.getRepo('Sample'); + expect(result).toEqual(repoData); + }); + }); + + describe('getRepoByUrl', () => { + it('should get the repo using the url', async () => { + const repoData: Partial = { + name: 'sample', + users: { canPush: [], canAuthorise: [] }, + url: 'https://github.com/finos/git-proxy.git', + }; + + vi.spyOn(repoModule.db, 'findOne').mockImplementation((query: any, cb: any) => + cb(null, repoData), + ); + + const result = await repoModule.getRepoByUrl('https://github.com/finos/git-proxy.git'); + expect(result).toEqual(repoData); + }); + + it('should return null if the repo is not found', async () => { + const spy = vi + .spyOn(repoModule.db, 'findOne') + .mockImplementation((query: any, cb: any) => cb(null, null)); + + const result = await repoModule.getRepoByUrl('https://github.com/finos/missing-repo.git'); + + expect(result).toBeNull(); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ url: 'https://github.com/finos/missing-repo.git' }), + expect.any(Function), + ); + }); + + it('should reject if the database returns an error', async () => { + vi.spyOn(repoModule.db, 'findOne').mockImplementation((query: any, cb: any) => + cb(new Error('DB error')), + ); + + await expect( + repoModule.getRepoByUrl('https://github.com/finos/git-proxy.git'), + ).rejects.toThrow('DB error'); + }); + }); +}); diff --git a/test/db/mongo/repo.test.js b/test/db/mongo/repo.test.js deleted file mode 100644 index 828aa1bd2..000000000 --- a/test/db/mongo/repo.test.js +++ /dev/null @@ -1,55 +0,0 @@ -const { expect } = require('chai'); -const sinon = require('sinon'); -const proxyqquire = require('proxyquire'); - -const repoCollection = { - findOne: sinon.stub(), -}; - -const connectionStub = sinon.stub().returns(repoCollection); - -const { getRepo, getRepoByUrl } = proxyqquire('../../../src/db/mongo/repo', { - './helper': { connect: connectionStub }, -}); - -describe('MongoDB', () => { - afterEach(function () { - sinon.restore(); - }); - - describe('getRepo', () => { - it('should get the repo using the name', async () => { - const repoData = { - name: 'sample', - users: { canPush: [] }, - url: 'http://example.com/sample-repo.git', - }; - repoCollection.findOne.resolves(repoData); - - const result = await getRepo('Sample'); - expect(result).to.deep.equal(repoData); - expect(connectionStub.calledWith('repos')).to.be.true; - expect(repoCollection.findOne.calledWith({ name: { $eq: 'sample' } })).to.be.true; - }); - }); - - describe('getRepoByUrl', () => { - it('should get the repo using the url', async () => { - const repoData = { - name: 'sample', - users: { canPush: [] }, - url: 'https://github.com/finos/git-proxy.git', - }; - repoCollection.findOne.resolves(repoData); - - const result = await getRepoByUrl('https://github.com/finos/git-proxy.git'); - expect(result).to.deep.equal(repoData); - expect(connectionStub.calledWith('repos')).to.be.true; - expect( - repoCollection.findOne.calledWith({ - url: { $eq: 'https://github.com/finos/git-proxy.git' }, - }), - ).to.be.true; - }); - }); -}); diff --git a/test/db/mongo/repo.test.ts b/test/db/mongo/repo.test.ts new file mode 100644 index 000000000..eea1e2c7a --- /dev/null +++ b/test/db/mongo/repo.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest'; +import { Repo } from '../../../src/db/types'; + +const mockFindOne = vi.fn(); +const mockConnect = vi.fn(() => ({ + findOne: mockFindOne, +})); + +vi.mock('../../../src/db/mongo/helper', () => ({ + connect: mockConnect, +})); + +describe('MongoDB', async () => { + const { getRepo, getRepoByUrl } = await import('../../../src/db/mongo/repo'); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('getRepo', () => { + it('should get the repo using the name', async () => { + const repoData: Partial = { + name: 'sample', + users: { canPush: [], canAuthorise: [] }, + url: 'http://example.com/sample-repo.git', + }; + + mockFindOne.mockResolvedValue(repoData); + + const result = await getRepo('Sample'); + + expect(result).toEqual(repoData); + expect(mockConnect).toHaveBeenCalledWith('repos'); + expect(mockFindOne).toHaveBeenCalledWith({ name: { $eq: 'sample' } }); + }); + }); + + describe('getRepoByUrl', () => { + it('should get the repo using the url', async () => { + const repoData: Partial = { + name: 'sample', + users: { canPush: [], canAuthorise: [] }, + url: 'https://github.com/finos/git-proxy.git', + }; + + mockFindOne.mockResolvedValue(repoData); + + const result = await getRepoByUrl('https://github.com/finos/git-proxy.git'); + + expect(result).toEqual(repoData); + expect(mockConnect).toHaveBeenCalledWith('repos'); + expect(mockFindOne).toHaveBeenCalledWith({ + url: { $eq: 'https://github.com/finos/git-proxy.git' }, + }); + }); + }); +}); diff --git a/test/extractRawBody.test.js b/test/extractRawBody.test.js deleted file mode 100644 index 2e88d3f1e..000000000 --- a/test/extractRawBody.test.js +++ /dev/null @@ -1,73 +0,0 @@ -const { expect } = require('chai'); -const sinon = require('sinon'); -const { PassThrough } = require('stream'); -const proxyquire = require('proxyquire').noCallThru(); - -const fakeRawBody = sinon.stub().resolves(Buffer.from('payload')); - -const fakeChain = { - executeChain: sinon.stub(), -}; - -const { extractRawBody, isPackPost } = proxyquire('../src/proxy/routes', { - 'raw-body': fakeRawBody, - '../chain': fakeChain, -}); - -describe('extractRawBody middleware', () => { - let req; - let res; - let next; - - beforeEach(() => { - req = new PassThrough(); - req.method = 'POST'; - req.url = '/proj/foo.git/git-upload-pack'; - - res = { - set: sinon.stub().returnsThis(), - status: sinon.stub().returnsThis(), - send: sinon.stub(), - end: sinon.stub(), - }; - next = sinon.spy(); - - fakeRawBody.resetHistory(); - fakeChain.executeChain.resetHistory(); - }); - - it('skips non-pack posts', async () => { - req.method = 'GET'; - await extractRawBody(req, res, next); - expect(next.calledOnce).to.be.true; - expect(fakeRawBody.called).to.be.false; - }); - - it('extracts raw body and sets bodyRaw property', async () => { - req.write('abcd'); - req.end(); - - await extractRawBody(req, res, next); - - expect(fakeRawBody.calledOnce).to.be.true; - expect(fakeChain.executeChain.called).to.be.false; - expect(next.calledOnce).to.be.true; - expect(req.bodyRaw).to.exist; - expect(typeof req.pipe).to.equal('function'); - }); -}); - -describe('isPackPost()', () => { - it('returns true for git-upload-pack POST', () => { - expect(isPackPost({ method: 'POST', url: '/a/b.git/git-upload-pack' })).to.be.true; - }); - it('returns true for git-upload-pack POST, with a gitlab style multi-level org', () => { - expect(isPackPost({ method: 'POST', url: '/a/bee/sea/dee.git/git-upload-pack' })).to.be.true; - }); - it('returns true for git-upload-pack POST, with a bare (no org) repo URL', () => { - expect(isPackPost({ method: 'POST', url: '/a.git/git-upload-pack' })).to.be.true; - }); - it('returns false for other URLs', () => { - expect(isPackPost({ method: 'POST', url: '/info/refs' })).to.be.false; - }); -}); diff --git a/test/extractRawBody.test.ts b/test/extractRawBody.test.ts new file mode 100644 index 000000000..30a4fb85a --- /dev/null +++ b/test/extractRawBody.test.ts @@ -0,0 +1,80 @@ +import { describe, it, beforeEach, expect, vi, Mock } from 'vitest'; +import { PassThrough } from 'stream'; + +// Tell Vitest to mock dependencies +vi.mock('raw-body', () => ({ + default: vi.fn().mockResolvedValue(Buffer.from('payload')), +})); + +vi.mock('../src/proxy/chain', () => ({ + executeChain: vi.fn(), +})); + +// Now import the module-under-test, which will receive the mocked deps +import { extractRawBody, isPackPost } from '../src/proxy/routes'; +import rawBody from 'raw-body'; +import * as chain from '../src/proxy/chain'; + +describe('extractRawBody middleware', () => { + let req: any; + let res: any; + let next: Mock; + + beforeEach(() => { + req = new PassThrough(); + req.method = 'POST'; + req.url = '/proj/foo.git/git-upload-pack'; + + res = { + set: vi.fn().mockReturnThis(), + status: vi.fn().mockReturnThis(), + send: vi.fn(), + end: vi.fn(), + }; + + next = vi.fn(); + + (rawBody as Mock).mockClear(); + (chain.executeChain as Mock).mockClear(); + }); + + it('skips non-pack posts', async () => { + req.method = 'GET'; + await extractRawBody(req, res, next); + expect(next).toHaveBeenCalledOnce(); + expect(rawBody).not.toHaveBeenCalled(); + }); + + it('extracts raw body and sets bodyRaw property', async () => { + req.write('abcd'); + req.end(); + + await extractRawBody(req, res, next); + + expect(rawBody).toHaveBeenCalledOnce(); + expect(chain.executeChain).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledOnce(); + expect(req.bodyRaw).toBeDefined(); + expect(typeof req.pipe).toBe('function'); + }); +}); + +describe('isPackPost()', () => { + it('returns true for git-upload-pack POST', () => { + expect(isPackPost({ method: 'POST', url: '/a/b.git/git-upload-pack' } as any)).toBe(true); + }); + + it('returns true for git-upload-pack POST, with a gitlab style multi-level org', () => { + expect(isPackPost({ method: 'POST', url: '/a/bee/sea/dee.git/git-upload-pack' } as any)).toBe( + true, + ); + }); + + it('returns true for git-upload-pack POST, with a bare (no org) repo URL', () => { + expect(isPackPost({ method: 'POST', url: '/a.git/git-upload-pack' } as any)).toBe(true); + }); + + it('returns false for other URLs', () => { + expect(isPackPost({ method: 'POST', url: '/info/refs' } as any)).toBe(false); + }); +}); diff --git a/test/generated-config.test.js b/test/generated-config.test.ts similarity index 75% rename from test/generated-config.test.js rename to test/generated-config.test.ts index cdeed2349..8ac5a73cb 100644 --- a/test/generated-config.test.js +++ b/test/generated-config.test.ts @@ -1,8 +1,6 @@ -const chai = require('chai'); -const { Convert } = require('../src/config/generated/config'); -const defaultSettings = require('../proxy.config.json'); - -const { expect } = chai; +import { describe, it, expect } from 'vitest'; +import { Convert, GitProxyConfig } from '../src/config/generated/config'; +import defaultSettings from '../proxy.config.json'; describe('Generated Config (QuickType)', () => { describe('Convert class', () => { @@ -33,12 +31,12 @@ describe('Generated Config (QuickType)', () => { 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'); + expect(result).toBeTypeOf('object'); + expect(result.proxyUrl).toBe('https://proxy.example.com'); + expect(result.cookieSecret).toBe('test-secret'); + expect(Array.isArray(result.authorisedList)).toBe(true); + expect(Array.isArray(result.authentication)).toBe(true); + expect(Array.isArray(result.sink)).toBe(true); }); it('should convert config object back to JSON', () => { @@ -52,27 +50,27 @@ describe('Generated Config (QuickType)', () => { enabled: true, }, ], - }; + } as GitProxyConfig; 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'); + expect(parsed).toBeTypeOf('object'); + expect(parsed.proxyUrl).toBe('https://proxy.example.com'); + expect(parsed.cookieSecret).toBe('test-secret'); }); it('should handle empty configuration object', () => { const emptyConfig = {}; const result = Convert.toGitProxyConfig(JSON.stringify(emptyConfig)); - expect(result).to.be.an('object'); + expect(result).toBeTypeOf('object'); }); it('should throw error for invalid JSON string', () => { expect(() => { Convert.toGitProxyConfig('invalid json'); - }).to.throw(); + }).toThrow(); }); it('should handle configuration with valid rate limit structure', () => { @@ -119,18 +117,18 @@ describe('Generated Config (QuickType)', () => { 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'); + expect(result).toBeTypeOf('object'); + expect(Array.isArray(result.authentication)).toBe(true); + expect(Array.isArray(result.authorisedList)).toBe(true); + expect(result.contactEmail).toBeTypeOf('string'); + expect(result.cookieSecret).toBeTypeOf('string'); + expect(result.csrfProtection).toBeTypeOf('boolean'); + expect(Array.isArray(result.plugins)).toBe(true); + expect(Array.isArray(result.privateOrganizations)).toBe(true); + expect(result.proxyUrl).toBeTypeOf('string'); + expect(result.rateLimit).toBeTypeOf('object'); + expect(result.sessionMaxAgeHours).toBeTypeOf('number'); + expect(Array.isArray(result.sink)).toBe(true); }); it('should handle malformed configuration gracefully', () => { @@ -141,9 +139,9 @@ describe('Generated Config (QuickType)', () => { try { const result = Convert.toGitProxyConfig(JSON.stringify(malformedConfig)); - expect(result).to.be.an('object'); + expect(result).toBeTypeOf('object'); } catch (error) { - expect(error).to.be.an('error'); + expect(error).toBeInstanceOf(Error); } }); @@ -163,10 +161,10 @@ describe('Generated Config (QuickType)', () => { 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); + expect(result.authorisedList).toHaveLength(2); + expect(result.authentication).toHaveLength(1); + expect(result.plugins).toHaveLength(2); + expect(result.privateOrganizations).toHaveLength(2); }); it('should handle nested object structures', () => { @@ -192,10 +190,10 @@ describe('Generated Config (QuickType)', () => { 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'); + expect(result.tls).toBeTypeOf('object'); + expect(result.tls!.enabled).toBeTypeOf('boolean'); + expect(result.rateLimit).toBeTypeOf('object'); + expect(result.tempPassword).toBeTypeOf('object'); }); it('should handle complex validation scenarios', () => { @@ -230,9 +228,9 @@ describe('Generated Config (QuickType)', () => { }; 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'); + expect(result).toBeTypeOf('object'); + expect(result.api).toBeTypeOf('object'); + expect(result.domains).toBeTypeOf('object'); }); it('should handle array validation edge cases', () => { @@ -261,9 +259,9 @@ describe('Generated Config (QuickType)', () => { }; 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); + expect(result.authorisedList).toHaveLength(2); + expect(result.plugins).toHaveLength(3); + expect(result.privateOrganizations).toHaveLength(2); }); it('should exercise transformation functions with edge cases', () => { @@ -299,10 +297,10 @@ describe('Generated Config (QuickType)', () => { }; 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); + expect(result.sessionMaxAgeHours).toBe(0); + expect(result.csrfProtection).toBe(false); + expect(result.tempPassword).toBeTypeOf('object'); + expect(result.tempPassword!.length).toBe(12); }); it('should test validation error paths', () => { @@ -310,7 +308,7 @@ describe('Generated Config (QuickType)', () => { // 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'); + expect(error).toBeInstanceOf(Error); } }); @@ -327,7 +325,7 @@ describe('Generated Config (QuickType)', () => { expect(() => { Convert.toGitProxyConfig(JSON.stringify(configWithNulls)); - }).to.throw('Invalid value'); + }).toThrow('Invalid value'); }); it('should test serialization back to JSON', () => { @@ -350,8 +348,8 @@ describe('Generated Config (QuickType)', () => { 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'); + expect(reparsed.proxyUrl).toBe('https://test.com'); + expect(reparsed.rateLimit).toBeTypeOf('object'); }); it('should validate the default configuration from proxy.config.json', () => { @@ -359,15 +357,15 @@ describe('Generated Config (QuickType)', () => { // This catches cases where schema updates haven't been reflected in the default config const result = Convert.toGitProxyConfig(JSON.stringify(defaultSettings)); - expect(result).to.be.an('object'); - expect(result.cookieSecret).to.be.a('string'); - expect(result.authorisedList).to.be.an('array'); - expect(result.authentication).to.be.an('array'); - expect(result.sink).to.be.an('array'); + expect(result).toBeTypeOf('object'); + expect(result.cookieSecret).toBeTypeOf('string'); + expect(Array.isArray(result.authorisedList)).toBe(true); + expect(Array.isArray(result.authentication)).toBe(true); + expect(Array.isArray(result.sink)).toBe(true); // Validate that serialization also works const serialized = Convert.gitProxyConfigToJson(result); - expect(() => JSON.parse(serialized)).to.not.throw(); + expect(() => JSON.parse(serialized)).not.toThrow(); }); }); }); diff --git a/test/integration/forcePush.integration.test.js b/test/integration/forcePush.integration.test.js deleted file mode 100644 index 0ef35c8fb..000000000 --- a/test/integration/forcePush.integration.test.js +++ /dev/null @@ -1,164 +0,0 @@ -const path = require('path'); -const simpleGit = require('simple-git'); -const fs = require('fs').promises; -const { Action } = require('../../src/proxy/actions'); -const { exec: getDiff } = require('../../src/proxy/processors/push-action/getDiff'); -const { exec: scanDiff } = require('../../src/proxy/processors/push-action/scanDiff'); - -const chai = require('chai'); -const expect = chai.expect; - -describe('Force Push Integration Test', () => { - let tempDir; - let git; - let initialCommitSHA; - let rebasedCommitSHA; - - before(async function () { - this.timeout(10000); - - tempDir = path.join(__dirname, '../temp-integration-repo'); - await fs.mkdir(tempDir, { recursive: true }); - git = simpleGit(tempDir); - - await git.init(); - await git.addConfig('user.name', 'Test User'); - await git.addConfig('user.email', 'test@example.com'); - - // Create initial commit - await fs.writeFile(path.join(tempDir, 'base.txt'), 'base content'); - await git.add('.'); - await git.commit('Initial commit'); - - // Create feature commit - await fs.writeFile(path.join(tempDir, 'feature.txt'), 'feature content'); - await git.add('.'); - await git.commit('Add feature'); - - const log = await git.log(); - initialCommitSHA = log.latest.hash; - - // Simulate rebase by amending commit (changes SHA) - await git.commit(['--amend', '-m', 'Add feature (rebased)']); - - const newLog = await git.log(); - rebasedCommitSHA = newLog.latest.hash; - - console.log(`Initial SHA: ${initialCommitSHA}`); - console.log(`Rebased SHA: ${rebasedCommitSHA}`); - }); - - after(async () => { - try { - await fs.rmdir(tempDir, { recursive: true }); - } catch (e) { - // Ignore cleanup errors - } - }); - - describe('Complete force push pipeline', () => { - it('should handle valid diff after rebase scenario', async function () { - this.timeout(5000); - - // Create action simulating force push with valid SHAs that have actual changes - const action = new Action( - 'valid-diff-integration', - 'push', - 'POST', - Date.now(), - 'test/repo.git', - ); - action.proxyGitPath = path.dirname(tempDir); - action.repoName = path.basename(tempDir); - - // Parent of initial commit to get actual diff content - const parentSHA = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; - action.commitFrom = parentSHA; - action.commitTo = rebasedCommitSHA; - action.commitData = [ - { - parent: parentSHA, - commit: rebasedCommitSHA, - message: 'Add feature (rebased)', - author: 'Test User', - }, - ]; - - const afterGetDiff = await getDiff({}, action); - expect(afterGetDiff.steps).to.have.length.greaterThan(0); - - const diffStep = afterGetDiff.steps.find((s) => s.stepName === 'diff'); - expect(diffStep).to.exist; - expect(diffStep.error).to.be.false; - expect(diffStep.content).to.be.a('string'); - expect(diffStep.content.length).to.be.greaterThan(0); - - const afterScanDiff = await scanDiff({}, afterGetDiff); - const scanStep = afterScanDiff.steps.find((s) => s.stepName === 'scanDiff'); - - expect(scanStep).to.exist; - expect(scanStep.error).to.be.false; - }); - - it('should handle unreachable commit SHA error', async function () { - this.timeout(5000); - - // Invalid SHA to trigger error - const action = new Action( - 'unreachable-sha-integration', - 'push', - 'POST', - Date.now(), - 'test/repo.git', - ); - action.proxyGitPath = path.dirname(tempDir); - action.repoName = path.basename(tempDir); - action.commitFrom = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; // Invalid SHA - action.commitTo = rebasedCommitSHA; - action.commitData = [ - { - parent: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef', - commit: rebasedCommitSHA, - message: 'Add feature (rebased)', - author: 'Test User', - }, - ]; - - const afterGetDiff = await getDiff({}, action); - expect(afterGetDiff.steps).to.have.length.greaterThan(0); - - const diffStep = afterGetDiff.steps.find((s) => s.stepName === 'diff'); - expect(diffStep).to.exist; - expect(diffStep.error).to.be.true; - expect(diffStep.errorMessage).to.be.a('string'); - expect(diffStep.errorMessage.length).to.be.greaterThan(0); - expect(diffStep.errorMessage).to.satisfy( - (msg) => msg.includes('fatal:') && msg.includes('Invalid revision range'), - 'Error message should contain git diff specific error for invalid SHA', - ); - - // scanDiff should not block on missing diff due to error - const afterScanDiff = await scanDiff({}, afterGetDiff); - const scanStep = afterScanDiff.steps.find((s) => s.stepName === 'scanDiff'); - - expect(scanStep).to.exist; - expect(scanStep.error).to.be.false; - }); - - it('should handle missing diff step gracefully', async function () { - const action = new Action( - 'missing-diff-integration', - 'push', - 'POST', - Date.now(), - 'test/repo.git', - ); - - const result = await scanDiff({}, action); - - expect(result.steps).to.have.length(1); - expect(result.steps[0].stepName).to.equal('scanDiff'); - expect(result.steps[0].error).to.be.false; - }); - }); -}); diff --git a/test/integration/forcePush.integration.test.ts b/test/integration/forcePush.integration.test.ts new file mode 100644 index 000000000..1cbc2ade3 --- /dev/null +++ b/test/integration/forcePush.integration.test.ts @@ -0,0 +1,172 @@ +import path from 'path'; +import simpleGit, { SimpleGit } from 'simple-git'; +import fs from 'fs/promises'; +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; + +import { Action } from '../../src/proxy/actions'; +import { exec as getDiff } from '../../src/proxy/processors/push-action/getDiff'; +import { exec as scanDiff } from '../../src/proxy/processors/push-action/scanDiff'; + +describe( + 'Force Push Integration Test', + () => { + let tempDir: string; + let git: SimpleGit; + let initialCommitSHA: string; + let rebasedCommitSHA: string; + + beforeAll(async () => { + tempDir = path.join(__dirname, '../temp-integration-repo'); + await fs.mkdir(tempDir, { recursive: true }); + git = simpleGit(tempDir); + + await git.init(); + await git.addConfig('user.name', 'Test User'); + await git.addConfig('user.email', 'test@example.com'); + + // Create initial commit + await fs.writeFile(path.join(tempDir, 'base.txt'), 'base content'); + await git.add('.'); + await git.commit('Initial commit'); + + // Create feature commit + await fs.writeFile(path.join(tempDir, 'feature.txt'), 'feature content'); + await git.add('.'); + await git.commit('Add feature'); + + const log = await git.log(); + initialCommitSHA = log.latest?.hash ?? ''; + + // Simulate rebase by amending commit (changes SHA) + await git.commit(['--amend', '-m', 'Add feature (rebased)']); + + const newLog = await git.log(); + rebasedCommitSHA = newLog.latest?.hash ?? ''; + + console.log(`Initial SHA: ${initialCommitSHA}`); + console.log(`Rebased SHA: ${rebasedCommitSHA}`); + }, 10000); + + afterAll(async () => { + try { + await fs.rm(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + describe('Complete force push pipeline', () => { + it('should handle valid diff after rebase scenario', async () => { + // Create action simulating force push with valid SHAs that have actual changes + const action = new Action( + 'valid-diff-integration', + 'push', + 'POST', + Date.now(), + 'test/repo.git', + ); + action.proxyGitPath = path.dirname(tempDir); + action.repoName = path.basename(tempDir); + + // Parent of initial commit to get actual diff content + const parentSHA = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; + action.commitFrom = parentSHA; + action.commitTo = rebasedCommitSHA; + action.commitData = [ + { + parent: parentSHA, + message: 'Add feature (rebased)', + author: 'Test User', + committer: 'Test User', + committerEmail: 'test@example.com', + tree: 'tree SHA', + authorEmail: 'test@example.com', + }, + ]; + + const afterGetDiff = await getDiff({}, action); + expect(afterGetDiff.steps.length).toBeGreaterThan(0); + + const diffStep = afterGetDiff.steps.find((s: any) => s.stepName === 'diff'); + if (!diffStep) { + throw new Error('Diff step not found'); + } + + expect(diffStep.error).toBe(false); + expect(typeof diffStep.content).toBe('string'); + expect(diffStep.content.length).toBeGreaterThan(0); + + const afterScanDiff = await scanDiff({}, afterGetDiff); + const scanStep = afterScanDiff.steps.find((s: any) => s.stepName === 'scanDiff'); + + expect(scanStep).toBeDefined(); + expect(scanStep?.error).toBe(false); + }); + + it('should handle unreachable commit SHA error', async () => { + // Invalid SHA to trigger error + const action = new Action( + 'unreachable-sha-integration', + 'push', + 'POST', + Date.now(), + 'test/repo.git', + ); + action.proxyGitPath = path.dirname(tempDir); + action.repoName = path.basename(tempDir); + action.commitFrom = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; + action.commitTo = rebasedCommitSHA; + action.commitData = [ + { + parent: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + message: 'Add feature (rebased)', + author: 'Test User', + committer: 'Test User', + committerEmail: 'test@example.com', + tree: 'tree SHA', + authorEmail: 'test@example.com', + }, + ]; + + const afterGetDiff = await getDiff({}, action); + expect(afterGetDiff.steps.length).toBeGreaterThan(0); + + const diffStep = afterGetDiff.steps.find((s: any) => s.stepName === 'diff'); + if (!diffStep) { + throw new Error('Diff step not found'); + } + + expect(diffStep.error).toBe(true); + expect(typeof diffStep.errorMessage).toBe('string'); + expect(diffStep.errorMessage?.length).toBeGreaterThan(0); + expect(diffStep.errorMessage).toSatisfy( + (msg: string) => msg.includes('fatal:') && msg.includes('Invalid revision range'), + ); + + // scanDiff should not block on missing diff due to error + const afterScanDiff = await scanDiff({}, afterGetDiff); + const scanStep = afterScanDiff.steps.find((s: any) => s.stepName === 'scanDiff'); + + expect(scanStep).toBeDefined(); + expect(scanStep?.error).toBe(false); + }); + + it('should handle missing diff step gracefully', async () => { + const action = new Action( + 'missing-diff-integration', + 'push', + 'POST', + Date.now(), + 'test/repo.git', + ); + + const result = await scanDiff({}, action); + + expect(result.steps.length).toBe(1); + expect(result.steps[0].stepName).toBe('scanDiff'); + expect(result.steps[0].error).toBe(false); + }); + }); + }, + { timeout: 20000 }, +); diff --git a/test/plugin/plugin.test.js b/test/plugin/plugin.test.js deleted file mode 100644 index bb1acbdf0..000000000 --- a/test/plugin/plugin.test.js +++ /dev/null @@ -1,130 +0,0 @@ -import chai from 'chai'; -import { spawnSync } from 'child_process'; -import { rmSync } from 'fs'; -import { join } from 'path'; -import { isCompatiblePlugin, PushActionPlugin, PluginLoader } from '../../src/plugin.ts'; - -chai.should(); - -const expect = chai.expect; - -const testPackagePath = join(__dirname, '../fixtures', 'test-package'); - -describe('loading plugins from packages', function () { - this.timeout(10000); - - before(function () { - spawnSync('npm', ['install'], { cwd: testPackagePath, timeout: 5000 }); - }); - - describe('CommonJS syntax', () => { - it('should load plugins that are the default export (module.exports = pluginObj)', async function () { - const loader = new PluginLoader([join(testPackagePath, 'default-export.js')]); - await loader.load(); - expect(loader.pushPlugins.length).to.equal(1); - expect(loader.pushPlugins.every((p) => isCompatiblePlugin(p))).to.be.true; - expect(loader.pushPlugins.every((p) => isCompatiblePlugin(p, 'isGitProxyPushActionPlugin'))) - .to.be.true; - }).timeout(10000); - - it('should load multiple plugins from a module that match the plugin class (module.exports = { pluginFoo, pluginBar })', async function () { - const loader = new PluginLoader([join(testPackagePath, 'multiple-export.js')]); - await loader.load(); - - // Should load the foo and bar plugins, but not the baz object which isn't a plugin - expect(loader.pushPlugins.length).to.equal(1); - expect(loader.pullPlugins.length).to.equal(1); - expect(loader.pushPlugins.every((p) => isCompatiblePlugin(p))).to.be.true; - expect(loader.pushPlugins.every((p) => isCompatiblePlugin(p, 'isGitProxyPushActionPlugin'))) - .to.be.true; - expect(loader.pullPlugins.every((p) => isCompatiblePlugin(p, 'isGitProxyPullActionPlugin'))) - .to.be.true; - }).timeout(10000); - - it('should load plugins that are subclassed from plugin classes', async function () { - const loader = new PluginLoader([join(testPackagePath, 'subclass.js')]); - await loader.load(); - expect(loader.pushPlugins.length).to.equal(1); - expect(loader.pushPlugins.every((p) => isCompatiblePlugin(p))).to.be.true; - expect(loader.pushPlugins.every((p) => isCompatiblePlugin(p, 'isGitProxyPushActionPlugin'))) - .to.be.true; - }).timeout(10000); - }); - - describe('ESM syntax', () => { - it('should load plugins that are the default export (exports default pluginObj)', async function () { - const loader = new PluginLoader([join(testPackagePath, 'esm-export.js')]); - await loader.load(); - expect(loader.pushPlugins.length).to.equal(1); - expect(loader.pushPlugins.every((p) => isCompatiblePlugin(p))).to.be.true; - expect(loader.pushPlugins.every((p) => isCompatiblePlugin(p, 'isGitProxyPushActionPlugin'))) - .to.be.true; - }).timeout(10000); - - it('should load multiple plugins from a module that match the plugin class (exports default { pluginFoo, pluginBar })', async function () { - const loader = new PluginLoader([join(testPackagePath, 'esm-multiple-export.js')]); - await loader.load(); - - // Should load the foo and bar plugins, but not the baz object which isn't a plugin - expect(loader.pushPlugins.length).to.equal(1); - expect(loader.pullPlugins.length).to.equal(1); - expect(loader.pushPlugins.every((p) => isCompatiblePlugin(p))).to.be.true; - expect(loader.pushPlugins.every((p) => isCompatiblePlugin(p, 'isGitProxyPushActionPlugin'))) - .to.be.true; - expect(loader.pullPlugins.every((p) => isCompatiblePlugin(p, 'isGitProxyPullActionPlugin'))) - .to.be.true; - }).timeout(10000); - - it('should load plugins that are subclassed from plugin classes (exports default class DummyPlugin extends PushActionPlugin {})', async function () { - const loader = new PluginLoader([join(testPackagePath, 'esm-subclass.js')]); - await loader.load(); - expect(loader.pushPlugins.length).to.equal(1); - expect(loader.pushPlugins.every((p) => isCompatiblePlugin(p))).to.be.true; - expect(loader.pushPlugins.every((p) => isCompatiblePlugin(p, 'isGitProxyPushActionPlugin'))) - .to.be.true; - }).timeout(10000); - }); - - it('should not load plugins that are not valid modules', async function () { - const loader = new PluginLoader([join(__dirname, './dummy.js')]); - await loader.load(); - expect(loader.pushPlugins.length).to.equal(0); - expect(loader.pullPlugins.length).to.equal(0); - }).timeout(10000); - - it('should not load plugins that are not extended from plugin objects', async function () { - const loader = new PluginLoader([join(__dirname, './fixtures/baz.js')]); - await loader.load(); - expect(loader.pushPlugins.length).to.equal(0); - expect(loader.pullPlugins.length).to.equal(0); - }).timeout(10000); - - after(function () { - rmSync(join(testPackagePath, 'node_modules'), { recursive: true }); - }); -}); - -describe('plugin functions', function () { - it('should return true for isCompatiblePlugin', function () { - const plugin = new PushActionPlugin(); - expect(isCompatiblePlugin(plugin)).to.be.true; - expect(isCompatiblePlugin(plugin, 'isGitProxyPushActionPlugin')).to.be.true; - }); - - it('should return false for isCompatiblePlugin', function () { - const plugin = {}; - expect(isCompatiblePlugin(plugin)).to.be.false; - }); - - it('should return true for isCompatiblePlugin with a custom type', function () { - class CustomPlugin extends PushActionPlugin { - constructor() { - super(); - this.isCustomPlugin = true; - } - } - const plugin = new CustomPlugin(); - expect(isCompatiblePlugin(plugin)).to.be.true; - expect(isCompatiblePlugin(plugin, 'isGitProxyPushActionPlugin')).to.be.true; - }); -}); diff --git a/test/plugin/plugin.test.ts b/test/plugin/plugin.test.ts new file mode 100644 index 000000000..0d0afe56f --- /dev/null +++ b/test/plugin/plugin.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawnSync } from 'child_process'; +import { rmSync } from 'fs'; +import { join } from 'path'; +import { isCompatiblePlugin, PushActionPlugin, PluginLoader } from '../../src/plugin'; + +const testPackagePath = join(__dirname, '../fixtures', 'test-package'); + +describe('loading plugins from packages', () => { + beforeAll(() => { + spawnSync('npm', ['install'], { cwd: testPackagePath, timeout: 5000 }); + }); + + describe('CommonJS syntax', () => { + it( + 'should load plugins that are the default export (module.exports = pluginObj)', + async () => { + const loader = new PluginLoader([join(testPackagePath, 'default-export.js')]); + await loader.load(); + expect(loader.pushPlugins.length).toBe(1); + expect(loader.pushPlugins.every((p) => isCompatiblePlugin(p))).toBe(true); + expect( + loader.pushPlugins.every((p) => isCompatiblePlugin(p, 'isGitProxyPushActionPlugin')), + ).toBe(true); + }, + { timeout: 10000 }, + ); + + it( + 'should load multiple plugins from a module that match the plugin class (module.exports = { pluginFoo, pluginBar })', + async () => { + const loader = new PluginLoader([join(testPackagePath, 'multiple-export.js')]); + await loader.load(); + expect(loader.pushPlugins.length).toBe(1); + expect(loader.pullPlugins.length).toBe(1); + expect(loader.pushPlugins.every((p) => isCompatiblePlugin(p))).toBe(true); + expect( + loader.pushPlugins.every((p) => isCompatiblePlugin(p, 'isGitProxyPushActionPlugin')), + ).toBe(true); + expect( + loader.pullPlugins.every((p) => isCompatiblePlugin(p, 'isGitProxyPullActionPlugin')), + ).toBe(true); + }, + { timeout: 10000 }, + ); + + it( + 'should load plugins that are subclassed from plugin classes', + async () => { + const loader = new PluginLoader([join(testPackagePath, 'subclass.js')]); + await loader.load(); + expect(loader.pushPlugins.length).toBe(1); + expect(loader.pushPlugins.every((p) => isCompatiblePlugin(p))).toBe(true); + expect( + loader.pushPlugins.every((p) => isCompatiblePlugin(p, 'isGitProxyPushActionPlugin')), + ).toBe(true); + }, + { timeout: 10000 }, + ); + }); + + describe('ESM syntax', () => { + it( + 'should load plugins that are the default export (exports default pluginObj)', + async () => { + const loader = new PluginLoader([join(testPackagePath, 'esm-export.js')]); + await loader.load(); + expect(loader.pushPlugins.length).toBe(1); + expect(loader.pushPlugins.every((p) => isCompatiblePlugin(p))).toBe(true); + expect( + loader.pushPlugins.every((p) => isCompatiblePlugin(p, 'isGitProxyPushActionPlugin')), + ).toBe(true); + }, + { timeout: 10000 }, + ); + it('should load multiple plugins from a module that match the plugin class (exports default { pluginFoo, pluginBar })', async () => { + const loader = new PluginLoader([join(testPackagePath, 'esm-multiple-export.js')]); + await loader.load(); + expect(loader.pushPlugins.length).toBe(1); + expect(loader.pullPlugins.length).toBe(1); + expect(loader.pushPlugins.every((p) => isCompatiblePlugin(p))).toBe(true); + expect( + loader.pushPlugins.every((p) => isCompatiblePlugin(p, 'isGitProxyPushActionPlugin')), + ).toBe(true); + expect( + loader.pullPlugins.every((p) => isCompatiblePlugin(p, 'isGitProxyPullActionPlugin')), + ).toBe(true); + }); + it('should load plugins that are subclassed from plugin classes (exports default class DummyPlugin extends PushActionPlugin {})', async () => { + const loader = new PluginLoader([join(testPackagePath, 'esm-subclass.js')]); + await loader.load(); + expect(loader.pushPlugins.length).toBe(1); + expect(loader.pushPlugins.every((p) => isCompatiblePlugin(p))).toBe(true); + expect( + loader.pushPlugins.every((p) => isCompatiblePlugin(p, 'isGitProxyPushActionPlugin')), + ).toBe(true); + }); + }); + + it( + 'should not load plugins that are not valid modules', + async () => { + const loader = new PluginLoader([join(__dirname, './dummy.js')]); + await loader.load(); + expect(loader.pushPlugins.length).toBe(0); + expect(loader.pullPlugins.length).toBe(0); + }, + { timeout: 10000 }, + ); + + it( + 'should not load plugins that are not extended from plugin objects', + async () => { + const loader = new PluginLoader([join(__dirname, './fixtures/baz.js')]); + await loader.load(); + expect(loader.pushPlugins.length).toBe(0); + expect(loader.pullPlugins.length).toBe(0); + }, + { timeout: 10000 }, + ); + + afterAll(() => { + rmSync(join(testPackagePath, 'node_modules'), { recursive: true }); + }); +}); + +describe('plugin functions', () => { + it('should return true for isCompatiblePlugin', () => { + const plugin = new PushActionPlugin(async () => {}); + expect(isCompatiblePlugin(plugin)).toBe(true); + expect(isCompatiblePlugin(plugin, 'isGitProxyPushActionPlugin')).toBe(true); + }); + + it('should return false for isCompatiblePlugin', () => { + const plugin = {}; + expect(isCompatiblePlugin(plugin)).toBe(false); + }); + + it('should return true for isCompatiblePlugin with a custom type', () => { + class CustomPlugin extends PushActionPlugin { + isCustomPlugin = true; + } + const plugin = new CustomPlugin(async () => {}); + expect(isCompatiblePlugin(plugin)).toBe(true); + expect(isCompatiblePlugin(plugin, 'isGitProxyPushActionPlugin')).toBe(true); + }); +}); diff --git a/test/preReceive/preReceive.test.js b/test/preReceive/preReceive.test.js deleted file mode 100644 index b9cfe0ecb..000000000 --- a/test/preReceive/preReceive.test.js +++ /dev/null @@ -1,138 +0,0 @@ -const { expect } = require('chai'); -const sinon = require('sinon'); -const path = require('path'); -const { exec } = require('../../src/proxy/processors/push-action/preReceive'); - -describe('Pre-Receive Hook Execution', function () { - let action; - let req; - - beforeEach(() => { - req = {}; - action = { - steps: [], - commitFrom: 'oldCommitHash', - commitTo: 'newCommitHash', - branch: 'feature-branch', - proxyGitPath: 'test/preReceive/mock/repo', - repoName: 'test-repo', - addStep: function (step) { - this.steps.push(step); - }, - setAutoApproval: sinon.stub(), - setAutoRejection: sinon.stub(), - }; - }); - - afterEach(() => { - sinon.restore(); - }); - - it('should skip execution when hook file does not exist', async () => { - const scriptPath = path.resolve(__dirname, 'pre-receive-hooks/missing-hook.sh'); - - const result = await exec(req, action, scriptPath); - - expect(result.steps).to.have.lengthOf(1); - expect(result.steps[0].error).to.be.false; - expect( - result.steps[0].logs.some((log) => - log.includes('Pre-receive hook not found, skipping execution.'), - ), - ).to.be.true; - expect(action.setAutoApproval.called).to.be.false; - expect(action.setAutoRejection.called).to.be.false; - }); - - it('should skip execution when hook directory does not exist', async () => { - const scriptPath = path.resolve(__dirname, 'non-existent-directory/pre-receive.sh'); - - const result = await exec(req, action, scriptPath); - - expect(result.steps).to.have.lengthOf(1); - expect(result.steps[0].error).to.be.false; - expect( - result.steps[0].logs.some((log) => - log.includes('Pre-receive hook not found, skipping execution.'), - ), - ).to.be.true; - expect(action.setAutoApproval.called).to.be.false; - expect(action.setAutoRejection.called).to.be.false; - }); - - it('should catch and handle unexpected errors', async () => { - const scriptPath = path.resolve(__dirname, 'pre-receive-hooks/always-exit-0.sh'); - - sinon.stub(require('fs'), 'existsSync').throws(new Error('Unexpected FS error')); - - const result = await exec(req, action, scriptPath); - - expect(result.steps).to.have.lengthOf(1); - expect(result.steps[0].error).to.be.true; - expect( - result.steps[0].logs.some((log) => log.includes('Hook execution error: Unexpected FS error')), - ).to.be.true; - expect(action.setAutoApproval.called).to.be.false; - expect(action.setAutoRejection.called).to.be.false; - }); - - it('should approve push automatically when hook returns status 0', async () => { - const scriptPath = path.resolve(__dirname, 'pre-receive-hooks/always-exit-0.sh'); - - const result = await exec(req, action, scriptPath); - - expect(result.steps).to.have.lengthOf(1); - expect(result.steps[0].error).to.be.false; - expect( - result.steps[0].logs.some((log) => - log.includes('Push automatically approved by pre-receive hook.'), - ), - ).to.be.true; - expect(action.setAutoApproval.calledOnce).to.be.true; - expect(action.setAutoRejection.called).to.be.false; - }); - - it('should reject push automatically when hook returns status 1', async () => { - const scriptPath = path.resolve(__dirname, 'pre-receive-hooks/always-exit-1.sh'); - - const result = await exec(req, action, scriptPath); - - expect(result.steps).to.have.lengthOf(1); - expect(result.steps[0].error).to.be.false; - expect( - result.steps[0].logs.some((log) => - log.includes('Push automatically rejected by pre-receive hook.'), - ), - ).to.be.true; - expect(action.setAutoRejection.calledOnce).to.be.true; - expect(action.setAutoApproval.called).to.be.false; - }); - - it('should execute hook successfully and require manual approval', async () => { - const scriptPath = path.resolve(__dirname, 'pre-receive-hooks/always-exit-2.sh'); - - const result = await exec(req, action, scriptPath); - - expect(result.steps).to.have.lengthOf(1); - expect(result.steps[0].error).to.be.false; - expect(result.steps[0].logs.some((log) => log.includes('Push requires manual approval.'))).to.be - .true; - expect(action.setAutoApproval.called).to.be.false; - expect(action.setAutoRejection.called).to.be.false; - }); - - it('should handle unexpected hook status codes', async () => { - const scriptPath = path.resolve(__dirname, 'pre-receive-hooks/always-exit-99.sh'); - - const result = await exec(req, action, scriptPath); - - expect(result.steps).to.have.lengthOf(1); - expect(result.steps[0].error).to.be.true; - expect(result.steps[0].logs.some((log) => log.includes('Unexpected hook status: 99'))).to.be - .true; - expect(result.steps[0].logs.some((log) => log.includes('Unknown pre-receive hook error.'))).to - .be.true; - expect(action.setAutoApproval.called).to.be.false; - expect(action.setAutoRejection.called).to.be.false; - }); -}); diff --git a/test/preReceive/preReceive.test.ts b/test/preReceive/preReceive.test.ts new file mode 100644 index 000000000..bc8f3a416 --- /dev/null +++ b/test/preReceive/preReceive.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import path from 'path'; +import * as fs from 'fs'; +import { exec } from '../../src/proxy/processors/push-action/preReceive'; + +// TODO: Replace with memfs to prevent test pollution issues +vi.mock('fs', { spy: true }); + +describe('Pre-Receive Hook Execution', () => { + let action: any; + let req: any; + + beforeEach(() => { + req = {}; + action = { + steps: [] as any[], + commitFrom: 'oldCommitHash', + commitTo: 'newCommitHash', + branch: 'feature-branch', + proxyGitPath: 'test/preReceive/mock/repo', + repoName: 'test-repo', + addStep(step: any) { + this.steps.push(step); + }, + setAutoApproval: vi.fn(), + setAutoRejection: vi.fn(), + }; + }); + + afterEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + }); + + it('should catch and handle unexpected errors', async () => { + const scriptPath = path.resolve(__dirname, 'pre-receive-hooks/always-exit-0.sh'); + + vi.mocked(fs.existsSync).mockImplementationOnce(() => { + throw new Error('Unexpected FS error'); + }); + + const result = await exec(req, action, scriptPath); + + expect(result.steps).toHaveLength(1); + expect(result.steps[0].error).toBe(true); + expect( + result.steps[0].logs.some((log: string) => + log.includes('Hook execution error: Unexpected FS error'), + ), + ).toBe(true); + expect(action.setAutoApproval).not.toHaveBeenCalled(); + expect(action.setAutoRejection).not.toHaveBeenCalled(); + }); + + it('should skip execution when hook file does not exist', async () => { + const scriptPath = path.resolve(__dirname, 'pre-receive-hooks/missing-hook.sh'); + + const result = await exec(req, action, scriptPath); + + expect(result.steps).toHaveLength(1); + expect(result.steps[0].error).toBe(false); + expect( + result.steps[0].logs.some((log: string) => + log.includes('Pre-receive hook not found, skipping execution.'), + ), + ).toBe(true); + expect(action.setAutoApproval).not.toHaveBeenCalled(); + expect(action.setAutoRejection).not.toHaveBeenCalled(); + }); + + it('should skip execution when hook directory does not exist', async () => { + const scriptPath = path.resolve(__dirname, 'non-existent-directory/pre-receive.sh'); + + const result = await exec(req, action, scriptPath); + + expect(result.steps).toHaveLength(1); + expect(result.steps[0].error).toBe(false); + expect( + result.steps[0].logs.some((log: string) => + log.includes('Pre-receive hook not found, skipping execution.'), + ), + ).toBe(true); + expect(action.setAutoApproval).not.toHaveBeenCalled(); + expect(action.setAutoRejection).not.toHaveBeenCalled(); + }); + + it('should approve push automatically when hook returns status 0', async () => { + const scriptPath = path.resolve(__dirname, 'pre-receive-hooks/always-exit-0.sh'); + + const result = await exec(req, action, scriptPath); + + expect(result.steps).toHaveLength(1); + expect(result.steps[0].error).toBe(false); + expect( + result.steps[0].logs.some((log: string) => + log.includes('Push automatically approved by pre-receive hook.'), + ), + ).toBe(true); + expect(action.setAutoApproval).toHaveBeenCalledTimes(1); + expect(action.setAutoRejection).not.toHaveBeenCalled(); + }); + + it('should reject push automatically when hook returns status 1', async () => { + const scriptPath = path.resolve(__dirname, 'pre-receive-hooks/always-exit-1.sh'); + + const result = await exec(req, action, scriptPath); + + expect(result.steps).toHaveLength(1); + expect(result.steps[0].error).toBe(false); + expect( + result.steps[0].logs.some((log: string) => + log.includes('Push automatically rejected by pre-receive hook.'), + ), + ).toBe(true); + expect(action.setAutoRejection).toHaveBeenCalledTimes(1); + expect(action.setAutoApproval).not.toHaveBeenCalled(); + }); + + it('should execute hook successfully and require manual approval', async () => { + const scriptPath = path.resolve(__dirname, 'pre-receive-hooks/always-exit-2.sh'); + + const result = await exec(req, action, scriptPath); + + expect(result.steps).toHaveLength(1); + expect(result.steps[0].error).toBe(false); + expect( + result.steps[0].logs.some((log: string) => log.includes('Push requires manual approval.')), + ).toBe(true); + expect(action.setAutoApproval).not.toHaveBeenCalled(); + expect(action.setAutoRejection).not.toHaveBeenCalled(); + }); + + it('should handle unexpected hook status codes', async () => { + const scriptPath = path.resolve(__dirname, 'pre-receive-hooks/always-exit-99.sh'); + + const result = await exec(req, action, scriptPath); + + expect(result.steps).toHaveLength(1); + expect(result.steps[0].error).toBe(true); + expect( + result.steps[0].logs.some((log: string) => log.includes('Unexpected hook status: 99')), + ).toBe(true); + expect( + result.steps[0].logs.some((log: string) => log.includes('Unknown pre-receive hook error.')), + ).toBe(true); + expect(action.setAutoApproval).not.toHaveBeenCalled(); + expect(action.setAutoRejection).not.toHaveBeenCalled(); + }); +}); diff --git a/test/processors/blockForAuth.test.js b/test/processors/blockForAuth.test.js deleted file mode 100644 index 18f4262e9..000000000 --- a/test/processors/blockForAuth.test.js +++ /dev/null @@ -1,135 +0,0 @@ -const fc = require('fast-check'); -const chai = require('chai'); -const sinon = require('sinon'); -const proxyquire = require('proxyquire').noCallThru(); -const { Step } = require('../../src/proxy/actions'); - -chai.should(); -const expect = chai.expect; - -describe('blockForAuth', () => { - let action; - let exec; - let getServiceUIURLStub; - let req; - let stepInstance; - let StepSpy; - - beforeEach(() => { - req = { - protocol: 'https', - headers: { host: 'example.com' }, - }; - - action = { - id: 'push_123', - addStep: sinon.stub(), - }; - - stepInstance = new Step('temp'); - sinon.stub(stepInstance, 'setAsyncBlock'); - - StepSpy = sinon.stub().returns(stepInstance); - - getServiceUIURLStub = sinon.stub().returns('http://localhost:8080'); - - const blockForAuth = proxyquire('../../src/proxy/processors/push-action/blockForAuth', { - '../../../service/urls': { getServiceUIURL: getServiceUIURLStub }, - '../../actions': { Step: StepSpy }, - }); - - exec = blockForAuth.exec; - }); - - afterEach(() => { - sinon.restore(); - }); - - describe('exec', () => { - it('should generate a correct shareable URL', async () => { - await exec(req, action); - expect(getServiceUIURLStub.calledOnce).to.be.true; - expect(getServiceUIURLStub.calledWithExactly(req)).to.be.true; - }); - - it('should create step with correct parameters', async () => { - await exec(req, action); - - expect(StepSpy.calledOnce).to.be.true; - expect(StepSpy.calledWithExactly('authBlock')).to.be.true; - expect(stepInstance.setAsyncBlock.calledOnce).to.be.true; - - const message = stepInstance.setAsyncBlock.firstCall.args[0]; - expect(message).to.include('http://localhost:8080/dashboard/push/push_123'); - expect(message).to.include('\x1B[32mGitProxy has received your push ✅\x1B[0m'); - expect(message).to.include('\x1B[34mhttp://localhost:8080/dashboard/push/push_123\x1B[0m'); - expect(message).to.include('🔗 Shareable Link'); - }); - - it('should add step to action exactly once', async () => { - await exec(req, action); - expect(action.addStep.calledOnce).to.be.true; - expect(action.addStep.calledWithExactly(stepInstance)).to.be.true; - }); - - it('should return action instance', async () => { - const result = await exec(req, action); - expect(result).to.equal(action); - }); - - it('should handle https URL format', async () => { - getServiceUIURLStub.returns('https://git-proxy-hosted-ui.com'); - await exec(req, action); - - const message = stepInstance.setAsyncBlock.firstCall.args[0]; - expect(message).to.include('https://git-proxy-hosted-ui.com/dashboard/push/push_123'); - }); - - it('should handle special characters in action ID', async () => { - action.id = 'push@special#chars!'; - await exec(req, action); - - const message = stepInstance.setAsyncBlock.firstCall.args[0]; - expect(message).to.include('/push/push@special#chars!'); - }); - }); - - describe('fuzzing', () => { - it('should create a step with correct parameters regardless of action ID', () => { - fc.assert( - fc.asyncProperty(fc.string(), async (actionId) => { - action.id = actionId; - - const freshStepInstance = new Step('temp'); - const setAsyncBlockStub = sinon.stub(freshStepInstance, 'setAsyncBlock'); - - const StepSpyLocal = sinon.stub().returns(freshStepInstance); - const getServiceUIURLStubLocal = sinon.stub().returns('http://localhost:8080'); - - const blockForAuth = proxyquire('../../src/proxy/processors/push-action/blockForAuth', { - '../../../service/urls': { getServiceUIURL: getServiceUIURLStubLocal }, - '../../actions': { Step: StepSpyLocal }, - }); - - const result = await blockForAuth.exec(req, action); - - expect(StepSpyLocal.calledOnce).to.be.true; - expect(StepSpyLocal.calledWithExactly('authBlock')).to.be.true; - expect(setAsyncBlockStub.calledOnce).to.be.true; - - const message = setAsyncBlockStub.firstCall.args[0]; - expect(message).to.include(`http://localhost:8080/dashboard/push/${actionId}`); - expect(message).to.include('\x1B[32mGitProxy has received your push ✅\x1B[0m'); - expect(message).to.include( - `\x1B[34mhttp://localhost:8080/dashboard/push/${actionId}\x1B[0m`, - ); - expect(message).to.include('🔗 Shareable Link'); - expect(result).to.equal(action); - }), - { - numRuns: 1000, - }, - ); - }); - }); -}); diff --git a/test/processors/blockForAuth.test.ts b/test/processors/blockForAuth.test.ts new file mode 100644 index 000000000..dc97d0059 --- /dev/null +++ b/test/processors/blockForAuth.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fc from 'fast-check'; + +import { exec } from '../../src/proxy/processors/push-action/blockForAuth'; +import { Step, Action } from '../../src/proxy/actions'; +import * as urls from '../../src/service/urls'; + +describe('blockForAuth.exec', () => { + let mockAction: Action; + let mockReq: any; + + beforeEach(() => { + // create a fake Action with spies + mockAction = { + id: 'action-123', + addStep: vi.fn(), + } as unknown as Action; + + mockReq = { some: 'req' }; + + // mock getServiceUIURL + vi.spyOn(urls, 'getServiceUIURL').mockReturnValue('http://mocked-service-ui'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should create a Step and add it to the action', async () => { + const result = await exec(mockReq, mockAction); + + expect(urls.getServiceUIURL).toHaveBeenCalledWith(mockReq); + expect(mockAction.addStep).toHaveBeenCalledTimes(1); + + const stepArg = (mockAction.addStep as any).mock.calls[0][0]; + expect(stepArg).toBeInstanceOf(Step); + expect(stepArg.stepName).toBe('authBlock'); + + expect(result).toBe(mockAction); + }); + + it('should set the async block message with the correct format', async () => { + await exec(mockReq, mockAction); + + const stepArg = (mockAction.addStep as any).mock.calls[0][0]; + const blockMessage = (stepArg as Step).blockedMessage; + + expect(blockMessage).toContain('GitProxy has received your push ✅'); + expect(blockMessage).toContain('🔗 Shareable Link'); + expect(blockMessage).toContain('http://mocked-service-ui/dashboard/push/action-123'); + + // check color codes are included + expect(blockMessage).includes('\x1B[32m'); + expect(blockMessage).includes('\x1B[34m'); + }); + + it('should set exec.displayName properly', () => { + expect(exec.displayName).toBe('blockForAuth.exec'); + }); + + describe('fuzzing', () => { + it('should not crash on random req', () => { + fc.assert( + fc.property(fc.anything(), (req) => { + exec(req, mockAction); + }), + { numRuns: 1000 }, + ); + }); + }); +}); diff --git a/test/processors/checkAuthorEmails.test.js b/test/processors/checkAuthorEmails.test.js deleted file mode 100644 index d96cc38b1..000000000 --- a/test/processors/checkAuthorEmails.test.js +++ /dev/null @@ -1,231 +0,0 @@ -const sinon = require('sinon'); -const proxyquire = require('proxyquire').noCallThru(); -const { expect } = require('chai'); -const fc = require('fast-check'); - -describe('checkAuthorEmails', () => { - let action; - let commitConfig; - let exec; - let getCommitConfigStub; - let stepSpy; - let StepStub; - - beforeEach(() => { - StepStub = class { - constructor() { - this.error = undefined; - } - log() {} - setError() {} - }; - stepSpy = sinon.spy(StepStub.prototype, 'log'); - sinon.spy(StepStub.prototype, 'setError'); - - commitConfig = { - author: { - email: { - domain: { allow: null }, - local: { block: null }, - }, - }, - }; - getCommitConfigStub = sinon.stub().returns(commitConfig); - - action = { - commitData: [], - addStep: sinon.stub().callsFake((step) => { - action.step = new StepStub(); - Object.assign(action.step, step); - return action.step; - }), - }; - - const checkAuthorEmails = proxyquire( - '../../src/proxy/processors/push-action/checkAuthorEmails', - { - '../../../config': { getCommitConfig: getCommitConfigStub }, - '../../actions': { Step: StepStub }, - }, - ); - - exec = checkAuthorEmails.exec; - }); - - afterEach(() => { - sinon.restore(); - }); - - describe('exec', () => { - it('should allow valid emails when no restrictions', async () => { - action.commitData = [ - { authorEmail: 'valid@example.com' }, - { authorEmail: 'another.valid@test.org' }, - ]; - - await exec({}, action); - - expect(action.step.error).to.be.undefined; - }); - - it('should block emails from forbidden domains', async () => { - commitConfig.author.email.domain.allow = 'example\\.com$'; - action.commitData = [ - { authorEmail: 'valid@example.com' }, - { authorEmail: 'invalid@forbidden.org' }, - ]; - - await exec({}, action); - - expect(action.step.error).to.be.true; - expect( - stepSpy.calledWith( - 'The following commit author e-mails are illegal: invalid@forbidden.org', - ), - ).to.be.true; - expect( - StepStub.prototype.setError.calledWith( - 'Your push has been blocked. Please verify your Git configured e-mail address is valid (e.g. john.smith@example.com)', - ), - ).to.be.true; - }); - - it('should block emails with forbidden usernames', async () => { - commitConfig.author.email.local.block = 'blocked'; - action.commitData = [ - { authorEmail: 'allowed@example.com' }, - { authorEmail: 'blocked.user@test.org' }, - ]; - - await exec({}, action); - - expect(action.step.error).to.be.true; - expect( - stepSpy.calledWith( - 'The following commit author e-mails are illegal: blocked.user@test.org', - ), - ).to.be.true; - }); - - it('should handle empty email strings', async () => { - action.commitData = [{ authorEmail: '' }, { authorEmail: 'valid@example.com' }]; - - await exec({}, action); - - expect(action.step.error).to.be.true; - expect(stepSpy.calledWith('The following commit author e-mails are illegal: ')).to.be.true; - }); - - it('should allow emails when both checks pass', async () => { - commitConfig.author.email.domain.allow = 'example\\.com$'; - commitConfig.author.email.local.block = 'forbidden'; - action.commitData = [ - { authorEmail: 'allowed@example.com' }, - { authorEmail: 'also.allowed@example.com' }, - ]; - - await exec({}, action); - - expect(action.step.error).to.be.undefined; - }); - - it('should block emails that fail both checks', async () => { - commitConfig.author.email.domain.allow = 'example\\.com$'; - commitConfig.author.email.local.block = 'forbidden'; - action.commitData = [{ authorEmail: 'forbidden@wrong.org' }]; - - await exec({}, action); - - expect(action.step.error).to.be.true; - expect( - stepSpy.calledWith('The following commit author e-mails are illegal: forbidden@wrong.org'), - ).to.be.true; - }); - - it('should handle emails without domain', async () => { - action.commitData = [{ authorEmail: 'nodomain@' }]; - - await exec({}, action); - - expect(action.step.error).to.be.true; - expect(stepSpy.calledWith('The following commit author e-mails are illegal: nodomain@')).to.be - .true; - }); - - it('should handle multiple illegal emails', async () => { - commitConfig.author.email.domain.allow = 'example\\.com$'; - action.commitData = [ - { authorEmail: 'invalid1@bad.org' }, - { authorEmail: 'invalid2@wrong.net' }, - { authorEmail: 'valid@example.com' }, - ]; - - await exec({}, action); - - expect(action.step.error).to.be.true; - expect( - stepSpy.calledWith( - 'The following commit author e-mails are illegal: invalid1@bad.org,invalid2@wrong.net', - ), - ).to.be.true; - }); - }); - - describe('fuzzing', () => { - it('should not crash on random string in commit email', () => { - fc.assert( - fc.property(fc.string(), (commitEmail) => { - action.commitData = [{ authorEmail: commitEmail }]; - exec({}, action); - }), - { - numRuns: 1000, - }, - ); - - expect(action.step.error).to.be.true; - expect(stepSpy.calledWith('The following commit author e-mails are illegal: ')).to.be.true; - }); - - it('should handle valid emails with random characters', () => { - fc.assert( - fc.property(fc.emailAddress(), (commitEmail) => { - action.commitData = [{ authorEmail: commitEmail }]; - exec({}, action); - }), - { - numRuns: 1000, - }, - ); - expect(action.step.error).to.be.undefined; - }); - - it('should handle invalid types in commit email', () => { - fc.assert( - fc.property(fc.anything(), (commitEmail) => { - action.commitData = [{ authorEmail: commitEmail }]; - exec({}, action); - }), - { - numRuns: 1000, - }, - ); - - expect(action.step.error).to.be.true; - expect(stepSpy.calledWith('The following commit author e-mails are illegal: ')).to.be.true; - }); - - it('should handle arrays of valid emails', () => { - fc.assert( - fc.property(fc.array(fc.emailAddress()), (commitEmails) => { - action.commitData = commitEmails.map((email) => ({ authorEmail: email })); - exec({}, action); - }), - { - numRuns: 1000, - }, - ); - expect(action.step.error).to.be.undefined; - }); - }); -}); diff --git a/test/processors/checkAuthorEmails.test.ts b/test/processors/checkAuthorEmails.test.ts new file mode 100644 index 000000000..71d4607cb --- /dev/null +++ b/test/processors/checkAuthorEmails.test.ts @@ -0,0 +1,654 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { exec } from '../../src/proxy/processors/push-action/checkAuthorEmails'; +import { Action } from '../../src/proxy/actions'; +import * as configModule from '../../src/config'; +import * as validator from 'validator'; +import { Commit } from '../../src/proxy/actions/Action'; + +// mock dependencies +vi.mock('../../src/config', async (importOriginal) => { + const actual: any = await importOriginal(); + return { + ...actual, + getCommitConfig: vi.fn(() => ({})), + }; +}); +vi.mock('validator', async (importOriginal) => { + const actual: any = await importOriginal(); + return { + ...actual, + isEmail: vi.fn(), + }; +}); + +describe('checkAuthorEmails', () => { + let mockAction: Action; + let mockReq: any; + let consoleLogSpy: any; + + beforeEach(async () => { + // setup default mocks + vi.mocked(validator.isEmail).mockImplementation((email: string) => { + // email validation mock + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + }); + + vi.mocked(configModule.getCommitConfig).mockReturnValue({ + author: { + email: { + domain: { + allow: '', + }, + local: { + block: '', + }, + }, + }, + }); + + // mock console.log to suppress output and verify calls + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // setup mock action + mockAction = { + commitData: [], + addStep: vi.fn(), + } as unknown as Action; + + mockReq = {}; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('isEmailAllowed logic (via exec)', () => { + describe('basic email validation', () => { + it('should allow valid email addresses', async () => { + mockAction.commitData = [ + { authorEmail: 'john.doe@example.com' } as Commit, + { authorEmail: 'jane.smith@company.org' } as Commit, + ]; + + const result = await exec(mockReq, mockAction); + + expect(result.addStep).toHaveBeenCalledTimes(1); + const step = vi.mocked(result.addStep).mock.calls[0][0]; + expect(step.error).toBe(false); + }); + + it('should reject empty email', async () => { + mockAction.commitData = [{ authorEmail: '' } as Commit]; + + const result = await exec(mockReq, mockAction); + + const step = vi.mocked(result.addStep).mock.calls[0][0]; + expect(step.error).toBe(true); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.objectContaining({ illegalEmails: [''] }), + ); + }); + + it('should reject null/undefined email', async () => { + vi.mocked(validator.isEmail).mockReturnValue(false); + mockAction.commitData = [{ authorEmail: null as any } as Commit]; + + const result = await exec(mockReq, mockAction); + + const step = vi.mocked(result.addStep).mock.calls[0][0]; + expect(step.error).toBe(true); + }); + + it('should reject invalid email format', async () => { + vi.mocked(validator.isEmail).mockReturnValue(false); + mockAction.commitData = [ + { authorEmail: 'not-an-email' } as Commit, + { authorEmail: 'missing@domain' } as Commit, + { authorEmail: '@nodomain.com' } as Commit, + ]; + + const result = await exec(mockReq, mockAction); + + const step = vi.mocked(result.addStep).mock.calls[0][0]; + expect(step.error).toBe(true); + }); + }); + + describe('domain allow list', () => { + it('should allow emails from permitted domains', async () => { + vi.mocked(configModule.getCommitConfig).mockReturnValue({ + author: { + email: { + domain: { + allow: '^(example\\.com|company\\.org)$', + }, + local: { + block: '', + }, + }, + }, + } as any); + + mockAction.commitData = [ + { authorEmail: 'user@example.com' } as Commit, + { authorEmail: 'admin@company.org' } as Commit, + ]; + + const result = await exec(mockReq, mockAction); + + const step = vi.mocked(result.addStep).mock.calls[0][0]; + expect(step.error).toBe(false); + }); + + it('should reject emails from non-permitted domains when allow list is set', async () => { + vi.mocked(configModule.getCommitConfig).mockReturnValue({ + author: { + email: { + domain: { + allow: '^example\\.com$', + }, + local: { + block: '', + }, + }, + }, + } as any); + + mockAction.commitData = [ + { authorEmail: 'user@notallowed.com' } as Commit, + { authorEmail: 'admin@different.org' } as Commit, + ]; + + const result = await exec(mockReq, mockAction); + + const step = vi.mocked(result.addStep).mock.calls[0][0]; + expect(step.error).toBe(true); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.objectContaining({ + illegalEmails: ['user@notallowed.com', 'admin@different.org'], + }), + ); + }); + + it('should handle partial domain matches correctly', async () => { + vi.mocked(configModule.getCommitConfig).mockReturnValue({ + author: { + email: { + domain: { + allow: 'example\\.com', + }, + local: { + block: '', + }, + }, + }, + } as any); + + mockAction.commitData = [ + { authorEmail: 'user@subdomain.example.com' } as Commit, + { authorEmail: 'user@example.com.fake.org' } as Commit, + ]; + + const result = await exec(mockReq, mockAction); + + // both should match because regex pattern 'example.com' appears in both + const step = vi.mocked(result.addStep).mock.calls[0][0]; + expect(step.error).toBe(false); + }); + + it('should allow all domains when allow list is empty', async () => { + vi.mocked(configModule.getCommitConfig).mockReturnValue({ + author: { + email: { + domain: { + allow: '', + }, + local: { + block: '', + }, + }, + }, + } as any); + + mockAction.commitData = [ + { authorEmail: 'user@anydomain.com' } as Commit, + { authorEmail: 'admin@otherdomain.org' } as Commit, + ]; + + const result = await exec(mockReq, mockAction); + + const step = vi.mocked(result.addStep).mock.calls[0][0]; + expect(step.error).toBe(false); + }); + }); + + describe('local part block list', () => { + it('should reject emails with blocked local parts', async () => { + vi.mocked(configModule.getCommitConfig).mockReturnValue({ + author: { + email: { + domain: { + allow: '', + }, + local: { + block: '^(noreply|donotreply|bounce)$', + }, + }, + }, + } as any); + + mockAction.commitData = [ + { authorEmail: 'noreply@example.com' } as Commit, + { authorEmail: 'donotreply@company.org' } as Commit, + ]; + + const result = await exec(mockReq, mockAction); + + const step = vi.mocked(result.addStep).mock.calls[0][0]; + expect(step.error).toBe(true); + }); + + it('should allow emails with non-blocked local parts', async () => { + vi.mocked(configModule.getCommitConfig).mockReturnValue({ + author: { + email: { + domain: { + allow: '', + }, + local: { + block: '^noreply$', + }, + }, + }, + } as any); + + mockAction.commitData = [ + { authorEmail: 'john.doe@example.com' } as Commit, + { authorEmail: 'valid.user@company.org' } as Commit, + ]; + + const result = await exec(mockReq, mockAction); + + const step = vi.mocked(result.addStep).mock.calls[0][0]; + expect(step.error).toBe(false); + }); + + it('should handle regex patterns in local block correctly', async () => { + vi.mocked(configModule.getCommitConfig).mockReturnValue({ + author: { + email: { + domain: { + allow: '', + }, + local: { + block: '^(test|temp|fake)', + }, + }, + }, + } as any); + + mockAction.commitData = [ + { authorEmail: 'test@example.com' } as Commit, + { authorEmail: 'temporary@example.com' } as Commit, + { authorEmail: 'fakeuser@example.com' } as Commit, + ]; + + const result = await exec(mockReq, mockAction); + + const step = vi.mocked(result.addStep).mock.calls[0][0]; + expect(step.error).toBe(true); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.objectContaining({ + illegalEmails: expect.arrayContaining([ + 'test@example.com', + 'temporary@example.com', + 'fakeuser@example.com', + ]), + }), + ); + }); + + it('should allow all local parts when block list is empty', async () => { + vi.mocked(configModule.getCommitConfig).mockReturnValue({ + author: { + email: { + domain: { + allow: '', + }, + local: { + block: '', + }, + }, + }, + } as any); + + mockAction.commitData = [ + { authorEmail: 'noreply@example.com' } as Commit, + { authorEmail: 'anything@example.com' } as Commit, + ]; + + const result = await exec(mockReq, mockAction); + + const step = vi.mocked(result.addStep).mock.calls[0][0]; + expect(step.error).toBe(false); + }); + }); + + describe('combined domain and local rules', () => { + it('should enforce both domain allow and local block rules', async () => { + vi.mocked(configModule.getCommitConfig).mockReturnValue({ + author: { + email: { + domain: { + allow: '^example\\.com$', + }, + local: { + block: '^noreply$', + }, + }, + }, + } as any); + + mockAction.commitData = [ + { authorEmail: 'valid@example.com' } as Commit, // valid + { authorEmail: 'noreply@example.com' } as Commit, // invalid: blocked local + { authorEmail: 'valid@otherdomain.com' } as Commit, // invalid: wrong domain + ]; + + const result = await exec(mockReq, mockAction); + + const step = vi.mocked(result.addStep).mock.calls[0][0]; + expect(step.error).toBe(true); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.objectContaining({ + illegalEmails: expect.arrayContaining(['noreply@example.com', 'valid@otherdomain.com']), + }), + ); + }); + }); + }); + + describe('exec function behavior', () => { + it('should create a step with name "checkAuthorEmails"', async () => { + mockAction.commitData = [{ authorEmail: 'user@example.com' } as Commit]; + + await exec(mockReq, mockAction); + + expect(mockAction.addStep).toHaveBeenCalledWith( + expect.objectContaining({ + stepName: 'checkAuthorEmails', + }), + ); + }); + + it('should handle unique author emails correctly', async () => { + mockAction.commitData = [ + { authorEmail: 'user1@example.com' } as Commit, + { authorEmail: 'user2@example.com' } as Commit, + { authorEmail: 'user1@example.com' } as Commit, // Duplicate + { authorEmail: 'user3@example.com' } as Commit, + { authorEmail: 'user2@example.com' } as Commit, // Duplicate + ]; + + await exec(mockReq, mockAction); + + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.objectContaining({ + uniqueAuthorEmails: expect.arrayContaining([ + 'user1@example.com', + 'user2@example.com', + 'user3@example.com', + ]), + }), + ); + // should only have 3 unique emails + const uniqueEmailsCall = consoleLogSpy.mock.calls.find( + (call: any) => call[0].uniqueAuthorEmails !== undefined, + ); + expect(uniqueEmailsCall[0].uniqueAuthorEmails).toHaveLength(3); + }); + + it('should handle empty commitData', async () => { + mockAction.commitData = []; + + const result = await exec(mockReq, mockAction); + + const step = vi.mocked(result.addStep).mock.calls[0][0]; + expect(step.error).toBe(false); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.objectContaining({ uniqueAuthorEmails: [] }), + ); + }); + + it('should handle undefined commitData', async () => { + mockAction.commitData = undefined; + + const result = await exec(mockReq, mockAction); + + const step = vi.mocked(result.addStep).mock.calls[0][0]; + expect(step.error).toBe(false); + }); + + it('should log error message when illegal emails found', async () => { + vi.mocked(validator.isEmail).mockReturnValue(false); + mockAction.commitData = [{ authorEmail: 'invalid-email' } as Commit]; + + await exec(mockReq, mockAction); + + expect(consoleLogSpy).toHaveBeenCalledWith( + 'The following commit author e-mails are illegal: invalid-email', + ); + }); + + it('should log success message when all emails are legal', async () => { + mockAction.commitData = [ + { authorEmail: 'user1@example.com' } as Commit, + { authorEmail: 'user2@example.com' } as Commit, + ]; + + await exec(mockReq, mockAction); + + expect(consoleLogSpy).toHaveBeenCalledWith( + 'The following commit author e-mails are legal: user1@example.com,user2@example.com', + ); + }); + + it('should set error on step when illegal emails found', async () => { + vi.mocked(validator.isEmail).mockReturnValue(false); + mockAction.commitData = [{ authorEmail: 'bad@email' } as Commit]; + + await exec(mockReq, mockAction); + + const step = vi.mocked(mockAction.addStep).mock.calls[0][0]; + expect(step.error).toBe(true); + }); + + it('should call step.log with illegal emails message', async () => { + vi.mocked(validator.isEmail).mockReturnValue(false); + mockAction.commitData = [{ authorEmail: 'illegal@email' } as Commit]; + + await exec(mockReq, mockAction); + + // re-execute to verify log call + vi.mocked(validator.isEmail).mockReturnValue(false); + await exec(mockReq, mockAction); + + // verify through console.log since step.log is called internally + expect(consoleLogSpy).toHaveBeenCalledWith( + 'The following commit author e-mails are illegal: illegal@email', + ); + }); + + it('should call step.setError with user-friendly message', async () => { + vi.mocked(validator.isEmail).mockReturnValue(false); + mockAction.commitData = [{ authorEmail: 'bad' } as Commit]; + + await exec(mockReq, mockAction); + + const step = vi.mocked(mockAction.addStep).mock.calls[0][0]; + expect(step.error).toBe(true); + expect(step.errorMessage).toBe( + 'Your push has been blocked. Please verify your Git configured e-mail address is valid (e.g. john.smith@example.com)', + ); + }); + + it('should return the action object', async () => { + mockAction.commitData = [{ authorEmail: 'user@example.com' } as Commit]; + + const result = await exec(mockReq, mockAction); + + expect(result).toBe(mockAction); + }); + + it('should handle mixed valid and invalid emails', async () => { + mockAction.commitData = [ + { authorEmail: 'valid@example.com' } as Commit, + { authorEmail: 'invalid' } as Commit, + { authorEmail: 'also.valid@example.com' } as Commit, + ]; + + vi.mocked(validator.isEmail).mockImplementation((email: string) => { + return email.includes('@') && email.includes('.'); + }); + + const result = await exec(mockReq, mockAction); + + const step = vi.mocked(result.addStep).mock.calls[0][0]; + expect(step.error).toBe(true); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.objectContaining({ + illegalEmails: ['invalid'], + }), + ); + }); + }); + + describe('displayName', () => { + it('should have correct displayName', () => { + expect(exec.displayName).toBe('checkAuthorEmails.exec'); + }); + }); + + describe('console logging behavior', () => { + it('should log all expected information for successful validation', async () => { + mockAction.commitData = [ + { authorEmail: 'user1@example.com' } as Commit, + { authorEmail: 'user2@example.com' } as Commit, + ]; + + await exec(mockReq, mockAction); + + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.objectContaining({ + uniqueAuthorEmails: expect.any(Array), + }), + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.objectContaining({ + illegalEmails: [], + }), + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.objectContaining({ + usingIllegalEmails: false, + }), + ); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('legal')); + }); + + it('should log all expected information for failed validation', async () => { + vi.mocked(validator.isEmail).mockReturnValue(false); + mockAction.commitData = [{ authorEmail: 'invalid' } as Commit]; + + await exec(mockReq, mockAction); + + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.objectContaining({ + uniqueAuthorEmails: ['invalid'], + }), + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.objectContaining({ + illegalEmails: ['invalid'], + }), + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.objectContaining({ + usingIllegalEmails: true, + }), + ); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('illegal')); + }); + }); + + describe('edge cases', () => { + it('should handle email with multiple @ symbols', async () => { + vi.mocked(validator.isEmail).mockReturnValue(false); + mockAction.commitData = [{ authorEmail: 'user@@example.com' } as Commit]; + + const result = await exec(mockReq, mockAction); + + const step = vi.mocked(result.addStep).mock.calls[0][0]; + expect(step.error).toBe(true); + }); + + it('should handle email without domain', async () => { + vi.mocked(validator.isEmail).mockReturnValue(false); + mockAction.commitData = [{ authorEmail: 'user@' } as Commit]; + + const result = await exec(mockReq, mockAction); + + const step = vi.mocked(result.addStep).mock.calls[0][0]; + expect(step.error).toBe(true); + }); + + it('should handle very long email addresses', async () => { + const longLocal = 'a'.repeat(64); + const longEmail = `${longLocal}@example.com`; + mockAction.commitData = [{ authorEmail: longEmail } as Commit]; + + const result = await exec(mockReq, mockAction); + + expect(result.addStep).toHaveBeenCalled(); + }); + + it('should handle special characters in local part', async () => { + mockAction.commitData = [ + { authorEmail: 'user+tag@example.com' } as Commit, + { authorEmail: 'user.name@example.com' } as Commit, + { authorEmail: 'user_name@example.com' } as Commit, + ]; + + const result = await exec(mockReq, mockAction); + + const step = vi.mocked(result.addStep).mock.calls[0][0]; + expect(step.error).toBe(false); + }); + + it('should handle case sensitivity in domain checking', async () => { + vi.mocked(configModule.getCommitConfig).mockReturnValue({ + author: { + email: { + domain: { + allow: '^example\\.com$', + }, + local: { + block: '', + }, + }, + }, + } as any); + + mockAction.commitData = [ + { authorEmail: 'user@EXAMPLE.COM' } as Commit, + { authorEmail: 'user@Example.Com' } as Commit, + ]; + + const result = await exec(mockReq, mockAction); + + const step = vi.mocked(result.addStep).mock.calls[0][0]; + // fails because regex is case-sensitive + expect(step.error).toBe(true); + }); + }); +}); diff --git a/test/processors/checkCommitMessages.test.js b/test/processors/checkCommitMessages.test.js deleted file mode 100644 index 73a10ca9d..000000000 --- a/test/processors/checkCommitMessages.test.js +++ /dev/null @@ -1,196 +0,0 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const proxyquire = require('proxyquire'); -const { Action, Step } = require('../../src/proxy/actions'); -const fc = require('fast-check'); - -chai.should(); -const expect = chai.expect; - -describe('checkCommitMessages', () => { - let commitConfig; - let exec; - let getCommitConfigStub; - let logStub; - - beforeEach(() => { - logStub = sinon.stub(console, 'log'); - - commitConfig = { - message: { - block: { - literals: ['secret', 'password'], - patterns: ['\\b\\d{4}-\\d{4}-\\d{4}-\\d{4}\\b'], // Credit card pattern - }, - }, - }; - - getCommitConfigStub = sinon.stub().returns(commitConfig); - - const checkCommitMessages = proxyquire( - '../../src/proxy/processors/push-action/checkCommitMessages', - { - '../../../config': { getCommitConfig: getCommitConfigStub }, - }, - ); - - exec = checkCommitMessages.exec; - }); - - afterEach(() => { - sinon.restore(); - }); - - describe('exec', () => { - let action; - let req; - let stepSpy; - - beforeEach(() => { - req = {}; - action = new Action('1234567890', 'push', 'POST', 1234567890, 'test/repo.git'); - action.commitData = [ - { message: 'Fix bug', author: 'test@example.com' }, - { message: 'Update docs', author: 'test@example.com' }, - ]; - stepSpy = sinon.spy(Step.prototype, 'log'); - }); - - it('should allow commit with valid messages', async () => { - const result = await exec(req, action); - - expect(result.steps).to.have.lengthOf(1); - expect(result.steps[0].error).to.be.false; - expect(logStub.calledWith('The following commit messages are legal: Fix bug,Update docs')).to - .be.true; - }); - - it('should block commit with illegal messages', async () => { - action.commitData?.push({ message: 'secret password here', author: 'test@example.com' }); - - const result = await exec(req, action); - - expect(result.steps).to.have.lengthOf(1); - expect(result.steps[0].error).to.be.true; - expect(stepSpy.calledWith('The following commit messages are illegal: secret password here')) - .to.be.true; - expect(result.steps[0].errorMessage).to.include('Your push has been blocked'); - expect(logStub.calledWith('The following commit messages are illegal: secret password here')) - .to.be.true; - }); - - it('should handle duplicate messages only once', async () => { - action.commitData = [ - { message: 'secret', author: 'test@example.com' }, - { message: 'secret', author: 'test@example.com' }, - { message: 'password', author: 'test@example.com' }, - ]; - - const result = await exec(req, action); - - expect(result.steps[0].error).to.be.true; - expect(stepSpy.calledWith('The following commit messages are illegal: secret,password')).to.be - .true; - expect(logStub.calledWith('The following commit messages are illegal: secret,password')).to.be - .true; - }); - - it('should not error when commit data is empty', async () => { - // Empty commit data happens when making a branch from an unapproved commit - // or when pushing an empty branch or deleting a branch - // This is handled in the checkEmptyBranch.exec action - action.commitData = []; - const result = await exec(req, action); - - expect(result.steps).to.have.lengthOf(1); - expect(result.steps[0].error).to.be.false; - expect(logStub.calledWith('The following commit messages are legal: ')).to.be.true; - }); - - it('should handle commit data with null values', async () => { - action.commitData = [ - { message: null, author: 'test@example.com' }, - { message: undefined, author: 'test@example.com' }, - ]; - - const result = await exec(req, action); - - expect(result.steps).to.have.lengthOf(1); - expect(result.steps[0].error).to.be.true; - }); - - it('should handle commit messages of incorrect type', async () => { - action.commitData = [ - { message: 123, author: 'test@example.com' }, - { message: {}, author: 'test@example.com' }, - ]; - - const result = await exec(req, action); - - expect(result.steps).to.have.lengthOf(1); - expect(result.steps[0].error).to.be.true; - expect(stepSpy.calledWith('The following commit messages are illegal: 123,[object Object]')) - .to.be.true; - expect(logStub.calledWith('The following commit messages are illegal: 123,[object Object]')) - .to.be.true; - }); - - it('should handle a mix of valid and invalid messages', async () => { - action.commitData = [ - { message: 'Fix bug', author: 'test@example.com' }, - { message: 'secret password here', author: 'test@example.com' }, - ]; - - const result = await exec(req, action); - - expect(result.steps).to.have.lengthOf(1); - expect(result.steps[0].error).to.be.true; - expect(stepSpy.calledWith('The following commit messages are illegal: secret password here')) - .to.be.true; - expect(logStub.calledWith('The following commit messages are illegal: secret password here')) - .to.be.true; - }); - - describe('fuzzing', () => { - it('should not crash on arbitrary commit messages', async () => { - await fc.assert( - fc.asyncProperty( - fc.array( - fc.record({ - message: fc.oneof( - fc.string(), - fc.constant(null), - fc.constant(undefined), - fc.integer(), - fc.double(), - fc.boolean(), - ), - author: fc.string(), - }), - { maxLength: 20 }, - ), - async (fuzzedCommits) => { - const fuzzAction = new Action('fuzz', 'push', 'POST', Date.now(), 'fuzz/repo'); - fuzzAction.commitData = Array.isArray(fuzzedCommits) ? fuzzedCommits : []; - - const result = await exec({}, fuzzAction); - - expect(result).to.have.property('steps'); - expect(result.steps[0]).to.have.property('error').that.is.a('boolean'); - }, - ), - { - examples: [ - [{ message: '', author: 'me' }], - [{ message: '1234-5678-9012-3456', author: 'me' }], - [{ message: null, author: 'me' }], - [{ message: {}, author: 'me' }], - [{ message: 'SeCrEt', author: 'me' }], - ], - numRuns: 1000, - }, - ); - }); - }); - }); -}); diff --git a/test/processors/checkCommitMessages.test.ts b/test/processors/checkCommitMessages.test.ts new file mode 100644 index 000000000..3a8fb334f --- /dev/null +++ b/test/processors/checkCommitMessages.test.ts @@ -0,0 +1,548 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { exec } from '../../src/proxy/processors/push-action/checkCommitMessages'; +import { Action } from '../../src/proxy/actions'; +import * as configModule from '../../src/config'; +import { Commit } from '../../src/proxy/actions/Action'; + +vi.mock('../../src/config', async (importOriginal) => { + const actual: any = await importOriginal(); + return { + ...actual, + getCommitConfig: vi.fn(() => ({})), + }; +}); + +describe('checkCommitMessages', () => { + let consoleLogSpy: ReturnType; + let mockCommitConfig: any; + + beforeEach(() => { + // spy on console.log to verify calls + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // default mock config + mockCommitConfig = { + message: { + block: { + literals: ['password', 'secret', 'token'], + patterns: ['http://.*', 'https://.*'], + }, + }, + }; + + vi.mocked(configModule.getCommitConfig).mockReturnValue(mockCommitConfig); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('isMessageAllowed', () => { + describe('Empty or invalid messages', () => { + it('should block empty string commit messages', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [{ message: '' } as Commit]; + + const result = await exec({}, action); + + expect(result.steps[0].error).toBe(true); + expect(consoleLogSpy).toHaveBeenCalledWith('No commit message included...'); + }); + + it('should block null commit messages', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [{ message: null as any } as Commit]; + + const result = await exec({}, action); + + expect(result.steps[0].error).toBe(true); + }); + + it('should block undefined commit messages', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [{ message: undefined as any } as Commit]; + + const result = await exec({}, action); + + expect(result.steps[0].error).toBe(true); + }); + + it('should block non-string commit messages', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [{ message: 123 as any } as Commit]; + + const result = await exec({}, action); + + expect(result.steps[0].error).toBe(true); + expect(consoleLogSpy).toHaveBeenCalledWith( + 'A non-string value has been captured for the commit message...', + ); + }); + + it('should block object commit messages', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [{ message: { text: 'fix: bug' } as any } as Commit]; + + const result = await exec({}, action); + + expect(result.steps[0].error).toBe(true); + }); + + it('should block array commit messages', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [{ message: ['fix: bug'] as any } as Commit]; + + const result = await exec({}, action); + + expect(result.steps[0].error).toBe(true); + }); + }); + + describe('Blocked literals', () => { + it('should block messages containing blocked literals (exact case)', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [{ message: 'Add password to config' } as Commit]; + + const result = await exec({}, action); + + expect(result.steps[0].error).toBe(true); + expect(consoleLogSpy).toHaveBeenCalledWith( + 'Commit message is blocked via configured literals/patterns...', + ); + }); + + it('should block messages containing blocked literals (case insensitive)', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [ + { message: 'Add PASSWORD to config' } as Commit, + { message: 'Store Secret key' } as Commit, + { message: 'Update TOKEN value' } as Commit, + ]; + + const result = await exec({}, action); + + expect(result.steps[0].error).toBe(true); + }); + + it('should block messages with literals in the middle of words', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [{ message: 'Update mypassword123' } as Commit]; + + const result = await exec({}, action); + + expect(result.steps[0].error).toBe(true); + }); + + it('should block when multiple literals are present', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [{ message: 'Add password and secret token' } as Commit]; + + const result = await exec({}, action); + + expect(result.steps[0].error).toBe(true); + }); + }); + + describe('Blocked patterns', () => { + it('should block messages containing http URLs', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [{ message: 'See http://example.com for details' } as Commit]; + + const result = await exec({}, action); + + expect(result.steps[0].error).toBe(true); + }); + + it('should block messages containing https URLs', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [{ message: 'Update docs at https://docs.example.com' } as Commit]; + + const result = await exec({}, action); + + expect(result.steps[0].error).toBe(true); + }); + + it('should block messages with multiple URLs', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [{ message: 'See http://example.com and https://other.com' } as Commit]; + + const result = await exec({}, action); + + expect(result.steps[0].error).toBe(true); + }); + + it('should handle custom regex patterns', async () => { + mockCommitConfig.message.block.patterns = ['\\d{3}-\\d{2}-\\d{4}']; + vi.mocked(configModule.getCommitConfig).mockReturnValue(mockCommitConfig); + + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [{ message: 'SSN: 123-45-6789' } as Commit]; + + const result = await exec({}, action); + + expect(result.steps[0].error).toBe(true); + }); + + it('should match patterns case-insensitively', async () => { + mockCommitConfig.message.block.patterns = ['PRIVATE']; + vi.mocked(configModule.getCommitConfig).mockReturnValue(mockCommitConfig); + + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [{ message: 'This is private information' } as Commit]; + + const result = await exec({}, action); + + expect(result.steps[0].error).toBe(true); + }); + }); + + describe('Combined blocking (literals and patterns)', () => { + it('should block when both literals and patterns match', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [{ message: 'password at http://example.com' } as Commit]; + + const result = await exec({}, action); + + expect(result.steps[0].error).toBe(true); + }); + + it('should block when only literals match', async () => { + mockCommitConfig.message.block.patterns = []; + vi.mocked(configModule.getCommitConfig).mockReturnValue(mockCommitConfig); + + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [{ message: 'Add secret key' } as Commit]; + + const result = await exec({}, action); + + expect(result.steps[0].error).toBe(true); + }); + + it('should block when only patterns match', async () => { + mockCommitConfig.message.block.literals = []; + vi.mocked(configModule.getCommitConfig).mockReturnValue(mockCommitConfig); + + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [{ message: 'Visit http://example.com' } as Commit]; + + const result = await exec({}, action); + + expect(result.steps[0].error).toBe(true); + }); + }); + + describe('Allowed messages', () => { + it('should allow valid commit messages', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [{ message: 'fix: resolve bug in user authentication' } as Commit]; + + const result = await exec({}, action); + + expect(result.steps[0].error).toBe(false); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('The following commit messages are legal:'), + ); + }); + + it('should allow messages with no blocked content', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [ + { message: 'feat: add new feature' } as Commit, + { message: 'chore: update dependencies' } as Commit, + { message: 'docs: improve documentation' } as Commit, + ]; + + const result = await exec({}, action); + + expect(result.steps[0].error).toBe(false); + }); + + it('should allow messages when config has empty block lists', async () => { + mockCommitConfig.message.block.literals = []; + mockCommitConfig.message.block.patterns = []; + vi.mocked(configModule.getCommitConfig).mockReturnValue(mockCommitConfig); + + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [{ message: 'Any message should pass' } as Commit]; + + const result = await exec({}, action); + + expect(result.steps[0].error).toBe(false); + }); + }); + + describe('Multiple commits', () => { + it('should handle multiple valid commits', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [ + { message: 'feat: add feature A' } as Commit, + { message: 'fix: resolve issue B' } as Commit, + { message: 'chore: update config C' } as Commit, + ]; + + const result = await exec({}, action); + + expect(result.steps[0].error).toBe(false); + }); + + it('should block when any commit is invalid', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [ + { message: 'feat: add feature A' } as Commit, + { message: 'fix: add password to config' } as Commit, + { message: 'chore: update config C' } as Commit, + ]; + + const result = await exec({}, action); + + expect(result.steps[0].error).toBe(true); + }); + + it('should block when multiple commits are invalid', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [ + { message: 'Add password' } as Commit, + { message: 'Store secret' } as Commit, + { message: 'feat: valid message' } as Commit, + ]; + + const result = await exec({}, action); + + expect(result.steps[0].error).toBe(true); + }); + + it('should deduplicate commit messages', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [{ message: 'fix: bug' } as Commit, { message: 'fix: bug' } as Commit]; + + const result = await exec({}, action); + + expect(consoleLogSpy).toHaveBeenCalledWith({ + uniqueCommitMessages: ['fix: bug'], + }); + expect(result.steps[0].error).toBe(false); + }); + + it('should handle mix of duplicate valid and invalid messages', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [ + { message: 'fix: bug' } as Commit, + { message: 'Add password' } as Commit, + { message: 'fix: bug' } as Commit, + ]; + + const result = await exec({}, action); + + expect(consoleLogSpy).toHaveBeenCalledWith({ + uniqueCommitMessages: ['fix: bug', 'Add password'], + }); + expect(result.steps[0].error).toBe(true); + }); + }); + + describe('Error handling and logging', () => { + it('should set error flag on step when messages are illegal', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [{ message: 'Add password' } as Commit]; + + const result = await exec({}, action); + + expect(result.steps[0].error).toBe(true); + }); + + it('should log error message to step', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [{ message: 'Add password' } as Commit]; + + const result = await exec({}, action); + const step = result.steps[0]; + + // first log is the "push blocked" message + expect(step.logs[1]).toContain( + 'The following commit messages are illegal: ["Add password"]', + ); + }); + + it('should set detailed error message', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [{ message: 'Add secret' } as Commit]; + + const result = await exec({}, action); + const step = result.steps[0]; + + expect(step.errorMessage).toContain('Your push has been blocked'); + expect(step.errorMessage).toContain('Add secret'); + }); + + it('should include all illegal messages in error', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [ + { message: 'Add password' } as Commit, + { message: 'Store token' } as Commit, + ]; + + const result = await exec({}, action); + const step = result.steps[0]; + + expect(step.errorMessage).toContain('Add password'); + expect(step.errorMessage).toContain('Store token'); + }); + + it('should log unique commit messages', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [ + { message: 'fix: bug A' } as Commit, + { message: 'fix: bug B' } as Commit, + ]; + + await exec({}, action); + + expect(consoleLogSpy).toHaveBeenCalledWith({ + uniqueCommitMessages: ['fix: bug A', 'fix: bug B'], + }); + }); + + it('should log illegal messages array', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [{ message: 'Add password' } as Commit]; + + await exec({}, action); + + expect(consoleLogSpy).toHaveBeenCalledWith({ + illegalMessages: ['Add password'], + }); + }); + + it('should log usingIllegalMessages flag', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [{ message: 'fix: bug' } as Commit]; + + await exec({}, action); + + expect(consoleLogSpy).toHaveBeenCalledWith({ + usingIllegalMessages: false, + }); + }); + }); + + describe('Edge cases', () => { + it('should handle action with no commitData', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = undefined; + + const result = await exec({}, action); + + // should handle gracefully + expect(result.steps).toHaveLength(1); + }); + + it('should handle action with empty commitData array', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = []; + + const result = await exec({}, action); + + expect(result.steps[0].error).toBe(false); + }); + + it('should handle whitespace-only messages', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [{ message: ' ' } as Commit]; + + const result = await exec({}, action); + + expect(result.steps[0].error).toBe(false); + }); + + it('should handle very long commit messages', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + const longMessage = 'fix: ' + 'a'.repeat(10000); + action.commitData = [{ message: longMessage } as Commit]; + + const result = await exec({}, action); + + expect(result.steps[0].error).toBe(false); + }); + + it('should handle special regex characters in literals', async () => { + mockCommitConfig.message.block.literals = ['$pecial', 'char*']; + vi.mocked(configModule.getCommitConfig).mockReturnValue(mockCommitConfig); + + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [{ message: 'Contains $pecial characters' } as Commit]; + + const result = await exec({}, action); + + expect(result.steps[0].error).toBe(true); + }); + + it('should handle unicode characters in messages', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [{ message: 'feat: 添加新功能 🎉' } as Commit]; + + const result = await exec({}, action); + + expect(result.steps[0].error).toBe(false); + }); + + it('should handle malformed regex patterns gracefully', async () => { + mockCommitConfig.message.block.patterns = ['[invalid']; + vi.mocked(configModule.getCommitConfig).mockReturnValue(mockCommitConfig); + + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [{ message: 'Any message' } as Commit]; + + // test that it doesn't crash + expect(() => exec({}, action)).not.toThrow(); + }); + }); + + describe('Function properties', () => { + it('should have displayName property', () => { + expect(exec.displayName).toBe('checkCommitMessages.exec'); + }); + }); + + describe('Step management', () => { + it('should create a step named "checkCommitMessages"', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [{ message: 'fix: bug' } as Commit]; + + const result = await exec({}, action); + + expect(result.steps[0].stepName).toBe('checkCommitMessages'); + }); + + it('should add step to action', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [{ message: 'fix: bug' } as Commit]; + + const initialStepCount = action.steps.length; + const result = await exec({}, action); + + expect(result.steps.length).toBe(initialStepCount + 1); + }); + + it('should return the same action object', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [{ message: 'fix: bug' } as Commit]; + + const result = await exec({}, action); + + expect(result).toBe(action); + }); + }); + + describe('Request parameter', () => { + it('should accept request parameter without using it', async () => { + const action = new Action('test', 'test', 'test', 1, 'test'); + action.commitData = [{ message: 'fix: bug' } as Commit]; + const mockRequest = { headers: {}, body: {} }; + + const result = await exec(mockRequest, action); + + expect(result.steps[0].error).toBe(false); + }); + }); + }); +}); diff --git a/test/processors/checkEmptyBranch.test.js b/test/processors/checkEmptyBranch.test.js deleted file mode 100644 index b2833122f..000000000 --- a/test/processors/checkEmptyBranch.test.js +++ /dev/null @@ -1,111 +0,0 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const proxyquire = require('proxyquire'); -const { Action } = require('../../src/proxy/actions'); - -chai.should(); -const expect = chai.expect; - -describe('checkEmptyBranch', () => { - let exec; - let simpleGitStub; - let gitRawStub; - - beforeEach(() => { - gitRawStub = sinon.stub(); - simpleGitStub = sinon.stub().callsFake((workingDir) => { - return { - raw: gitRawStub, - cwd: workingDir, - }; - }); - - const checkEmptyBranch = proxyquire('../../src/proxy/processors/push-action/checkEmptyBranch', { - 'simple-git': { - default: simpleGitStub, - __esModule: true, - '@global': true, - '@noCallThru': true, - }, - // deeply mocking fs to prevent simple-git from validating directories (which fails) - fs: { - existsSync: sinon.stub().returns(true), - lstatSync: sinon.stub().returns({ - isDirectory: () => true, - isFile: () => false, - }), - '@global': true, - }, - }); - - exec = checkEmptyBranch.exec; - }); - - afterEach(() => { - sinon.restore(); - }); - - describe('exec', () => { - let action; - let req; - - beforeEach(() => { - req = {}; - action = new Action('1234567890', 'push', 'POST', 1234567890, 'test/repo'); - action.proxyGitPath = '/tmp/gitproxy'; - action.repoName = 'test-repo'; - action.commitFrom = '0000000000000000000000000000000000000000'; - action.commitTo = 'abcdef1234567890abcdef1234567890abcdef12'; - action.commitData = []; - }); - - it('should pass through if commitData is already populated', async () => { - action.commitData = [{ message: 'Existing commit' }]; - - const result = await exec(req, action); - - expect(result.steps).to.have.lengthOf(0); - expect(simpleGitStub.called).to.be.false; - }); - - it('should block empty branch pushes with a commit that exists', async () => { - gitRawStub.resolves('commit\n'); - - const result = await exec(req, action); - - expect(simpleGitStub.calledWith('/tmp/gitproxy/test-repo')).to.be.true; - expect(gitRawStub.calledWith(['cat-file', '-t', action.commitTo])).to.be.true; - - const step = result.steps.find((s) => s.stepName === 'checkEmptyBranch'); - expect(step).to.exist; - expect(step.error).to.be.true; - expect(step.errorMessage).to.include('Push blocked: Empty branch'); - }); - - it('should block pushes if commitTo does not resolve', async () => { - gitRawStub.rejects(new Error('fatal: Not a valid object name')); - - const result = await exec(req, action); - - expect(gitRawStub.calledWith(['cat-file', '-t', action.commitTo])).to.be.true; - - const step = result.steps.find((s) => s.stepName === 'checkEmptyBranch'); - expect(step).to.exist; - expect(step.error).to.be.true; - expect(step.errorMessage).to.include('Push blocked: Commit data not found'); - }); - - it('should block non-empty branch pushes with empty commitData', async () => { - action.commitFrom = 'abcdef1234567890abcdef1234567890abcdef12'; - - const result = await exec(req, action); - - expect(simpleGitStub.called).to.be.false; - - const step = result.steps.find((s) => s.stepName === 'checkEmptyBranch'); - expect(step).to.exist; - expect(step.error).to.be.true; - expect(step.errorMessage).to.include('Push blocked: Commit data not found'); - }); - }); -}); diff --git a/test/processors/checkEmptyBranch.test.ts b/test/processors/checkEmptyBranch.test.ts new file mode 100644 index 000000000..bb13250ef --- /dev/null +++ b/test/processors/checkEmptyBranch.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { Action } from '../../src/proxy/actions'; + +vi.mock('simple-git'); +vi.mock('fs'); + +describe('checkEmptyBranch', () => { + let exec: (req: any, action: Action) => Promise; + let simpleGitMock: any; + let gitRawMock: ReturnType; + + beforeEach(async () => { + vi.resetModules(); + + gitRawMock = vi.fn(); + simpleGitMock = vi.fn((workingDir: string) => ({ + raw: gitRawMock, + cwd: workingDir, + })); + + vi.doMock('simple-git', () => ({ + default: simpleGitMock, + })); + + // mocking fs to prevent simple-git from validating directories + vi.doMock('fs', async (importOriginal) => { + const actual: any = await importOriginal(); + return { + ...actual, + existsSync: vi.fn().mockReturnValue(true), + lstatSync: vi.fn().mockReturnValue({ + isDirectory: () => true, + isFile: () => false, + }), + }; + }); + + // import the module after mocks are set up + const checkEmptyBranch = await import( + '../../src/proxy/processors/push-action/checkEmptyBranch' + ); + exec = checkEmptyBranch.exec; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('exec', () => { + let action: Action; + let req: any; + + beforeEach(() => { + req = {}; + action = new Action('1234567890', 'push', 'POST', 1234567890, 'test/repo'); + action.proxyGitPath = '/tmp/gitproxy'; + action.repoName = 'test-repo'; + action.commitFrom = '0000000000000000000000000000000000000000'; + action.commitTo = 'abcdef1234567890abcdef1234567890abcdef12'; + action.commitData = []; + }); + + it('should pass through if commitData is already populated', async () => { + action.commitData = [{ message: 'Existing commit' }] as any; + + const result = await exec(req, action); + + expect(result.steps).toHaveLength(0); + expect(simpleGitMock).not.toHaveBeenCalled(); + }); + + it('should block empty branch pushes with a commit that exists', async () => { + gitRawMock.mockResolvedValue('commit\n'); + + const result = await exec(req, action); + + expect(simpleGitMock).toHaveBeenCalledWith('/tmp/gitproxy/test-repo'); + expect(gitRawMock).toHaveBeenCalledWith(['cat-file', '-t', action.commitTo]); + + const step = result.steps.find((s) => s.stepName === 'checkEmptyBranch'); + expect(step).toBeDefined(); + expect(step?.error).toBe(true); + expect(step?.errorMessage).toContain('Push blocked: Empty branch'); + }); + + it('should block pushes if commitTo does not resolve', async () => { + gitRawMock.mockRejectedValue(new Error('fatal: Not a valid object name')); + + const result = await exec(req, action); + + expect(gitRawMock).toHaveBeenCalledWith(['cat-file', '-t', action.commitTo]); + + const step = result.steps.find((s) => s.stepName === 'checkEmptyBranch'); + expect(step).toBeDefined(); + expect(step?.error).toBe(true); + expect(step?.errorMessage).toContain('Push blocked: Commit data not found'); + }); + + it('should block non-empty branch pushes with empty commitData', async () => { + action.commitFrom = 'abcdef1234567890abcdef1234567890abcdef12'; + + const result = await exec(req, action); + + expect(simpleGitMock).not.toHaveBeenCalled(); + + const step = result.steps.find((s) => s.stepName === 'checkEmptyBranch'); + expect(step).toBeDefined(); + expect(step?.error).toBe(true); + expect(step?.errorMessage).toContain('Push blocked: Commit data not found'); + }); + }); +}); diff --git a/test/processors/checkIfWaitingAuth.test.js b/test/processors/checkIfWaitingAuth.test.js deleted file mode 100644 index 0ee9988bb..000000000 --- a/test/processors/checkIfWaitingAuth.test.js +++ /dev/null @@ -1,121 +0,0 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const proxyquire = require('proxyquire'); -const { Action } = require('../../src/proxy/actions'); - -chai.should(); -const expect = chai.expect; - -describe('checkIfWaitingAuth', () => { - let exec; - let getPushStub; - - beforeEach(() => { - getPushStub = sinon.stub(); - - const checkIfWaitingAuth = proxyquire( - '../../src/proxy/processors/push-action/checkIfWaitingAuth', - { - '../../../db': { getPush: getPushStub }, - }, - ); - - exec = checkIfWaitingAuth.exec; - }); - - afterEach(() => { - sinon.restore(); - }); - - describe('exec', () => { - let action; - let req; - - beforeEach(() => { - req = {}; - action = new Action('1234567890', 'push', 'POST', 1234567890, 'test/repo.git'); - }); - - it('should set allowPush when action exists and is authorized', async () => { - const authorizedAction = new Action( - '1234567890', - 'push', - 'POST', - 1234567890, - 'test/repo.git', - ); - authorizedAction.authorised = true; - getPushStub.resolves(authorizedAction); - - const result = await exec(req, action); - - expect(result.steps).to.have.lengthOf(1); - expect(result.steps[0].error).to.be.false; - expect(result.allowPush).to.be.true; - expect(result).to.deep.equal(authorizedAction); - }); - - it('should not set allowPush when action exists but not authorized', async () => { - const unauthorizedAction = new Action( - '1234567890', - 'push', - 'POST', - 1234567890, - 'test/repo.git', - ); - unauthorizedAction.authorised = false; - getPushStub.resolves(unauthorizedAction); - - const result = await exec(req, action); - - expect(result.steps).to.have.lengthOf(1); - expect(result.steps[0].error).to.be.false; - expect(result.allowPush).to.be.false; - }); - - it('should not set allowPush when action does not exist', async () => { - getPushStub.resolves(null); - - const result = await exec(req, action); - - expect(result.steps).to.have.lengthOf(1); - expect(result.steps[0].error).to.be.false; - expect(result.allowPush).to.be.false; - }); - - it('should not modify action when it has an error', async () => { - action.error = true; - const authorizedAction = new Action( - '1234567890', - 'push', - 'POST', - 1234567890, - 'test/repo.git', - ); - authorizedAction.authorised = true; - getPushStub.resolves(authorizedAction); - - const result = await exec(req, action); - - expect(result.steps).to.have.lengthOf(1); - expect(result.steps[0].error).to.be.false; - expect(result.allowPush).to.be.false; - expect(result.error).to.be.true; - }); - - it('should add step with error when getPush throws', async () => { - const error = new Error('DB error'); - getPushStub.rejects(error); - - try { - await exec(req, action); - throw new Error('Should have thrown'); - } catch (e) { - expect(e).to.equal(error); - expect(action.steps).to.have.lengthOf(1); - expect(action.steps[0].error).to.be.true; - expect(action.steps[0].errorMessage).to.contain('DB error'); - } - }); - }); -}); diff --git a/test/processors/checkIfWaitingAuth.test.ts b/test/processors/checkIfWaitingAuth.test.ts new file mode 100644 index 000000000..fe68bab4a --- /dev/null +++ b/test/processors/checkIfWaitingAuth.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { Action } from '../../src/proxy/actions'; +import * as checkIfWaitingAuthModule from '../../src/proxy/processors/push-action/checkIfWaitingAuth'; + +vi.mock('../../src/db', () => ({ + getPush: vi.fn(), +})); +import { getPush } from '../../src/db'; + +describe('checkIfWaitingAuth', () => { + const getPushMock = vi.mocked(getPush); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('exec', () => { + let action: Action; + let req: any; + + beforeEach(() => { + req = {}; + action = new Action('1234567890', 'push', 'POST', 1234567890, 'test/repo.git'); + }); + + it('should set allowPush when action exists and is authorized', async () => { + const authorizedAction = new Action( + '1234567890', + 'push', + 'POST', + 1234567890, + 'test/repo.git', + ); + authorizedAction.authorised = true; + getPushMock.mockResolvedValue(authorizedAction); + + const result = await checkIfWaitingAuthModule.exec(req, action); + + expect(result.steps).toHaveLength(1); + expect(result.steps[0].error).toBe(false); + expect(result.allowPush).toBe(true); + expect(result).toEqual(authorizedAction); + }); + + it('should not set allowPush when action exists but not authorized', async () => { + const unauthorizedAction = new Action( + '1234567890', + 'push', + 'POST', + 1234567890, + 'test/repo.git', + ); + unauthorizedAction.authorised = false; + getPushMock.mockResolvedValue(unauthorizedAction); + + const result = await checkIfWaitingAuthModule.exec(req, action); + + expect(result.steps).toHaveLength(1); + expect(result.steps[0].error).toBe(false); + expect(result.allowPush).toBe(false); + }); + + it('should not set allowPush when action does not exist', async () => { + getPushMock.mockResolvedValue(null); + + const result = await checkIfWaitingAuthModule.exec(req, action); + + expect(result.steps).toHaveLength(1); + expect(result.steps[0].error).toBe(false); + expect(result.allowPush).toBe(false); + }); + + it('should not modify action when it has an error', async () => { + action.error = true; + const authorizedAction = new Action( + '1234567890', + 'push', + 'POST', + 1234567890, + 'test/repo.git', + ); + authorizedAction.authorised = true; + getPushMock.mockResolvedValue(authorizedAction); + + const result = await checkIfWaitingAuthModule.exec(req, action); + + expect(result.steps).toHaveLength(1); + expect(result.steps[0].error).toBe(false); + expect(result.allowPush).toBe(false); + expect(result.error).toBe(true); + }); + + it('should add step with error when getPush throws', async () => { + const error = new Error('DB error'); + getPushMock.mockRejectedValue(error); + + await expect(checkIfWaitingAuthModule.exec(req, action)).rejects.toThrow(error); + + expect(action.steps).toHaveLength(1); + expect(action.steps[0].error).toBe(true); + expect(action.steps[0].errorMessage).toContain('DB error'); + }); + }); +}); diff --git a/test/processors/checkUserPushPermission.test.js b/test/processors/checkUserPushPermission.test.js deleted file mode 100644 index c566ca362..000000000 --- a/test/processors/checkUserPushPermission.test.js +++ /dev/null @@ -1,158 +0,0 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const proxyquire = require('proxyquire'); -const fc = require('fast-check'); -const { Action, Step } = require('../../src/proxy/actions'); - -chai.should(); -const expect = chai.expect; - -describe('checkUserPushPermission', () => { - let exec; - let getUsersStub; - let isUserPushAllowedStub; - let logStub; - let errorStub; - - beforeEach(() => { - logStub = sinon.stub(console, 'log'); - errorStub = sinon.stub(console, 'error'); - getUsersStub = sinon.stub(); - isUserPushAllowedStub = sinon.stub(); - - const checkUserPushPermission = proxyquire( - '../../src/proxy/processors/push-action/checkUserPushPermission', - { - '../../../db': { - getUsers: getUsersStub, - isUserPushAllowed: isUserPushAllowedStub, - }, - }, - ); - - exec = checkUserPushPermission.exec; - }); - - afterEach(() => { - sinon.restore(); - }); - - describe('exec', () => { - let action; - let req; - let stepSpy; - - beforeEach(() => { - req = {}; - action = new Action( - '1234567890', - 'push', - 'POST', - 1234567890, - 'https://github.com/finos/git-proxy.git', - ); - action.user = 'git-user'; - action.userEmail = 'db-user@test.com'; - stepSpy = sinon.spy(Step.prototype, 'log'); - }); - - it('should allow push when user has permission', async () => { - getUsersStub.resolves([ - { username: 'db-user', email: 'db-user@test.com', gitAccount: 'git-user' }, - ]); - isUserPushAllowedStub.resolves(true); - - const result = await exec(req, action); - - expect(result.steps).to.have.lengthOf(1); - expect(result.steps[0].error).to.be.false; - expect(stepSpy.lastCall.args[0]).to.equal( - 'User db-user@test.com is allowed to push on repo https://github.com/finos/git-proxy.git', - ); - expect(logStub.lastCall.args[0]).to.equal( - 'User db-user@test.com permission on Repo https://github.com/finos/git-proxy.git : true', - ); - }); - - it('should reject push when user has no permission', async () => { - getUsersStub.resolves([ - { username: 'db-user', email: 'db-user@test.com', gitAccount: 'git-user' }, - ]); - isUserPushAllowedStub.resolves(false); - - const result = await exec(req, action); - - expect(result.steps).to.have.lengthOf(1); - expect(result.steps[0].error).to.be.true; - expect(stepSpy.lastCall.args[0]).to.equal( - 'Your push has been blocked (db-user@test.com is not allowed to push on repo https://github.com/finos/git-proxy.git)', - ); - expect(result.steps[0].errorMessage).to.include('Your push has been blocked'); - expect(logStub.lastCall.args[0]).to.equal('User not allowed to Push'); - }); - - it('should reject push when no user found for git account', async () => { - getUsersStub.resolves([]); - - const result = await exec(req, action); - - expect(result.steps).to.have.lengthOf(1); - expect(result.steps[0].error).to.be.true; - expect(stepSpy.lastCall.args[0]).to.equal( - 'Your push has been blocked (db-user@test.com is not allowed to push on repo https://github.com/finos/git-proxy.git)', - ); - expect(result.steps[0].errorMessage).to.include('Your push has been blocked'); - }); - - it('should handle multiple users for git account by rejecting the push', async () => { - getUsersStub.resolves([ - { username: 'user1', email: 'db-user@test.com', gitAccount: 'git-user' }, - { username: 'user2', email: 'db-user@test.com', gitAccount: 'git-user' }, - ]); - - const result = await exec(req, action); - - expect(result.steps).to.have.lengthOf(1); - expect(result.steps[0].error).to.be.true; - expect(stepSpy.lastCall.args[0]).to.equal( - 'Your push has been blocked (there are multiple users with email db-user@test.com)', - ); - expect(errorStub.lastCall.args[0]).to.equal( - 'Multiple users found with email address db-user@test.com, ending', - ); - }); - - it('should return error when no user is set in the action', async () => { - action.user = null; - action.userEmail = null; - getUsersStub.resolves([]); - const result = await exec(req, action); - expect(result.steps).to.have.lengthOf(1); - expect(result.steps[0].error).to.be.true; - expect(result.steps[0].errorMessage).to.include( - 'Push blocked: User not found. Please contact an administrator for support.', - ); - }); - - describe('fuzzing', () => { - it('should not crash on arbitrary getUsers return values (fuzzing)', async () => { - const userList = fc.sample( - fc.array( - fc.record({ - username: fc.string(), - gitAccount: fc.string(), - }), - { maxLength: 5 }, - ), - 1, - )[0]; - getUsersStub.resolves(userList); - - const result = await exec(req, action); - - expect(result.steps).to.have.lengthOf(1); - expect(result.steps[0].error).to.be.true; - }); - }); - }); -}); diff --git a/test/processors/checkUserPushPermission.test.ts b/test/processors/checkUserPushPermission.test.ts new file mode 100644 index 000000000..6e029a321 --- /dev/null +++ b/test/processors/checkUserPushPermission.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fc from 'fast-check'; +import { Action, Step } from '../../src/proxy/actions'; +import type { Mock } from 'vitest'; + +vi.mock('../../src/db', () => ({ + getUsers: vi.fn(), + isUserPushAllowed: vi.fn(), +})); + +// import after mocking +import { getUsers, isUserPushAllowed } from '../../src/db'; +import { exec } from '../../src/proxy/processors/push-action/checkUserPushPermission'; + +describe('checkUserPushPermission', () => { + let getUsersMock: Mock; + let isUserPushAllowedMock: Mock; + let consoleLogSpy: ReturnType; + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + getUsersMock = vi.mocked(getUsers); + isUserPushAllowedMock = vi.mocked(isUserPushAllowed); + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + describe('exec', () => { + let action: Action; + let req: any; + let stepLogSpy: ReturnType; + + beforeEach(() => { + req = {}; + action = new Action( + '1234567890', + 'push', + 'POST', + 1234567890, + 'https://github.com/finos/git-proxy.git', + ); + action.user = 'git-user'; + action.userEmail = 'db-user@test.com'; + stepLogSpy = vi.spyOn(Step.prototype, 'log'); + }); + + it('should allow push when user has permission', async () => { + getUsersMock.mockResolvedValue([ + { username: 'db-user', email: 'db-user@test.com', gitAccount: 'git-user' }, + ]); + isUserPushAllowedMock.mockResolvedValue(true); + + const result = await exec(req, action); + + expect(result.steps).toHaveLength(1); + expect(result.steps[0].error).toBe(false); + expect(stepLogSpy).toHaveBeenLastCalledWith( + 'User db-user@test.com is allowed to push on repo https://github.com/finos/git-proxy.git', + ); + expect(consoleLogSpy).toHaveBeenLastCalledWith( + 'User db-user@test.com permission on Repo https://github.com/finos/git-proxy.git : true', + ); + }); + + it('should reject push when user has no permission', async () => { + getUsersMock.mockResolvedValue([ + { username: 'db-user', email: 'db-user@test.com', gitAccount: 'git-user' }, + ]); + isUserPushAllowedMock.mockResolvedValue(false); + + const result = await exec(req, action); + + expect(result.steps).toHaveLength(1); + expect(result.steps[0].error).toBe(true); + expect(stepLogSpy).toHaveBeenLastCalledWith( + `Your push has been blocked (db-user@test.com is not allowed to push on repo https://github.com/finos/git-proxy.git)`, + ); + expect(result.steps[0].errorMessage).toContain('Your push has been blocked'); + expect(consoleLogSpy).toHaveBeenLastCalledWith('User not allowed to Push'); + }); + + it('should reject push when no user found for git account', async () => { + getUsersMock.mockResolvedValue([]); + + const result = await exec(req, action); + + expect(result.steps).toHaveLength(1); + expect(result.steps[0].error).toBe(true); + expect(stepLogSpy).toHaveBeenLastCalledWith( + `Your push has been blocked (db-user@test.com is not allowed to push on repo https://github.com/finos/git-proxy.git)`, + ); + expect(result.steps[0].errorMessage).toContain('Your push has been blocked'); + }); + + it('should handle multiple users for git account by rejecting the push', async () => { + getUsersMock.mockResolvedValue([ + { username: 'user1', email: 'db-user@test.com', gitAccount: 'git-user' }, + { username: 'user2', email: 'db-user@test.com', gitAccount: 'git-user' }, + ]); + + const result = await exec(req, action); + + expect(result.steps).toHaveLength(1); + expect(result.steps[0].error).toBe(true); + expect(stepLogSpy).toHaveBeenLastCalledWith( + 'Your push has been blocked (there are multiple users with email db-user@test.com)', + ); + expect(consoleErrorSpy).toHaveBeenLastCalledWith( + 'Multiple users found with email address db-user@test.com, ending', + ); + }); + + it('should return error when no user is set in the action', async () => { + action.user = undefined; + action.userEmail = undefined; + getUsersMock.mockResolvedValue([]); + + const result = await exec(req, action); + + expect(result.steps).toHaveLength(1); + expect(result.steps[0].error).toBe(true); + expect(result.steps[0].errorMessage).toContain( + 'Push blocked: User not found. Please contact an administrator for support.', + ); + }); + + describe('fuzzing', () => { + it('should not crash on arbitrary getUsers return values (fuzzing)', async () => { + const userList = fc.sample( + fc.array( + fc.record({ + username: fc.string(), + gitAccount: fc.string(), + }), + { maxLength: 5 }, + ), + 1, + )[0]; + getUsersMock.mockResolvedValue(userList); + + const result = await exec(req, action); + + expect(result.steps).toHaveLength(1); + expect(result.steps[0].error).toBe(true); + }); + }); + }); +}); diff --git a/test/processors/clearBareClone.test.js b/test/processors/clearBareClone.test.ts similarity index 55% rename from test/processors/clearBareClone.test.js rename to test/processors/clearBareClone.test.ts index c58460913..60624196c 100644 --- a/test/processors/clearBareClone.test.js +++ b/test/processors/clearBareClone.test.ts @@ -1,20 +1,16 @@ -const fs = require('fs'); -const chai = require('chai'); -const clearBareClone = require('../../src/proxy/processors/push-action/clearBareClone').exec; -const pullRemote = require('../../src/proxy/processors/push-action/pullRemote').exec; -const { Action } = require('../../src/proxy/actions/Action'); -chai.should(); - -const expect = chai.expect; +import { describe, it, expect, afterEach } from 'vitest'; +import fs from 'fs'; +import { exec as clearBareClone } from '../../src/proxy/processors/push-action/clearBareClone'; +import { exec as pullRemote } from '../../src/proxy/processors/push-action/pullRemote'; +import { Action } from '../../src/proxy/actions/Action'; const actionId = '123__456'; const timestamp = Date.now(); -describe('clear bare and local clones', async () => { +describe('clear bare and local clones', () => { it('pull remote generates a local .remote folder', async () => { const action = new Action(actionId, 'type', 'get', timestamp, 'finos/git-proxy.git'); action.url = 'https://github.com/finos/git-proxy.git'; - const authorization = `Basic ${Buffer.from('JamieSlome:test').toString('base64')}`; await pullRemote( @@ -26,19 +22,20 @@ describe('clear bare and local clones', async () => { action, ); - expect(fs.existsSync(`./.remote/${actionId}`)).to.be.true; - }).timeout(20000); + expect(fs.existsSync(`./.remote/${actionId}`)).toBe(true); + }, 20000); it('clear bare clone function purges .remote folder and specific clone folder', async () => { const action = new Action(actionId, 'type', 'get', timestamp, 'finos/git-proxy.git'); await clearBareClone(null, action); - expect(fs.existsSync(`./.remote`)).to.throw; - expect(fs.existsSync(`./.remote/${actionId}`)).to.throw; + + expect(fs.existsSync(`./.remote`)).toBe(false); + expect(fs.existsSync(`./.remote/${actionId}`)).toBe(false); }); afterEach(() => { if (fs.existsSync(`./.remote`)) { - fs.rmdirSync(`./.remote`, { recursive: true }); + fs.rmSync(`./.remote`, { recursive: true }); } }); }); diff --git a/test/processors/getDiff.test.js b/test/processors/getDiff.test.ts similarity index 71% rename from test/processors/getDiff.test.js rename to test/processors/getDiff.test.ts index a6b2a64bd..ed5a48594 100644 --- a/test/processors/getDiff.test.js +++ b/test/processors/getDiff.test.ts @@ -1,18 +1,17 @@ -const path = require('path'); -const simpleGit = require('simple-git'); -const fs = require('fs').promises; -const fc = require('fast-check'); -const { Action } = require('../../src/proxy/actions'); -const { exec } = require('../../src/proxy/processors/push-action/getDiff'); - -const chai = require('chai'); -const expect = chai.expect; +import path from 'path'; +import simpleGit, { SimpleGit } from 'simple-git'; +import fs from 'fs/promises'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fc from 'fast-check'; +import { Action } from '../../src/proxy/actions'; +import { exec } from '../../src/proxy/processors/push-action/getDiff'; +import { Commit } from '../../src/proxy/actions/Action'; describe('getDiff', () => { - let tempDir; - let git; + let tempDir: string; + let git: SimpleGit; - before(async () => { + beforeAll(async () => { // Create a temp repo to avoid mocking simple-git tempDir = path.join(__dirname, 'temp-test-repo'); await fs.mkdir(tempDir, { recursive: true }); @@ -27,8 +26,8 @@ describe('getDiff', () => { await git.commit('initial commit'); }); - after(async () => { - await fs.rmdir(tempDir, { recursive: true }); + afterAll(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); }); it('should get diff between commits', async () => { @@ -41,13 +40,13 @@ describe('getDiff', () => { action.repoName = 'temp-test-repo'; action.commitFrom = 'HEAD~1'; action.commitTo = 'HEAD'; - action.commitData = [{ parent: '0000000000000000000000000000000000000000' }]; + action.commitData = [{ parent: '0000000000000000000000000000000000000000' } as Commit]; const result = await exec({}, action); - expect(result.steps[0].error).to.be.false; - expect(result.steps[0].content).to.include('modified content'); - expect(result.steps[0].content).to.include('initial content'); + expect(result.steps[0].error).toBe(false); + expect(result.steps[0].content).toContain('modified content'); + expect(result.steps[0].content).toContain('initial content'); }); it('should get diff between commits with no changes', async () => { @@ -56,12 +55,12 @@ describe('getDiff', () => { action.repoName = 'temp-test-repo'; action.commitFrom = 'HEAD~1'; action.commitTo = 'HEAD'; - action.commitData = [{ parent: '0000000000000000000000000000000000000000' }]; + action.commitData = [{ parent: '0000000000000000000000000000000000000000' } as Commit]; const result = await exec({}, action); - expect(result.steps[0].error).to.be.false; - expect(result.steps[0].content).to.include('initial content'); + expect(result.steps[0].error).toBe(false); + expect(result.steps[0].content).toContain('initial content'); }); it('should throw an error if no commit data is provided', async () => { @@ -73,23 +72,23 @@ describe('getDiff', () => { action.commitData = []; const result = await exec({}, action); - expect(result.steps[0].error).to.be.true; - expect(result.steps[0].errorMessage).to.contain( + expect(result.steps[0].error).toBe(true); + expect(result.steps[0].errorMessage).toContain( 'Your push has been blocked because no commit data was found', ); }); - it('should throw an error if no commit data is provided', async () => { + it('should throw an error if commit data is undefined', async () => { const action = new Action('1234567890', 'push', 'POST', 1234567890, 'test/repo.git'); action.proxyGitPath = __dirname; // Temp dir parent path action.repoName = 'temp-test-repo'; action.commitFrom = 'HEAD~1'; action.commitTo = 'HEAD'; - action.commitData = undefined; + action.commitData = undefined as any; const result = await exec({}, action); - expect(result.steps[0].error).to.be.true; - expect(result.steps[0].errorMessage).to.contain( + expect(result.steps[0].error).toBe(true); + expect(result.steps[0].errorMessage).toContain( 'Your push has been blocked because no commit data was found', ); }); @@ -109,15 +108,14 @@ describe('getDiff', () => { action.repoName = path.basename(tempDir); action.commitFrom = '0000000000000000000000000000000000000000'; action.commitTo = headCommit; - action.commitData = [{ parent: parentCommit }]; + action.commitData = [{ parent: parentCommit } as Commit]; const result = await exec({}, action); - expect(result.steps[0].error).to.be.false; - expect(result.steps[0].content).to.not.be.null; - expect(result.steps[0].content.length).to.be.greaterThan(0); + expect(result.steps[0].error).toBe(false); + expect(result.steps[0].content).not.toBeNull(); + expect(result.steps[0].content!.length).toBeGreaterThan(0); }); - describe('fuzzing', () => { it('should handle random action inputs without crashing', async function () { // Not comprehensive but helps prevent crashing on bad input @@ -134,13 +132,13 @@ describe('getDiff', () => { action.repoName = 'temp-test-repo'; action.commitFrom = from; action.commitTo = to; - action.commitData = commitData; + action.commitData = commitData as any; const result = await exec({}, action); - expect(result).to.have.property('steps'); - expect(result.steps[0]).to.have.property('error'); - expect(result.steps[0]).to.have.property('content'); + expect(result).toHaveProperty('steps'); + expect(result.steps[0]).toHaveProperty('error'); + expect(result.steps[0]).toHaveProperty('content'); }, ), { numRuns: 10 }, @@ -158,12 +156,12 @@ describe('getDiff', () => { action.repoName = 'temp-test-repo'; action.commitFrom = from; action.commitTo = to; - action.commitData = [{ parent: '0000000000000000000000000000000000000000' }]; + action.commitData = [{ parent: '0000000000000000000000000000000000000000' } as Commit]; const result = await exec({}, action); - expect(result.steps[0].error).to.be.true; - expect(result.steps[0].errorMessage).to.contain('Invalid revision range'); + expect(result.steps[0].error).toBe(true); + expect(result.steps[0].errorMessage).toContain('Invalid revision range'); }, ), { numRuns: 10 }, diff --git a/test/processors/gitLeaks.test.js b/test/processors/gitLeaks.test.js deleted file mode 100644 index 1461611f8..000000000 --- a/test/processors/gitLeaks.test.js +++ /dev/null @@ -1,324 +0,0 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const proxyquire = require('proxyquire'); -const { Action, Step } = require('../../src/proxy/actions'); - -chai.should(); -const expect = chai.expect; - -describe('gitleaks', () => { - describe('exec', () => { - let exec; - let stubs; - let action; - let req; - let stepSpy; - let logStub; - let errorStub; - - beforeEach(() => { - stubs = { - getAPIs: sinon.stub(), - fs: { - stat: sinon.stub(), - access: sinon.stub(), - constants: { R_OK: 0 }, - }, - spawn: sinon.stub(), - }; - - logStub = sinon.stub(console, 'log'); - errorStub = sinon.stub(console, 'error'); - - const gitleaksModule = proxyquire('../../src/proxy/processors/push-action/gitleaks', { - '../../../config': { getAPIs: stubs.getAPIs }, - 'node:fs/promises': stubs.fs, - 'node:child_process': { spawn: stubs.spawn }, - }); - - exec = gitleaksModule.exec; - - req = {}; - action = new Action('1234567890', 'push', 'POST', 1234567890, 'test/repo.git'); - action.proxyGitPath = '/tmp'; - action.repoName = 'test-repo'; - action.commitFrom = 'abc123'; - action.commitTo = 'def456'; - - stepSpy = sinon.spy(Step.prototype, 'setError'); - }); - - afterEach(() => { - sinon.restore(); - }); - - it('should handle config loading failure', async () => { - stubs.getAPIs.throws(new Error('Config error')); - - const result = await exec(req, action); - - expect(result.error).to.be.true; - expect(result.steps).to.have.lengthOf(1); - expect(result.steps[0].error).to.be.true; - expect(stepSpy.calledWith('failed setup gitleaks, please contact an administrator\n')).to.be - .true; - expect(errorStub.calledWith('failed to get gitleaks config, please fix the error:')).to.be - .true; - }); - - it('should skip scanning when plugin is disabled', async () => { - stubs.getAPIs.returns({ gitleaks: { enabled: false } }); - - const result = await exec(req, action); - - expect(result.error).to.be.false; - expect(result.steps).to.have.lengthOf(1); - expect(result.steps[0].error).to.be.false; - expect(logStub.calledWith('gitleaks is disabled, skipping')).to.be.true; - }); - - it('should handle successful scan with no findings', async () => { - stubs.getAPIs.returns({ gitleaks: { enabled: true } }); - - const gitRootCommitMock = { - exitCode: 0, - stdout: 'rootcommit123\n', - stderr: '', - }; - - const gitleaksMock = { - exitCode: 0, - stdout: '', - stderr: 'No leaks found', - }; - - stubs.spawn - .onFirstCall() - .returns({ - on: (event, cb) => { - if (event === 'close') cb(gitRootCommitMock.exitCode); - return { stdout: { on: () => {} }, stderr: { on: () => {} } }; - }, - stdout: { on: (_, cb) => cb(gitRootCommitMock.stdout) }, - stderr: { on: (_, cb) => cb(gitRootCommitMock.stderr) }, - }) - .onSecondCall() - .returns({ - on: (event, cb) => { - if (event === 'close') cb(gitleaksMock.exitCode); - return { stdout: { on: () => {} }, stderr: { on: () => {} } }; - }, - stdout: { on: (_, cb) => cb(gitleaksMock.stdout) }, - stderr: { on: (_, cb) => cb(gitleaksMock.stderr) }, - }); - - const result = await exec(req, action); - - expect(result.error).to.be.false; - expect(result.steps).to.have.lengthOf(1); - expect(result.steps[0].error).to.be.false; - expect(logStub.calledWith('succeeded')).to.be.true; - expect(logStub.calledWith('No leaks found')).to.be.true; - }); - - it('should handle scan with findings', async () => { - stubs.getAPIs.returns({ gitleaks: { enabled: true } }); - - const gitRootCommitMock = { - exitCode: 0, - stdout: 'rootcommit123\n', - stderr: '', - }; - - const gitleaksMock = { - exitCode: 99, - stdout: 'Found secret in file.txt\n', - stderr: 'Warning: potential leak', - }; - - stubs.spawn - .onFirstCall() - .returns({ - on: (event, cb) => { - if (event === 'close') cb(gitRootCommitMock.exitCode); - return { stdout: { on: () => {} }, stderr: { on: () => {} } }; - }, - stdout: { on: (_, cb) => cb(gitRootCommitMock.stdout) }, - stderr: { on: (_, cb) => cb(gitRootCommitMock.stderr) }, - }) - .onSecondCall() - .returns({ - on: (event, cb) => { - if (event === 'close') cb(gitleaksMock.exitCode); - return { stdout: { on: () => {} }, stderr: { on: () => {} } }; - }, - stdout: { on: (_, cb) => cb(gitleaksMock.stdout) }, - stderr: { on: (_, cb) => cb(gitleaksMock.stderr) }, - }); - - const result = await exec(req, action); - - expect(result.error).to.be.true; - expect(result.steps).to.have.lengthOf(1); - expect(result.steps[0].error).to.be.true; - expect(stepSpy.calledWith('\nFound secret in file.txt\nWarning: potential leak')).to.be.true; - }); - - it('should handle gitleaks execution failure', async () => { - stubs.getAPIs.returns({ gitleaks: { enabled: true } }); - - const gitRootCommitMock = { - exitCode: 0, - stdout: 'rootcommit123\n', - stderr: '', - }; - - const gitleaksMock = { - exitCode: 1, - stdout: '', - stderr: 'Command failed', - }; - - stubs.spawn - .onFirstCall() - .returns({ - on: (event, cb) => { - if (event === 'close') cb(gitRootCommitMock.exitCode); - return { stdout: { on: () => {} }, stderr: { on: () => {} } }; - }, - stdout: { on: (_, cb) => cb(gitRootCommitMock.stdout) }, - stderr: { on: (_, cb) => cb(gitRootCommitMock.stderr) }, - }) - .onSecondCall() - .returns({ - on: (event, cb) => { - if (event === 'close') cb(gitleaksMock.exitCode); - return { stdout: { on: () => {} }, stderr: { on: () => {} } }; - }, - stdout: { on: (_, cb) => cb(gitleaksMock.stdout) }, - stderr: { on: (_, cb) => cb(gitleaksMock.stderr) }, - }); - - const result = await exec(req, action); - - expect(result.error).to.be.true; - expect(result.steps).to.have.lengthOf(1); - expect(result.steps[0].error).to.be.true; - expect(stepSpy.calledWith('failed to run gitleaks, please contact an administrator\n')).to.be - .true; - }); - - it('should handle gitleaks spawn failure', async () => { - stubs.getAPIs.returns({ gitleaks: { enabled: true } }); - stubs.spawn.onFirstCall().throws(new Error('Spawn error')); - - const result = await exec(req, action); - - expect(result.error).to.be.true; - expect(result.steps).to.have.lengthOf(1); - expect(result.steps[0].error).to.be.true; - expect(stepSpy.calledWith('failed to spawn gitleaks, please contact an administrator\n')).to - .be.true; - }); - - it('should handle empty gitleaks entry in proxy.config.json', async () => { - stubs.getAPIs.returns({ gitleaks: {} }); - const result = await exec(req, action); - expect(result.error).to.be.false; - expect(result.steps).to.have.lengthOf(1); - expect(result.steps[0].error).to.be.false; - }); - - it('should handle invalid gitleaks entry in proxy.config.json', async () => { - stubs.getAPIs.returns({ gitleaks: 'invalid config' }); - stubs.spawn.onFirstCall().returns({ - on: (event, cb) => { - if (event === 'close') cb(0); - return { stdout: { on: () => {} }, stderr: { on: () => {} } }; - }, - stdout: { on: (_, cb) => cb('') }, - stderr: { on: (_, cb) => cb('') }, - }); - - const result = await exec(req, action); - - expect(result.error).to.be.false; - expect(result.steps).to.have.lengthOf(1); - expect(result.steps[0].error).to.be.false; - }); - - it('should handle custom config path', async () => { - stubs.getAPIs.returns({ - gitleaks: { - enabled: true, - configPath: `../fixtures/gitleaks-config.toml`, - }, - }); - - stubs.fs.stat.resolves({ isFile: () => true }); - stubs.fs.access.resolves(); - - const gitRootCommitMock = { - exitCode: 0, - stdout: 'rootcommit123\n', - stderr: '', - }; - - const gitleaksMock = { - exitCode: 0, - stdout: '', - stderr: 'No leaks found', - }; - - stubs.spawn - .onFirstCall() - .returns({ - on: (event, cb) => { - if (event === 'close') cb(gitRootCommitMock.exitCode); - return { stdout: { on: () => {} }, stderr: { on: () => {} } }; - }, - stdout: { on: (_, cb) => cb(gitRootCommitMock.stdout) }, - stderr: { on: (_, cb) => cb(gitRootCommitMock.stderr) }, - }) - .onSecondCall() - .returns({ - on: (event, cb) => { - if (event === 'close') cb(gitleaksMock.exitCode); - return { stdout: { on: () => {} }, stderr: { on: () => {} } }; - }, - stdout: { on: (_, cb) => cb(gitleaksMock.stdout) }, - stderr: { on: (_, cb) => cb(gitleaksMock.stderr) }, - }); - - const result = await exec(req, action); - - expect(result.error).to.be.false; - expect(result.steps[0].error).to.be.false; - expect(stubs.spawn.secondCall.args[1]).to.include( - '--config=../fixtures/gitleaks-config.toml', - ); - }); - - it('should handle invalid custom config path', async () => { - stubs.getAPIs.returns({ - gitleaks: { - enabled: true, - configPath: '/invalid/path.toml', - }, - }); - - stubs.fs.stat.rejects(new Error('File not found')); - - const result = await exec(req, action); - - expect(result.error).to.be.true; - expect(result.steps).to.have.lengthOf(1); - expect(result.steps[0].error).to.be.true; - expect( - errorStub.calledWith( - 'could not read file at the config path provided, will not be fed to gitleaks', - ), - ).to.be.true; - }); - }); -}); diff --git a/test/processors/gitLeaks.test.ts b/test/processors/gitLeaks.test.ts new file mode 100644 index 000000000..3e9d9234a --- /dev/null +++ b/test/processors/gitLeaks.test.ts @@ -0,0 +1,347 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { Action, Step } from '../../src/proxy/actions'; + +vi.mock('../../src/config', async (importOriginal) => { + const actual: any = await importOriginal(); + return { + ...actual, + getAPIs: vi.fn(), + }; +}); + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual: any = await importOriginal(); + return { + ...actual, + default: { + stat: vi.fn(), + access: vi.fn(), + constants: { R_OK: 0 }, + }, + stat: vi.fn(), + access: vi.fn(), + constants: { R_OK: 0 }, + }; +}); + +vi.mock('node:child_process', async (importOriginal) => { + const actual: any = await importOriginal(); + return { + ...actual, + spawn: vi.fn(), + }; +}); + +describe('gitleaks', () => { + describe('exec', () => { + let exec: any; + let action: Action; + let req: any; + let stepSpy: any; + let logStub: any; + let errorStub: any; + let getAPIs: any; + let fsModule: any; + let spawn: any; + + beforeEach(async () => { + vi.clearAllMocks(); + + const configModule = await import('../../src/config'); + getAPIs = configModule.getAPIs; + + const fsPromises = await import('node:fs/promises'); + fsModule = fsPromises.default || fsPromises; + + const childProcess = await import('node:child_process'); + spawn = childProcess.spawn; + + logStub = vi.spyOn(console, 'log').mockImplementation(() => {}); + errorStub = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const gitleaksModule = await import('../../src/proxy/processors/push-action/gitleaks'); + exec = gitleaksModule.exec; + + req = {}; + action = new Action('1234567890', 'push', 'POST', 1234567890, 'test/repo.git'); + action.proxyGitPath = '/tmp'; + action.repoName = 'test-repo'; + action.commitFrom = 'abc123'; + action.commitTo = 'def456'; + + stepSpy = vi.spyOn(Step.prototype, 'setError'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should handle config loading failure', async () => { + vi.mocked(getAPIs).mockImplementation(() => { + throw new Error('Config error'); + }); + + const result = await exec(req, action); + + expect(result.error).toBe(true); + expect(result.steps).toHaveLength(1); + expect(result.steps[0].error).toBe(true); + expect(stepSpy).toHaveBeenCalledWith( + 'failed setup gitleaks, please contact an administrator\n', + ); + expect(errorStub).toHaveBeenCalledWith( + 'failed to get gitleaks config, please fix the error:', + expect.any(Error), + ); + }); + + it('should skip scanning when plugin is disabled', async () => { + vi.mocked(getAPIs).mockReturnValue({ gitleaks: { enabled: false } }); + + const result = await exec(req, action); + + expect(result.error).toBe(false); + expect(result.steps).toHaveLength(1); + expect(result.steps[0].error).toBe(false); + expect(logStub).toHaveBeenCalledWith('gitleaks is disabled, skipping'); + }); + + it('should handle successful scan with no findings', async () => { + vi.mocked(getAPIs).mockReturnValue({ gitleaks: { enabled: true } }); + + const gitRootCommitMock = { + exitCode: 0, + stdout: 'rootcommit123\n', + stderr: '', + }; + + const gitleaksMock = { + exitCode: 0, + stdout: '', + stderr: 'No leaks found', + }; + + vi.mocked(spawn) + .mockReturnValueOnce({ + on: (event: string, cb: (exitCode: number) => void) => { + if (event === 'close') cb(gitRootCommitMock.exitCode); + return { stdout: { on: () => {} }, stderr: { on: () => {} } }; + }, + stdout: { on: (_: string, cb: (stdout: string) => void) => cb(gitRootCommitMock.stdout) }, + stderr: { on: (_: string, cb: (stderr: string) => void) => cb(gitRootCommitMock.stderr) }, + } as any) + .mockReturnValueOnce({ + on: (event: string, cb: (exitCode: number) => void) => { + if (event === 'close') cb(gitleaksMock.exitCode); + return { stdout: { on: () => {} }, stderr: { on: () => {} } }; + }, + stdout: { on: (_: string, cb: (stdout: string) => void) => cb(gitleaksMock.stdout) }, + stderr: { on: (_: string, cb: (stderr: string) => void) => cb(gitleaksMock.stderr) }, + } as any); + + const result = await exec(req, action); + + expect(result.error).toBe(false); + expect(result.steps).toHaveLength(1); + expect(result.steps[0].error).toBe(false); + expect(logStub).toHaveBeenCalledWith('succeeded'); + expect(logStub).toHaveBeenCalledWith('No leaks found'); + }); + + it('should handle scan with findings', async () => { + vi.mocked(getAPIs).mockReturnValue({ gitleaks: { enabled: true } }); + + const gitRootCommitMock = { + exitCode: 0, + stdout: 'rootcommit123\n', + stderr: '', + }; + + const gitleaksMock = { + exitCode: 99, + stdout: 'Found secret in file.txt\n', + stderr: 'Warning: potential leak', + }; + + vi.mocked(spawn) + .mockReturnValueOnce({ + on: (event: string, cb: (exitCode: number) => void) => { + if (event === 'close') cb(gitRootCommitMock.exitCode); + return { stdout: { on: () => {} }, stderr: { on: () => {} } }; + }, + stdout: { on: (_: string, cb: (stdout: string) => void) => cb(gitRootCommitMock.stdout) }, + stderr: { on: (_: string, cb: (stderr: string) => void) => cb(gitRootCommitMock.stderr) }, + } as any) + .mockReturnValueOnce({ + on: (event: string, cb: (exitCode: number) => void) => { + if (event === 'close') cb(gitleaksMock.exitCode); + return { stdout: { on: () => {} }, stderr: { on: () => {} } }; + }, + stdout: { on: (_: string, cb: (stdout: string) => void) => cb(gitleaksMock.stdout) }, + stderr: { on: (_: string, cb: (stderr: string) => void) => cb(gitleaksMock.stderr) }, + } as any); + + const result = await exec(req, action); + + expect(result.error).toBe(true); + expect(result.steps).toHaveLength(1); + expect(result.steps[0].error).toBe(true); + expect(stepSpy).toHaveBeenCalledWith('\nFound secret in file.txt\nWarning: potential leak'); + }); + + it('should handle gitleaks execution failure', async () => { + vi.mocked(getAPIs).mockReturnValue({ gitleaks: { enabled: true } }); + + const gitRootCommitMock = { + exitCode: 0, + stdout: 'rootcommit123\n', + stderr: '', + }; + + const gitleaksMock = { + exitCode: 1, + stdout: '', + stderr: 'Command failed', + }; + + vi.mocked(spawn) + .mockReturnValueOnce({ + on: (event: string, cb: (exitCode: number) => void) => { + if (event === 'close') cb(gitRootCommitMock.exitCode); + return { stdout: { on: () => {} }, stderr: { on: () => {} } }; + }, + stdout: { on: (_: string, cb: (stdout: string) => void) => cb(gitRootCommitMock.stdout) }, + stderr: { on: (_: string, cb: (stderr: string) => void) => cb(gitRootCommitMock.stderr) }, + } as any) + .mockReturnValueOnce({ + on: (event: string, cb: (exitCode: number) => void) => { + if (event === 'close') cb(gitleaksMock.exitCode); + return { stdout: { on: () => {} }, stderr: { on: () => {} } }; + }, + stdout: { on: (_: string, cb: (stdout: string) => void) => cb(gitleaksMock.stdout) }, + stderr: { on: (_: string, cb: (stderr: string) => void) => cb(gitleaksMock.stderr) }, + } as any); + + const result = await exec(req, action); + + expect(result.error).toBe(true); + expect(result.steps).toHaveLength(1); + expect(result.steps[0].error).toBe(true); + expect(stepSpy).toHaveBeenCalledWith( + 'failed to run gitleaks, please contact an administrator\n', + ); + }); + + it('should handle gitleaks spawn failure', async () => { + vi.mocked(getAPIs).mockReturnValue({ gitleaks: { enabled: true } }); + vi.mocked(spawn).mockImplementationOnce(() => { + throw new Error('Spawn error'); + }); + + const result = await exec(req, action); + + expect(result.error).toBe(true); + expect(result.steps).toHaveLength(1); + expect(result.steps[0].error).toBe(true); + expect(stepSpy).toHaveBeenCalledWith( + 'failed to spawn gitleaks, please contact an administrator\n', + ); + }); + + it('should handle empty gitleaks entry in proxy.config.json', async () => { + vi.mocked(getAPIs).mockReturnValue({ gitleaks: {} }); + const result = await exec(req, action); + expect(result.error).toBe(false); + expect(result.steps).toHaveLength(1); + expect(result.steps[0].error).toBe(false); + }); + + it('should handle invalid gitleaks entry in proxy.config.json', async () => { + vi.mocked(getAPIs).mockReturnValue({ gitleaks: 'invalid config' } as any); + vi.mocked(spawn).mockReturnValueOnce({ + on: (event: string, cb: (exitCode: number) => void) => { + if (event === 'close') cb(0); + return { stdout: { on: () => {} }, stderr: { on: () => {} } }; + }, + stdout: { on: (_: string, cb: (stdout: string) => void) => cb('') }, + stderr: { on: (_: string, cb: (stderr: string) => void) => cb('') }, + } as any); + + const result = await exec(req, action); + + expect(result.error).toBe(false); + expect(result.steps).toHaveLength(1); + expect(result.steps[0].error).toBe(false); + }); + + it('should handle custom config path', async () => { + vi.mocked(getAPIs).mockReturnValue({ + gitleaks: { + enabled: true, + configPath: `../fixtures/gitleaks-config.toml`, + }, + }); + + vi.mocked(fsModule.stat).mockResolvedValue({ isFile: () => true } as any); + vi.mocked(fsModule.access).mockResolvedValue(undefined); + + const gitRootCommitMock = { + exitCode: 0, + stdout: 'rootcommit123\n', + stderr: '', + }; + + const gitleaksMock = { + exitCode: 0, + stdout: '', + stderr: 'No leaks found', + }; + + vi.mocked(spawn) + .mockReturnValueOnce({ + on: (event: string, cb: (exitCode: number) => void) => { + if (event === 'close') cb(gitRootCommitMock.exitCode); + return { stdout: { on: () => {} }, stderr: { on: () => {} } }; + }, + stdout: { on: (_: string, cb: (stdout: string) => void) => cb(gitRootCommitMock.stdout) }, + stderr: { on: (_: string, cb: (stderr: string) => void) => cb(gitRootCommitMock.stderr) }, + } as any) + .mockReturnValueOnce({ + on: (event: string, cb: (exitCode: number) => void) => { + if (event === 'close') cb(gitleaksMock.exitCode); + return { stdout: { on: () => {} }, stderr: { on: () => {} } }; + }, + stdout: { on: (_: string, cb: (stdout: string) => void) => cb(gitleaksMock.stdout) }, + stderr: { on: (_: string, cb: (stderr: string) => void) => cb(gitleaksMock.stderr) }, + } as any); + + const result = await exec(req, action); + + expect(result.error).toBe(false); + expect(result.steps[0].error).toBe(false); + expect(vi.mocked(spawn).mock.calls[1][1]).toContain( + '--config=../fixtures/gitleaks-config.toml', + ); + }); + + it('should handle invalid custom config path', async () => { + vi.mocked(getAPIs).mockReturnValue({ + gitleaks: { + enabled: true, + configPath: '/invalid/path.toml', + }, + }); + + vi.mocked(fsModule.stat).mockRejectedValue(new Error('File not found')); + + const result = await exec(req, action); + + expect(result.error).toBe(true); + expect(result.steps).toHaveLength(1); + expect(result.steps[0].error).toBe(true); + expect(errorStub).toHaveBeenCalledWith( + 'could not read file at the config path provided, will not be fed to gitleaks', + ); + }); + }); +}); diff --git a/test/processors/scanDiff.emptyDiff.test.js b/test/processors/scanDiff.emptyDiff.test.ts similarity index 62% rename from test/processors/scanDiff.emptyDiff.test.js rename to test/processors/scanDiff.emptyDiff.test.ts index 4a89aba2e..252b04db5 100644 --- a/test/processors/scanDiff.emptyDiff.test.js +++ b/test/processors/scanDiff.emptyDiff.test.ts @@ -1,8 +1,6 @@ -const { Action } = require('../../src/proxy/actions'); -const { exec } = require('../../src/proxy/processors/push-action/scanDiff'); - -const chai = require('chai'); -const expect = chai.expect; +import { describe, it, expect } from 'vitest'; +import { Action, Step } from '../../src/proxy/actions'; +import { exec } from '../../src/proxy/processors/push-action/scanDiff'; describe('scanDiff - Empty Diff Handling', () => { describe('Empty diff scenarios', () => { @@ -11,13 +9,13 @@ describe('scanDiff - Empty Diff Handling', () => { // Simulate getDiff step with empty content const diffStep = { stepName: 'diff', content: '', error: false }; - action.steps = [diffStep]; + action.steps = [diffStep as Step]; const result = await exec({}, action); - expect(result.steps.length).to.equal(2); // diff step + scanDiff step - expect(result.steps[1].error).to.be.false; - expect(result.steps[1].errorMessage).to.be.null; + expect(result.steps.length).toBe(2); // diff step + scanDiff step + expect(result.steps[1].error).toBe(false); + expect(result.steps[1].errorMessage).toBeNull(); }); it('should allow null diff', async () => { @@ -25,13 +23,13 @@ describe('scanDiff - Empty Diff Handling', () => { // Simulate getDiff step with null content const diffStep = { stepName: 'diff', content: null, error: false }; - action.steps = [diffStep]; + action.steps = [diffStep as Step]; const result = await exec({}, action); - expect(result.steps.length).to.equal(2); - expect(result.steps[1].error).to.be.false; - expect(result.steps[1].errorMessage).to.be.null; + expect(result.steps.length).toBe(2); + expect(result.steps[1].error).toBe(false); + expect(result.steps[1].errorMessage).toBeNull(); }); it('should allow undefined diff', async () => { @@ -39,13 +37,13 @@ describe('scanDiff - Empty Diff Handling', () => { // Simulate getDiff step with undefined content const diffStep = { stepName: 'diff', content: undefined, error: false }; - action.steps = [diffStep]; + action.steps = [diffStep as Step]; const result = await exec({}, action); - expect(result.steps.length).to.equal(2); - expect(result.steps[1].error).to.be.false; - expect(result.steps[1].errorMessage).to.be.null; + expect(result.steps.length).toBe(2); + expect(result.steps[1].error).toBe(false); + expect(result.steps[1].errorMessage).toBeNull(); }); }); @@ -61,31 +59,30 @@ index 1234567..abcdefg 100644 +++ b/config.js @@ -1,3 +1,4 @@ module.exports = { -+ newFeature: true, - database: "production" ++ newFeature: true, + database: "production" };`; const diffStep = { stepName: 'diff', content: normalDiff, error: false }; - action.steps = [diffStep]; + action.steps = [diffStep as Step]; const result = await exec({}, action); - expect(result.steps[1].error).to.be.false; - expect(result.steps[1].errorMessage).to.be.null; + expect(result.steps[1].error).toBe(false); + expect(result.steps[1].errorMessage).toBeNull(); }); }); describe('Error conditions', () => { it('should handle non-string diff content', async () => { const action = new Action('non-string-test', 'push', 'POST', Date.now(), 'test/repo.git'); - - const diffStep = { stepName: 'diff', content: 12345, error: false }; - action.steps = [diffStep]; + const diffStep = { stepName: 'diff', content: 12345 as any, error: false }; + action.steps = [diffStep as Step]; const result = await exec({}, action); - expect(result.steps[1].error).to.be.true; - expect(result.steps[1].errorMessage).to.include('non-string value'); + expect(result.steps[1].error).toBe(true); + expect(result.steps[1].errorMessage).toContain('non-string value'); }); }); }); diff --git a/test/processors/scanDiff.test.js b/test/processors/scanDiff.test.ts similarity index 53% rename from test/processors/scanDiff.test.js rename to test/processors/scanDiff.test.ts index 28d949385..3403171b7 100644 --- a/test/processors/scanDiff.test.js +++ b/test/processors/scanDiff.test.ts @@ -1,18 +1,17 @@ -const chai = require('chai'); -const crypto = require('crypto'); -const processor = require('../../src/proxy/processors/push-action/scanDiff'); -const { Action } = require('../../src/proxy/actions/Action'); -const { expect } = chai; -const config = require('../../src/config'); -const db = require('../../src/db'); -chai.should(); - -// Load blocked literals and patterns from configuration... -const commitConfig = require('../../src/config/index').getCommitConfig(); +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import crypto from 'crypto'; +import * as processor from '../../src/proxy/processors/push-action/scanDiff'; +import { Action, Step } from '../../src/proxy/actions'; +import * as config from '../../src/config'; +import * as db from '../../src/db'; + +// Load blocked literals and patterns from configuration +const commitConfig = config.getCommitConfig(); const privateOrganizations = config.getPrivateOrganizations(); -const blockedLiterals = commitConfig.diff.block.literals; -const generateDiff = (value) => { +const blockedLiterals = commitConfig.diff?.block?.literals ?? []; + +const generateDiff = (value: string): string => { return `diff --git a/package.json b/package.json index 38cdc3e..8a9c321 100644 --- a/package.json @@ -29,7 +28,7 @@ index 38cdc3e..8a9c321 100644 `; }; -const generateMultiLineDiff = () => { +const generateMultiLineDiff = (): string => { return `diff --git a/README.md b/README.md index 8b97e49..de18d43 100644 --- a/README.md @@ -43,7 +42,7 @@ index 8b97e49..de18d43 100644 `; }; -const generateMultiLineDiffWithLiteral = () => { +const generateMultiLineDiffWithLiteral = (): string => { return `diff --git a/README.md b/README.md index 8b97e49..de18d43 100644 --- a/README.md @@ -56,128 +55,136 @@ index 8b97e49..de18d43 100644 +emdedded_blocked.Te$t.Literal? `; }; -describe('Scan commit diff...', async () => { - privateOrganizations[0] = 'private-org-test'; - commitConfig.diff = { - block: { - //n.b. the example literal includes special chars that would be interpreted as RegEx if not escaped properly - literals: ['blocked.Te$t.Literal?'], - patterns: [], - providers: { - 'AWS (Amazon Web Services) Access Key ID': - 'A(AG|CC|GP|ID|IP|KI|NP|NV|PK|RO|SC|SI)A[A-Z0-9]{16}', - 'Google Cloud Platform API Key': 'AIza[0-9A-Za-z-_]{35}', - 'GitHub Personal Access Token': 'ghp_[a-zA-Z0-9]{36}', - 'GitHub Fine Grained Personal Access Token': 'github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}', - 'GitHub Actions Token': 'ghs_[a-zA-Z0-9]{36}', - 'JSON Web Token (JWT)': 'ey[A-Za-z0-9-_=]{18,}.ey[A-Za-z0-9-_=]{18,}.[A-Za-z0-9-_.]{18,}', + +const TEST_REPO = { + project: 'private-org-test', + name: 'repo.git', + url: 'https://github.com/private-org-test/repo.git', + _id: undefined as any, +}; + +describe('Scan commit diff', () => { + beforeAll(async () => { + privateOrganizations[0] = 'private-org-test'; + commitConfig.diff = { + block: { + //n.b. the example literal includes special chars that would be interpreted as RegEx if not escaped properly + literals: ['blocked.Te$t.Literal?'], + patterns: [], + providers: { + 'AWS (Amazon Web Services) Access Key ID': + 'A(AG|CC|GP|ID|IP|KI|NP|NV|PK|RO|SC|SI)A[A-Z0-9]{16}', + 'Google Cloud Platform API Key': 'AIza[0-9A-Za-z-_]{35}', + 'GitHub Personal Access Token': 'ghp_[a-zA-Z0-9]{36}', + 'GitHub Fine Grained Personal Access Token': 'github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}', + 'GitHub Actions Token': 'ghs_[a-zA-Z0-9]{36}', + 'JSON Web Token (JWT)': 'ey[A-Za-z0-9-_=]{18,}.ey[A-Za-z0-9-_=]{18,}.[A-Za-z0-9-_.]{18,}', + }, }, - }, - }; + }; - before(async () => { // needed for private org tests const repo = await db.createRepo(TEST_REPO); TEST_REPO._id = repo._id; }); - after(async () => { + afterAll(async () => { await db.deleteRepo(TEST_REPO._id); }); - it('A diff including an AWS (Amazon Web Services) Access Key ID blocks the proxy...', async () => { + it('should block push when diff includes AWS Access Key ID', async () => { const action = new Action('1', 'type', 'method', 1, 'test/repo.git'); action.steps = [ { stepName: 'diff', content: generateDiff('AKIAIOSFODNN7EXAMPLE'), - }, + } as Step, ]; action.setCommit('38cdc3e', '8a9c321'); action.setBranch('b'); action.setMessage('Message'); const { error, errorMessage } = await processor.exec(null, action); - expect(error).to.be.true; - expect(errorMessage).to.contains('Your push has been blocked'); + + expect(error).toBe(true); + expect(errorMessage).toContain('Your push has been blocked'); }); - // Formatting test - it('A diff including multiple AWS (Amazon Web Services) Access Keys ID blocks the proxy...', async () => { + // Formatting tests + it('should block push when diff includes multiple AWS Access Keys', async () => { const action = new Action('1', 'type', 'method', 1, 'test/repo.git'); action.steps = [ { stepName: 'diff', content: generateMultiLineDiff(), - }, + } as Step, ]; action.setCommit('8b97e49', 'de18d43'); const { error, errorMessage } = await processor.exec(null, action); - expect(error).to.be.true; - expect(errorMessage).to.contains('Your push has been blocked'); - expect(errorMessage).to.contains('Line(s) of code: 3,4'); // blocked lines - expect(errorMessage).to.contains('#1 AWS (Amazon Web Services) Access Key ID'); // type of error - expect(errorMessage).to.contains('#2 AWS (Amazon Web Services) Access Key ID'); // type of error + expect(error).toBe(true); + expect(errorMessage).toContain('Your push has been blocked'); + expect(errorMessage).toContain('Line(s) of code: 3,4'); + expect(errorMessage).toContain('#1 AWS (Amazon Web Services) Access Key ID'); + expect(errorMessage).toContain('#2 AWS (Amazon Web Services) Access Key ID'); }); - // Formatting test - it('A diff including multiple AWS Access Keys ID and Literal blocks the proxy with appropriate message...', async () => { + it('should block push when diff includes multiple AWS Access Keys and blocked literal with appropriate message', async () => { const action = new Action('1', 'type', 'method', 1, 'test/repo.git'); action.steps = [ { stepName: 'diff', content: generateMultiLineDiffWithLiteral(), - }, + } as Step, ]; action.setCommit('8b97e49', 'de18d43'); const { error, errorMessage } = await processor.exec(null, action); - expect(error).to.be.true; - expect(errorMessage).to.contains('Your push has been blocked'); - expect(errorMessage).to.contains('Line(s) of code: 3'); // blocked lines - expect(errorMessage).to.contains('Line(s) of code: 4'); // blocked lines - expect(errorMessage).to.contains('Line(s) of code: 5'); // blocked lines - expect(errorMessage).to.contains('#1 AWS (Amazon Web Services) Access Key ID'); // type of error - expect(errorMessage).to.contains('#2 AWS (Amazon Web Services) Access Key ID'); // type of error - expect(errorMessage).to.contains('#3 Offending Literal'); + expect(error).toBe(true); + expect(errorMessage).toContain('Your push has been blocked'); + expect(errorMessage).toContain('Line(s) of code: 3'); + expect(errorMessage).toContain('Line(s) of code: 4'); + expect(errorMessage).toContain('Line(s) of code: 5'); + expect(errorMessage).toContain('#1 AWS (Amazon Web Services) Access Key ID'); + expect(errorMessage).toContain('#2 AWS (Amazon Web Services) Access Key ID'); + expect(errorMessage).toContain('#3 Offending Literal'); }); - it('A diff including a Google Cloud Platform API Key blocks the proxy...', async () => { + it('should block push when diff includes Google Cloud Platform API Key', async () => { const action = new Action('1', 'type', 'method', 1, 'test/repo.git'); action.steps = [ { stepName: 'diff', content: generateDiff('AIza0aB7Z4Rfs23MnPqars81yzu19KbH72zaFda'), - }, + } as Step, ]; action.commitFrom = '38cdc3e'; action.commitTo = '8a9c321'; const { error, errorMessage } = await processor.exec(null, action); - expect(error).to.be.true; - expect(errorMessage).to.contains('Your push has been blocked'); + expect(error).toBe(true); + expect(errorMessage).toContain('Your push has been blocked'); }); - it('A diff including a GitHub Personal Access Token blocks the proxy...', async () => { + it('should block push when diff includes GitHub Personal Access Token', async () => { const action = new Action('1', 'type', 'method', 1, 'test/repo.git'); action.steps = [ { stepName: 'diff', content: generateDiff(`ghp_${crypto.randomBytes(36).toString('hex')}`), - }, + } as Step, ]; const { error, errorMessage } = await processor.exec(null, action); - expect(error).to.be.true; - expect(errorMessage).to.contains('Your push has been blocked'); + expect(error).toBe(true); + expect(errorMessage).toContain('Your push has been blocked'); }); - it('A diff including a GitHub Fine Grained Personal Access Token blocks the proxy...', async () => { + it('should block push when diff includes GitHub Fine Grained Personal Access Token', async () => { const action = new Action('1', 'type', 'method', 1, 'test/repo.git'); action.steps = [ { @@ -185,35 +192,35 @@ describe('Scan commit diff...', async () => { content: generateDiff( `github_pat_1SMAGDFOYZZK3P9ndFemen_${crypto.randomBytes(59).toString('hex')}`, ), - }, + } as Step, ]; action.commitFrom = '38cdc3e'; action.commitTo = '8a9c321'; const { error, errorMessage } = await processor.exec(null, action); - expect(error).to.be.true; - expect(errorMessage).to.contains('Your push has been blocked'); + expect(error).toBe(true); + expect(errorMessage).toContain('Your push has been blocked'); }); - it('A diff including a GitHub Actions Token blocks the proxy...', async () => { + it('should block push when diff includes GitHub Actions Token', async () => { const action = new Action('1', 'type', 'method', 1, 'test/repo.git'); action.steps = [ { stepName: 'diff', content: generateDiff(`ghs_${crypto.randomBytes(20).toString('hex')}`), - }, + } as Step, ]; action.commitFrom = '38cdc3e'; action.commitTo = '8a9c321'; const { error, errorMessage } = await processor.exec(null, action); - expect(error).to.be.true; - expect(errorMessage).to.contains('Your push has been blocked'); + expect(error).toBe(true); + expect(errorMessage).toContain('Your push has been blocked'); }); - it('A diff including a JSON Web Token (JWT) blocks the proxy...', async () => { + it('should block push when diff includes JSON Web Token (JWT)', async () => { const action = new Action('1', 'type', 'method', 1, 'test/repo.git'); action.steps = [ { @@ -221,87 +228,83 @@ describe('Scan commit diff...', async () => { content: generateDiff( `eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ1cm46Z21haWwuY29tOmNsaWVudElkOjEyMyIsInN1YiI6IkphbmUgRG9lIiwiaWF0IjoxNTIzOTAxMjM0LCJleHAiOjE1MjM5ODc2MzR9.s5_hA8hyIT5jXfU9PlXJ-R74m5F_aPcVEFJSV-g-_kX`, ), - }, + } as Step, ]; action.commitFrom = '38cdc3e'; action.commitTo = '8a9c321'; const { error, errorMessage } = await processor.exec(null, action); - expect(error).to.be.true; - expect(errorMessage).to.contains('Your push has been blocked'); + expect(error).toBe(true); + expect(errorMessage).toContain('Your push has been blocked'); }); - it('A diff including a blocked literal blocks the proxy...', async () => { - for (const [literal] of blockedLiterals.entries()) { + it('should block push when diff includes blocked literal', async () => { + for (const literal of blockedLiterals) { const action = new Action('1', 'type', 'method', 1, 'test/repo.git'); action.steps = [ { stepName: 'diff', content: generateDiff(literal), - }, + } as Step, ]; action.commitFrom = '38cdc3e'; action.commitTo = '8a9c321'; const { error, errorMessage } = await processor.exec(null, action); - expect(error).to.be.true; - expect(errorMessage).to.contains('Your push has been blocked'); + expect(error).toBe(true); + expect(errorMessage).toContain('Your push has been blocked'); } }); - it('When no diff is present, the proxy allows the push (legitimate empty diff)...', async () => { + + it('should allow push when no diff is present (legitimate empty diff)', async () => { const action = new Action('1', 'type', 'method', 1, 'test/repo.git'); action.steps = [ { stepName: 'diff', content: null, - }, + } as Step, ]; const result = await processor.exec(null, action); const scanDiffStep = result.steps.find((s) => s.stepName === 'scanDiff'); - expect(scanDiffStep.error).to.be.false; + expect(scanDiffStep?.error).toBe(false); }); - it('When diff is not a string, the proxy is blocked...', async () => { + it('should block push when diff is not a string', async () => { const action = new Action('1', 'type', 'method', 1, 'test/repo.git'); action.steps = [ { stepName: 'diff', - content: 1337, - }, + content: 1337 as any, + } as Step, ]; const { error, errorMessage } = await processor.exec(null, action); - expect(error).to.be.true; - expect(errorMessage).to.contains('Your push has been blocked'); + expect(error).toBe(true); + expect(errorMessage).toContain('Your push has been blocked'); }); - it('A diff with no secrets or sensitive information does not block the proxy...', async () => { + it('should allow push when diff has no secrets or sensitive information', async () => { const action = new Action('1', 'type', 'method', 1, 'test/repo.git'); action.steps = [ { stepName: 'diff', content: generateDiff(''), - }, + } as Step, ]; action.commitFrom = '38cdc3e'; action.commitTo = '8a9c321'; const { error } = await processor.exec(null, action); - expect(error).to.be.false; - }); - const TEST_REPO = { - project: 'private-org-test', - name: 'repo.git', - url: 'https://github.com/private-org-test/repo.git', - }; + expect(error).toBe(false); + }); - it('A diff including a provider token in a private organization does not block the proxy...', async () => { + it('should allow push when diff includes provider token in private organization', async () => { const action = new Action( '1', 'type', @@ -313,10 +316,11 @@ describe('Scan commit diff...', async () => { { stepName: 'diff', content: generateDiff('AKIAIOSFODNN7EXAMPLE'), - }, + } as Step, ]; const { error } = await processor.exec(null, action); - expect(error).to.be.false; + + expect(error).toBe(false); }); }); diff --git a/test/processors/testCheckRepoInAuthList.test.js b/test/processors/testCheckRepoInAuthList.test.js deleted file mode 100644 index 9328cb8c3..000000000 --- a/test/processors/testCheckRepoInAuthList.test.js +++ /dev/null @@ -1,52 +0,0 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const fc = require('fast-check'); -const actions = require('../../src/proxy/actions/Action'); -const processor = require('../../src/proxy/processors/push-action/checkRepoInAuthorisedList'); -const expect = chai.expect; -const db = require('../../src/db'); - -describe('Check a Repo is in the authorised list', async () => { - afterEach(() => { - sinon.restore(); - }); - - it('accepts the action if the repository is whitelisted in the db', async () => { - sinon.stub(db, 'getRepoByUrl').resolves({ - name: 'repo-is-ok', - project: 'thisproject', - url: 'https://github.com/thisproject/repo-is-ok', - }); - - const action = new actions.Action('123', 'type', 'get', 1234, 'thisproject/repo-is-ok'); - const result = await processor.exec(null, action); - expect(result.error).to.be.false; - expect(result.steps[0].logs[0]).to.eq( - 'checkRepoInAuthorisedList - repo thisproject/repo-is-ok is in the authorisedList', - ); - }); - - it('rejects the action if repository not in the db', async () => { - sinon.stub(db, 'getRepoByUrl').resolves(null); - - const action = new actions.Action('123', 'type', 'get', 1234, 'thisproject/repo-is-not-ok'); - const result = await processor.exec(null, action); - expect(result.error).to.be.true; - expect(result.steps[0].logs[0]).to.eq( - 'checkRepoInAuthorisedList - repo thisproject/repo-is-not-ok is not in the authorised whitelist, ending', - ); - }); - - describe('fuzzing', () => { - it('should not crash on random repo names', async () => { - await fc.assert( - fc.asyncProperty(fc.string(), async (repoName) => { - const action = new actions.Action('123', 'type', 'get', 1234, repoName); - const result = await processor.exec(null, action); - expect(result.error).to.be.true; - }), - { numRuns: 1000 }, - ); - }); - }); -}); diff --git a/test/processors/testCheckRepoInAuthList.test.ts b/test/processors/testCheckRepoInAuthList.test.ts new file mode 100644 index 000000000..a4915a92c --- /dev/null +++ b/test/processors/testCheckRepoInAuthList.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import fc from 'fast-check'; +import { Action } from '../../src/proxy/actions/Action'; +import * as processor from '../../src/proxy/processors/push-action/checkRepoInAuthorisedList'; +import * as db from '../../src/db'; + +describe('Check a Repo is in the authorised list', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('accepts the action if the repository is whitelisted in the db', async () => { + vi.spyOn(db, 'getRepoByUrl').mockResolvedValue({ + name: 'repo-is-ok', + project: 'thisproject', + url: 'https://github.com/thisproject/repo-is-ok', + users: { canPush: [], canAuthorise: [] }, + }); + + const action = new Action('123', 'type', 'get', 1234, 'thisproject/repo-is-ok'); + const result = await processor.exec(null, action); + + expect(result.error).toBe(false); + expect(result.steps[0].logs[0]).toBe( + 'checkRepoInAuthorisedList - repo thisproject/repo-is-ok is in the authorisedList', + ); + }); + + it('rejects the action if repository not in the db', async () => { + vi.spyOn(db, 'getRepoByUrl').mockResolvedValue(null); + + const action = new Action('123', 'type', 'get', 1234, 'thisproject/repo-is-not-ok'); + const result = await processor.exec(null, action); + + expect(result.error).toBe(true); + expect(result.steps[0].logs[0]).toBe( + 'checkRepoInAuthorisedList - repo thisproject/repo-is-not-ok is not in the authorised whitelist, ending', + ); + }); + + describe('fuzzing', () => { + it('should not crash on random repo names', async () => { + await fc.assert( + fc.asyncProperty(fc.string(), async (repoName) => { + const action = new Action('123', 'type', 'get', 1234, repoName); + const result = await processor.exec(null, action); + expect(result.error).toBe(true); + }), + { numRuns: 1000 }, + ); + }); + }); +}); diff --git a/test/processors/writePack.test.js b/test/processors/writePack.test.js deleted file mode 100644 index 746b700ac..000000000 --- a/test/processors/writePack.test.js +++ /dev/null @@ -1,115 +0,0 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const proxyquire = require('proxyquire'); -const { Action, Step } = require('../../src/proxy/actions'); - -chai.should(); -const expect = chai.expect; - -describe('writePack', () => { - let exec; - let readdirSyncStub; - let spawnSyncStub; - let stepLogSpy; - let stepSetContentSpy; - let stepSetErrorSpy; - - beforeEach(() => { - spawnSyncStub = sinon.stub(); - readdirSyncStub = sinon.stub(); - - readdirSyncStub.onFirstCall().returns(['old1.idx']); - readdirSyncStub.onSecondCall().returns(['old1.idx', 'new1.idx']); - - stepLogSpy = sinon.spy(Step.prototype, 'log'); - stepSetContentSpy = sinon.spy(Step.prototype, 'setContent'); - stepSetErrorSpy = sinon.spy(Step.prototype, 'setError'); - - const writePack = proxyquire('../../src/proxy/processors/push-action/writePack', { - child_process: { spawnSync: spawnSyncStub }, - fs: { readdirSync: readdirSyncStub }, - }); - - exec = writePack.exec; - }); - - afterEach(() => { - sinon.restore(); - }); - - describe('exec', () => { - let action; - let req; - - beforeEach(() => { - req = { - body: 'pack data', - }; - action = new Action( - '1234567890', - 'push', - 'POST', - 1234567890, - 'https://github.com/finos/git-proxy.git', - ); - action.proxyGitPath = '/path/to'; - action.repoName = 'repo'; - }); - - it('should execute git receive-pack with correct parameters', async () => { - const dummySpawnOutput = { stdout: 'git receive-pack output', stderr: '', status: 0 }; - spawnSyncStub.returns(dummySpawnOutput); - - const result = await exec(req, action); - - expect(spawnSyncStub.callCount).to.equal(2); - expect(spawnSyncStub.firstCall.args[0]).to.equal('git'); - expect(spawnSyncStub.firstCall.args[1]).to.deep.equal(['config', 'receive.unpackLimit', '0']); - expect(spawnSyncStub.firstCall.args[2]).to.include({ cwd: '/path/to/repo' }); - - expect(spawnSyncStub.secondCall.args[0]).to.equal('git'); - expect(spawnSyncStub.secondCall.args[1]).to.deep.equal(['receive-pack', 'repo']); - expect(spawnSyncStub.secondCall.args[2]).to.include({ - cwd: '/path/to', - input: 'pack data', - }); - - expect(stepLogSpy.calledWith('new idx files: new1.idx')).to.be.true; - expect(stepSetContentSpy.calledWith(dummySpawnOutput)).to.be.true; - - expect(result.steps).to.have.lengthOf(1); - expect(result.steps[0].error).to.be.false; - expect(result.newIdxFiles).to.deep.equal(['new1.idx']); - }); - - it('should handle errors from git receive-pack', async () => { - const error = new Error('git error'); - spawnSyncStub.throws(error); - - try { - await exec(req, action); - throw new Error('Expected error to be thrown'); - } catch (e) { - expect(stepSetErrorSpy.calledOnce).to.be.true; - expect(stepSetErrorSpy.firstCall.args[0]).to.include('git error'); - - expect(action.steps).to.have.lengthOf(1); - expect(action.steps[0].error).to.be.true; - } - }); - - it('should always add the step to the action even if error occurs', async () => { - spawnSyncStub.throws(new Error('git error')); - - try { - await exec(req, action); - } catch (e) { - expect(action.steps).to.have.lengthOf(1); - } - }); - - it('should have the correct displayName', () => { - expect(exec.displayName).to.equal('writePack.exec'); - }); - }); -}); diff --git a/test/processors/writePack.test.ts b/test/processors/writePack.test.ts new file mode 100644 index 000000000..85d948243 --- /dev/null +++ b/test/processors/writePack.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { Action, Step } from '../../src/proxy/actions'; +import * as childProcess from 'child_process'; +import * as fs from 'fs'; + +vi.mock('child_process'); +vi.mock('fs'); + +describe('writePack', () => { + let exec: any; + let readdirSyncMock: any; + let spawnSyncMock: any; + let stepLogSpy: any; + let stepSetContentSpy: any; + let stepSetErrorSpy: any; + + beforeEach(async () => { + vi.clearAllMocks(); + + spawnSyncMock = vi.mocked(childProcess.spawnSync); + readdirSyncMock = vi.mocked(fs.readdirSync); + readdirSyncMock + .mockReturnValueOnce(['old1.idx'] as any) + .mockReturnValueOnce(['old1.idx', 'new1.idx'] as any); + + stepLogSpy = vi.spyOn(Step.prototype, 'log'); + stepSetContentSpy = vi.spyOn(Step.prototype, 'setContent'); + stepSetErrorSpy = vi.spyOn(Step.prototype, 'setError'); + + const writePack = await import('../../src/proxy/processors/push-action/writePack'); + exec = writePack.exec; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('exec', () => { + let action: Action; + let req: any; + + beforeEach(() => { + req = { + body: 'pack data', + }; + + action = new Action( + '1234567890', + 'push', + 'POST', + 1234567890, + 'https://github.com/finos/git-proxy.git', + ); + action.proxyGitPath = '/path/to'; + action.repoName = 'repo'; + }); + + it('should execute git receive-pack with correct parameters', async () => { + const dummySpawnOutput = { stdout: 'git receive-pack output', stderr: '', status: 0 }; + spawnSyncMock.mockReturnValue(dummySpawnOutput); + + const result = await exec(req, action); + + expect(spawnSyncMock).toHaveBeenCalledTimes(2); + expect(spawnSyncMock).toHaveBeenNthCalledWith( + 1, + 'git', + ['config', 'receive.unpackLimit', '0'], + expect.objectContaining({ cwd: '/path/to/repo' }), + ); + expect(spawnSyncMock).toHaveBeenNthCalledWith( + 2, + 'git', + ['receive-pack', 'repo'], + expect.objectContaining({ + cwd: '/path/to', + input: 'pack data', + }), + ); + + expect(stepLogSpy).toHaveBeenCalledWith('new idx files: new1.idx'); + expect(stepSetContentSpy).toHaveBeenCalledWith(dummySpawnOutput); + expect(result.steps).toHaveLength(1); + expect(result.steps[0].error).toBe(false); + expect(result.newIdxFiles).toEqual(['new1.idx']); + }); + + it('should handle errors from git receive-pack', async () => { + const error = new Error('git error'); + spawnSyncMock.mockImplementation(() => { + throw error; + }); + + await expect(exec(req, action)).rejects.toThrow('git error'); + + expect(stepSetErrorSpy).toHaveBeenCalledOnce(); + expect(stepSetErrorSpy).toHaveBeenCalledWith(expect.stringContaining('git error')); + expect(action.steps).toHaveLength(1); + expect(action.steps[0].error).toBe(true); + }); + + it('should always add the step to the action even if error occurs', async () => { + spawnSyncMock.mockImplementation(() => { + throw new Error('git error'); + }); + + await expect(exec(req, action)).rejects.toThrow('git error'); + + expect(action.steps).toHaveLength(1); + }); + + it('should have the correct displayName', () => { + expect(exec.displayName).toBe('writePack.exec'); + }); + }); +}); diff --git a/test/proxy.test.js b/test/proxy.test.js deleted file mode 100644 index 2612e9383..000000000 --- a/test/proxy.test.js +++ /dev/null @@ -1,142 +0,0 @@ -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')]; - const ProxyClass = require('../src/proxy/index').default; - proxyModule = new ProxyClass(); - }); - - 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/proxy.test.ts b/test/proxy.test.ts new file mode 100644 index 000000000..6e6e3b41e --- /dev/null +++ b/test/proxy.test.ts @@ -0,0 +1,156 @@ +import https from 'https'; +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; +import fs from 'fs'; + +// TODO: rewrite/fix these tests +describe.skip('Proxy Module TLS Certificate Loading', () => { + let proxyModule: any; + let mockConfig: any; + let mockHttpServer: any; + let mockHttpsServer: any; + + beforeEach(async () => { + vi.resetModules(); + + mockConfig = { + getCommitConfig: vi.fn(), + getTLSEnabled: vi.fn(), + getTLSKeyPemPath: vi.fn(), + getTLSCertPemPath: vi.fn(), + getPlugins: vi.fn().mockReturnValue([]), + getAuthorisedList: vi.fn().mockReturnValue([]), + }; + + const mockDb = { + getRepos: vi.fn().mockResolvedValue([]), + createRepo: vi.fn().mockResolvedValue(undefined), + addUserCanPush: vi.fn().mockResolvedValue(undefined), + addUserCanAuthorise: vi.fn().mockResolvedValue(undefined), + }; + + const mockPluginLoader = { + load: vi.fn().mockResolvedValue(undefined), + }; + + mockHttpServer = { + listen: vi.fn().mockImplementation((_port, cb) => { + if (cb) cb(); + return mockHttpServer; + }), + close: vi.fn().mockImplementation((cb) => { + if (cb) cb(); + }), + }; + + mockHttpsServer = { + listen: vi.fn().mockImplementation((_port, cb) => { + if (cb) cb(); + return mockHttpsServer; + }), + close: vi.fn().mockImplementation((cb) => { + if (cb) cb(); + }), + }; + + vi.doMock('../src/plugin', () => { + return { + PluginLoader: vi.fn(() => mockPluginLoader), + }; + }); + + vi.doMock('../src/config', async (importOriginal) => { + const actual: any = await importOriginal(); + return { + ...actual, + getTLSEnabled: mockConfig.getTLSEnabled, + getTLSKeyPemPath: mockConfig.getTLSKeyPemPath, + getTLSCertPemPath: mockConfig.getTLSCertPemPath, + getPlugins: mockConfig.getPlugins, + getAuthorisedList: mockConfig.getAuthorisedList, + }; + }); + + vi.doMock('../src/db', () => ({ + getRepos: mockDb.getRepos, + createRepo: mockDb.createRepo, + addUserCanPush: mockDb.addUserCanPush, + addUserCanAuthorise: mockDb.addUserCanAuthorise, + })); + + vi.doMock('../src/proxy/chain', async (importOriginal) => { + const actual: any = await importOriginal(); + return { + ...actual, + chainPluginLoader: null, + }; + }); + + vi.spyOn(https, 'createServer').mockReturnValue({ + listen: vi.fn().mockReturnThis(), + close: vi.fn(), + } as any); + + process.env.NODE_ENV = 'test'; + process.env.GIT_PROXY_HTTPS_SERVER_PORT = '8443'; + + const ProxyClass = (await import('../src/proxy/index')).default; + proxyModule = new ProxyClass(); + }); + + afterEach(async () => { + try { + await proxyModule.stop(); + } catch { + // ignore cleanup errors + } + vi.restoreAllMocks(); + }); + + 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.mockReturnValue(true); + mockConfig.getTLSKeyPemPath.mockReturnValue('/path/to/key.pem'); + mockConfig.getTLSCertPemPath.mockReturnValue('/path/to/cert.pem'); + + const fsStub = vi.spyOn(fs, 'readFileSync'); + fsStub.mockReturnValue(Buffer.from('default-cert')); + fsStub.mockImplementation((path: any) => { + if (path === '/path/to/key.pem') return mockKeyContent; + if (path === '/path/to/cert.pem') return mockCertContent; + return Buffer.from('default-cert'); + }); + + await proxyModule.start(); + + expect(fsStub).toHaveBeenCalledWith('/path/to/key.pem'); + expect(fsStub).toHaveBeenCalledWith('/path/to/cert.pem'); + }); + + it('should not read TLS files when TLS is disabled', async () => { + mockConfig.getTLSEnabled.mockReturnValue(false); + mockConfig.getTLSKeyPemPath.mockReturnValue('/path/to/key.pem'); + mockConfig.getTLSCertPemPath.mockReturnValue('/path/to/cert.pem'); + + const fsStub = vi.spyOn(fs, 'readFileSync'); + + await proxyModule.start(); + + expect(fsStub).not.toHaveBeenCalled(); + }); + + it('should not read TLS files when paths are not provided', async () => { + mockConfig.getTLSEnabled.mockReturnValue(true); + mockConfig.getTLSKeyPemPath.mockReturnValue(null); + mockConfig.getTLSCertPemPath.mockReturnValue(null); + + const fsStub = vi.spyOn(fs, 'readFileSync'); + + await proxyModule.start(); + + expect(fsStub).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/test/proxyURL.test.js b/test/proxyURL.test.js deleted file mode 100644 index 4d12b5199..000000000 --- a/test/proxyURL.test.js +++ /dev/null @@ -1,51 +0,0 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const express = require('express'); -const chaiHttp = require('chai-http'); -const { getProxyURL } = require('../src/service/urls'); -const config = require('../src/config'); - -chai.use(chaiHttp); -chai.should(); -const expect = chai.expect; - -const genSimpleServer = () => { - const app = express(); - app.get('/', (req, res) => { - res.contentType('text/html'); - res.send(getProxyURL(req)); - }); - return app; -}; - -describe('proxyURL', async () => { - afterEach(() => { - sinon.restore(); - }); - - it('pulls the request path with no override', async () => { - const app = genSimpleServer(); - const res = await chai.request(app).get('/').send(); - res.should.have.status(200); - - // request url without trailing slash - const reqURL = res.request.url.slice(0, -1); - expect(res.text).to.equal(reqURL); - expect(res.text).to.match(/https?:\/\/127.0.0.1:\d+/); - }); - - it('can override providing a proxy value', async () => { - const proxyURL = 'https://amazing-proxy.path.local'; - // stub getDomains - const configGetDomainsStub = sinon.stub(config, 'getDomains').returns({ proxy: proxyURL }); - - const app = genSimpleServer(); - const res = await chai.request(app).get('/').send(); - res.should.have.status(200); - - // the stub worked - expect(configGetDomainsStub.calledOnce).to.be.true; - - expect(res.text).to.equal(proxyURL); - }); -}); diff --git a/test/proxyURL.test.ts b/test/proxyURL.test.ts new file mode 100644 index 000000000..8e865addd --- /dev/null +++ b/test/proxyURL.test.ts @@ -0,0 +1,50 @@ +import { describe, it, afterEach, expect, vi } from 'vitest'; +import request from 'supertest'; +import express from 'express'; + +import { getProxyURL } from '../src/service/urls'; +import * as config from '../src/config'; + +const genSimpleServer = () => { + const app = express(); + app.get('/', (req, res) => { + res.type('html'); + res.send(getProxyURL(req)); + }); + return app; +}; + +describe('proxyURL', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('pulls the request path with no override', async () => { + const app = genSimpleServer(); + const res = await request(app).get('/'); + + expect(res.status).toBe(200); + + // request url without trailing slash + const reqURL = res.request.url.slice(0, -1); + expect(res.text).toBe(reqURL); + expect(res.text).toMatch(/https?:\/\/127.0.0.1:\d+/); + }); + + it('can override providing a proxy value', async () => { + const proxyURL = 'https://amazing-proxy.path.local'; + + // stub getDomains + const spy = vi.spyOn(config, 'getDomains').mockReturnValue({ proxy: proxyURL }); + + const app = genSimpleServer(); + const res = await request(app).get('/'); + + expect(res.status).toBe(200); + + // the stub worked + expect(spy).toHaveBeenCalledTimes(1); + + expect(res.text).toBe(proxyURL); + }); +}); diff --git a/test/services/routes/auth.test.js b/test/services/routes/auth.test.js deleted file mode 100644 index 171f70009..000000000 --- a/test/services/routes/auth.test.js +++ /dev/null @@ -1,228 +0,0 @@ -const chai = require('chai'); -const chaiHttp = require('chai-http'); -const sinon = require('sinon'); -const express = require('express'); -const authRoutes = require('../../../src/service/routes/auth').default; -const db = require('../../../src/db'); - -const { expect } = chai; -chai.use(chaiHttp); - -const newApp = (username) => { - const app = express(); - app.use(express.json()); - - if (username) { - app.use((req, res, next) => { - req.user = { username }; - next(); - }); - } - - app.use('/auth', authRoutes.router); - return app; -}; - -describe('Auth API', function () { - afterEach(function () { - sinon.restore(); - }); - - describe('/gitAccount', () => { - beforeEach(() => { - sinon.stub(db, 'findUser').callsFake((username) => { - if (username === 'alice') { - return Promise.resolve({ - username: 'alice', - displayName: 'Alice Munro', - gitAccount: 'ORIGINAL_GIT_ACCOUNT', - email: 'alice@example.com', - admin: true, - }); - } else if (username === 'bob') { - return Promise.resolve({ - username: 'bob', - displayName: 'Bob Woodward', - gitAccount: 'WOODY_GIT_ACCOUNT', - email: 'bob@example.com', - admin: false, - }); - } - return Promise.resolve(null); - }); - }); - - afterEach(() => { - sinon.restore(); - }); - - it('POST /gitAccount returns Unauthorized if authenticated user not in request', async () => { - const res = await chai.request(newApp()).post('/auth/gitAccount').send({ - username: 'alice', - gitAccount: '', - }); - - expect(res).to.have.status(401); - }); - - it('POST /gitAccount updates git account for authenticated user', async () => { - const updateUserStub = sinon.stub(db, 'updateUser').resolves(); - - const res = await chai.request(newApp('alice')).post('/auth/gitAccount').send({ - username: 'alice', - gitAccount: 'UPDATED_GIT_ACCOUNT', - }); - - expect(res).to.have.status(200); - expect( - updateUserStub.calledOnceWith({ - username: 'alice', - displayName: 'Alice Munro', - gitAccount: 'UPDATED_GIT_ACCOUNT', - email: 'alice@example.com', - admin: true, - }), - ).to.be.true; - }); - - it('POST /gitAccount prevents non-admin user changing a different user gitAccount', async () => { - const updateUserStub = sinon.stub(db, 'updateUser').resolves(); - - const res = await chai.request(newApp('bob')).post('/auth/gitAccount').send({ - username: 'phil', - gitAccount: 'UPDATED_GIT_ACCOUNT', - }); - - expect(res).to.have.status(403); - expect(updateUserStub.called).to.be.false; - }); - - it('POST /gitAccount lets admin user change a different users gitAccount', async () => { - const updateUserStub = sinon.stub(db, 'updateUser').resolves(); - - const res = await chai.request(newApp('alice')).post('/auth/gitAccount').send({ - username: 'bob', - gitAccount: 'UPDATED_GIT_ACCOUNT', - }); - - expect(res).to.have.status(200); - expect( - updateUserStub.calledOnceWith({ - username: 'bob', - displayName: 'Bob Woodward', - email: 'bob@example.com', - admin: false, - gitAccount: 'UPDATED_GIT_ACCOUNT', - }), - ).to.be.true; - }); - - it('POST /gitAccount allows non-admin user to update their own gitAccount', async () => { - const updateUserStub = sinon.stub(db, 'updateUser').resolves(); - - const res = await chai.request(newApp('bob')).post('/auth/gitAccount').send({ - username: 'bob', - gitAccount: 'UPDATED_GIT_ACCOUNT', - }); - - expect(res).to.have.status(200); - expect( - updateUserStub.calledOnceWith({ - username: 'bob', - displayName: 'Bob Woodward', - email: 'bob@example.com', - admin: false, - gitAccount: 'UPDATED_GIT_ACCOUNT', - }), - ).to.be.true; - }); - }); - - describe('loginSuccessHandler', function () { - it('should log in user and return public user data', async function () { - const user = { - username: 'bob', - password: 'secret', - email: 'bob@example.com', - displayName: 'Bob', - }; - - const res = { - send: sinon.spy(), - }; - - await authRoutes.loginSuccessHandler()({ user }, res); - - expect(res.send.calledOnce).to.be.true; - expect(res.send.firstCall.args[0]).to.deep.equal({ - message: 'success', - user: { - admin: false, - displayName: 'Bob', - email: 'bob@example.com', - gitAccount: '', - title: '', - username: 'bob', - }, - }); - }); - }); - - describe('/me', function () { - it('GET /me returns Unauthorized if authenticated user not in request', async () => { - const res = await chai.request(newApp()).get('/auth/me'); - - expect(res).to.have.status(401); - }); - - it('GET /me serializes public data representation of current authenticated user', async function () { - sinon.stub(db, 'findUser').resolves({ - username: 'alice', - password: 'secret-hashed-password', - email: 'alice@example.com', - displayName: 'Alice Walker', - otherUserData: 'should not be returned', - }); - - const res = await chai.request(newApp('alice')).get('/auth/me'); - expect(res).to.have.status(200); - expect(res.body).to.deep.equal({ - username: 'alice', - displayName: 'Alice Walker', - email: 'alice@example.com', - title: '', - gitAccount: '', - admin: false, - }); - }); - }); - - describe('/profile', function () { - it('GET /profile returns Unauthorized if authenticated user not in request', async () => { - const res = await chai.request(newApp()).get('/auth/profile'); - - expect(res).to.have.status(401); - }); - - it('GET /profile serializes public data representation of current authenticated user', async function () { - sinon.stub(db, 'findUser').resolves({ - username: 'alice', - password: 'secret-hashed-password', - email: 'alice@example.com', - displayName: 'Alice Walker', - otherUserData: 'should not be returned', - }); - - const res = await chai.request(newApp('alice')).get('/auth/profile'); - expect(res).to.have.status(200); - expect(res.body).to.deep.equal({ - username: 'alice', - displayName: 'Alice Walker', - email: 'alice@example.com', - title: '', - gitAccount: '', - admin: false, - }); - }); - }); -}); diff --git a/test/services/routes/auth.test.ts b/test/services/routes/auth.test.ts new file mode 100644 index 000000000..09d28eddb --- /dev/null +++ b/test/services/routes/auth.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import express, { Express } from 'express'; +import authRoutes from '../../../src/service/routes/auth'; +import * as db from '../../../src/db'; + +const newApp = (username?: string): Express => { + const app = express(); + app.use(express.json()); + + if (username) { + app.use((req, _res, next) => { + req.user = { username }; + next(); + }); + } + + app.use('/auth', authRoutes.router); + return app; +}; + +describe('Auth API', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('/gitAccount', () => { + beforeEach(() => { + vi.spyOn(db, 'findUser').mockImplementation((username: string) => { + if (username === 'alice') { + return Promise.resolve({ + username: 'alice', + displayName: 'Alice Munro', + gitAccount: 'ORIGINAL_GIT_ACCOUNT', + email: 'alice@example.com', + admin: true, + password: '', + title: '', + }); + } else if (username === 'bob') { + return Promise.resolve({ + username: 'bob', + displayName: 'Bob Woodward', + gitAccount: 'WOODY_GIT_ACCOUNT', + email: 'bob@example.com', + admin: false, + password: '', + title: '', + }); + } + return Promise.resolve(null); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('POST /gitAccount returns Unauthorized if authenticated user not in request', async () => { + const res = await request(newApp()).post('/auth/gitAccount').send({ + username: 'alice', + gitAccount: '', + }); + + expect(res.status).toBe(401); + }); + + it('POST /gitAccount updates git account for authenticated user', async () => { + const updateUserSpy = vi.spyOn(db, 'updateUser').mockResolvedValue(); + + const res = await request(newApp('alice')).post('/auth/gitAccount').send({ + username: 'alice', + gitAccount: 'UPDATED_GIT_ACCOUNT', + }); + + expect(res.status).toBe(200); + expect(updateUserSpy).toHaveBeenCalledOnce(); + expect(updateUserSpy).toHaveBeenCalledWith({ + username: 'alice', + displayName: 'Alice Munro', + gitAccount: 'UPDATED_GIT_ACCOUNT', + email: 'alice@example.com', + admin: true, + password: '', + title: '', + }); + }); + + it('POST /gitAccount prevents non-admin user changing a different user gitAccount', async () => { + const updateUserSpy = vi.spyOn(db, 'updateUser').mockResolvedValue(); + + const res = await request(newApp('bob')).post('/auth/gitAccount').send({ + username: 'phil', + gitAccount: 'UPDATED_GIT_ACCOUNT', + }); + + expect(res.status).toBe(403); + expect(updateUserSpy).not.toHaveBeenCalled(); + }); + + it('POST /gitAccount lets admin user change a different users gitAccount', async () => { + const updateUserSpy = vi.spyOn(db, 'updateUser').mockResolvedValue(); + + const res = await request(newApp('alice')).post('/auth/gitAccount').send({ + username: 'bob', + gitAccount: 'UPDATED_GIT_ACCOUNT', + }); + + expect(res.status).toBe(200); + expect(updateUserSpy).toHaveBeenCalledOnce(); + expect(updateUserSpy).toHaveBeenCalledWith({ + username: 'bob', + displayName: 'Bob Woodward', + email: 'bob@example.com', + admin: false, + gitAccount: 'UPDATED_GIT_ACCOUNT', + password: '', + title: '', + }); + }); + + it('POST /gitAccount allows non-admin user to update their own gitAccount', async () => { + const updateUserSpy = vi.spyOn(db, 'updateUser').mockResolvedValue(); + + const res = await request(newApp('bob')).post('/auth/gitAccount').send({ + username: 'bob', + gitAccount: 'UPDATED_GIT_ACCOUNT', + }); + + expect(res.status).toBe(200); + expect(updateUserSpy).toHaveBeenCalledOnce(); + expect(updateUserSpy).toHaveBeenCalledWith({ + username: 'bob', + displayName: 'Bob Woodward', + email: 'bob@example.com', + admin: false, + gitAccount: 'UPDATED_GIT_ACCOUNT', + password: '', + title: '', + }); + }); + }); + + describe('loginSuccessHandler', () => { + it('should log in user and return public user data', async () => { + const user = { + username: 'bob', + password: 'secret', + email: 'bob@example.com', + displayName: 'Bob', + admin: false, + gitAccount: '', + title: '', + }; + + const sendSpy = vi.fn(); + const res = { + send: sendSpy, + } as any; + + await authRoutes.loginSuccessHandler()({ user } as any, res); + + expect(sendSpy).toHaveBeenCalledOnce(); + expect(sendSpy).toHaveBeenCalledWith({ + message: 'success', + user: { + admin: false, + displayName: 'Bob', + email: 'bob@example.com', + gitAccount: '', + title: '', + username: 'bob', + }, + }); + }); + }); + + describe('/me', () => { + it('GET /me returns Unauthorized if authenticated user not in request', async () => { + const res = await request(newApp()).get('/auth/me'); + + expect(res.status).toBe(401); + }); + + it('GET /me serializes public data representation of current authenticated user', async () => { + vi.spyOn(db, 'findUser').mockResolvedValue({ + username: 'alice', + password: 'secret-hashed-password', + email: 'alice@example.com', + displayName: 'Alice Walker', + admin: false, + gitAccount: '', + title: '', + }); + + const res = await request(newApp('alice')).get('/auth/me'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ + username: 'alice', + displayName: 'Alice Walker', + email: 'alice@example.com', + title: '', + gitAccount: '', + admin: false, + }); + }); + }); + + describe('/profile', () => { + it('GET /profile returns Unauthorized if authenticated user not in request', async () => { + const res = await request(newApp()).get('/auth/profile'); + + expect(res.status).toBe(401); + }); + + it('GET /profile serializes public data representation of current authenticated user', async () => { + vi.spyOn(db, 'findUser').mockResolvedValue({ + username: 'alice', + password: 'secret-hashed-password', + email: 'alice@example.com', + displayName: 'Alice Walker', + admin: false, + gitAccount: '', + title: '', + }); + + const res = await request(newApp('alice')).get('/auth/profile'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ + username: 'alice', + displayName: 'Alice Walker', + email: 'alice@example.com', + title: '', + gitAccount: '', + admin: false, + }); + }); + }); +}); diff --git a/test/services/routes/users.test.js b/test/services/routes/users.test.js deleted file mode 100644 index ae4fe9cce..000000000 --- a/test/services/routes/users.test.js +++ /dev/null @@ -1,67 +0,0 @@ -const chai = require('chai'); -const chaiHttp = require('chai-http'); -const sinon = require('sinon'); -const express = require('express'); -const usersRouter = require('../../../src/service/routes/users').default; -const db = require('../../../src/db'); - -const { expect } = chai; -chai.use(chaiHttp); - -describe('Users API', function () { - let app; - - before(function () { - app = express(); - app.use(express.json()); - app.use('/users', usersRouter); - }); - - beforeEach(function () { - sinon.stub(db, 'getUsers').resolves([ - { - username: 'alice', - password: 'secret-hashed-password', - email: 'alice@example.com', - displayName: 'Alice Walker', - }, - ]); - sinon - .stub(db, 'findUser') - .resolves({ username: 'bob', password: 'hidden', email: 'bob@example.com' }); - }); - - afterEach(function () { - sinon.restore(); - }); - - it('GET /users only serializes public data needed for ui, not user secrets like password', async function () { - const res = await chai.request(app).get('/users'); - expect(res).to.have.status(200); - expect(res.body).to.deep.equal([ - { - username: 'alice', - displayName: 'Alice Walker', - email: 'alice@example.com', - title: '', - gitAccount: '', - admin: false, - }, - ]); - }); - - it('GET /users/:id does not serialize password', async function () { - const res = await chai.request(app).get('/users/bob'); - expect(res).to.have.status(200); - console.log(`Response body: ${res.body}`); - - expect(res.body).to.deep.equal({ - username: 'bob', - displayName: '', - email: 'bob@example.com', - title: '', - gitAccount: '', - admin: false, - }); - }); -}); diff --git a/test/services/routes/users.test.ts b/test/services/routes/users.test.ts new file mode 100644 index 000000000..2dc401ad9 --- /dev/null +++ b/test/services/routes/users.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import express, { Express } from 'express'; +import request from 'supertest'; +import usersRouter from '../../../src/service/routes/users'; +import * as db from '../../../src/db'; + +describe('Users API', () => { + let app: Express; + + beforeEach(() => { + app = express(); + app.use(express.json()); + app.use('/users', usersRouter); + + vi.spyOn(db, 'getUsers').mockResolvedValue([ + { + username: 'alice', + password: 'secret-hashed-password', + email: 'alice@example.com', + displayName: 'Alice Walker', + }, + ] as any); + + vi.spyOn(db, 'findUser').mockResolvedValue({ + username: 'bob', + password: 'hidden', + email: 'bob@example.com', + } as any); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('GET /users only serializes public data needed for ui, not user secrets like password', async () => { + const res = await request(app).get('/users'); + + expect(res.status).toBe(200); + expect(res.body).toEqual([ + { + username: 'alice', + displayName: 'Alice Walker', + email: 'alice@example.com', + title: '', + gitAccount: '', + admin: false, + }, + ]); + }); + + it('GET /users/:id does not serialize password', async () => { + const res = await request(app).get('/users/bob'); + + expect(res.status).toBe(200); + console.log(`Response body: ${JSON.stringify(res.body)}`); + expect(res.body).toEqual({ + username: 'bob', + displayName: '', + email: 'bob@example.com', + title: '', + gitAccount: '', + admin: false, + }); + }); +}); diff --git a/test/testActiveDirectoryAuth.test.js b/test/testActiveDirectoryAuth.test.js deleted file mode 100644 index 29d1d3226..000000000 --- a/test/testActiveDirectoryAuth.test.js +++ /dev/null @@ -1,151 +0,0 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const proxyquire = require('proxyquire'); -const expect = chai.expect; - -describe('ActiveDirectory auth method', () => { - let ldapStub; - let dbStub; - let passportStub; - let strategyCallback; - - const newConfig = JSON.stringify({ - authentication: [ - { - type: 'ActiveDirectory', - enabled: true, - adminGroup: 'test-admin-group', - userGroup: 'test-user-group', - domain: 'test.com', - adConfig: { - url: 'ldap://test-url', - baseDN: 'dc=test,dc=com', - searchBase: 'ou=users,dc=test,dc=com', - }, - }, - ], - }); - - beforeEach(() => { - ldapStub = { - isUserInAdGroup: sinon.stub(), - }; - - dbStub = { - updateUser: sinon.stub(), - }; - - passportStub = { - use: sinon.stub(), - serializeUser: sinon.stub(), - deserializeUser: sinon.stub(), - }; - - const fsStub = { - existsSync: sinon.stub().returns(true), - readFileSync: sinon.stub().returns(newConfig), - }; - - const config = proxyquire('../src/config', { - fs: fsStub, - }); - - // Initialize the user config after proxyquiring to load the stubbed config - config.initUserConfig(); - - const { configure } = proxyquire('../src/service/passport/activeDirectory', { - './ldaphelper': ldapStub, - '../../db': dbStub, - '../../config': config, - 'passport-activedirectory': function (options, callback) { - strategyCallback = callback; - return { - name: 'ActiveDirectory', - authenticate: () => {}, - }; - }, - }); - - configure(passportStub); - }); - - it('should authenticate a valid user and mark them as admin', async () => { - const mockReq = {}; - const mockProfile = { - _json: { - sAMAccountName: 'test-user', - mail: 'test@test.com', - userPrincipalName: 'test@test.com', - title: 'Test User', - }, - displayName: 'Test User', - }; - - ldapStub.isUserInAdGroup.onCall(0).resolves(true).onCall(1).resolves(true); - - const done = sinon.spy(); - - await strategyCallback(mockReq, mockProfile, {}, done); - - expect(done.calledOnce).to.be.true; - const [err, user] = done.firstCall.args; - expect(err).to.be.null; - expect(user).to.have.property('username', 'test-user'); - expect(user).to.have.property('email', 'test@test.com'); - expect(user).to.have.property('displayName', 'Test User'); - expect(user).to.have.property('admin', true); - expect(user).to.have.property('title', 'Test User'); - - expect(dbStub.updateUser.calledOnce).to.be.true; - }); - - it('should fail if user is not in user group', async () => { - const mockReq = {}; - const mockProfile = { - _json: { - sAMAccountName: 'bad-user', - mail: 'bad@test.com', - userPrincipalName: 'bad@test.com', - title: 'Bad User', - }, - displayName: 'Bad User', - }; - - ldapStub.isUserInAdGroup.onCall(0).resolves(false); - - const done = sinon.spy(); - - await strategyCallback(mockReq, mockProfile, {}, done); - - expect(done.calledOnce).to.be.true; - const [err, user] = done.firstCall.args; - expect(err).to.include('not a member'); - expect(user).to.be.null; - - expect(dbStub.updateUser.notCalled).to.be.true; - }); - - it('should handle LDAP errors gracefully', async () => { - const mockReq = {}; - const mockProfile = { - _json: { - sAMAccountName: 'error-user', - mail: 'err@test.com', - userPrincipalName: 'err@test.com', - title: 'Whoops', - }, - displayName: 'Error User', - }; - - ldapStub.isUserInAdGroup.rejects(new Error('LDAP error')); - - const done = sinon.spy(); - - await strategyCallback(mockReq, mockProfile, {}, done); - - expect(done.calledOnce).to.be.true; - const [err, user] = done.firstCall.args; - expect(err).to.contain('LDAP error'); - expect(user).to.be.null; - }); -}); diff --git a/test/testActiveDirectoryAuth.test.ts b/test/testActiveDirectoryAuth.test.ts new file mode 100644 index 000000000..9be626424 --- /dev/null +++ b/test/testActiveDirectoryAuth.test.ts @@ -0,0 +1,170 @@ +import { describe, it, beforeEach, expect, vi, type Mock } from 'vitest'; + +let ldapStub: { isUserInAdGroup: Mock }; +let dbStub: { updateUser: Mock }; +let passportStub: { + use: Mock; + serializeUser: Mock; + deserializeUser: Mock; +}; +let strategyCallback: ( + req: any, + profile: any, + ad: any, + done: (err: any, user: any) => void, +) => void; + +const newConfig = JSON.stringify({ + authentication: [ + { + type: 'ActiveDirectory', + enabled: true, + adminGroup: 'test-admin-group', + userGroup: 'test-user-group', + domain: 'test.com', + adConfig: { + url: 'ldap://test-url', + baseDN: 'dc=test,dc=com', + searchBase: 'ou=users,dc=test,dc=com', + }, + }, + ], +}); + +describe('ActiveDirectory auth method', () => { + beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + + ldapStub = { + isUserInAdGroup: vi.fn(), + }; + + dbStub = { + updateUser: vi.fn(), + }; + + passportStub = { + use: vi.fn(), + serializeUser: vi.fn(), + deserializeUser: vi.fn(), + }; + + // mock fs for config + vi.doMock('fs', (importOriginal) => { + const actual = importOriginal(); + return { + ...actual, + existsSync: vi.fn().mockReturnValue(true), + readFileSync: vi.fn().mockReturnValue(newConfig), + }; + }); + + // mock ldaphelper before importing activeDirectory + vi.doMock('../src/service/passport/ldaphelper', () => ldapStub); + vi.doMock('../src/db', () => dbStub); + + vi.doMock('passport-activedirectory', () => ({ + default: function (options: any, callback: (err: any, user: any) => void) { + strategyCallback = callback; + return { + name: 'ActiveDirectory', + authenticate: () => {}, + }; + }, + })); + + // First import config + const config = await import('../src/config'); + config.initUserConfig(); + vi.doMock('../src/config', () => config); + + // then configure activeDirectory + const { configure } = await import('../src/service/passport/activeDirectory.js'); + configure(passportStub as any); + }); + + it('should authenticate a valid user and mark them as admin', async () => { + const mockReq = {}; + const mockProfile = { + _json: { + sAMAccountName: 'test-user', + mail: 'test@test.com', + userPrincipalName: 'test@test.com', + title: 'Test User', + }, + displayName: 'Test User', + }; + + (ldapStub.isUserInAdGroup as Mock) + .mockResolvedValueOnce(true) // adminGroup check + .mockResolvedValueOnce(true); // userGroup check + + const done = vi.fn(); + + await strategyCallback(mockReq, mockProfile, {}, done); + + expect(done).toHaveBeenCalledOnce(); + const [err, user] = done.mock.calls[0]; + expect(err).toBeNull(); + expect(user).toMatchObject({ + username: 'test-user', + email: 'test@test.com', + displayName: 'Test User', + admin: true, + title: 'Test User', + }); + + expect(dbStub.updateUser).toHaveBeenCalledOnce(); + }); + + it('should fail if user is not in user group', async () => { + const mockReq = {}; + const mockProfile = { + _json: { + sAMAccountName: 'bad-user', + mail: 'bad@test.com', + userPrincipalName: 'bad@test.com', + title: 'Bad User', + }, + displayName: 'Bad User', + }; + + (ldapStub.isUserInAdGroup as Mock).mockResolvedValueOnce(false); + + const done = vi.fn(); + + await strategyCallback(mockReq, mockProfile, {}, done); + + expect(done).toHaveBeenCalledOnce(); + const [err, user] = done.mock.calls[0]; + expect(err).toContain('not a member'); + expect(user).toBeNull(); + + expect(dbStub.updateUser).not.toHaveBeenCalled(); + }); + + it('should handle LDAP errors gracefully', async () => { + const mockReq = {}; + const mockProfile = { + _json: { + sAMAccountName: 'error-user', + mail: 'err@test.com', + userPrincipalName: 'err@test.com', + title: 'Whoops', + }, + displayName: 'Error User', + }; + + (ldapStub.isUserInAdGroup as Mock).mockRejectedValueOnce(new Error('LDAP error')); + + const done = vi.fn(); + + await strategyCallback(mockReq, mockProfile, {}, done); + + expect(done).toHaveBeenCalledOnce(); + const [err, user] = done.mock.calls[0]; + expect(err).toContain('LDAP error'); + expect(user).toBeNull(); + }); +}); diff --git a/test/testAuthMethods.test.js b/test/testAuthMethods.test.js deleted file mode 100644 index fc7054071..000000000 --- a/test/testAuthMethods.test.js +++ /dev/null @@ -1,67 +0,0 @@ -const chai = require('chai'); -const config = require('../src/config'); -const sinon = require('sinon'); -const proxyquire = require('proxyquire'); - -chai.should(); -const expect = chai.expect; - -describe('auth methods', async () => { - it('should return a local auth method by default', async function () { - const authMethods = config.getAuthMethods(); - expect(authMethods).to.have.lengthOf(1); - expect(authMethods[0].type).to.equal('local'); - }); - - it('should return an error if no auth methods are enabled', async function () { - const newConfig = JSON.stringify({ - authentication: [ - { type: 'local', enabled: false }, - { type: 'ActiveDirectory', enabled: false }, - { type: 'openidconnect', enabled: false }, - ], - }); - - const fsStub = { - existsSync: sinon.stub().returns(true), - readFileSync: sinon.stub().returns(newConfig), - }; - - const config = proxyquire('../src/config', { - fs: fsStub, - }); - - // Initialize the user config after proxyquiring to load the stubbed config - config.initUserConfig(); - - expect(() => config.getAuthMethods()).to.throw(Error, 'No authentication method enabled'); - }); - - it('should return an array of enabled auth methods when overridden', async function () { - const newConfig = JSON.stringify({ - authentication: [ - { type: 'local', enabled: true }, - { type: 'ActiveDirectory', enabled: true }, - { type: 'openidconnect', enabled: true }, - ], - }); - - const fsStub = { - existsSync: sinon.stub().returns(true), - readFileSync: sinon.stub().returns(newConfig), - }; - - const config = proxyquire('../src/config', { - fs: fsStub, - }); - - // Initialize the user config after proxyquiring to load the stubbed config - config.initUserConfig(); - - const authMethods = config.getAuthMethods(); - expect(authMethods).to.have.lengthOf(3); - expect(authMethods[0].type).to.equal('local'); - expect(authMethods[1].type).to.equal('ActiveDirectory'); - expect(authMethods[2].type).to.equal('openidconnect'); - }); -}); diff --git a/test/testAuthMethods.test.ts b/test/testAuthMethods.test.ts new file mode 100644 index 000000000..bae9d7bb3 --- /dev/null +++ b/test/testAuthMethods.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +describe('auth methods', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('should return a local auth method by default', async () => { + const config = await import('../src/config'); + const authMethods = config.getAuthMethods(); + expect(authMethods).toHaveLength(1); + expect(authMethods[0].type).toBe('local'); + }); + + it('should return an error if no auth methods are enabled', async () => { + const newConfig = JSON.stringify({ + authentication: [ + { type: 'local', enabled: false }, + { type: 'ActiveDirectory', enabled: false }, + { type: 'openidconnect', enabled: false }, + ], + }); + + vi.doMock('fs', () => ({ + existsSync: () => true, + readFileSync: () => newConfig, + })); + + const config = await import('../src/config'); + config.initUserConfig(); + + expect(() => config.getAuthMethods()).toThrowError(/No authentication method enabled/); + }); + + it('should return an array of enabled auth methods when overridden', async () => { + const newConfig = JSON.stringify({ + authentication: [ + { type: 'local', enabled: true }, + { type: 'ActiveDirectory', enabled: true }, + { type: 'openidconnect', enabled: true }, + ], + }); + + vi.doMock('fs', () => ({ + existsSync: () => true, + readFileSync: () => newConfig, + })); + + const config = await import('../src/config'); + config.initUserConfig(); + + const authMethods = config.getAuthMethods(); + expect(authMethods).toHaveLength(3); + expect(authMethods[0].type).toBe('local'); + expect(authMethods[1].type).toBe('ActiveDirectory'); + expect(authMethods[2].type).toBe('openidconnect'); + }); +}); diff --git a/test/testCheckUserPushPermission.test.js b/test/testCheckUserPushPermission.test.ts similarity index 60% rename from test/testCheckUserPushPermission.test.js rename to test/testCheckUserPushPermission.test.ts index dd7e9d187..e084735cc 100644 --- a/test/testCheckUserPushPermission.test.js +++ b/test/testCheckUserPushPermission.test.ts @@ -1,9 +1,7 @@ -const chai = require('chai'); -const processor = require('../src/proxy/processors/push-action/checkUserPushPermission'); -const { Action } = require('../src/proxy/actions/Action'); -const { expect } = chai; -const db = require('../src/db'); -chai.should(); +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import * as processor from '../src/proxy/processors/push-action/checkUserPushPermission'; +import { Action } from '../src/proxy/actions/Action'; +import * as db from '../src/db'; const TEST_ORG = 'finos'; const TEST_REPO = 'user-push-perms-test.git'; @@ -14,24 +12,22 @@ const TEST_USERNAME_2 = 'push-perms-test-2'; const TEST_EMAIL_2 = 'push-perms-test-2@test.com'; const TEST_EMAIL_3 = 'push-perms-test-3@test.com'; -describe('CheckUserPushPermissions...', async () => { - let testRepo = null; +describe('CheckUserPushPermissions...', () => { + let testRepo: any = null; - before(async function () { - // await db.deleteRepo(TEST_REPO); - // await db.deleteUser(TEST_USERNAME_1); - // await db.deleteUser(TEST_USERNAME_2); + beforeAll(async () => { testRepo = await db.createRepo({ project: TEST_ORG, name: TEST_REPO, url: TEST_URL, }); + await db.createUser(TEST_USERNAME_1, 'abc', TEST_EMAIL_1, TEST_USERNAME_1, false); await db.addUserCanPush(testRepo._id, TEST_USERNAME_1); await db.createUser(TEST_USERNAME_2, 'abc', TEST_EMAIL_2, TEST_USERNAME_2, false); }); - after(async function () { + afterAll(async () => { await db.deleteRepo(testRepo._id); await db.deleteUser(TEST_USERNAME_1); await db.deleteUser(TEST_USERNAME_2); @@ -40,23 +36,23 @@ describe('CheckUserPushPermissions...', async () => { it('A committer that is approved should be allowed to push...', async () => { const action = new Action('1', 'type', 'method', 1, TEST_URL); action.userEmail = TEST_EMAIL_1; - const { error } = await processor.exec(null, action); - expect(error).to.be.false; + const { error } = await processor.exec(null as any, action); + expect(error).toBe(false); }); it('A committer that is NOT approved should NOT be allowed to push...', async () => { const action = new Action('1', 'type', 'method', 1, TEST_URL); action.userEmail = TEST_EMAIL_2; - const { error, errorMessage } = await processor.exec(null, action); - expect(error).to.be.true; - expect(errorMessage).to.contains('Your push has been blocked'); + const { error, errorMessage } = await processor.exec(null as any, action); + expect(error).toBe(true); + expect(errorMessage).toContain('Your push has been blocked'); }); it('An unknown committer should NOT be allowed to push...', async () => { const action = new Action('1', 'type', 'method', 1, TEST_URL); action.userEmail = TEST_EMAIL_3; - const { error, errorMessage } = await processor.exec(null, action); - expect(error).to.be.true; - expect(errorMessage).to.contains('Your push has been blocked'); + const { error, errorMessage } = await processor.exec(null as any, action); + expect(error).toBe(true); + expect(errorMessage).toContain('Your push has been blocked'); }); }); diff --git a/test/testConfig.test.js b/test/testConfig.test.js deleted file mode 100644 index c099dffea..000000000 --- a/test/testConfig.test.js +++ /dev/null @@ -1,489 +0,0 @@ -const chai = require('chai'); -const fs = require('fs'); -const path = require('path'); -const defaultSettings = require('../proxy.config.json'); -const fixtures = 'fixtures'; - -chai.should(); -const expect = chai.expect; - -describe('default configuration', function () { - it('should use default values if no user-settings.json file exists', function () { - const config = require('../src/config'); - config.logConfiguration(); - const enabledMethods = defaultSettings.authentication.filter((method) => method.enabled); - - expect(config.getAuthMethods()).to.deep.equal(enabledMethods); - expect(config.getDatabase()).to.be.eql(defaultSettings.sink[0]); - expect(config.getTempPasswordConfig()).to.be.eql(defaultSettings.tempPassword); - expect(config.getAuthorisedList()).to.be.eql(defaultSettings.authorisedList); - expect(config.getRateLimit()).to.be.eql(defaultSettings.rateLimit); - expect(config.getTLSKeyPemPath()).to.be.eql(defaultSettings.tls.key); - expect(config.getTLSCertPemPath()).to.be.eql(defaultSettings.tls.cert); - expect(config.getTLSEnabled()).to.be.eql(defaultSettings.tls.enabled); - expect(config.getDomains()).to.be.eql(defaultSettings.domains); - expect(config.getURLShortener()).to.be.eql(defaultSettings.urlShortener); - expect(config.getContactEmail()).to.be.eql(defaultSettings.contactEmail); - expect(config.getPlugins()).to.be.eql(defaultSettings.plugins); - expect(config.getCSRFProtection()).to.be.eql(defaultSettings.csrfProtection); - expect(config.getAttestationConfig()).to.be.eql(defaultSettings.attestationConfig); - expect(config.getAPIs()).to.be.eql(defaultSettings.api); - }); - after(function () { - delete require.cache[require.resolve('../src/config')]; - }); -}); - -describe('user configuration', function () { - let tempDir; - let tempUserFile; - let oldEnv; - - beforeEach(function () { - delete require.cache[require.resolve('../src/config/env')]; - 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').setConfigFile(tempUserFile); - }); - - it('should override default settings for authorisedList', function () { - const user = { - authorisedList: [{ project: 'foo', name: 'bar', url: 'https://github.com/foo/bar.git' }], - }; - fs.writeFileSync(tempUserFile, JSON.stringify(user)); - - // Invalidate cache to force reload - const config = require('../src/config'); - config.invalidateCache(); - const enabledMethods = defaultSettings.authentication.filter((method) => method.enabled); - - expect(config.getAuthorisedList()).to.be.eql(user.authorisedList); - expect(config.getAuthMethods()).to.deep.equal(enabledMethods); - expect(config.getDatabase()).to.be.eql(defaultSettings.sink[0]); - expect(config.getTempPasswordConfig()).to.be.eql(defaultSettings.tempPassword); - }); - - it('should override default settings for authentication', function () { - 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.invalidateCache(); - const authMethods = config.getAuthMethods(); - const oidcAuth = authMethods.find((method) => method.type === 'openidconnect'); - - 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]); - expect(config.getTempPasswordConfig()).to.be.eql(defaultSettings.tempPassword); - }); - - it('should override default settings for database', function () { - const user = { sink: [{ type: 'postgres', enabled: true }] }; - fs.writeFileSync(tempUserFile, JSON.stringify(user)); - - const config = require('../src/config'); - config.invalidateCache(); - const enabledMethods = defaultSettings.authentication.filter((method) => method.enabled); - - expect(config.getDatabase()).to.be.eql(user.sink[0]); - expect(config.getDatabase()).to.not.be.eql(defaultSettings.sink[0]); - expect(config.getAuthMethods()).to.deep.equal(enabledMethods); - expect(config.getTempPasswordConfig()).to.be.eql(defaultSettings.tempPassword); - }); - - it('should override default settings for SSL certificate', function () { - 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.invalidateCache(); - - expect(config.getTLSKeyPemPath()).to.be.eql(user.tls.key); - expect(config.getTLSCertPemPath()).to.be.eql(user.tls.cert); - }); - - it('should override default settings for rate limiting', function () { - const limitConfig = { rateLimit: { windowMs: 60000, limit: 1500 } }; - fs.writeFileSync(tempUserFile, JSON.stringify(limitConfig)); - - const config = require('../src/config'); - config.invalidateCache(); - - expect(config.getRateLimit().windowMs).to.be.eql(limitConfig.rateLimit.windowMs); - expect(config.getRateLimit().limit).to.be.eql(limitConfig.rateLimit.limit); - }); - - it('should override default settings for attestation config', function () { - const user = { - attestationConfig: { - questions: [ - { label: 'Testing Label Change', tooltip: { text: 'Testing Tooltip Change', links: [] } }, - ], - }, - }; - fs.writeFileSync(tempUserFile, JSON.stringify(user)); - - const config = require('../src/config'); - config.invalidateCache(); - - expect(config.getAttestationConfig()).to.be.eql(user.attestationConfig); - }); - - it('should override default settings for url shortener', 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.invalidateCache(); - - expect(config.getURLShortener()).to.be.eql(user.urlShortener); - }); - - it('should override default settings for contact email', function () { - const user = { contactEmail: 'test@example.com' }; - fs.writeFileSync(tempUserFile, JSON.stringify(user)); - - const config = require('../src/config'); - config.invalidateCache(); - - expect(config.getContactEmail()).to.be.eql(user.contactEmail); - }); - - it('should override default settings for plugins', function () { - const user = { plugins: ['plugin1', 'plugin2'] }; - fs.writeFileSync(tempUserFile, JSON.stringify(user)); - - const config = require('../src/config'); - 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', - }, - }; - - fs.writeFileSync(tempUserFile, JSON.stringify(user)); - - const config = require('../src/config'); - config.invalidateCache(); - - expect(config.getTLSCertPemPath()).to.be.eql(user.tls.cert); - expect(config.getTLSKeyPemPath()).to.be.eql(user.tls.key); - expect(config.getTLSEnabled()).to.be.eql(user.tls.enabled); - }); - - it('should prioritize tls.key and tls.cert over sslKeyPemPath and sslCertPemPath', function () { - const user = { - tls: { enabled: true, key: 'good-key.pem', cert: 'good-cert.pem' }, - sslKeyPemPath: 'bad-key.pem', - sslCertPemPath: 'bad-cert.pem', - }; - fs.writeFileSync(tempUserFile, JSON.stringify(user)); - - // Invalidate cache to force reload - const config = require('../src/config'); - config.invalidateCache(); - - expect(config.getTLSCertPemPath()).to.be.eql(user.tls.cert); - expect(config.getTLSKeyPemPath()).to.be.eql(user.tls.key); - expect(config.getTLSEnabled()).to.be.eql(user.tls.enabled); - }); - - it('should use sslKeyPemPath and sslCertPemPath if tls.key and tls.cert are not present', 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.invalidateCache(); - - expect(config.getTLSCertPemPath()).to.be.eql(user.sslCertPemPath); - expect(config.getTLSKeyPemPath()).to.be.eql(user.sslKeyPemPath); - expect(config.getTLSEnabled()).to.be.eql(false); - }); - - it('should override default settings for api', 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.invalidateCache(); - - expect(config.getAPIs()).to.be.eql(user.api); - }); - - it('should override default settings for cookieSecret if env var is used', function () { - fs.writeFileSync(tempUserFile, '{}'); - process.env.GIT_PROXY_COOKIE_SECRET = 'test-cookie-secret'; - - const config = require('../src/config'); - 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, - }, - ], - }; - fs.writeFileSync(tempUserFile, JSON.stringify(user)); - process.env.GIT_PROXY_MONGO_CONNECTION_STRING = 'mongodb://example.com:27017/test'; - - const config = require('../src/config'); - 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); - process.env = oldEnv; - delete require.cache[require.resolve('../src/config')]; - }); -}); - -describe('validate config files', function () { - const config = require('../src/config/file'); - - it('all valid config files should pass validation', function () { - const validConfigFiles = ['proxy.config.valid-1.json', 'proxy.config.valid-2.json']; - for (const testConfigFile of validConfigFiles) { - expect(config.validate(path.join(__dirname, fixtures, testConfigFile))).to.be.true; - } - }); - - it('all invalid config files should fail validation', function () { - const invalidConfigFiles = ['proxy.config.invalid-1.json', 'proxy.config.invalid-2.json']; - for (const testConfigFile of invalidConfigFiles) { - const test = function () { - config.validate(path.join(__dirname, fixtures, testConfigFile)); - }; - expect(test).to.throw(); - } - }); - - 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')]; - }); -}); diff --git a/test/testConfig.test.ts b/test/testConfig.test.ts new file mode 100644 index 000000000..a8ae2bbd5 --- /dev/null +++ b/test/testConfig.test.ts @@ -0,0 +1,455 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import defaultSettings from '../proxy.config.json'; + +import * as configFile from '../src/config/file'; + +const fixtures = 'fixtures'; + +describe('default configuration', () => { + afterEach(() => { + vi.resetModules(); + }); + + it('should use default values if no user-settings.json file exists', async () => { + const config = await import('../src/config'); + config.logConfiguration(); + const enabledMethods = defaultSettings.authentication.filter((method) => method.enabled); + + expect(config.getAuthMethods()).toEqual(enabledMethods); + expect(config.getDatabase()).toEqual(defaultSettings.sink[0]); + expect(config.getTempPasswordConfig()).toEqual(defaultSettings.tempPassword); + expect(config.getAuthorisedList()).toEqual(defaultSettings.authorisedList); + expect(config.getRateLimit()).toEqual(defaultSettings.rateLimit); + expect(config.getTLSKeyPemPath()).toEqual(defaultSettings.tls.key); + expect(config.getTLSCertPemPath()).toEqual(defaultSettings.tls.cert); + expect(config.getTLSEnabled()).toEqual(defaultSettings.tls.enabled); + expect(config.getDomains()).toEqual(defaultSettings.domains); + expect(config.getURLShortener()).toEqual(defaultSettings.urlShortener); + expect(config.getContactEmail()).toEqual(defaultSettings.contactEmail); + expect(config.getPlugins()).toEqual(defaultSettings.plugins); + expect(config.getCSRFProtection()).toEqual(defaultSettings.csrfProtection); + expect(config.getAttestationConfig()).toEqual(defaultSettings.attestationConfig); + expect(config.getAPIs()).toEqual(defaultSettings.api); + }); +}); + +describe('user configuration', () => { + let tempDir: string; + let tempUserFile: string; + let oldEnv: NodeJS.ProcessEnv; + + beforeEach(async () => { + vi.resetModules(); + oldEnv = { ...process.env }; + tempDir = fs.mkdtempSync('gitproxy-test'); + tempUserFile = path.join(tempDir, 'test-settings.json'); + const fileModule = await import('../src/config/file'); + fileModule.setConfigFile(tempUserFile); + }); + + afterEach(() => { + if (fs.existsSync(tempUserFile)) { + fs.rmSync(tempUserFile); + } + if (fs.existsSync(tempDir)) { + fs.rmdirSync(tempDir); + } + process.env = { ...oldEnv }; + vi.resetModules(); + }); + + it('should override default settings for authorisedList', async () => { + const user = { + authorisedList: [{ project: 'foo', name: 'bar', url: 'https://github.com/foo/bar.git' }], + }; + fs.writeFileSync(tempUserFile, JSON.stringify(user)); + + const config = await import('../src/config'); + config.invalidateCache(); + const enabledMethods = defaultSettings.authentication.filter((method) => method.enabled); + + expect(config.getAuthorisedList()).toEqual(user.authorisedList); + expect(config.getAuthMethods()).toEqual(enabledMethods); + expect(config.getDatabase()).toEqual(defaultSettings.sink[0]); + expect(config.getTempPasswordConfig()).toEqual(defaultSettings.tempPassword); + }); + + it('should override default settings for authentication', async () => { + 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)); + + const config = await import('../src/config'); + config.invalidateCache(); + const authMethods = config.getAuthMethods(); + const oidcAuth = authMethods.find((method: any) => method.type === 'openidconnect'); + + expect(oidcAuth).toBeDefined(); + expect(oidcAuth?.enabled).toBe(true); + expect(config.getAuthMethods()).toContainEqual(user.authentication[0]); + expect(config.getAuthMethods()).not.toEqual(defaultSettings.authentication); + expect(config.getDatabase()).toEqual(defaultSettings.sink[0]); + expect(config.getTempPasswordConfig()).toEqual(defaultSettings.tempPassword); + }); + + it('should override default settings for database', async () => { + const user = { sink: [{ type: 'postgres', enabled: true }] }; + fs.writeFileSync(tempUserFile, JSON.stringify(user)); + + const config = await import('../src/config'); + config.invalidateCache(); + const enabledMethods = defaultSettings.authentication.filter((method) => method.enabled); + + expect(config.getDatabase()).toEqual(user.sink[0]); + expect(config.getDatabase()).not.toEqual(defaultSettings.sink[0]); + expect(config.getAuthMethods()).toEqual(enabledMethods); + expect(config.getTempPasswordConfig()).toEqual(defaultSettings.tempPassword); + }); + + it('should override default settings for SSL certificate', async () => { + const user = { + tls: { + enabled: true, + key: 'my-key.pem', + cert: 'my-cert.pem', + }, + }; + fs.writeFileSync(tempUserFile, JSON.stringify(user)); + + const config = await import('../src/config'); + config.invalidateCache(); + + expect(config.getTLSKeyPemPath()).toEqual(user.tls.key); + expect(config.getTLSCertPemPath()).toEqual(user.tls.cert); + }); + + it('should override default settings for rate limiting', async () => { + const limitConfig = { rateLimit: { windowMs: 60000, limit: 1500 } }; + fs.writeFileSync(tempUserFile, JSON.stringify(limitConfig)); + + const config = await import('../src/config'); + config.invalidateCache(); + + expect(config.getRateLimit()?.windowMs).toBe(limitConfig.rateLimit.windowMs); + expect(config.getRateLimit()?.limit).toBe(limitConfig.rateLimit.limit); + }); + + it('should override default settings for attestation config', async () => { + const user = { + attestationConfig: { + questions: [ + { label: 'Testing Label Change', tooltip: { text: 'Testing Tooltip Change', links: [] } }, + ], + }, + }; + fs.writeFileSync(tempUserFile, JSON.stringify(user)); + + const config = await import('../src/config'); + config.invalidateCache(); + + expect(config.getAttestationConfig()).toEqual(user.attestationConfig); + }); + + it('should override default settings for url shortener', async () => { + const user = { urlShortener: 'https://url-shortener.com' }; + fs.writeFileSync(tempUserFile, JSON.stringify(user)); + + const config = await import('../src/config'); + config.invalidateCache(); + + expect(config.getURLShortener()).toBe(user.urlShortener); + }); + + it('should override default settings for contact email', async () => { + const user = { contactEmail: 'test@example.com' }; + fs.writeFileSync(tempUserFile, JSON.stringify(user)); + + const config = await import('../src/config'); + config.invalidateCache(); + + expect(config.getContactEmail()).toBe(user.contactEmail); + }); + + it('should override default settings for plugins', async () => { + const user = { plugins: ['plugin1', 'plugin2'] }; + fs.writeFileSync(tempUserFile, JSON.stringify(user)); + + const config = await import('../src/config'); + config.invalidateCache(); + + expect(config.getPlugins()).toEqual(user.plugins); + }); + + it('should override default settings for sslCertPemPath', async () => { + const user = { tls: { enabled: true, key: 'my-key.pem', cert: 'my-cert.pem' } }; + fs.writeFileSync(tempUserFile, JSON.stringify(user)); + + const config = await import('../src/config'); + config.invalidateCache(); + + expect(config.getTLSCertPemPath()).toBe(user.tls.cert); + expect(config.getTLSKeyPemPath()).toBe(user.tls.key); + expect(config.getTLSEnabled()).toBe(user.tls.enabled); + }); + + it('should prioritize tls.key and tls.cert over sslKeyPemPath and sslCertPemPath', async () => { + const user = { + tls: { enabled: true, key: 'good-key.pem', cert: 'good-cert.pem' }, + sslKeyPemPath: 'bad-key.pem', + sslCertPemPath: 'bad-cert.pem', + }; + fs.writeFileSync(tempUserFile, JSON.stringify(user)); + + const config = await import('../src/config'); + config.invalidateCache(); + + expect(config.getTLSCertPemPath()).toBe(user.tls.cert); + expect(config.getTLSKeyPemPath()).toBe(user.tls.key); + expect(config.getTLSEnabled()).toBe(user.tls.enabled); + }); + + it('should use sslKeyPemPath and sslCertPemPath if tls.key and tls.cert are not present', async () => { + const user = { sslKeyPemPath: 'good-key.pem', sslCertPemPath: 'good-cert.pem' }; + fs.writeFileSync(tempUserFile, JSON.stringify(user)); + + const config = await import('../src/config'); + config.invalidateCache(); + + expect(config.getTLSCertPemPath()).toBe(user.sslCertPemPath); + expect(config.getTLSKeyPemPath()).toBe(user.sslKeyPemPath); + expect(config.getTLSEnabled()).toBe(false); + }); + + it('should override default settings for api', async () => { + const user = { api: { gitlab: { baseUrl: 'https://gitlab.com' } } }; + fs.writeFileSync(tempUserFile, JSON.stringify(user)); + + const config = await import('../src/config'); + config.invalidateCache(); + + expect(config.getAPIs()).toEqual(user.api); + }); + + it('should override default settings for cookieSecret if env var is used', async () => { + fs.writeFileSync(tempUserFile, '{}'); + process.env.GIT_PROXY_COOKIE_SECRET = 'test-cookie-secret'; + + const config = await import('../src/config'); + config.invalidateCache(); + + expect(config.getCookieSecret()).toBe('test-cookie-secret'); + }); + + it('should override default settings for mongo connection string if env var is used', async () => { + 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 = await import('../src/config'); + config.invalidateCache(); + + expect(config.getDatabase().connectionString).toBe('mongodb://example.com:27017/test'); + }); + + it('should test cache invalidation function', async () => { + fs.writeFileSync(tempUserFile, '{}'); + + const config = await import('../src/config'); + + const firstLoad = config.getAuthorisedList(); + config.invalidateCache(); + const secondLoad = config.getAuthorisedList(); + + expect(firstLoad).toEqual(secondLoad); + }); + + it('should test reloadConfiguration function', async () => { + fs.writeFileSync(tempUserFile, '{}'); + + const config = await import('../src/config'); + await expect(config.reloadConfiguration()).resolves.not.toThrow(); + }); + + it('should handle configuration errors during initialization', async () => { + const user = { invalidConfig: 'this should cause validation error' }; + fs.writeFileSync(tempUserFile, JSON.stringify(user)); + + const config = await import('../src/config'); + expect(() => config.getAuthorisedList()).not.toThrow(); + }); + + it('should test all getter functions for coverage', async () => { + fs.writeFileSync(tempUserFile, '{}'); + + const config = await import('../src/config'); + + expect(() => config.getProxyUrl()).not.toThrow(); + expect(() => config.getCookieSecret()).not.toThrow(); + expect(() => config.getSessionMaxAgeHours()).not.toThrow(); + expect(() => config.getCommitConfig()).not.toThrow(); + expect(() => config.getPrivateOrganizations()).not.toThrow(); + expect(() => config.getUIRouteAuth()).not.toThrow(); + }); + + it('should test getAuthentication function returns first auth method', async () => { + const user = { + authentication: [ + { type: 'ldap', enabled: true }, + { type: 'local', enabled: true }, + ], + }; + fs.writeFileSync(tempUserFile, JSON.stringify(user)); + + const config = await import('../src/config'); + config.invalidateCache(); + + const firstAuth = config.getAuthentication(); + expect(firstAuth).toBeInstanceOf(Object); + expect(firstAuth.type).toBe('ldap'); + }); +}); + +describe('validate config files', () => { + it('all valid config files should pass validation', () => { + const validConfigFiles = ['proxy.config.valid-1.json', 'proxy.config.valid-2.json']; + for (const testConfigFile of validConfigFiles) { + expect(configFile.validate(path.join(__dirname, fixtures, testConfigFile))).toBe(true); + } + }); + + it('all invalid config files should fail validation', () => { + const invalidConfigFiles = ['proxy.config.invalid-1.json', 'proxy.config.invalid-2.json']; + for (const testConfigFile of invalidConfigFiles) { + expect(() => configFile.validate(path.join(__dirname, fixtures, testConfigFile))).toThrow(); + } + }); + + it('should validate using default config file when no path provided', () => { + const originalConfigFile = configFile.configFile; + const mainConfigPath = path.join(__dirname, '..', 'proxy.config.json'); + configFile.setConfigFile(mainConfigPath); + + try { + expect(() => configFile.validate()).not.toThrow(); + } finally { + configFile.setConfigFile(originalConfigFile); + } + }); +}); + +describe('setConfigFile function', () => { + let originalConfigFile: string | undefined; + + beforeEach(() => { + originalConfigFile = configFile.configFile; + }); + + afterEach(() => { + configFile.setConfigFile(originalConfigFile!); + }); + + it('should set the config file path', () => { + const newPath = '/tmp/new-config.json'; + configFile.setConfigFile(newPath); + expect(configFile.configFile).toBe(newPath); + }); + + it('should allow changing config file multiple times', () => { + const firstPath = '/tmp/first-config.json'; + const secondPath = '/tmp/second-config.json'; + + configFile.setConfigFile(firstPath); + expect(configFile.configFile).toBe(firstPath); + + configFile.setConfigFile(secondPath); + expect(configFile.configFile).toBe(secondPath); + }); +}); + +describe('Configuration Update Handling', () => { + let tempDir: string; + let tempUserFile: string; + let oldEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + oldEnv = { ...process.env }; + tempDir = fs.mkdtempSync('gitproxy-test'); + tempUserFile = path.join(tempDir, 'test-settings.json'); + configFile.setConfigFile(tempUserFile); + }); + + it('should test ConfigLoader initialization', async () => { + const configWithSources = { + configurationSources: { + enabled: true, + sources: [ + { + type: 'file', + enabled: true, + path: tempUserFile, + }, + ], + }, + }; + + fs.writeFileSync(tempUserFile, JSON.stringify(configWithSources)); + + const config = await import('../src/config'); + config.invalidateCache(); + + expect(() => config.getAuthorisedList()).not.toThrow(); + }); + + it('should handle config loader initialization errors', async () => { + const invalidConfigSources = { + configurationSources: { + enabled: true, + sources: [ + { + type: 'invalid-type', + enabled: true, + path: tempUserFile, + }, + ], + }, + }; + + fs.writeFileSync(tempUserFile, JSON.stringify(invalidConfigSources)); + + const consoleErrorSpy = vi.spyOn(console, 'error'); + + const config = await import('../src/config'); + config.invalidateCache(); + + expect(() => config.getAuthorisedList()).not.toThrow(); + + consoleErrorSpy.mockRestore(); + }); + + afterEach(() => { + if (fs.existsSync(tempUserFile)) { + fs.rmSync(tempUserFile, { force: true }); + } + if (fs.existsSync(tempDir)) { + fs.rmdirSync(tempDir); + } + process.env = oldEnv; + + vi.resetModules(); + }); +}); diff --git a/test/testDb.test.js b/test/testDb.test.js deleted file mode 100644 index 2f32a99b0..000000000 --- a/test/testDb.test.js +++ /dev/null @@ -1,880 +0,0 @@ -// This test needs to run first -const chai = require('chai'); -const db = require('../src/db'); -const { Repo, User } = require('../src/db/types'); -const { Action } = require('../src/proxy/actions/Action'); -const { Step } = require('../src/proxy/actions/Step'); - -const { expect } = chai; - -const TEST_REPO = { - project: 'finos', - name: 'db-test-repo', - url: 'https://github.com/finos/db-test-repo.git', -}; - -const TEST_NONEXISTENT_REPO = { - project: 'MegaCorp', - name: 'repo', - url: 'https://example.com/MegaCorp/MegaGroup/repo.git', - _id: 'ABCDEFGHIJKLMNOP', -}; - -const TEST_USER = { - username: 'db-u1', - password: 'abc', - gitAccount: 'db-test-user', - email: 'db-test@test.com', - admin: true, -}; - -const TEST_PUSH = { - steps: [], - error: false, - blocked: true, - allowPush: false, - authorised: false, - canceled: true, - rejected: false, - autoApproved: false, - autoRejected: false, - commitData: [], - id: '0000000000000000000000000000000000000000__1744380874110', - type: 'push', - method: 'get', - timestamp: 1744380903338, - project: 'finos', - repoName: 'db-test-repo.git', - url: TEST_REPO.url, - repo: 'finos/db-test-repo.git', - user: 'db-test-user', - userEmail: 'db-test@test.com', - lastStep: null, - blockedMessage: - '\n\n\nGitProxy has received your push:\n\nhttp://localhost:8080/requests/0000000000000000000000000000000000000000__1744380874110\n\n\n', - _id: 'GIMEz8tU2KScZiTz', - attestation: null, -}; - -const TEST_REPO_DOT_GIT = { - project: 'finos', - name: 'db.git-test-repo', - url: 'https://github.com/finos/db.git-test-repo.git', -}; - -// the same as TEST_PUSH but with .git somewhere valid within the name -// to ensure a global replace isn't done when trimming, just to the end -const TEST_PUSH_DOT_GIT = { - ...TEST_PUSH, - repoName: 'db.git-test-repo.git', - url: 'https://github.com/finos/db.git-test-repo.git', - repo: 'finos/db.git-test-repo.git', -}; - -/** - * Clean up response data from the DB by removing an extraneous properties, - * allowing comparison with expect. - * @param {object} example Example element from which columns to retain are extracted - * @param {array | object} responses Array of responses to clean. - * @return {array} Array of cleaned up responses. - */ -const cleanResponseData = (example, responses) => { - const columns = Object.keys(example); - - if (Array.isArray(responses)) { - return responses.map((response) => { - const cleanResponse = {}; - columns.forEach((col) => { - cleanResponse[col] = response[col]; - }); - return cleanResponse; - }); - } else if (typeof responses === 'object') { - const cleanResponse = {}; - columns.forEach((col) => { - cleanResponse[col] = responses[col]; - }); - return cleanResponse; - } else { - throw new Error(`Can only clean arrays or objects, but a ${typeof responses} was passed`); - } -}; - -// Use this test as a template -describe('Database clients', async () => { - before(async function () {}); - - it('should be able to construct a repo instance', async function () { - const repo = new Repo('project', 'name', 'https://github.com/finos.git-proxy.git', null, 'id'); - expect(repo._id).to.equal('id'); - expect(repo.project).to.equal('project'); - expect(repo.name).to.equal('name'); - expect(repo.url).to.equal('https://github.com/finos.git-proxy.git'); - expect(repo.users).to.deep.equals({ canPush: [], canAuthorise: [] }); - - const repo2 = new Repo( - 'project', - 'name', - 'https://github.com/finos.git-proxy.git', - { canPush: ['bill'], canAuthorise: ['ben'] }, - 'id', - ); - expect(repo2.users).to.deep.equals({ canPush: ['bill'], canAuthorise: ['ben'] }); - }); - - it('should be able to construct a user instance', async function () { - const user = new User( - 'username', - 'password', - 'gitAccount', - 'email@domain.com', - true, - null, - 'id', - ); - expect(user.username).to.equal('username'); - expect(user.username).to.equal('username'); - expect(user.gitAccount).to.equal('gitAccount'); - expect(user.email).to.equal('email@domain.com'); - expect(user.admin).to.equal(true); - expect(user.oidcId).to.be.null; - expect(user._id).to.equal('id'); - - const user2 = new User( - 'username', - 'password', - 'gitAccount', - 'email@domain.com', - false, - 'oidcId', - 'id', - ); - expect(user2.admin).to.equal(false); - expect(user2.oidcId).to.equal('oidcId'); - }); - - it('should be able to construct a valid action instance', async function () { - const action = new Action( - 'id', - 'type', - 'method', - Date.now(), - 'https://github.com/finos/git-proxy.git', - ); - expect(action.project).to.equal('finos'); - expect(action.repoName).to.equal('git-proxy.git'); - }); - - it('should be able to block an action by adding a blocked step', async function () { - const action = new Action( - 'id', - 'type', - 'method', - Date.now(), - 'https://github.com/finos.git-proxy.git', - ); - const step = new Step('stepName', false, null, false, null); - step.setAsyncBlock('blockedMessage'); - action.addStep(step); - expect(action.blocked).to.be.true; - expect(action.blockedMessage).to.equal('blockedMessage'); - expect(action.getLastStep()).to.deep.equals(step); - expect(action.continue()).to.be.false; - }); - - it('should be able to error an action by adding a step with an error', async function () { - const action = new Action( - 'id', - 'type', - 'method', - Date.now(), - 'https://github.com/finos.git-proxy.git', - ); - const step = new Step('stepName', true, 'errorMessage', false, null); - action.addStep(step); - expect(action.error).to.be.true; - expect(action.errorMessage).to.equal('errorMessage'); - expect(action.getLastStep()).to.deep.equals(step); - expect(action.continue()).to.be.false; - }); - - it('should be able to create a repo', async function () { - await db.createRepo(TEST_REPO); - const repos = await db.getRepos(); - const cleanRepos = cleanResponseData(TEST_REPO, repos); - expect(cleanRepos).to.deep.include(TEST_REPO); - }); - - it('should be able to filter repos', async function () { - // uppercase the filter value to confirm db client is lowercasing inputs - const repos = await db.getRepos({ name: TEST_REPO.name.toUpperCase() }); - const cleanRepos = cleanResponseData(TEST_REPO, repos); - expect(cleanRepos[0]).to.eql(TEST_REPO); - - const repos2 = await db.getRepos({ url: TEST_REPO.url }); - const cleanRepos2 = cleanResponseData(TEST_REPO, repos2); - expect(cleanRepos2[0]).to.eql(TEST_REPO); - - // passing an empty query should produce same results as no query - const repos3 = await db.getRepos(); - const repos4 = await db.getRepos({}); - expect(repos3).to.have.same.deep.members(repos4); - }); - - it('should be able to retrieve a repo by url', async function () { - const repo = await db.getRepoByUrl(TEST_REPO.url); - const cleanRepo = cleanResponseData(TEST_REPO, repo); - expect(cleanRepo).to.eql(TEST_REPO); - }); - - it('should be able to retrieve a repo by id', async function () { - // _id is autogenerated by the DB so we need to retrieve it before we can use it - const repo = await db.getRepoByUrl(TEST_REPO.url); - const repoById = await db.getRepoById(repo._id); - const cleanRepo = cleanResponseData(TEST_REPO, repoById); - expect(cleanRepo).to.eql(TEST_REPO); - }); - - it('should be able to delete a repo', async function () { - // _id is autogenerated by the DB so we need to retrieve it before we can use it - const repo = await db.getRepoByUrl(TEST_REPO.url); - await db.deleteRepo(repo._id); - const repos = await db.getRepos(); - const cleanRepos = cleanResponseData(TEST_REPO, repos); - expect(cleanRepos).to.not.deep.include(TEST_REPO); - }); - - it('should be able to create a repo with a blank project', async function () { - // test with a null value - let threwError = false; - let testRepo = { - project: null, - name: TEST_REPO.name, - url: TEST_REPO.url, - }; - try { - const repo = await db.createRepo(testRepo); - await db.deleteRepo(repo._id, true); - } catch (e) { - threwError = true; - } - expect(threwError).to.be.false; - - // test with an empty string - threwError = false; - testRepo = { - project: '', - name: TEST_REPO.name, - url: TEST_REPO.url, - }; - try { - const repo = await db.createRepo(testRepo); - await db.deleteRepo(repo._id, true); - } catch (e) { - threwError = true; - } - expect(threwError).to.be.false; - - // test with an undefined property - threwError = false; - testRepo = { - name: TEST_REPO.name, - url: TEST_REPO.url, - }; - try { - const repo = await db.createRepo(testRepo); - await db.deleteRepo(repo._id, true); - } catch (e) { - threwError = true; - } - expect(threwError).to.be.false; - }); - - it('should NOT be able to create a repo with blank name or url', async function () { - // null name - let threwError = false; - let testRepo = { - project: TEST_REPO.project, - name: null, - url: TEST_REPO.url, - }; - try { - await db.createRepo(testRepo); - } catch (e) { - threwError = true; - } - expect(threwError).to.be.true; - - // blank name - threwError = false; - testRepo = { - project: TEST_REPO.project, - name: '', - url: TEST_REPO.url, - }; - try { - await db.createRepo(testRepo); - } catch (e) { - threwError = true; - } - expect(threwError).to.be.true; - - // undefined name - threwError = false; - testRepo = { - project: TEST_REPO.project, - url: TEST_REPO.url, - }; - try { - await db.createRepo(testRepo); - } catch (e) { - threwError = true; - } - expect(threwError).to.be.true; - - // null url - testRepo = { - project: TEST_REPO.project, - name: TEST_REPO.name, - url: null, - }; - try { - await db.createRepo(testRepo); - } catch (e) { - threwError = true; - } - expect(threwError).to.be.true; - - // blank url - testRepo = { - project: TEST_REPO.project, - name: TEST_REPO.name, - url: '', - }; - try { - await db.createRepo(testRepo); - } catch (e) { - threwError = true; - } - expect(threwError).to.be.true; - - // undefined url - testRepo = { - project: TEST_REPO.project, - name: TEST_REPO.name, - }; - try { - await db.createRepo(testRepo); - } catch (e) { - threwError = true; - } - expect(threwError).to.be.true; - }); - - it('should throw an error when creating a user and username or email is not set', async function () { - // null username - let threwError = false; - let message = null; - try { - await db.createUser( - null, - TEST_USER.password, - TEST_USER.email, - TEST_USER.gitAccount, - TEST_USER.admin, - ); - } catch (e) { - threwError = true; - message = e.message; - } - expect(threwError).to.be.true; - expect(message).to.equal('username cannot be empty'); - - // blank username - threwError = false; - try { - await db.createUser( - '', - TEST_USER.password, - TEST_USER.email, - TEST_USER.gitAccount, - TEST_USER.admin, - ); - } catch (e) { - threwError = true; - message = e.message; - } - expect(threwError).to.be.true; - expect(message).to.equal('username cannot be empty'); - - // null email - threwError = false; - try { - await db.createUser( - TEST_USER.username, - TEST_USER.password, - null, - TEST_USER.gitAccount, - TEST_USER.admin, - ); - } catch (e) { - threwError = true; - message = e.message; - } - expect(threwError).to.be.true; - expect(message).to.equal('email cannot be empty'); - - // blank username - threwError = false; - try { - await db.createUser( - TEST_USER.username, - TEST_USER.password, - '', - TEST_USER.gitAccount, - TEST_USER.admin, - ); - } catch (e) { - threwError = true; - message = e.message; - } - expect(threwError).to.be.true; - expect(message).to.equal('email cannot be empty'); - }); - - it('should be able to create a user', async function () { - await db.createUser( - TEST_USER.username, - TEST_USER.password, - TEST_USER.email, - TEST_USER.gitAccount, - TEST_USER.admin, - ); - const users = await db.getUsers(); - console.log('TEST USER:', JSON.stringify(TEST_USER, null, 2)); - console.log('USERS:', JSON.stringify(users, null, 2)); - // remove password as it will have been hashed - - const { password: _, ...TEST_USER_CLEAN } = TEST_USER; - const cleanUsers = cleanResponseData(TEST_USER_CLEAN, users); - expect(cleanUsers).to.deep.include(TEST_USER_CLEAN); - }); - - it('should throw an error when creating a duplicate username', async function () { - let threwError = false; - let message = null; - try { - await db.createUser( - TEST_USER.username, - TEST_USER.password, - 'prefix_' + TEST_USER.email, - TEST_USER.gitAccount, - TEST_USER.admin, - ); - } catch (e) { - threwError = true; - message = e.message; - } - expect(threwError).to.be.true; - expect(message).to.equal(`user ${TEST_USER.username} already exists`); - }); - - it('should throw an error when creating a user with a duplicate email', async function () { - let threwError = false; - let message = null; - try { - await db.createUser( - 'prefix_' + TEST_USER.username, - TEST_USER.password, - TEST_USER.email, - TEST_USER.gitAccount, - TEST_USER.admin, - ); - } catch (e) { - threwError = true; - message = e.message; - } - expect(threwError).to.be.true; - expect(message).to.equal(`A user with email ${TEST_USER.email} already exists`); - }); - - it('should be able to find a user', async function () { - const user = await db.findUser(TEST_USER.username); - - const { password: _, ...TEST_USER_CLEAN } = TEST_USER; - - const { password: _2, _id: _3, ...DB_USER_CLEAN } = user; - - expect(DB_USER_CLEAN).to.eql(TEST_USER_CLEAN); - }); - - it('should be able to filter getUsers', async function () { - // uppercase the filter value to confirm db client is lowercasing inputs - const users = await db.getUsers({ username: TEST_USER.username.toUpperCase() }); - - const { password: _, ...TEST_USER_CLEAN } = TEST_USER; - const cleanUsers = cleanResponseData(TEST_USER_CLEAN, users); - expect(cleanUsers[0]).to.eql(TEST_USER_CLEAN); - - const users2 = await db.getUsers({ email: TEST_USER.email.toUpperCase() }); - const cleanUsers2 = cleanResponseData(TEST_USER_CLEAN, users2); - expect(cleanUsers2[0]).to.eql(TEST_USER_CLEAN); - }); - - it('should be able to delete a user', async function () { - await db.deleteUser(TEST_USER.username); - const users = await db.getUsers(); - const cleanUsers = cleanResponseData(TEST_USER, users); - expect(cleanUsers).to.not.deep.include(TEST_USER); - }); - - it('should be able to update a user', async function () { - await db.createUser( - TEST_USER.username, - TEST_USER.password, - TEST_USER.email, - TEST_USER.gitAccount, - TEST_USER.admin, - ); - - // has fewer properties to prove that records are merged - const updateToApply = { - username: TEST_USER.username, - gitAccount: 'updatedGitAccount', - admin: false, - }; - - const updatedUser = { - // remove password as it will have been hashed - username: TEST_USER.username, - email: TEST_USER.email, - gitAccount: 'updatedGitAccount', - admin: false, - }; - await db.updateUser(updateToApply); - - const users = await db.getUsers(); - const cleanUsers = cleanResponseData(updatedUser, users); - expect(cleanUsers).to.deep.include(updatedUser); - await db.deleteUser(TEST_USER.username); - }); - - it('should be able to create a user via updateUser', async function () { - await db.updateUser(TEST_USER); - - const users = await db.getUsers(); - // remove password as it will have been hashed - - const { password: _, ...TEST_USER_CLEAN } = TEST_USER; - const cleanUsers = cleanResponseData(TEST_USER_CLEAN, users); - expect(cleanUsers).to.deep.include(TEST_USER_CLEAN); - // leave user in place for next test(s) - }); - - it('should throw an error when authorising a user to push on non-existent repo', async function () { - let threwError = false; - try { - // uppercase the filter value to confirm db client is lowercasing inputs - await db.addUserCanPush(TEST_NONEXISTENT_REPO._id, TEST_USER.username); - } catch (e) { - threwError = true; - } - expect(threwError).to.be.true; - }); - - it('should be able to authorise a user to push and confirm that they can', async function () { - // first create the repo and check that user is not allowed to push - await db.createRepo(TEST_REPO); - - let allowed = await db.isUserPushAllowed(TEST_REPO.url, TEST_USER.username); - expect(allowed).to.be.false; - - const repo = await db.getRepoByUrl(TEST_REPO.url); - - // uppercase the filter value to confirm db client is lowercasing inputs - await db.addUserCanPush(repo._id, TEST_USER.username.toUpperCase()); - - // repeat, should not throw an error if already set - await db.addUserCanPush(repo._id, TEST_USER.username.toUpperCase()); - - // confirm the setting exists - allowed = await db.isUserPushAllowed(TEST_REPO.url, TEST_USER.username); - expect(allowed).to.be.true; - - // confirm that casing doesn't matter - allowed = await db.isUserPushAllowed(TEST_REPO.url, TEST_USER.username.toUpperCase()); - expect(allowed).to.be.true; - }); - - it('should throw an error when de-authorising a user to push on non-existent repo', async function () { - let threwError = false; - try { - await db.removeUserCanPush(TEST_NONEXISTENT_REPO._id, TEST_USER.username); - } catch (e) { - threwError = true; - } - expect(threwError).to.be.true; - }); - - it("should be able to de-authorise a user to push and confirm that they can't", async function () { - let threwError = false; - try { - // repo should already exist with user able to push after previous test - let allowed = await db.isUserPushAllowed(TEST_REPO.url, TEST_USER.username); - expect(allowed).to.be.true; - - const repo = await db.getRepoByUrl(TEST_REPO.url); - - // uppercase the filter value to confirm db client is lowercasing inputs - await db.removeUserCanPush(repo._id, TEST_USER.username.toUpperCase()); - - // repeat, should not throw an error if already unset - await db.removeUserCanPush(repo._id, TEST_USER.username.toUpperCase()); - - // confirm the setting exists - allowed = await db.isUserPushAllowed(TEST_REPO.url, TEST_USER.username); - expect(allowed).to.be.false; - - // confirm that casing doesn't matter - allowed = await db.isUserPushAllowed(TEST_REPO.url, TEST_USER.username.toUpperCase()); - expect(allowed).to.be.false; - } catch (e) { - console.error('Error thrown at: ' + e.stack, e); - threwError = true; - } - expect(threwError).to.be.false; - }); - - it('should throw an error when authorising a user to authorise on non-existent repo', async function () { - let threwError = false; - try { - await db.addUserCanAuthorise(TEST_NONEXISTENT_REPO._id, TEST_USER.username); - } catch (e) { - threwError = true; - } - expect(threwError).to.be.true; - }); - - it('should throw an error when de-authorising a user to push on non-existent repo', async function () { - let threwError = false; - try { - // uppercase the filter value to confirm db client is lowercasing inputs - await db.removeUserCanAuthorise(TEST_NONEXISTENT_REPO._id, TEST_USER.username); - } catch (e) { - threwError = true; - } - expect(threwError).to.be.true; - }); - - it('should NOT throw an error when checking whether a user can push on non-existent repo', async function () { - const allowed = await db.isUserPushAllowed(TEST_NONEXISTENT_REPO.url, TEST_USER.username); - expect(allowed).to.be.false; - }); - - it('should be able to create a push', async function () { - await db.writeAudit(TEST_PUSH); - const pushes = await db.getPushes(); - const cleanPushes = cleanResponseData(TEST_PUSH, pushes); - expect(cleanPushes).to.deep.include(TEST_PUSH); - }); - - it('should be able to delete a push', async function () { - await db.deletePush(TEST_PUSH.id); - const pushes = await db.getPushes(); - const cleanPushes = cleanResponseData(TEST_PUSH, pushes); - expect(cleanPushes).to.not.deep.include(TEST_PUSH); - }); - - it('should be able to authorise a push', async function () { - // first create the push - await db.writeAudit(TEST_PUSH); - let threwError = false; - try { - const msg = await db.authorise(TEST_PUSH.id); - expect(msg).to.have.property('message'); - } catch (e) { - console.error('Error: ', e); - threwError = true; - } - expect(threwError).to.be.false; - // clean up - await db.deletePush(TEST_PUSH.id); - }); - - it('should throw an error when authorising a non-existent a push', async function () { - let threwError = false; - try { - await db.authorise(TEST_PUSH.id); - } catch (e) { - threwError = true; - } - expect(threwError).to.be.true; - }); - - it('should be able to reject a push', async function () { - // first create the push - await db.writeAudit(TEST_PUSH); - let threwError = false; - try { - const msg = await db.reject(TEST_PUSH.id); - expect(msg).to.have.property('message'); - } catch (e) { - threwError = true; - } - expect(threwError).to.be.false; - // clean up - await db.deletePush(TEST_PUSH.id); - }); - - it('should throw an error when rejecting a non-existent a push', async function () { - let threwError = false; - try { - await db.reject(TEST_PUSH.id); - } catch (e) { - threwError = true; - } - expect(threwError).to.be.true; - }); - - it('should be able to cancel a push', async function () { - // first create the push - await db.writeAudit(TEST_PUSH); - let threwError = false; - try { - const msg = await db.cancel(TEST_PUSH.id); - expect(msg).to.have.property('message'); - } catch (e) { - threwError = true; - } - expect(threwError).to.be.false; - // clean up - await db.deletePush(TEST_PUSH.id); - }); - - it('should throw an error when cancelling a non-existent a push', async function () { - let threwError = false; - try { - await db.cancel(TEST_PUSH.id); - } catch (e) { - threwError = true; - } - expect(threwError).to.be.true; - }); - - it('should be able to check if a user can cancel push', async function () { - let threwError = false; - try { - const repo = await db.getRepoByUrl(TEST_REPO.url); - - // push does not exist yet, should return false - let allowed = await db.canUserCancelPush(TEST_PUSH.id, TEST_USER.username); - expect(allowed).to.be.false; - - // create the push - user should already exist and not authorised to push - await db.writeAudit(TEST_PUSH); - allowed = await db.canUserCancelPush(TEST_PUSH.id, TEST_USER.username); - expect(allowed).to.be.false; - - // authorise user and recheck - await db.addUserCanPush(repo._id, TEST_USER.username); - allowed = await db.canUserCancelPush(TEST_PUSH.id, TEST_USER.username); - expect(allowed).to.be.true; - - // deauthorise user and recheck - await db.removeUserCanPush(repo._id, TEST_USER.username); - allowed = await db.canUserCancelPush(TEST_PUSH.id, TEST_USER.username); - expect(allowed).to.be.false; - } catch (e) { - console.error(e); - threwError = true; - } - expect(threwError).to.be.false; - // clean up - await db.deletePush(TEST_PUSH.id); - }); - - it('should be able to check if a user can approve/reject push', async function () { - let allowed = undefined; - - try { - // push does not exist yet, should return false - allowed = await db.canUserApproveRejectPush(TEST_PUSH.id, TEST_USER.username); - expect(allowed).to.be.false; - } catch (e) { - expect.fail(e); - } - - try { - // create the push - user should already exist and not authorised to push - await db.writeAudit(TEST_PUSH); - allowed = await db.canUserApproveRejectPush(TEST_PUSH.id, TEST_USER.username); - expect(allowed).to.be.false; - } catch (e) { - expect.fail(e); - } - - try { - const repo = await db.getRepoByUrl(TEST_REPO.url); - - // authorise user and recheck - await db.addUserCanAuthorise(repo._id, TEST_USER.username); - allowed = await db.canUserApproveRejectPush(TEST_PUSH.id, TEST_USER.username); - expect(allowed).to.be.true; - - // deauthorise user and recheck - await db.removeUserCanAuthorise(repo._id, TEST_USER.username); - allowed = await db.canUserApproveRejectPush(TEST_PUSH.id, TEST_USER.username); - expect(allowed).to.be.false; - } catch (e) { - expect.fail(e); - } - - // clean up - await db.deletePush(TEST_PUSH.id); - }); - - it('should be able to check if a user can approve/reject push including .git within the repo name', async function () { - let allowed = undefined; - const repo = await db.createRepo(TEST_REPO_DOT_GIT); - try { - // push does not exist yet, should return false - allowed = await db.canUserApproveRejectPush(TEST_PUSH_DOT_GIT.id, TEST_USER.username); - expect(allowed).to.be.false; - } catch (e) { - expect.fail(e); - } - - try { - // create the push - user should already exist and not authorised to push - await db.writeAudit(TEST_PUSH_DOT_GIT); - allowed = await db.canUserApproveRejectPush(TEST_PUSH_DOT_GIT.id, TEST_USER.username); - expect(allowed).to.be.false; - } catch (e) { - expect.fail(e); - } - - try { - // authorise user and recheck - await db.addUserCanAuthorise(repo._id, TEST_USER.username); - allowed = await db.canUserApproveRejectPush(TEST_PUSH_DOT_GIT.id, TEST_USER.username); - expect(allowed).to.be.true; - } catch (e) { - expect.fail(e); - } - - // clean up - await db.deletePush(TEST_PUSH_DOT_GIT.id); - await db.removeUserCanAuthorise(repo._id, TEST_USER.username); - }); - - after(async function () { - // _id is autogenerated by the DB so we need to retrieve it before we can use it - const repo = await db.getRepoByUrl(TEST_REPO.url); - await db.deleteRepo(repo._id, true); - const repoDotGit = await db.getRepoByUrl(TEST_REPO_DOT_GIT.url); - await db.deleteRepo(repoDotGit._id); - await db.deleteUser(TEST_USER.username); - await db.deletePush(TEST_PUSH.id); - await db.deletePush(TEST_PUSH_DOT_GIT.id); - }); -}); diff --git a/test/testDb.test.ts b/test/testDb.test.ts new file mode 100644 index 000000000..f3452f9f3 --- /dev/null +++ b/test/testDb.test.ts @@ -0,0 +1,667 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as db from '../src/db'; +import { Repo, User } from '../src/db/types'; +import { Action } from '../src/proxy/actions/Action'; +import { Step } from '../src/proxy/actions/Step'; +import { AuthorisedRepo } from '../src/config/generated/config'; + +const TEST_REPO = { + project: 'finos', + name: 'db-test-repo', + url: 'https://github.com/finos/db-test-repo.git', +}; + +const TEST_NONEXISTENT_REPO = { + project: 'MegaCorp', + name: 'repo', + url: 'https://example.com/MegaCorp/MegaGroup/repo.git', + _id: 'ABCDEFGHIJKLMNOP', +}; + +const TEST_USER = { + username: 'db-u1', + password: 'abc', + gitAccount: 'db-test-user', + email: 'db-test@test.com', + admin: true, +}; + +const TEST_PUSH = { + steps: [], + error: false, + blocked: true, + allowPush: false, + authorised: false, + canceled: true, + rejected: false, + autoApproved: false, + autoRejected: false, + commitData: [], + id: '0000000000000000000000000000000000000000__1744380874110', + type: 'push', + method: 'get', + timestamp: 1744380903338, + project: 'finos', + repoName: 'db-test-repo.git', + url: TEST_REPO.url, + repo: 'finos/db-test-repo.git', + user: 'db-test-user', + userEmail: 'db-test@test.com', + lastStep: null, + blockedMessage: + '\n\n\nGitProxy has received your push:\n\nhttp://localhost:8080/requests/0000000000000000000000000000000000000000__1744380874110\n\n\n', + _id: 'GIMEz8tU2KScZiTz', + attestation: null, +}; + +const TEST_REPO_DOT_GIT = { + project: 'finos', + name: 'db.git-test-repo', + url: 'https://github.com/finos/db.git-test-repo.git', +}; + +// the same as TEST_PUSH but with .git somewhere valid within the name +// to ensure a global replace isn't done when trimming, just to the end +const TEST_PUSH_DOT_GIT = { + ...TEST_PUSH, + repoName: 'db.git-test-repo.git', + url: 'https://github.com/finos/db.git-test-repo.git', + repo: 'finos/db.git-test-repo.git', +}; + +/** + * Clean up response data from the DB by removing an extraneous properties, + * allowing comparison with expect. + * @param {object} example Example element from which columns to retain are extracted + * @param {array | object} responses Array of responses to clean. + * @return {array} Array of cleaned up responses. + */ +const cleanResponseData = (example: T, responses: T[] | T): T[] | T => { + const columns = Object.keys(example); + + if (Array.isArray(responses)) { + return responses.map((response) => { + const cleanResponse: Partial = {}; + columns.forEach((col) => { + // @ts-expect-error dynamic indexing + cleanResponse[col] = response[col]; + }); + return cleanResponse as T; + }); + } else if (typeof responses === 'object') { + const cleanResponse: Partial = {}; + columns.forEach((col) => { + // @ts-expect-error dynamic indexing + cleanResponse[col] = responses[col]; + }); + return cleanResponse as T; + } else { + throw new Error(`Can only clean arrays or objects, but a ${typeof responses} was passed`); + } +}; + +// Use this test as a template +describe('Database clients', () => { + beforeAll(async function () {}); + + it('should be able to construct a repo instance', () => { + const repo = new Repo( + 'project', + 'name', + 'https://github.com/finos.git-proxy.git', + undefined, + 'id', + ); + expect(repo._id).toBe('id'); + expect(repo.project).toBe('project'); + expect(repo.name).toBe('name'); + expect(repo.url).toBe('https://github.com/finos.git-proxy.git'); + expect(repo.users).toEqual({ canPush: [], canAuthorise: [] }); + + const repo2 = new Repo( + 'project', + 'name', + 'https://github.com/finos.git-proxy.git', + { canPush: ['bill'], canAuthorise: ['ben'] }, + 'id', + ); + expect(repo2.users).toEqual({ canPush: ['bill'], canAuthorise: ['ben'] }); + }); + + it('should be able to construct a user instance', () => { + const user = new User( + 'username', + 'password', + 'gitAccount', + 'email@domain.com', + true, + null, + 'id', + ); + expect(user.username).toBe('username'); + expect(user.gitAccount).toBe('gitAccount'); + expect(user.email).toBe('email@domain.com'); + expect(user.admin).toBe(true); + expect(user.oidcId).toBeNull(); + expect(user._id).toBe('id'); + + const user2 = new User( + 'username', + 'password', + 'gitAccount', + 'email@domain.com', + false, + 'oidcId', + 'id', + ); + expect(user2.admin).toBe(false); + expect(user2.oidcId).toBe('oidcId'); + }); + + it('should be able to construct a valid action instance', () => { + const action = new Action( + 'id', + 'type', + 'method', + Date.now(), + 'https://github.com/finos/git-proxy.git', + ); + expect(action.project).toBe('finos'); + expect(action.repoName).toBe('git-proxy.git'); + }); + + it('should be able to block an action by adding a blocked step', () => { + const action = new Action( + 'id', + 'type', + 'method', + Date.now(), + 'https://github.com/finos.git-proxy.git', + ); + const step = new Step('stepName', false, null, false, null); + step.setAsyncBlock('blockedMessage'); + action.addStep(step); + expect(action.blocked).toBe(true); + expect(action.blockedMessage).toBe('blockedMessage'); + expect(action.getLastStep()).toEqual(step); + expect(action.continue()).toBe(false); + }); + + it('should be able to error an action by adding a step with an error', () => { + const action = new Action( + 'id', + 'type', + 'method', + Date.now(), + 'https://github.com/finos.git-proxy.git', + ); + const step = new Step('stepName', true, 'errorMessage', false, null); + action.addStep(step); + expect(action.error).toBe(true); + expect(action.errorMessage).toBe('errorMessage'); + expect(action.getLastStep()).toEqual(step); + expect(action.continue()).toBe(false); + }); + + it('should be able to create a repo', async () => { + await db.createRepo(TEST_REPO); + const repos = await db.getRepos(); + const cleanRepos = cleanResponseData(TEST_REPO, repos) as (typeof TEST_REPO)[]; + expect(cleanRepos).toContainEqual(TEST_REPO); + }); + + it('should be able to filter repos', async () => { + // uppercase the filter value to confirm db client is lowercasing inputs + const repos = await db.getRepos({ name: TEST_REPO.name.toUpperCase() }); + const cleanRepos = cleanResponseData(TEST_REPO, repos); + // @ts-expect-error dynamic indexing + expect(cleanRepos[0]).toEqual(TEST_REPO); + + const repos2 = await db.getRepos({ url: TEST_REPO.url }); + const cleanRepos2 = cleanResponseData(TEST_REPO, repos2); + // @ts-expect-error dynamic indexing + expect(cleanRepos2[0]).toEqual(TEST_REPO); + + const repos3 = await db.getRepos(); + const repos4 = await db.getRepos({}); + expect(repos3).toEqual(expect.arrayContaining(repos4)); + expect(repos4).toEqual(expect.arrayContaining(repos3)); + }); + + it('should be able to retrieve a repo by url', async () => { + const repo = await db.getRepoByUrl(TEST_REPO.url); + if (!repo) { + throw new Error('Repo not found'); + } + + const cleanRepo = cleanResponseData(TEST_REPO, repo); + expect(cleanRepo).toEqual(TEST_REPO); + }); + + it('should be able to retrieve a repo by id', async () => { + // _id is autogenerated by the DB so we need to retrieve it before we can use it + const repo = await db.getRepoByUrl(TEST_REPO.url); + if (!repo || !repo._id) { + throw new Error('Repo not found'); + } + + const repoById = await db.getRepoById(repo._id); + const cleanRepo = cleanResponseData(TEST_REPO, repoById!); + expect(cleanRepo).toEqual(TEST_REPO); + }); + + it('should be able to delete a repo', async () => { + // _id is autogenerated by the DB so we need to retrieve it before we can use it + const repo = await db.getRepoByUrl(TEST_REPO.url); + if (!repo || !repo._id) { + throw new Error('Repo not found'); + } + + await db.deleteRepo(repo._id); + const repos = await db.getRepos(); + const cleanRepos = cleanResponseData(TEST_REPO, repos); + expect(cleanRepos).not.toContainEqual(TEST_REPO); + }); + + it('should be able to create a repo with a blank project', async () => { + const variations = [ + { project: null, name: TEST_REPO.name, url: TEST_REPO.url }, // null value + { project: '', name: TEST_REPO.name, url: TEST_REPO.url }, // empty string + { name: TEST_REPO.name, url: TEST_REPO.url }, // project undefined + ]; + + for (const testRepo of variations) { + let threwError = false; + try { + const repo = await db.createRepo(testRepo as AuthorisedRepo); + await db.deleteRepo(repo._id); + } catch { + threwError = true; + } + expect(threwError).toBe(false); + } + }); + + it('should NOT be able to create a repo with blank name or url', async () => { + const invalids = [ + { project: TEST_REPO.project, name: null, url: TEST_REPO.url }, // null name + { project: TEST_REPO.project, name: '', url: TEST_REPO.url }, // blank name + { project: TEST_REPO.project, url: TEST_REPO.url }, // undefined name + { project: TEST_REPO.project, name: TEST_REPO.name, url: null }, // null url + { project: TEST_REPO.project, name: TEST_REPO.name, url: '' }, // blank url + { project: TEST_REPO.project, name: TEST_REPO.name }, // undefined url + ]; + + for (const bad of invalids) { + await expect(db.createRepo(bad as AuthorisedRepo)).rejects.toThrow(); + } + }); + + it('should throw an error when creating a user and username or email is not set', async () => { + // null username + await expect( + db.createUser( + null as any, + TEST_USER.password, + TEST_USER.email, + TEST_USER.gitAccount, + TEST_USER.admin, + ), + ).rejects.toThrow('username cannot be empty'); + + // blank username + await expect( + db.createUser('', TEST_USER.password, TEST_USER.email, TEST_USER.gitAccount, TEST_USER.admin), + ).rejects.toThrow('username cannot be empty'); + + // null email + await expect( + db.createUser( + TEST_USER.username, + TEST_USER.password, + null as any, + TEST_USER.gitAccount, + TEST_USER.admin, + ), + ).rejects.toThrow('email cannot be empty'); + + // blank email + await expect( + db.createUser( + TEST_USER.username, + TEST_USER.password, + '', + TEST_USER.gitAccount, + TEST_USER.admin, + ), + ).rejects.toThrow('email cannot be empty'); + }); + + it('should be able to create a user', async () => { + await db.createUser( + TEST_USER.username, + TEST_USER.password, + TEST_USER.email, + TEST_USER.gitAccount, + TEST_USER.admin, + ); + const users = await db.getUsers(); + // remove password as it will have been hashed + const { password: _, ...TEST_USER_CLEAN } = TEST_USER; + const cleanUsers = cleanResponseData(TEST_USER_CLEAN, users); + expect(cleanUsers).toContainEqual(TEST_USER_CLEAN); + }); + + it('should throw an error when creating a duplicate username', async () => { + await expect( + db.createUser( + TEST_USER.username, + TEST_USER.password, + 'prefix_' + TEST_USER.email, + TEST_USER.gitAccount, + TEST_USER.admin, + ), + ).rejects.toThrow(`user ${TEST_USER.username} already exists`); + }); + + it('should throw an error when creating a user with a duplicate email', async () => { + await expect( + db.createUser( + 'prefix_' + TEST_USER.username, + TEST_USER.password, + TEST_USER.email, + TEST_USER.gitAccount, + TEST_USER.admin, + ), + ).rejects.toThrow(`A user with email ${TEST_USER.email} already exists`); + }); + + it('should be able to find a user', async () => { + const user = await db.findUser(TEST_USER.username); + const { password: _, ...TEST_USER_CLEAN } = TEST_USER; + const { password: _2, _id: _3, ...DB_USER_CLEAN } = user!; + expect(DB_USER_CLEAN).toEqual(TEST_USER_CLEAN); + }); + + it('should be able to filter getUsers', async () => { + const users = await db.getUsers({ username: TEST_USER.username.toUpperCase() }); + const { password: _, ...TEST_USER_CLEAN } = TEST_USER; + const cleanUsers = cleanResponseData(TEST_USER_CLEAN, users); + // @ts-expect-error dynamic indexing + expect(cleanUsers[0]).toEqual(TEST_USER_CLEAN); + + const users2 = await db.getUsers({ email: TEST_USER.email.toUpperCase() }); + const cleanUsers2 = cleanResponseData(TEST_USER_CLEAN, users2); + // @ts-expect-error dynamic indexing + expect(cleanUsers2[0]).toEqual(TEST_USER_CLEAN); + }); + + it('should be able to delete a user', async () => { + await db.deleteUser(TEST_USER.username); + const users = await db.getUsers(); + const cleanUsers = cleanResponseData(TEST_USER, users as any); + expect(cleanUsers).not.toContainEqual(TEST_USER); + }); + + it('should be able to update a user', async () => { + await db.createUser( + TEST_USER.username, + TEST_USER.password, + TEST_USER.email, + TEST_USER.gitAccount, + TEST_USER.admin, + ); + + // has fewer properties to prove that records are merged + const updateToApply = { + username: TEST_USER.username, + gitAccount: 'updatedGitAccount', + admin: false, + }; + + const updatedUser = { + // remove password as it will have been hashed + username: TEST_USER.username, + email: TEST_USER.email, + gitAccount: 'updatedGitAccount', + admin: false, + }; + + await db.updateUser(updateToApply); + + const users = await db.getUsers(); + const cleanUsers = cleanResponseData(updatedUser, users); + expect(cleanUsers).toContainEqual(updatedUser); + + await db.deleteUser(TEST_USER.username); + }); + + it('should be able to create a user via updateUser', async () => { + await db.updateUser(TEST_USER); + const users = await db.getUsers(); + // remove password as it will have been hashed + const { password: _, ...TEST_USER_CLEAN } = TEST_USER; + const cleanUsers = cleanResponseData(TEST_USER_CLEAN, users); + expect(cleanUsers).toContainEqual(TEST_USER_CLEAN); + }); + + it('should throw an error when authorising a user to push on non-existent repo', async () => { + await expect( + db.addUserCanPush(TEST_NONEXISTENT_REPO._id, TEST_USER.username), + ).rejects.toThrow(); + }); + + it('should be able to authorise a user to push and confirm that they can', async () => { + // first create the repo and check that user is not allowed to push + await db.createRepo(TEST_REPO); + + let allowed = await db.isUserPushAllowed(TEST_REPO.url, TEST_USER.username); + expect(allowed).toBe(false); + + const repo = await db.getRepoByUrl(TEST_REPO.url); + if (!repo || !repo._id) { + throw new Error('Repo not found'); + } + + // uppercase the filter value to confirm db client is lowercasing inputs + await db.addUserCanPush(repo._id, TEST_USER.username.toUpperCase()); + + // repeat, should not throw an error if already set + await db.addUserCanPush(repo._id, TEST_USER.username.toUpperCase()); + + // confirm the setting exists + allowed = await db.isUserPushAllowed(TEST_REPO.url, TEST_USER.username); + expect(allowed).toBe(true); + + // confirm that casing doesn't matter + allowed = await db.isUserPushAllowed(TEST_REPO.url, TEST_USER.username.toUpperCase()); + expect(allowed).toBe(true); + }); + + it('should throw an error when de-authorising a user to push on non-existent repo', async () => { + await expect( + db.removeUserCanPush(TEST_NONEXISTENT_REPO._id, TEST_USER.username), + ).rejects.toThrow(); + }); + + it("should be able to de-authorise a user to push and confirm that they can't", async () => { + // repo should already exist with user able to push after previous test + let allowed = await db.isUserPushAllowed(TEST_REPO.url, TEST_USER.username); + expect(allowed).toBe(true); + + const repo = await db.getRepoByUrl(TEST_REPO.url); + if (!repo || !repo._id) { + throw new Error('Repo not found'); + } + + // uppercase the filter value to confirm db client is lowercasing inputs + await db.removeUserCanPush(repo._id, TEST_USER.username.toUpperCase()); + + // repeat, should not throw an error if already set + await db.removeUserCanPush(repo._id, TEST_USER.username.toUpperCase()); + + // confirm the setting exists + allowed = await db.isUserPushAllowed(TEST_REPO.url, TEST_USER.username); + expect(allowed).toBe(false); + + // confirm that casing doesn't matter + allowed = await db.isUserPushAllowed(TEST_REPO.url, TEST_USER.username.toUpperCase()); + expect(allowed).toBe(false); + }); + + it('should throw an error when authorising a user to authorise on non-existent repo', async () => { + await expect( + db.addUserCanAuthorise(TEST_NONEXISTENT_REPO._id, TEST_USER.username), + ).rejects.toThrow(); + }); + + it('should throw an error when de-authorising a user to push on non-existent repo', async () => { + await expect( + db.removeUserCanAuthorise(TEST_NONEXISTENT_REPO._id, TEST_USER.username), + ).rejects.toThrow(); + }); + + it('should NOT throw an error when checking whether a user can push on non-existent repo', async () => { + const allowed = await db.isUserPushAllowed(TEST_NONEXISTENT_REPO.url, TEST_USER.username); + expect(allowed).toBe(false); + }); + + it('should be able to create a push', async () => { + await db.writeAudit(TEST_PUSH as any); + const pushes = await db.getPushes({}); + const cleanPushes = cleanResponseData(TEST_PUSH, pushes as any); + expect(cleanPushes).toContainEqual(TEST_PUSH); + }, 20000); + + it('should be able to delete a push', async () => { + await db.deletePush(TEST_PUSH.id); + const pushes = await db.getPushes({}); + const cleanPushes = cleanResponseData(TEST_PUSH, pushes as any); + expect(cleanPushes).not.toContainEqual(TEST_PUSH); + }); + + it('should be able to authorise a push', async () => { + await db.writeAudit(TEST_PUSH as any); + const msg = await db.authorise(TEST_PUSH.id, null); + expect(msg).toHaveProperty('message'); + await db.deletePush(TEST_PUSH.id); + }); + + it('should throw an error when authorising a non-existent a push', async () => { + await expect(db.authorise(TEST_PUSH.id, null)).rejects.toThrow(); + }); + + it('should be able to reject a push', async () => { + await db.writeAudit(TEST_PUSH as any); + const msg = await db.reject(TEST_PUSH.id, null); + expect(msg).toHaveProperty('message'); + await db.deletePush(TEST_PUSH.id); + }); + + it('should throw an error when rejecting a non-existent a push', async () => { + await expect(db.reject(TEST_PUSH.id, null)).rejects.toThrow(); + }); + + it('should be able to cancel a push', async () => { + await db.writeAudit(TEST_PUSH as any); + const msg = await db.cancel(TEST_PUSH.id); + expect(msg).toHaveProperty('message'); + await db.deletePush(TEST_PUSH.id); + }); + + it('should throw an error when cancelling a non-existent a push', async () => { + await expect(db.cancel(TEST_PUSH.id)).rejects.toThrow(); + }); + + it('should be able to check if a user can cancel push', async () => { + const repo = await db.getRepoByUrl(TEST_REPO.url); + if (!repo || !repo._id) { + throw new Error('Repo not found'); + } + + // push does not exist yet, should return false + let allowed = await db.canUserCancelPush(TEST_PUSH.id, TEST_USER.username); + expect(allowed).toBe(false); + + // create the push - user should already exist and not authorised to push + await db.writeAudit(TEST_PUSH as any); + allowed = await db.canUserCancelPush(TEST_PUSH.id, TEST_USER.username); + expect(allowed).toBe(false); + + // authorise user and recheck + await db.addUserCanPush(repo._id, TEST_USER.username); + allowed = await db.canUserCancelPush(TEST_PUSH.id, TEST_USER.username); + expect(allowed).toBe(true); + + // deauthorise user and recheck + await db.removeUserCanPush(repo._id, TEST_USER.username); + allowed = await db.canUserCancelPush(TEST_PUSH.id, TEST_USER.username); + expect(allowed).toBe(false); + + // clean up + await db.deletePush(TEST_PUSH.id); + }); + + it('should be able to check if a user can approve/reject push', async () => { + let allowed = await db.canUserApproveRejectPush(TEST_PUSH.id, TEST_USER.username); + expect(allowed).toBe(false); + + // push does not exist yet, should return false + await db.writeAudit(TEST_PUSH as any); + allowed = await db.canUserApproveRejectPush(TEST_PUSH.id, TEST_USER.username); + expect(allowed).toBe(false); + + // create the push - user should already exist and not authorised to push + const repo = await db.getRepoByUrl(TEST_REPO.url); + if (!repo || !repo._id) { + throw new Error('Repo not found'); + } + + await db.addUserCanAuthorise(repo._id, TEST_USER.username); + allowed = await db.canUserApproveRejectPush(TEST_PUSH.id, TEST_USER.username); + expect(allowed).toBe(true); + + // deauthorise user and recheck + await db.removeUserCanAuthorise(repo._id, TEST_USER.username); + allowed = await db.canUserApproveRejectPush(TEST_PUSH.id, TEST_USER.username); + expect(allowed).toBe(false); + + // clean up + await db.deletePush(TEST_PUSH.id); + }); + + it('should be able to check if a user can approve/reject push including .git within the repo name', async () => { + const repo = await db.createRepo(TEST_REPO_DOT_GIT); + + // push does not exist yet, should return false + let allowed = await db.canUserApproveRejectPush(TEST_PUSH_DOT_GIT.id, TEST_USER.username); + expect(allowed).toBe(false); + + // create the push - user should already exist and not authorised to push + await db.writeAudit(TEST_PUSH_DOT_GIT as any); + allowed = await db.canUserApproveRejectPush(TEST_PUSH_DOT_GIT.id, TEST_USER.username); + expect(allowed).toBe(false); + + // authorise user and recheck + await db.addUserCanAuthorise(repo._id, TEST_USER.username); + allowed = await db.canUserApproveRejectPush(TEST_PUSH_DOT_GIT.id, TEST_USER.username); + expect(allowed).toBe(true); + + // clean up + await db.deletePush(TEST_PUSH_DOT_GIT.id); + await db.removeUserCanAuthorise(repo._id, TEST_USER.username); + }); + + afterAll(async () => { + // _id is autogenerated by the DB so we need to retrieve it before we can use it + const repo = await db.getRepoByUrl(TEST_REPO.url); + if (repo) await db.deleteRepo(repo._id!); + + const repoDotGit = await db.getRepoByUrl(TEST_REPO_DOT_GIT.url); + if (repoDotGit) await db.deleteRepo(repoDotGit._id!); + + await db.deleteUser(TEST_USER.username); + await db.deletePush(TEST_PUSH.id); + await db.deletePush(TEST_PUSH_DOT_GIT.id); + }); +}); diff --git a/test/testJwtAuthHandler.test.js b/test/testJwtAuthHandler.test.js deleted file mode 100644 index cf0ee8f09..000000000 --- a/test/testJwtAuthHandler.test.js +++ /dev/null @@ -1,208 +0,0 @@ -const { expect } = require('chai'); -const sinon = require('sinon'); -const axios = require('axios'); -const jwt = require('jsonwebtoken'); -const { jwkToBuffer } = require('jwk-to-pem'); - -const { assignRoles, getJwks, validateJwt } = require('../src/service/passport/jwtUtils'); -const { jwtAuthHandler } = require('../src/service/passport/jwtAuthHandler'); - -describe('getJwks', () => { - it('should fetch JWKS keys from authority', async () => { - const jwksResponse = { keys: [{ kid: 'test-key', kty: 'RSA', n: 'abc', e: 'AQAB' }] }; - - const getStub = sinon.stub(axios, 'get'); - getStub.onFirstCall().resolves({ data: { jwks_uri: 'https://mock.com/jwks' } }); - getStub.onSecondCall().resolves({ data: jwksResponse }); - - const keys = await getJwks('https://mock.com'); - expect(keys).to.deep.equal(jwksResponse.keys); - - getStub.restore(); - }); - - it('should throw error if fetch fails', async () => { - const stub = sinon.stub(axios, 'get').rejects(new Error('Network fail')); - try { - await getJwks('https://fail.com'); - } catch (err) { - expect(err.message).to.equal('Failed to fetch JWKS'); - } - stub.restore(); - }); -}); - -describe('validateJwt', () => { - let decodeStub; - let verifyStub; - let pemStub; - let getJwksStub; - - beforeEach(() => { - const jwksResponse = { keys: [{ kid: 'test-key', kty: 'RSA', n: 'abc', e: 'AQAB' }] }; - const getStub = sinon.stub(axios, 'get'); - getStub.onFirstCall().resolves({ data: { jwks_uri: 'https://mock.com/jwks' } }); - getStub.onSecondCall().resolves({ data: jwksResponse }); - - getJwksStub = sinon.stub().resolves(jwksResponse.keys); - decodeStub = sinon.stub(jwt, 'decode'); - verifyStub = sinon.stub(jwt, 'verify'); - pemStub = sinon.stub(jwkToBuffer); - - pemStub.returns('fake-public-key'); - getJwksStub.returns(jwksResponse.keys); - }); - - afterEach(() => sinon.restore()); - - it('should validate a correct JWT', async () => { - const mockJwk = { kid: '123', kty: 'RSA', n: 'abc', e: 'AQAB' }; - const mockPem = 'fake-public-key'; - - decodeStub.returns({ header: { kid: '123' } }); - getJwksStub.resolves([mockJwk]); - pemStub.returns(mockPem); - verifyStub.returns({ azp: 'client-id', sub: 'user123' }); - - const { verifiedPayload } = await validateJwt( - 'fake.token.here', - 'https://issuer.com', - 'client-id', - 'client-id', - getJwksStub, - ); - expect(verifiedPayload.sub).to.equal('user123'); - }); - - it('should return error if JWT invalid', async () => { - decodeStub.returns(null); // Simulate broken token - - const { error } = await validateJwt( - 'bad.token', - 'https://issuer.com', - 'client-id', - 'client-id', - getJwksStub, - ); - expect(error).to.include('Invalid JWT'); - }); -}); - -describe('assignRoles', () => { - it('should assign admin role based on claim', () => { - const user = { username: 'admin-user' }; - const payload = { admin: 'admin' }; - const mapping = { admin: { admin: 'admin' } }; - - assignRoles(mapping, payload, user); - expect(user.admin).to.be.true; - }); - - it('should assign multiple roles based on claims', () => { - const user = { username: 'multi-role-user' }; - const payload = { 'custom-claim-admin': 'custom-value', editor: 'editor' }; - const mapping = { - admin: { 'custom-claim-admin': 'custom-value' }, - editor: { editor: 'editor' }, - }; - - assignRoles(mapping, payload, user); - expect(user.admin).to.be.true; - expect(user.editor).to.be.true; - }); - - it('should not assign role if claim mismatch', () => { - const user = { username: 'basic-user' }; - const payload = { admin: 'nope' }; - const mapping = { admin: { admin: 'admin' } }; - - assignRoles(mapping, payload, user); - expect(user.admin).to.be.undefined; - }); - - it('should not assign role if no mapping provided', () => { - const user = { username: 'no-role-user' }; - const payload = { admin: 'admin' }; - - assignRoles(null, payload, user); - expect(user.admin).to.be.undefined; - }); -}); - -describe('jwtAuthHandler', () => { - let req; - let res; - let next; - let jwtConfig; - let validVerifyResponse; - - beforeEach(() => { - req = { header: sinon.stub(), isAuthenticated: sinon.stub(), user: {} }; - res = { status: sinon.stub().returnsThis(), send: sinon.stub() }; - next = sinon.stub(); - - jwtConfig = { - clientID: 'client-id', - authorityURL: 'https://accounts.google.com', - expectedAudience: 'expected-audience', - roleMapping: { admin: { admin: 'admin' } }, - }; - - validVerifyResponse = { - header: { kid: '123' }, - azp: 'client-id', - sub: 'user123', - admin: 'admin', - }; - }); - - afterEach(() => { - sinon.restore(); - }); - - it('should call next if user is authenticated', async () => { - req.isAuthenticated.returns(true); - await jwtAuthHandler()(req, res, next); - expect(next.calledOnce).to.be.true; - }); - - it('should return 401 if no token provided', async () => { - req.header.returns(null); - await jwtAuthHandler(jwtConfig)(req, res, next); - - expect(res.status.calledWith(401)).to.be.true; - expect(res.send.calledWith('No token provided\n')).to.be.true; - }); - - it('should return 500 if authorityURL not configured', async () => { - req.header.returns('Bearer fake-token'); - jwtConfig.authorityURL = null; - sinon.stub(jwt, 'verify').returns(validVerifyResponse); - - await jwtAuthHandler(jwtConfig)(req, res, next); - - expect(res.status.calledWith(500)).to.be.true; - expect(res.send.calledWith({ message: 'OIDC authority URL is not configured\n' })).to.be.true; - }); - - it('should return 500 if clientID not configured', async () => { - req.header.returns('Bearer fake-token'); - jwtConfig.clientID = null; - sinon.stub(jwt, 'verify').returns(validVerifyResponse); - - await jwtAuthHandler(jwtConfig)(req, res, next); - - expect(res.status.calledWith(500)).to.be.true; - expect(res.send.calledWith({ message: 'OIDC client ID is not configured\n' })).to.be.true; - }); - - it('should return 401 if JWT validation fails', async () => { - req.header.returns('Bearer fake-token'); - sinon.stub(jwt, 'verify').throws(new Error('Invalid token')); - - await jwtAuthHandler(jwtConfig)(req, res, next); - - expect(res.status.calledWith(401)).to.be.true; - expect(res.send.calledWithMatch(/JWT validation failed:/)).to.be.true; - }); -}); diff --git a/test/testJwtAuthHandler.test.ts b/test/testJwtAuthHandler.test.ts new file mode 100644 index 000000000..61b625b72 --- /dev/null +++ b/test/testJwtAuthHandler.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest'; +import axios from 'axios'; +import jwt from 'jsonwebtoken'; +import * as jwkToBufferModule from 'jwk-to-pem'; + +import { assignRoles, getJwks, validateJwt } from '../src/service/passport/jwtUtils'; +import { jwtAuthHandler } from '../src/service/passport/jwtAuthHandler'; + +describe('getJwks', () => { + afterEach(() => vi.restoreAllMocks()); + + it('should fetch JWKS keys from authority', async () => { + const jwksResponse = { keys: [{ kid: 'test-key', kty: 'RSA', n: 'abc', e: 'AQAB' }] }; + + const getStub = vi.spyOn(axios, 'get'); + getStub.mockResolvedValueOnce({ data: { jwks_uri: 'https://mock.com/jwks' } }); + getStub.mockResolvedValueOnce({ data: jwksResponse }); + + const keys = await getJwks('https://mock.com'); + expect(keys).toEqual(jwksResponse.keys); + }); + + it('should throw error if fetch fails', async () => { + vi.spyOn(axios, 'get').mockRejectedValue(new Error('Network fail')); + await expect(getJwks('https://fail.com')).rejects.toThrow('Failed to fetch JWKS'); + }); +}); + +describe('validateJwt', () => { + let decodeStub: ReturnType; + let verifyStub: ReturnType; + let pemStub: ReturnType; + let getJwksStub: ReturnType; + + beforeEach(() => { + const jwksResponse = { keys: [{ kid: 'test-key', kty: 'RSA', n: 'abc', e: 'AQAB' }] }; + + vi.mock('jwk-to-pem', () => { + return { + default: vi.fn().mockReturnValue('fake-public-key'), + }; + }); + + vi.spyOn(axios, 'get') + .mockResolvedValueOnce({ data: { jwks_uri: 'https://mock.com/jwks' } }) + .mockResolvedValueOnce({ data: jwksResponse }); + + getJwksStub = vi.fn().mockResolvedValue(jwksResponse.keys); + decodeStub = vi.spyOn(jwt, 'decode') as any; + verifyStub = vi.spyOn(jwt, 'verify') as any; + pemStub = vi.fn().mockReturnValue('fake-public-key'); + + (jwkToBufferModule.default as Mock).mockImplementation(pemStub); + }); + + afterEach(() => vi.restoreAllMocks()); + + it('should validate a correct JWT', async () => { + const mockJwk = { kid: '123', kty: 'RSA', n: 'abc', e: 'AQAB' }; + const mockPem = 'fake-public-key'; + + decodeStub.mockReturnValue({ header: { kid: '123' } }); + getJwksStub.mockResolvedValue([mockJwk]); + pemStub.mockReturnValue(mockPem); + verifyStub.mockReturnValue({ azp: 'client-id', sub: 'user123' }); + + const { verifiedPayload } = await validateJwt( + 'fake.token.here', + 'https://issuer.com', + 'client-id', + 'client-id', + getJwksStub, + ); + expect(verifiedPayload?.sub).toBe('user123'); + }); + + it('should return error if JWT invalid', async () => { + decodeStub.mockReturnValue(null); // broken token + + const { error } = await validateJwt( + 'bad.token', + 'https://issuer.com', + 'client-id', + 'client-id', + getJwksStub, + ); + expect(error).toContain('Invalid JWT'); + }); +}); + +describe('assignRoles', () => { + it('should assign admin role based on claim', () => { + const user = { username: 'admin-user', admin: undefined }; + const payload = { admin: 'admin' }; + const mapping = { admin: { admin: 'admin' } }; + + assignRoles(mapping, payload, user); + expect(user.admin).toBe(true); + }); + + it('should assign multiple roles based on claims', () => { + const user = { username: 'multi-role-user', admin: undefined, editor: undefined }; + const payload = { 'custom-claim-admin': 'custom-value', editor: 'editor' }; + const mapping = { + admin: { 'custom-claim-admin': 'custom-value' }, + editor: { editor: 'editor' }, + }; + + assignRoles(mapping, payload, user); + expect(user.admin).toBe(true); + expect(user.editor).toBe(true); + }); + + it('should not assign role if claim mismatch', () => { + const user = { username: 'basic-user', admin: undefined }; + const payload = { admin: 'nope' }; + const mapping = { admin: { admin: 'admin' } }; + + assignRoles(mapping, payload, user); + expect(user.admin).toBeUndefined(); + }); + + it('should not assign role if no mapping provided', () => { + const user = { username: 'no-role-user', admin: undefined }; + const payload = { admin: 'admin' }; + + assignRoles(null as any, payload, user); + expect(user.admin).toBeUndefined(); + }); +}); + +describe('jwtAuthHandler', () => { + let req: any; + let res: any; + let next: any; + let jwtConfig: any; + let validVerifyResponse: any; + + beforeEach(() => { + req = { header: vi.fn(), isAuthenticated: vi.fn(), user: {} }; + res = { status: vi.fn().mockReturnThis(), send: vi.fn() }; + next = vi.fn(); + + jwtConfig = { + clientID: 'client-id', + authorityURL: 'https://accounts.google.com', + expectedAudience: 'expected-audience', + roleMapping: { admin: { admin: 'admin' } }, + }; + + validVerifyResponse = { + header: { kid: '123' }, + azp: 'client-id', + sub: 'user123', + admin: 'admin', + }; + }); + + afterEach(() => vi.restoreAllMocks()); + + it('should call next if user is authenticated', async () => { + req.isAuthenticated.mockReturnValue(true); + await jwtAuthHandler()(req, res, next); + expect(next).toHaveBeenCalledOnce(); + }); + + it('should return 401 if no token provided', async () => { + req.header.mockReturnValue(null); + await jwtAuthHandler(jwtConfig)(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.send).toHaveBeenCalledWith('No token provided\n'); + }); + + it('should return 500 if authorityURL not configured', async () => { + req.header.mockReturnValue('Bearer fake-token'); + jwtConfig.authorityURL = null; + vi.spyOn(jwt, 'verify').mockReturnValue(validVerifyResponse); + + await jwtAuthHandler(jwtConfig)(req, res, next); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.send).toHaveBeenCalledWith({ message: 'OIDC authority URL is not configured\n' }); + }); + + it('should return 500 if clientID not configured', async () => { + req.header.mockReturnValue('Bearer fake-token'); + jwtConfig.clientID = null; + vi.spyOn(jwt, 'verify').mockReturnValue(validVerifyResponse); + + await jwtAuthHandler(jwtConfig)(req, res, next); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.send).toHaveBeenCalledWith({ message: 'OIDC client ID is not configured\n' }); + }); + + it('should return 401 if JWT validation fails', async () => { + req.header.mockReturnValue('Bearer fake-token'); + vi.spyOn(jwt, 'verify').mockImplementation(() => { + throw new Error('Invalid token'); + }); + + await jwtAuthHandler(jwtConfig)(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.send).toHaveBeenCalledWith(expect.stringMatching(/JWT validation failed:/)); + }); +}); diff --git a/test/testLogin.test.js b/test/testLogin.test.js deleted file mode 100644 index cb6a0e922..000000000 --- a/test/testLogin.test.js +++ /dev/null @@ -1,291 +0,0 @@ -// Import the dependencies for testing -const chai = require('chai'); -const chaiHttp = require('chai-http'); -const db = require('../src/db'); -const service = require('../src/service').default; - -chai.use(chaiHttp); -chai.should(); -const expect = chai.expect; - -describe('auth', async () => { - let app; - let cookie; - - before(async function () { - app = await service.start(); - await db.deleteUser('login-test-user'); - }); - - describe('test login / logout', async function () { - // Test to get all students record - it('should get 401 not logged in', async function () { - const res = await chai.request(app).get('/api/auth/profile'); - - res.should.have.status(401); - }); - - it('should be able to login', async function () { - const res = await chai.request(app).post('/api/auth/login').send({ - username: 'admin', - password: 'admin', - }); - - expect(res).to.have.cookie('connect.sid'); - res.should.have.status(200); - - // Get the connect cooie - res.headers['set-cookie'].forEach((x) => { - if (x.startsWith('connect')) { - cookie = x.split(';')[0]; - } - }); - }); - - it('should now be able to access the user login metadata', async function () { - const res = await chai.request(app).get('/api/auth/me').set('Cookie', `${cookie}`); - res.should.have.status(200); - }); - - it('should now be able to access the profile', async function () { - const res = await chai.request(app).get('/api/auth/profile').set('Cookie', `${cookie}`); - res.should.have.status(200); - }); - - it('should be able to set the git account', async function () { - console.log(`cookie: ${cookie}`); - const res = await chai - .request(app) - .post('/api/auth/gitAccount') - .set('Cookie', `${cookie}`) - .send({ - username: 'admin', - gitAccount: 'new-account', - }); - res.should.have.status(200); - }); - - it('should throw an error if the username is not provided when setting the git account', async function () { - const res = await chai - .request(app) - .post('/api/auth/gitAccount') - .set('Cookie', `${cookie}`) - .send({ - gitAccount: 'new-account', - }); - console.log(`res: ${JSON.stringify(res)}`); - res.should.have.status(400); - }); - - it('should now be able to logout', async function () { - const res = await chai.request(app).post('/api/auth/logout').set('Cookie', `${cookie}`); - res.should.have.status(200); - }); - - it('test cannot access profile page', async function () { - const res = await chai.request(app).get('/api/auth/profile').set('Cookie', `${cookie}`); - - res.should.have.status(401); - }); - - it('should fail to login with invalid username', async function () { - const res = await chai.request(app).post('/api/auth/login').send({ - username: 'invalid', - password: 'admin', - }); - res.should.have.status(401); - }); - - it('should fail to login with invalid password', async function () { - const res = await chai.request(app).post('/api/auth/login').send({ - username: 'admin', - password: 'invalid', - }); - res.should.have.status(401); - }); - - it('should fail to set the git account if the user is not logged in', async function () { - const res = await chai.request(app).post('/api/auth/gitAccount').send({ - username: 'admin', - gitAccount: 'new-account', - }); - res.should.have.status(401); - }); - - it('should fail to get the current user metadata if not logged in', async function () { - const res = await chai.request(app).get('/api/auth/me'); - res.should.have.status(401); - }); - - it('should fail to login with invalid credentials', async function () { - const res = await chai.request(app).post('/api/auth/login').send({ - username: 'admin', - password: 'invalid', - }); - res.should.have.status(401); - }); - }); - - describe('test create user', async function () { - beforeEach(async function () { - await db.deleteUser('newuser'); - await db.deleteUser('nonadmin'); - }); - - it('should fail to create user when not authenticated', async function () { - const res = await chai.request(app).post('/api/auth/create-user').send({ - username: 'newuser', - password: 'newpass', - email: 'new@email.com', - gitAccount: 'newgit', - }); - - res.should.have.status(401); - res.body.should.have - .property('message') - .eql('You are not authorized to perform this action...'); - }); - - it('should fail to create user when not admin', async function () { - await db.deleteUser('nonadmin'); - await db.createUser('nonadmin', 'nonadmin', 'nonadmin@test.com', 'nonadmin', false); - - // First login as non-admin user - const loginRes = await chai.request(app).post('/api/auth/login').send({ - username: 'nonadmin', - password: 'nonadmin', - }); - - loginRes.should.have.status(200); - - let nonAdminCookie; - // Get the connect cooie - loginRes.headers['set-cookie'].forEach((x) => { - if (x.startsWith('connect')) { - nonAdminCookie = x.split(';')[0]; - } - }); - - console.log('nonAdminCookie', nonAdminCookie); - - const res = await chai - .request(app) - .post('/api/auth/create-user') - .set('Cookie', nonAdminCookie) - .send({ - username: 'newuser', - password: 'newpass', - email: 'new@email.com', - gitAccount: 'newgit', - }); - - res.should.have.status(401); - res.body.should.have - .property('message') - .eql('You are not authorized to perform this action...'); - }); - - it('should fail to create user with missing required fields', async function () { - // First login as admin - const loginRes = await chai.request(app).post('/api/auth/login').send({ - username: 'admin', - password: 'admin', - }); - - const adminCookie = loginRes.headers['set-cookie'][0].split(';')[0]; - - const res = await chai - .request(app) - .post('/api/auth/create-user') - .set('Cookie', adminCookie) - .send({ - username: 'newuser', - // missing password - email: 'new@email.com', - gitAccount: 'newgit', - }); - - res.should.have.status(400); - res.body.should.have - .property('message') - .eql('Missing required fields: username, password, email, and gitAccount are required'); - }); - - it('should successfully create a new user', async function () { - // First login as admin - const loginRes = await chai.request(app).post('/api/auth/login').send({ - username: 'admin', - password: 'admin', - }); - - const adminCookie = loginRes.headers['set-cookie'][0].split(';')[0]; - - const res = await chai - .request(app) - .post('/api/auth/create-user') - .set('Cookie', adminCookie) - .send({ - username: 'newuser', - password: 'newpass', - email: 'new@email.com', - gitAccount: 'newgit', - admin: false, - }); - - res.should.have.status(201); - res.body.should.have.property('message').eql('User created successfully'); - res.body.should.have.property('username').eql('newuser'); - - // Verify we can login with the new user - const newUserLoginRes = await chai.request(app).post('/api/auth/login').send({ - username: 'newuser', - password: 'newpass', - }); - - newUserLoginRes.should.have.status(200); - }); - - it('should fail to create user when username already exists', async function () { - // First login as admin - const loginRes = await chai.request(app).post('/api/auth/login').send({ - username: 'admin', - password: 'admin', - }); - - const adminCookie = loginRes.headers['set-cookie'][0].split(';')[0]; - - const res = await chai - .request(app) - .post('/api/auth/create-user') - .set('Cookie', adminCookie) - .send({ - username: 'newuser', - password: 'newpass', - email: 'new@email.com', - gitAccount: 'newgit', - admin: false, - }); - - res.should.have.status(201); - - // Verify we can login with the new user - const failCreateRes = await chai - .request(app) - .post('/api/auth/create-user') - .set('Cookie', adminCookie) - .send({ - username: 'newuser', - password: 'newpass', - email: 'new@email.com', - gitAccount: 'newgit', - admin: false, - }); - - failCreateRes.should.have.status(400); - }); - }); - - after(async function () { - await service.httpServer.close(); - }); -}); diff --git a/test/testLogin.test.ts b/test/testLogin.test.ts new file mode 100644 index 000000000..4f9093b3d --- /dev/null +++ b/test/testLogin.test.ts @@ -0,0 +1,246 @@ +import request from 'supertest'; +import { beforeAll, afterAll, beforeEach, describe, it, expect } from 'vitest'; +import * as db from '../src/db'; +import service from '../src/service'; +import Proxy from '../src/proxy'; +import { Express } from 'express'; + +describe('login', () => { + let app: Express; + let cookie: string; + + beforeAll(async () => { + app = await service.start(new Proxy()); + await db.deleteUser('login-test-user'); + }); + + describe('test login / logout', () => { + it('should get 401 if not logged in', async () => { + const res = await request(app).get('/api/auth/profile'); + expect(res.status).toBe(401); + }); + + it('should be able to login', async () => { + const res = await request(app).post('/api/auth/login').send({ + username: 'admin', + password: 'admin', + }); + + expect(res.status).toBe(200); + expect(res.headers['set-cookie']).toBeDefined(); + + (res.headers['set-cookie'] as unknown as string[]).forEach((x: string) => { + if (x.startsWith('connect')) { + cookie = x.split(';')[0]; + } + }); + }); + + it('should now be able to access the user login metadata', async () => { + const res = await request(app).get('/api/auth/me').set('Cookie', cookie); + expect(res.status).toBe(200); + }); + + it('should now be able to access the profile', async () => { + const res = await request(app).get('/api/auth/profile').set('Cookie', cookie); + expect(res.status).toBe(200); + }); + + it('should be able to set the git account', async () => { + const res = await request(app).post('/api/auth/gitAccount').set('Cookie', cookie).send({ + username: 'admin', + gitAccount: 'new-account', + }); + expect(res.status).toBe(200); + }); + + it('should throw an error if the username is not provided when setting the git account', async () => { + const res = await request(app).post('/api/auth/gitAccount').set('Cookie', cookie).send({ + gitAccount: 'new-account', + }); + expect(res.status).toBe(400); + }); + + it('should now be able to logout', async () => { + const res = await request(app).post('/api/auth/logout').set('Cookie', cookie); + expect(res.status).toBe(200); + }); + + it('test cannot access profile page', async () => { + const res = await request(app).get('/api/auth/profile').set('Cookie', cookie); + expect(res.status).toBe(401); + }); + + it('should fail to login with invalid username', async () => { + const res = await request(app).post('/api/auth/login').send({ + username: 'invalid', + password: 'admin', + }); + expect(res.status).toBe(401); + }); + + it('should fail to login with invalid password', async () => { + const res = await request(app).post('/api/auth/login').send({ + username: 'admin', + password: 'invalid', + }); + expect(res.status).toBe(401); + }); + + it('should fail to set the git account if the user is not logged in', async () => { + const res = await request(app).post('/api/auth/gitAccount').send({ + username: 'admin', + gitAccount: 'new-account', + }); + expect(res.status).toBe(401); + }); + + it('should fail to get the current user metadata if not logged in', async () => { + const res = await request(app).get('/api/auth/me'); + expect(res.status).toBe(401); + }); + + it('should fail to login with invalid credentials', async () => { + const res = await request(app).post('/api/auth/login').send({ + username: 'admin', + password: 'invalid', + }); + expect(res.status).toBe(401); + }); + }); + + describe('test create user', () => { + beforeEach(async () => { + await db.deleteUser('newuser'); + await db.deleteUser('nonadmin'); + }); + + it('should fail to create user when not authenticated', async () => { + const res = await request(app).post('/api/auth/create-user').send({ + username: 'newuser', + password: 'newpass', + email: 'new@email.com', + gitAccount: 'newgit', + }); + + expect(res.status).toBe(401); + expect(res.body.message).toBe('You are not authorized to perform this action...'); + }); + + it('should fail to create user when not admin', async () => { + await db.deleteUser('nonadmin'); + await db.createUser('nonadmin', 'nonadmin', 'nonadmin@test.com', 'nonadmin', false); + + const loginRes = await request(app).post('/api/auth/login').send({ + username: 'nonadmin', + password: 'nonadmin', + }); + + expect(loginRes.status).toBe(200); + + let nonAdminCookie: string; + (loginRes.headers['set-cookie'] as unknown as string[]).forEach((x: string) => { + if (x.startsWith('connect')) { + nonAdminCookie = x.split(';')[0]; + } + }); + + const res = await request(app) + .post('/api/auth/create-user') + .set('Cookie', nonAdminCookie!) + .send({ + username: 'newuser', + password: 'newpass', + email: 'new@email.com', + gitAccount: 'newgit', + }); + + expect(res.status).toBe(401); + expect(res.body.message).toBe('You are not authorized to perform this action...'); + }); + + it('should fail to create user with missing required fields', async () => { + const loginRes = await request(app).post('/api/auth/login').send({ + username: 'admin', + password: 'admin', + }); + + const adminCookie = loginRes.headers['set-cookie'][0].split(';')[0]; + + const res = await request(app).post('/api/auth/create-user').set('Cookie', adminCookie).send({ + username: 'newuser', + email: 'new@email.com', + gitAccount: 'newgit', + }); + + expect(res.status).toBe(400); + expect(res.body.message).toBe( + 'Missing required fields: username, password, email, and gitAccount are required', + ); + }); + + it('should successfully create a new user', async () => { + const loginRes = await request(app).post('/api/auth/login').send({ + username: 'admin', + password: 'admin', + }); + + const adminCookie = loginRes.headers['set-cookie'][0].split(';')[0]; + + const res = await request(app).post('/api/auth/create-user').set('Cookie', adminCookie).send({ + username: 'newuser', + password: 'newpass', + email: 'new@email.com', + gitAccount: 'newgit', + admin: false, + }); + + expect(res.status).toBe(201); + expect(res.body.message).toBe('User created successfully'); + expect(res.body.username).toBe('newuser'); + + const newUserLoginRes = await request(app).post('/api/auth/login').send({ + username: 'newuser', + password: 'newpass', + }); + + expect(newUserLoginRes.status).toBe(200); + }); + + it('should fail to create user when username already exists', async () => { + const loginRes = await request(app).post('/api/auth/login').send({ + username: 'admin', + password: 'admin', + }); + + const adminCookie = loginRes.headers['set-cookie'][0].split(';')[0]; + + const res = await request(app).post('/api/auth/create-user').set('Cookie', adminCookie).send({ + username: 'newuser', + password: 'newpass', + email: 'new@email.com', + gitAccount: 'newgit', + admin: false, + }); + + expect(res.status).toBe(201); + + const failCreateRes = await request(app) + .post('/api/auth/create-user') + .set('Cookie', adminCookie) + .send({ + username: 'newuser', + password: 'newpass', + email: 'new@email.com', + gitAccount: 'newgit', + admin: false, + }); + + expect(failCreateRes.status).toBe(400); + }); + }); + + afterAll(() => { + service.httpServer.close(); + }); +}); diff --git a/test/testOidc.test.js b/test/testOidc.test.js deleted file mode 100644 index 46eb74550..000000000 --- a/test/testOidc.test.js +++ /dev/null @@ -1,176 +0,0 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const proxyquire = require('proxyquire'); -const expect = chai.expect; -const { safelyExtractEmail, getUsername } = require('../src/service/passport/oidc'); - -describe('OIDC auth method', () => { - let dbStub; - let passportStub; - let configure; - let discoveryStub; - let fetchUserInfoStub; - let strategyCtorStub; - let strategyCallback; - - const newConfig = JSON.stringify({ - authentication: [ - { - type: 'openidconnect', - enabled: true, - oidcConfig: { - issuer: 'https://fake-issuer.com', - clientID: 'test-client-id', - clientSecret: 'test-client-secret', - callbackURL: 'https://example.com/callback', - scope: 'openid profile email', - }, - }, - ], - }); - - beforeEach(() => { - dbStub = { - findUserByOIDC: sinon.stub(), - createUser: sinon.stub(), - }; - - passportStub = { - use: sinon.stub(), - serializeUser: sinon.stub(), - deserializeUser: sinon.stub(), - }; - - discoveryStub = sinon.stub().resolves({ some: 'config' }); - fetchUserInfoStub = sinon.stub(); - - // Fake Strategy constructor - strategyCtorStub = function (options, verifyFn) { - strategyCallback = verifyFn; - return { - name: 'openidconnect', - currentUrl: sinon.stub().returns({}), - }; - }; - - const fsStub = { - existsSync: sinon.stub().returns(true), - readFileSync: sinon.stub().returns(newConfig), - }; - - const config = proxyquire('../src/config', { - fs: fsStub, - }); - config.initUserConfig(); - - ({ configure } = proxyquire('../src/service/passport/oidc', { - '../../db': dbStub, - '../../config': config, - 'openid-client': { - discovery: discoveryStub, - fetchUserInfo: fetchUserInfoStub, - }, - 'openid-client/passport': { - Strategy: strategyCtorStub, - }, - })); - }); - - afterEach(() => { - sinon.restore(); - }); - - it('should configure passport with OIDC strategy', async () => { - await configure(passportStub); - - expect(discoveryStub.calledOnce).to.be.true; - expect(passportStub.use.calledOnce).to.be.true; - expect(passportStub.serializeUser.calledOnce).to.be.true; - expect(passportStub.deserializeUser.calledOnce).to.be.true; - }); - - it('should authenticate an existing user', async () => { - await configure(passportStub); - - const mockTokenSet = { - claims: () => ({ sub: 'user123' }), - access_token: 'access-token', - }; - dbStub.findUserByOIDC.resolves({ id: 'user123', username: 'test-user' }); - fetchUserInfoStub.resolves({ sub: 'user123', email: 'user@test.com' }); - - const done = sinon.spy(); - - await strategyCallback(mockTokenSet, done); - - expect(done.calledOnce).to.be.true; - const [err, user] = done.firstCall.args; - expect(err).to.be.null; - expect(user).to.have.property('username', 'test-user'); - }); - - it('should handle discovery errors', async () => { - discoveryStub.rejects(new Error('discovery failed')); - - try { - await configure(passportStub); - throw new Error('Expected configure to throw'); - } catch (err) { - expect(err.message).to.include('discovery failed'); - } - }); - - it('should fail if no email in new user profile', async () => { - await configure(passportStub); - - const mockTokenSet = { - claims: () => ({ sub: 'sub-no-email' }), - access_token: 'access-token', - }; - dbStub.findUserByOIDC.resolves(null); - fetchUserInfoStub.resolves({ sub: 'sub-no-email' }); - - const done = sinon.spy(); - - await strategyCallback(mockTokenSet, done); - - const [err, user] = done.firstCall.args; - expect(err).to.be.instanceOf(Error); - expect(err.message).to.include('No email found'); - expect(user).to.be.undefined; - }); - - describe('safelyExtractEmail', () => { - it('should extract email from profile', () => { - const profile = { email: 'test@test.com' }; - const email = safelyExtractEmail(profile); - expect(email).to.equal('test@test.com'); - }); - - it('should extract email from profile with emails array', () => { - const profile = { emails: [{ value: 'test@test.com' }] }; - const email = safelyExtractEmail(profile); - expect(email).to.equal('test@test.com'); - }); - - it('should return null if no email in profile', () => { - const profile = { name: 'test' }; - const email = safelyExtractEmail(profile); - expect(email).to.be.null; - }); - }); - - describe('getUsername', () => { - it('should generate username from email', () => { - const email = 'test@test.com'; - const username = getUsername(email); - expect(username).to.equal('test'); - }); - - it('should return empty string if no email', () => { - const email = ''; - const username = getUsername(email); - expect(username).to.equal(''); - }); - }); -}); diff --git a/test/testOidc.test.ts b/test/testOidc.test.ts new file mode 100644 index 000000000..5561b7be8 --- /dev/null +++ b/test/testOidc.test.ts @@ -0,0 +1,164 @@ +import { describe, it, beforeEach, afterEach, expect, vi, type Mock } from 'vitest'; + +import { + safelyExtractEmail, + getUsername, + handleUserAuthentication, +} from '../src/service/passport/oidc'; + +describe('OIDC auth method', () => { + let dbStub: any; + let passportStub: any; + let configure: any; + let discoveryStub: Mock; + let fetchUserInfoStub: Mock; + + const newConfig = JSON.stringify({ + authentication: [ + { + type: 'openidconnect', + enabled: true, + oidcConfig: { + issuer: 'https://fake-issuer.com', + clientID: 'test-client-id', + clientSecret: 'test-client-secret', + callbackURL: 'https://example.com/callback', + scope: 'openid profile email', + }, + }, + ], + }); + + beforeEach(async () => { + dbStub = { + findUserByOIDC: vi.fn(), + createUser: vi.fn(), + }; + + passportStub = { + use: vi.fn(), + serializeUser: vi.fn(), + deserializeUser: vi.fn(), + }; + + discoveryStub = vi.fn().mockResolvedValue({ some: 'config' }); + fetchUserInfoStub = vi.fn(); + + const strategyCtorStub = function (_options: any, verifyFn: any) { + return { + name: 'openidconnect', + currentUrl: vi.fn().mockReturnValue({}), + }; + }; + + // First mock the dependencies + vi.resetModules(); + vi.doMock('../src/config', async () => { + const actual = await vi.importActual('../src/config'); + return { + ...actual, + default: { + ...actual.default, + initUserConfig: vi.fn(), + }, + initUserConfig: vi.fn(), + }; + }); + vi.doMock('fs', async (importOriginal) => { + const actual: any = await importOriginal(); + return { + ...actual, + existsSync: vi.fn().mockReturnValue(true), + readFileSync: vi.fn().mockReturnValue(newConfig), + }; + }); + vi.doMock('../../db', () => dbStub); + vi.doMock('../../config', async () => { + const actual = await vi.importActual('../src/config'); + return actual; + }); + vi.doMock('openid-client', () => ({ + discovery: discoveryStub, + fetchUserInfo: fetchUserInfoStub, + })); + vi.doMock('openid-client/passport', () => ({ + Strategy: strategyCtorStub, + })); + + // then import fresh OIDC module with mocks applied + const oidcModule = await import('../src/service/passport/oidc'); + configure = oidcModule.configure; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should configure passport with OIDC strategy', async () => { + await configure(passportStub); + + expect(discoveryStub).toHaveBeenCalledOnce(); + expect(passportStub.use).toHaveBeenCalledOnce(); + expect(passportStub.serializeUser).toHaveBeenCalledOnce(); + expect(passportStub.deserializeUser).toHaveBeenCalledOnce(); + }); + + it('should authenticate an existing user', async () => { + dbStub.findUserByOIDC.mockResolvedValue({ id: 'user123', username: 'test-user' }); + + const done = vi.fn(); + await handleUserAuthentication({ sub: 'user123', email: 'user123@test.com' }, done); + + expect(done).toHaveBeenCalledWith(null, expect.objectContaining({ username: 'user123' })); + }); + + it('should handle discovery errors', async () => { + discoveryStub.mockRejectedValue(new Error('discovery failed')); + + await expect(configure(passportStub)).rejects.toThrow(/discovery failed/); + }); + + it('should fail if no email in new user profile', async () => { + const done = vi.fn(); + await handleUserAuthentication({ sub: 'sub-no-email' }, done); + + const [err, user] = done.mock.calls[0]; + expect(err).toBeInstanceOf(Error); + expect(err.message).toMatch(/No email/); + expect(user).toBeUndefined(); + }); + + describe('safelyExtractEmail', () => { + it('should extract email from profile', () => { + const profile = { email: 'test@test.com' }; + const email = safelyExtractEmail(profile); + expect(email).toBe('test@test.com'); + }); + + it('should extract email from profile with emails array', () => { + const profile = { emails: [{ value: 'test@test.com' }] }; + const email = safelyExtractEmail(profile); + expect(email).toBe('test@test.com'); + }); + + it('should return null if no email in profile', () => { + const profile = { name: 'test' }; + const email = safelyExtractEmail(profile); + expect(email).toBeNull(); + }); + }); + + describe('getUsername', () => { + it('should generate username from email', () => { + const email = 'test@test.com'; + const username = getUsername(email); + expect(username).toBe('test'); + }); + + it('should return empty string if no email', () => { + const email = ''; + const username = getUsername(email); + expect(username).toBe(''); + }); + }); +}); diff --git a/test/testParseAction.test.js b/test/testParseAction.test.ts similarity index 53% rename from test/testParseAction.test.js rename to test/testParseAction.test.ts index 3592cc081..ef283b5ef 100644 --- a/test/testParseAction.test.js +++ b/test/testParseAction.test.ts @@ -1,10 +1,8 @@ -// Import the dependencies for testing -const chai = require('chai'); -chai.should(); -const expect = chai.expect; -const preprocessor = require('../src/proxy/processors/pre-processor/parseAction'); -const db = require('../src/db'); -let testRepo = null; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as preprocessor from '../src/proxy/processors/pre-processor/parseAction'; +import * as db from '../src/db'; + +let testRepo: any = null; const TEST_REPO = { url: 'https://github.com/finos/git-proxy.git', @@ -12,20 +10,23 @@ const TEST_REPO = { project: 'finos', }; -describe('Pre-processor: parseAction', async () => { - before(async function () { - // make sure the test repo exists as the presence of the repo makes a difference to handling of urls +describe('Pre-processor: parseAction', () => { + beforeAll(async () => { + // make sure the test repo exists as the presence of the repo makes a difference to handling of urls testRepo = await db.getRepoByUrl(TEST_REPO.url); if (!testRepo) { testRepo = await db.createRepo(TEST_REPO); } }); - after(async function () { + + afterAll(async () => { // clean up test DB - await db.deleteRepo(testRepo._id); + if (testRepo?._id) { + await db.deleteRepo(testRepo._id); + } }); - it('should be able to parse a pull request into an action', async function () { + it('should be able to parse a pull request into an action', async () => { const req = { originalUrl: '/github.com/finos/git-proxy.git/git-upload-pack', method: 'GET', @@ -33,13 +34,13 @@ describe('Pre-processor: parseAction', async () => { }; const action = await preprocessor.exec(req); - expect(action.timestamp).is.greaterThan(0); - expect(action.id).to.not.be.false; - expect(action.type).to.equal('pull'); - expect(action.url).to.equal('https://github.com/finos/git-proxy.git'); + expect(action.timestamp).toBeGreaterThan(0); + expect(action.id).not.toBeFalsy(); + expect(action.type).toBe('pull'); + expect(action.url).toBe('https://github.com/finos/git-proxy.git'); }); - it('should be able to parse a pull request with a legacy path into an action', async function () { + it('should be able to parse a pull request with a legacy path into an action', async () => { const req = { originalUrl: '/finos/git-proxy.git/git-upload-pack', method: 'GET', @@ -47,13 +48,13 @@ describe('Pre-processor: parseAction', async () => { }; const action = await preprocessor.exec(req); - expect(action.timestamp).is.greaterThan(0); - expect(action.id).to.not.be.false; - expect(action.type).to.equal('pull'); - expect(action.url).to.equal('https://github.com/finos/git-proxy.git'); + expect(action.timestamp).toBeGreaterThan(0); + expect(action.id).not.toBeFalsy(); + expect(action.type).toBe('pull'); + expect(action.url).toBe('https://github.com/finos/git-proxy.git'); }); - it('should be able to parse a push request into an action', async function () { + it('should be able to parse a push request into an action', async () => { const req = { originalUrl: '/github.com/finos/git-proxy.git/git-receive-pack', method: 'POST', @@ -61,13 +62,13 @@ describe('Pre-processor: parseAction', async () => { }; const action = await preprocessor.exec(req); - expect(action.timestamp).is.greaterThan(0); - expect(action.id).to.not.be.false; - expect(action.type).to.equal('push'); - expect(action.url).to.equal('https://github.com/finos/git-proxy.git'); + expect(action.timestamp).toBeGreaterThan(0); + expect(action.id).not.toBeFalsy(); + expect(action.type).toBe('push'); + expect(action.url).toBe('https://github.com/finos/git-proxy.git'); }); - it('should be able to parse a push request with a legacy path into an action', async function () { + it('should be able to parse a push request with a legacy path into an action', async () => { const req = { originalUrl: '/finos/git-proxy.git/git-receive-pack', method: 'POST', @@ -75,9 +76,9 @@ describe('Pre-processor: parseAction', async () => { }; const action = await preprocessor.exec(req); - expect(action.timestamp).is.greaterThan(0); - expect(action.id).to.not.be.false; - expect(action.type).to.equal('push'); - expect(action.url).to.equal('https://github.com/finos/git-proxy.git'); + expect(action.timestamp).toBeGreaterThan(0); + expect(action.id).not.toBeFalsy(); + expect(action.type).toBe('push'); + expect(action.url).toBe('https://github.com/finos/git-proxy.git'); }); }); diff --git a/test/testParsePush.test.js b/test/testParsePush.test.ts similarity index 66% rename from test/testParsePush.test.js rename to test/testParsePush.test.ts index 944b5dba9..25740048d 100644 --- a/test/testParsePush.test.js +++ b/test/testParsePush.test.ts @@ -1,17 +1,16 @@ -const { expect } = require('chai'); -const sinon = require('sinon'); -const zlib = require('zlib'); -const { createHash } = require('crypto'); -const fs = require('fs'); -const path = require('path'); - -const { +import { afterEach, describe, it, beforeEach, expect, vi, type Mock } from 'vitest'; +import { deflateSync } from 'zlib'; +import { createHash } from 'crypto'; +import fs from 'fs'; +import path from 'path'; + +import { exec, getCommitData, getContents, getPackMeta, parsePacketLines, -} = require('../src/proxy/processors/push-action/parsePush'); +} from '../src/proxy/processors/push-action/parsePush'; import { EMPTY_COMMIT_HASH, FLUSH_PACKET, PACK_SIGNATURE } from '../src/proxy/processors/constants'; @@ -33,7 +32,7 @@ function createSamplePackBuffer( header.writeUInt32BE(numEntries, 8); // Number of entries const originalContent = Buffer.from(commitContent, 'utf8'); - const compressedContent = zlib.deflateSync(originalContent); // actual zlib for setup + const compressedContent = deflateSync(originalContent); // actual zlib for setup const objectHeader = encodeGitObjectHeader(type, originalContent.length); // Combine parts and append checksum @@ -155,12 +154,12 @@ function createMultiObjectSamplePackBuffer() { for (let i = 0; i < numEntries; i++) { const commitContent = TEST_MULTI_OBJ_COMMIT_CONTENT[i]; const originalContent = Buffer.from(commitContent.content, 'utf8'); - const compressedContent = zlib.deflateSync(originalContent); + const compressedContent = deflateSync(originalContent); let objectHeader; if (commitContent.type == 7) { // ref_delta objectHeader = encodeGitObjectHeader(commitContent.type, originalContent.length, { - baseSha: Buffer.from(commitContent.baseSha, 'hex'), + baseSha: Buffer.from(commitContent.baseSha as string, 'hex'), }); } else if (commitContent.type == 6) { // ofs_delta @@ -194,7 +193,7 @@ function createMultiObjectSamplePackBuffer() { * @param {number} distance The offset value to encode. * @return {Buffer} The encoded buffer. */ -const encodeOfsDeltaOffset = (distance) => { +const encodeOfsDeltaOffset = (distance: number) => { // this encoding differs from the little endian size encoding // its a big endian 7-bit encoding, with odd handling of the continuation bit let val = distance; @@ -216,7 +215,7 @@ const encodeOfsDeltaOffset = (distance) => { * @param {Buffer} [options.baseSha] - SHA-1 hash for ref_delta (20 bytes). * @return {Buffer} - Encoded header buffer. */ -function encodeGitObjectHeader(type, size, options = {}) { +function encodeGitObjectHeader(type: number, size: number, options: any = {}) { const headerBytes = []; // First byte: type (3 bits), size (lower 4 bits), continuation bit @@ -265,7 +264,7 @@ function encodeGitObjectHeader(type, size, options = {}) { * @param {string[]} lines - Array of lines to be included in the buffer. * @return {Buffer} - The generated buffer containing the packet lines. */ -function createPacketLineBuffer(lines) { +function createPacketLineBuffer(lines: string[]) { let buffer = Buffer.alloc(0); lines.forEach((line) => { const lengthInHex = (line.length + 4).toString(16).padStart(4, '0'); @@ -291,25 +290,22 @@ function createEmptyPackBuffer() { } describe('parsePackFile', () => { - let action; - let req; - let sandbox; + let action: any; + let req: any; beforeEach(() => { - sandbox = sinon.createSandbox(); - // Mock Action and Step and spy on methods action = { branch: null, commitFrom: null, commitTo: null, - commitData: [], + commitData: [] as any[], user: null, - steps: [], - addStep: sandbox.spy(function (step) { + steps: [] as any[], + addStep: vi.fn(function (this: any, step: any) { this.steps.push(step); }), - setCommit: sandbox.spy(function (from, to) { + setCommit: vi.fn(function (this: any, from: string, to: string) { this.commitFrom = from; this.commitTo = to; }), @@ -321,54 +317,36 @@ describe('parsePackFile', () => { }); afterEach(() => { - sandbox.restore(); + vi.clearAllMocks(); }); describe('parsePush.getContents', () => { it('should retrieve all object data from a multiple object push', async () => { const packBuffer = createMultiObjectSamplePackBuffer(); const [packMeta, contentBuffer] = getPackMeta(packBuffer); - expect(packMeta.entries).to.equal( - TEST_MULTI_OBJ_COMMIT_CONTENT.length, - `PACK meta entries (${packMeta.entries}) don't match the expected number (${TEST_MULTI_OBJ_COMMIT_CONTENT.length})`, - ); + expect(packMeta.entries).toBe(TEST_MULTI_OBJ_COMMIT_CONTENT.length); const gitObjects = await getContents(contentBuffer, TEST_MULTI_OBJ_COMMIT_CONTENT.length); - expect(gitObjects.length).to.equal( - TEST_MULTI_OBJ_COMMIT_CONTENT.length, - `The number of objects extracted (${gitObjects.length}) didn't match the expected number (${TEST_MULTI_OBJ_COMMIT_CONTENT.length})`, - ); + expect(gitObjects.length).toBe(TEST_MULTI_OBJ_COMMIT_CONTENT.length); for (let index = 0; index < TEST_MULTI_OBJ_COMMIT_CONTENT.length; index++) { const expected = TEST_MULTI_OBJ_COMMIT_CONTENT[index]; const actual = gitObjects[index]; - expect(actual.type).to.equal( - expected.type, - `Type extracted (${actual.type}) didn't match\nactual: ${JSON.stringify(actual, null, 2)}\nexpected: ${JSON.stringify(expected, null, 2)}`, - ); - expect(actual.content).to.equal( - expected.content, - `Content didn't match\nactual: ${JSON.stringify(actual, null, 2)}\nexpected: ${JSON.stringify(expected, null, 2)}`, - ); + expect(actual.type).toBe(expected.type); + expect(actual.content).toBe(expected.content); // type 6 ofs_delta if (expected.baseOffset) { - expect(actual.baseOffset).to.equal( - expected.baseOffset, - `Base SHA extracted for ofs_delta didn't match\nactual: ${JSON.stringify(actual, null, 2)}\nexpected: ${JSON.stringify(expected, null, 2)}`, - ); + expect(actual.baseOffset).toBe(expected.baseOffset); } // type t ref_delta if (expected.baseSha) { - expect(actual.baseSha).to.equal( - expected.baseSha, - `Base SHA extracted for ref_delta didn't match\nactual: ${JSON.stringify(actual, null, 2)}\nexpected: ${JSON.stringify(expected, null, 2)}`, - ); + expect(actual.baseSha).toBe(expected.baseSha); } } - }); + }, 20000); it("should throw an error if the pack file can't be parsed", async () => { const packBuffer = createMultiObjectSamplePackBuffer(); @@ -377,19 +355,9 @@ describe('parsePackFile', () => { // break the content buffer so it won't parse const brokenContentBuffer = contentBuffer.subarray(2); - let errorThrown = null; - - try { - await getContents(brokenContentBuffer, TEST_MULTI_OBJ_COMMIT_CONTENT.length); - } catch (e) { - errorThrown = e; - } - - expect(errorThrown, 'No error was thrown!').to.not.be.null; - expect(errorThrown.message).to.contain( - 'Error during ', - `Expected the error message to include "Error during", but the message returned (${errorThrown.message}) did not`, - ); + await expect( + getContents(brokenContentBuffer, TEST_MULTI_OBJ_COMMIT_CONTENT.length), + ).rejects.toThrowError(/Error during/); }); }); @@ -398,35 +366,35 @@ describe('parsePackFile', () => { req.body = undefined; const result = await exec(req, action); - expect(result).to.equal(action); + expect(result).toBe(action); const step = action.steps[0]; - expect(step.stepName).to.equal('parsePackFile'); - expect(step.error).to.be.true; - expect(step.errorMessage).to.include('No body found in request'); + expect(step.stepName).toBe('parsePackFile'); + expect(step.error).toBe(true); + expect(step.errorMessage).toContain('No body found in request'); }); it('should add error step if req.body is empty', async () => { req.body = Buffer.alloc(0); const result = await exec(req, action); - expect(result).to.equal(action); + expect(result).toBe(action); const step = action.steps[0]; - expect(step.stepName).to.equal('parsePackFile'); - expect(step.error).to.be.true; - expect(step.errorMessage).to.include('No body found in request'); + expect(step.stepName).toBe('parsePackFile'); + expect(step.error).toBe(true); + expect(step.errorMessage).toContain('No body found in request'); }); it('should add error step if no ref updates found', async () => { const packetLines = ['some other line\n', 'another line\n']; - req.body = createPacketLineBuffer(packetLines); // We don't include PACK data (only testing ref updates) + req.body = createPacketLineBuffer(packetLines); const result = await exec(req, action); - expect(result).to.equal(action); + expect(result).toBe(action); const step = action.steps[0]; - expect(step.stepName).to.equal('parsePackFile'); - expect(step.error).to.be.true; - expect(step.errorMessage).to.include('pushing to a single branch'); - expect(step.logs[0]).to.include('Invalid number of branch updates'); + expect(step.stepName).toBe('parsePackFile'); + expect(step.error).toBe(true); + expect(step.errorMessage).toContain('pushing to a single branch'); + expect(step.logs[0]).toContain('Invalid number of branch updates'); }); it('should add error step if multiple ref updates found', async () => { @@ -437,13 +405,13 @@ describe('parsePackFile', () => { req.body = createPacketLineBuffer(packetLines); const result = await exec(req, action); - expect(result).to.equal(action); + expect(result).toBe(action); const step = action.steps[0]; - expect(step.stepName).to.equal('parsePackFile'); - expect(step.error).to.be.true; - expect(step.errorMessage).to.include('pushing to a single branch'); - expect(step.logs[0]).to.include('Invalid number of branch updates'); - expect(step.logs[1]).to.include('Expected 1, but got 2'); + expect(step.stepName).toBe('parsePackFile'); + expect(step.error).toBe(true); + expect(step.errorMessage).toContain('pushing to a single branch'); + expect(step.logs[0]).toContain('Invalid number of branch updates'); + expect(step.logs[1]).toContain('Expected 1, but got 2'); }); it('should add error step if PACK data is missing', async () => { @@ -451,19 +419,19 @@ describe('parsePackFile', () => { const newCommit = 'b'.repeat(40); const ref = 'refs/heads/feature/test'; const packetLines = [`${oldCommit} ${newCommit} ${ref}\0capa\n`]; - req.body = createPacketLineBuffer(packetLines); const result = await exec(req, action); - expect(result).to.equal(action); + expect(result).toBe(action); const step = action.steps[0]; - expect(step.stepName).to.equal('parsePackFile'); - expect(step.error).to.be.true; - expect(step.errorMessage).to.include('PACK data is missing'); + expect(step.stepName).toBe('parsePackFile'); + expect(step.error).toBe(true); + expect(step.errorMessage).toContain('PACK data is missing'); - expect(action.branch).to.equal(ref); - expect(action.setCommit.calledOnceWith(oldCommit, newCommit)).to.be.true; + expect(action.branch).toBe(ref); + expect(action.setCommit).toHaveBeenCalledOnce(); + expect(action.setCommit).toHaveBeenCalledWith(oldCommit, newCommit); }); it('should successfully parse a valid push request (simulated)', async () => { @@ -481,39 +449,40 @@ describe('parsePackFile', () => { 'This is the commit body.'; const numEntries = 1; - const packBuffer = createSamplePackBuffer(numEntries, commitContent, 1); // Use real zlib + const packBuffer = createSamplePackBuffer(numEntries, commitContent, 1); req.body = Buffer.concat([createPacketLineBuffer([packetLine]), packBuffer]); const result = await exec(req, action); - expect(result).to.equal(action); + expect(result).toBe(action); // Check step and action properties - const step = action.steps.find((s) => s.stepName === 'parsePackFile'); - expect(step).to.exist; - expect(step.error).to.be.false; - expect(step.errorMessage).to.be.null; + const step = action.steps.find((s: any) => s.stepName === 'parsePackFile'); + expect(step).toBeDefined(); + expect(step.error).toBe(false); + expect(step.errorMessage).toBeNull(); - expect(action.branch).to.equal(ref); - expect(action.setCommit.calledOnceWith(oldCommit, newCommit)).to.be.true; - expect(action.commitFrom).to.equal(oldCommit); - expect(action.commitTo).to.equal(newCommit); - expect(action.user).to.equal('Test Committer'); + expect(action.branch).toBe(ref); + expect(action.setCommit).toHaveBeenCalledWith(oldCommit, newCommit); + expect(action.commitFrom).toBe(oldCommit); + expect(action.commitTo).toBe(newCommit); + expect(action.user).toBe('Test Committer'); // Check parsed commit data - const commitMessages = action.commitData.map((commit) => commit.message); - expect(action.commitData).to.be.an('array').with.lengthOf(1); - expect(commitMessages[0]).to.equal('feat: Add new feature\n\nThis is the commit body.'); + expect(action.commitData).toHaveLength(1); + expect(action.commitData[0].message).toBe( + 'feat: Add new feature\n\nThis is the commit body.', + ); const parsedCommit = action.commitData[0]; - expect(parsedCommit.tree).to.equal('1234567890abcdef1234567890abcdef12345678'); - expect(parsedCommit.parent).to.equal('abcdef1234567890abcdef1234567890abcdef12'); - expect(parsedCommit.author).to.equal('Test Author'); - expect(parsedCommit.committer).to.equal('Test Committer'); - expect(parsedCommit.commitTimestamp).to.equal('1234567890'); - expect(parsedCommit.message).to.equal('feat: Add new feature\n\nThis is the commit body.'); - expect(parsedCommit.authorEmail).to.equal('author@example.com'); - - expect(step.content.meta).to.deep.equal({ + expect(parsedCommit.tree).toBe('1234567890abcdef1234567890abcdef12345678'); + expect(parsedCommit.parent).toBe('abcdef1234567890abcdef1234567890abcdef12'); + expect(parsedCommit.author).toBe('Test Author'); + expect(parsedCommit.committer).toBe('Test Committer'); + expect(parsedCommit.commitTimestamp).toBe('1234567890'); + expect(parsedCommit.message).toBe('feat: Add new feature\n\nThis is the commit body.'); + expect(parsedCommit.authorEmail).toBe('author@example.com'); + + expect(step.content.meta).toEqual({ sig: PACK_SIGNATURE, version: 2, entries: numEntries, @@ -533,41 +502,37 @@ describe('parsePackFile', () => { // see ../fixtures/captured-push.bin for details of how the content of this file were captured const capturedPushPath = path.join(__dirname, 'fixtures', 'captured-push.bin'); - - console.log(`Reading captured pack file from ${capturedPushPath}`); const pushBuffer = fs.readFileSync(capturedPushPath); - console.log(`Got buffer length: ${pushBuffer.length}`); - req.body = pushBuffer; const result = await exec(req, action); - expect(result).to.equal(action); + expect(result).toBe(action); // Check step and action properties - const step = action.steps.find((s) => s.stepName === 'parsePackFile'); - expect(step).to.exist; - expect(step.error).to.be.false; - expect(step.errorMessage).to.be.null; + const step = action.steps.find((s: any) => s.stepName === 'parsePackFile'); + expect(step).toBeDefined(); + expect(step.error).toBe(false); + expect(step.errorMessage).toBeNull(); - expect(action.branch).to.equal(ref); - expect(action.setCommit.calledOnceWith(oldCommit, newCommit)).to.be.true; - expect(action.commitFrom).to.equal(oldCommit); - expect(action.commitTo).to.equal(newCommit); - expect(action.user).to.equal(author); + expect(action.branch).toBe(ref); + expect(action.setCommit).toHaveBeenCalledWith(oldCommit, newCommit); + expect(action.commitFrom).toBe(oldCommit); + expect(action.commitTo).toBe(newCommit); + expect(action.user).toBe(author); // Check parsed commit data - const commitMessages = action.commitData.map((commit) => commit.message); - expect(action.commitData).to.be.an('array').with.lengthOf(1); - expect(commitMessages[0]).to.equal(message); + expect(action.commitData).toHaveLength(1); + expect(action.commitData[0].message).toBe(message); const parsedCommit = action.commitData[0]; - expect(parsedCommit.tree).to.equal(tree); - expect(parsedCommit.parent).to.equal(parent); - expect(parsedCommit.author).to.equal(author); - expect(parsedCommit.committer).to.equal(author); - expect(parsedCommit.commitTimestamp).to.equal(timestamp); - expect(parsedCommit.message).to.equal(message); - expect(step.content.meta).to.deep.equal({ + expect(parsedCommit.tree).toBe(tree); + expect(parsedCommit.parent).toBe(parent); + expect(parsedCommit.author).toBe(author); + expect(parsedCommit.committer).toBe(author); + expect(parsedCommit.commitTimestamp).toBe(timestamp); + expect(parsedCommit.message).toBe(message); + + expect(step.content.meta).toEqual({ sig: PACK_SIGNATURE, version: 2, entries: numEntries, @@ -584,77 +549,47 @@ describe('parsePackFile', () => { req.body = Buffer.concat([createPacketLineBuffer([packetLine]), packBuffer]); const result = await exec(req, action); - expect(result).to.equal(action); + expect(result).toBe(action); // Check step and action properties - const step = action.steps.find((s) => s.stepName === 'parsePackFile'); - expect(step).to.exist; - expect(step.error).to.be.false; - expect(step.errorMessage).to.be.null; + const step = action.steps.find((s: any) => s.stepName === 'parsePackFile'); + expect(step).toBeDefined(); + expect(step.error).toBe(false); + expect(step.errorMessage).toBeNull(); - expect(action.branch).to.equal(ref); - expect(action.setCommit.calledOnceWith(oldCommit, newCommit)).to.be.true; - expect(action.commitFrom).to.equal(oldCommit); - expect(action.commitTo).to.equal(newCommit); - expect(action.user).to.equal('CCCCCCCCCCC'); + expect(action.branch).toBe(ref); + expect(action.setCommit).toHaveBeenCalledWith(oldCommit, newCommit); + expect(action.commitFrom).toBe(oldCommit); + expect(action.commitTo).toBe(newCommit); + expect(action.user).toBe('CCCCCCCCCCC'); // Check parsed commit messages only - const expectedCommits = TEST_MULTI_OBJ_COMMIT_CONTENT.filter((value) => value.type == 1); + const expectedCommits = TEST_MULTI_OBJ_COMMIT_CONTENT.filter((v) => v.type === 1); - expect(action.commitData) - .to.be.an('array') - .with.lengthOf( - expectedCommits.length, - "We didn't find the expected number of commit messages", - ); + expect(action.commitData).toHaveLength(expectedCommits.length); - for (let index = 0; index < expectedCommits.length; index++) { - expect(action.commitData[index].message).to.equal( - expectedCommits[index].message.trim(), // trailing new lines will be removed from messages - "Commit message didn't match", - ); - expect(action.commitData[index].tree).to.equal( - expectedCommits[index].tree, - "tree didn't match", - ); - expect(action.commitData[index].parent).to.equal( - expectedCommits[index].parent, - "parent didn't match", - ); - expect(action.commitData[index].author).to.equal( - expectedCommits[index].author, - "author didn't match", - ); - expect(action.commitData[index].authorEmail).to.equal( - expectedCommits[index].authorEmail, - "authorEmail didn't match", - ); - expect(action.commitData[index].committer).to.equal( - expectedCommits[index].committer, - "committer didn't match", - ); - expect(action.commitData[index].committerEmail).to.equal( - expectedCommits[index].committerEmail, - "committerEmail didn't match", - ); - expect(action.commitData[index].commitTimestamp).to.equal( - expectedCommits[index].commitTimestamp, - "commitTimestamp didn't match", + for (let i = 0; i < expectedCommits.length; i++) { + expect(action.commitData[i].message).toBe( + expectedCommits[i].message.trim(), // trailing new lines will be removed from messages ); + expect(action.commitData[i].tree).toBe(expectedCommits[i].tree); + expect(action.commitData[i].parent).toBe(expectedCommits[i].parent); + expect(action.commitData[i].author).toBe(expectedCommits[i].author); + expect(action.commitData[i].authorEmail).toBe(expectedCommits[i].authorEmail); + expect(action.commitData[i].committer).toBe(expectedCommits[i].committer); + expect(action.commitData[i].committerEmail).toBe(expectedCommits[i].committerEmail); + expect(action.commitData[i].commitTimestamp).toBe(expectedCommits[i].commitTimestamp); } - expect(step.content.meta).to.deep.equal( - { - sig: PACK_SIGNATURE, - version: 2, - entries: TEST_MULTI_OBJ_COMMIT_CONTENT.length, - }, - "PACK file metadata didn't match", - ); + expect(step.content.meta).toEqual({ + sig: PACK_SIGNATURE, + version: 2, + entries: TEST_MULTI_OBJ_COMMIT_CONTENT.length, + }); }); it('should handle initial commit (zero hash oldCommit)', async () => { - const oldCommit = '0'.repeat(40); // Zero hash + const oldCommit = '0'.repeat(40); const newCommit = 'b'.repeat(40); const ref = 'refs/heads/main'; const packetLine = `${oldCommit} ${newCommit} ${ref}\0capabilities\n`; @@ -665,33 +600,32 @@ describe('parsePackFile', () => { 'author Test Author 1234567890 +0000\n' + 'committer Test Committer 1234567890 +0100\n\n' + 'feat: Initial commit'; - const parentFromCommit = '0'.repeat(40); // Expected parent hash const packBuffer = createSamplePackBuffer(1, commitContent, 1); // Use real zlib req.body = Buffer.concat([createPacketLineBuffer([packetLine]), packBuffer]); const result = await exec(req, action); + expect(result).toBe(action); - expect(result).to.equal(action); - const step = action.steps.find((s) => s.stepName === 'parsePackFile'); - expect(step).to.exist; - expect(step.error).to.be.false; + const step = action.steps.find((s: any) => s.stepName === 'parsePackFile'); + expect(step).toBeDefined(); + expect(step.error).toBe(false); - expect(action.branch).to.equal(ref); - expect(action.setCommit.calledOnceWith(oldCommit, newCommit)).to.be.true; + expect(action.branch).toBe(ref); + expect(action.setCommit).toHaveBeenCalledWith(oldCommit, newCommit); // commitFrom should still be the zero hash - expect(action.commitFrom).to.equal(oldCommit); - expect(action.commitTo).to.equal(newCommit); - expect(action.user).to.equal('Test Committer'); + expect(action.commitFrom).toBe(oldCommit); + expect(action.commitTo).toBe(newCommit); + expect(action.user).toBe('Test Committer'); // Check parsed commit data reflects no parent (zero hash) - expect(action.commitData[0].parent).to.equal(parentFromCommit); + expect(action.commitData[0].parent).toBe(oldCommit); }); it('should handle commit with multiple parents (merge commit)', async () => { const oldCommit = 'a'.repeat(40); - const newCommit = 'c'.repeat(40); // Merge commit hash + const newCommit = 'c'.repeat(40); const ref = 'refs/heads/main'; const packetLine = `${oldCommit} ${newCommit} ${ref}\0capabilities\n`; @@ -709,20 +643,18 @@ describe('parsePackFile', () => { req.body = Buffer.concat([createPacketLineBuffer([packetLine]), packBuffer]); const result = await exec(req, action); - expect(result).to.equal(action); + expect(result).toBe(action); // Check step and action properties - const step = action.steps.find((s) => s.stepName === 'parsePackFile'); - expect(step).to.exist; - expect(step.error).to.be.false; + const step = action.steps.find((s: any) => s.stepName === 'parsePackFile'); + expect(step).toBeDefined(); + expect(step.error).toBe(false); - expect(action.branch).to.equal(ref); - expect(action.setCommit.calledOnceWith(oldCommit, newCommit)).to.be.true; - expect(action.commitFrom).to.equal(oldCommit); - expect(action.commitTo).to.equal(newCommit); + expect(action.branch).toBe(ref); + expect(action.setCommit).toHaveBeenCalledWith(oldCommit, newCommit); // Parent should be the FIRST parent in the commit content - expect(action.commitData[0].parent).to.equal(parent1); + expect(action.commitData[0].parent).toBe(parent1); }); it('should add error step if getCommitData throws error', async () => { @@ -742,12 +674,12 @@ describe('parsePackFile', () => { req.body = Buffer.concat([createPacketLineBuffer([packetLine]), packBuffer]); const result = await exec(req, action); - expect(result).to.equal(action); + expect(result).toBe(action); - const step = action.steps.find((s) => s.stepName === 'parsePackFile'); - expect(step).to.exist; - expect(step.error).to.be.true; - expect(step.errorMessage).to.include('Invalid commit data: Missing tree'); + const step = action.steps.find((s: any) => s.stepName === 'parsePackFile'); + expect(step).toBeDefined(); + expect(step.error).toBe(true); + expect(step.errorMessage).toContain('Invalid commit data: Missing tree'); }); it('should add error step if data after flush packet does not start with "PACK"', async () => { @@ -761,16 +693,16 @@ describe('parsePackFile', () => { req.body = Buffer.concat([packetLineBuffer, garbageData]); const result = await exec(req, action); - expect(result).to.equal(action); + expect(result).toBe(action); const step = action.steps[0]; - expect(step.stepName).to.equal('parsePackFile'); - expect(step.error).to.be.true; - expect(step.errorMessage).to.include('Invalid PACK data structure'); - expect(step.errorMessage).to.not.include('PACK data is missing'); + expect(step.stepName).toBe('parsePackFile'); + expect(step.error).toBe(true); + expect(step.errorMessage).toContain('Invalid PACK data structure'); + expect(step.errorMessage).not.toContain('PACK data is missing'); - expect(action.branch).to.equal(ref); - expect(action.setCommit.calledOnceWith(oldCommit, newCommit)).to.be.true; + expect(action.branch).toBe(ref); + expect(action.setCommit).toHaveBeenCalledWith(oldCommit, newCommit); }); it('should correctly identify PACK data even if "PACK" appears in packet lines', async () => { @@ -793,24 +725,26 @@ describe('parsePackFile', () => { req.body = Buffer.concat([packetLineBuffer, samplePackBuffer]); const result = await exec(req, action); - expect(result).to.equal(action); - expect(action.steps.length).to.equal(1); + + expect(result).toBe(action); + expect(action.steps).toHaveLength(1); // Check that the step was added correctly, and no error present const step = action.steps[0]; - expect(step.stepName).to.equal('parsePackFile'); - expect(step.error).to.be.false; - expect(step.errorMessage).to.be.null; + expect(step.stepName).toBe('parsePackFile'); + expect(step.error).toBe(false); + expect(step.errorMessage).toBeNull(); // Verify action properties were parsed correctly - expect(action.branch).to.equal(ref); - expect(action.setCommit.calledOnceWith(oldCommit, newCommit)).to.be.true; - expect(action.commitFrom).to.equal(oldCommit); - expect(action.commitTo).to.equal(newCommit); - expect(action.commitData).to.be.an('array').with.lengthOf(1); - expect(action.commitData[0].message).to.equal('Test commit message with PACK inside'); - expect(action.commitData[0].committer).to.equal('Test Committer'); - expect(action.user).to.equal('Test Committer'); + expect(action.branch).toBe(ref); + expect(action.setCommit).toHaveBeenCalledWith(oldCommit, newCommit); + expect(action.commitFrom).toBe(oldCommit); + expect(action.commitTo).toBe(newCommit); + expect(Array.isArray(action.commitData)).toBe(true); + expect(action.commitData).toHaveLength(1); + expect(action.commitData[0].message).toBe('Test commit message with PACK inside'); + expect(action.commitData[0].committer).toBe('Test Committer'); + expect(action.user).toBe('Test Committer'); }); it('should handle PACK data starting immediately after flush packet', async () => { @@ -825,17 +759,16 @@ describe('parsePackFile', () => { 'author Test Author 1234567890 +0000\n' + 'committer Test Committer 1234567890 +0000\n\n' + 'Commit A'; - const samplePackBuffer = createSamplePackBuffer(1, commitContent, 1); - const packetLineBuffer = createPacketLineBuffer(packetLines); - req.body = Buffer.concat([packetLineBuffer, samplePackBuffer]); + const samplePackBuffer = createSamplePackBuffer(1, commitContent, 1); + req.body = Buffer.concat([createPacketLineBuffer(packetLines), samplePackBuffer]); const result = await exec(req, action); + expect(result).toBe(action); - expect(result).to.equal(action); const step = action.steps[0]; - expect(step.error).to.be.false; - expect(action.commitData[0].message).to.equal('Commit A'); + expect(step.error).toBe(false); + expect(action.commitData[0].message).toBe('Commit A'); }); it('should add error step if PACK header parsing fails (getPackMeta with wrong signature)', async () => { @@ -851,17 +784,16 @@ describe('parsePackFile', () => { req.body = Buffer.concat([packetLineBuffer, badPackBuffer]); const result = await exec(req, action); - expect(result).to.equal(action); + expect(result).toBe(action); const step = action.steps[0]; - expect(step.stepName).to.equal('parsePackFile'); - expect(step.error).to.be.true; - expect(step.errorMessage).to.include('Invalid PACK data structure'); + expect(step.stepName).toBe('parsePackFile'); + expect(step.error).toBe(true); + expect(step.errorMessage).toContain('Invalid PACK data structure'); }); it('should return empty commitData on empty branch push', async () => { const emptyPackBuffer = createEmptyPackBuffer(); - const newCommit = 'b'.repeat(40); const ref = 'refs/heads/feature/emptybranch'; const packetLine = `${EMPTY_COMMIT_HASH} ${newCommit} ${ref}\0capabilities\n`; @@ -869,16 +801,15 @@ describe('parsePackFile', () => { req.body = Buffer.concat([createPacketLineBuffer([packetLine]), emptyPackBuffer]); const result = await exec(req, action); + expect(result).toBe(action); - expect(result).to.equal(action); - - const step = action.steps.find((s) => s.stepName === 'parsePackFile'); - expect(step).to.exist; - expect(step.error).to.be.false; - expect(action.branch).to.equal(ref); - expect(action.setCommit.calledOnceWith(EMPTY_COMMIT_HASH, newCommit)).to.be.true; + const step = action.steps.find((s: any) => s.stepName === 'parsePackFile'); + expect(step).toBeTruthy(); + expect(step.error).toBe(false); - expect(action.commitData).to.be.an('array').with.lengthOf(0); + expect(action.branch).toBe(ref); + expect(action.setCommit).toHaveBeenCalledWith(EMPTY_COMMIT_HASH, newCommit); + expect(action.commitData).toHaveLength(0); }); }); @@ -887,44 +818,43 @@ describe('parsePackFile', () => { const buffer = createSamplePackBuffer(5); // 5 entries const [meta, contentBuff] = getPackMeta(buffer); - expect(meta).to.deep.equal({ + expect(meta).toEqual({ sig: PACK_SIGNATURE, version: 2, entries: 5, }); - expect(contentBuff).to.be.instanceOf(Buffer); - expect(contentBuff.length).to.equal(buffer.length - 12); // Remaining buffer after header + expect(contentBuff).toBeInstanceOf(Buffer); + expect(contentBuff.length).toBe(buffer.length - 12); // Remaining buffer after header }); it('should handle buffer exactly 12 bytes long', () => { const buffer = createSamplePackBuffer(1).slice(0, 12); // Only header const [meta, contentBuff] = getPackMeta(buffer); - expect(meta).to.deep.equal({ + expect(meta).toEqual({ sig: PACK_SIGNATURE, version: 2, entries: 1, }); - expect(contentBuff.length).to.equal(0); // No content left + expect(contentBuff.length).toBe(0); // No content left }); }); - describe('getCommitData', () => { it('should return empty array if no type 1 contents', () => { const contents = [ { type: 2, content: 'blob' }, { type: 3, content: 'tree' }, ]; - expect(getCommitData(contents)).to.deep.equal([]); + expect(getCommitData(contents as any)).toEqual([]); }); it('should parse a single valid commit object', () => { const commitContent = `tree 123\nparent 456\nauthor Au Thor 111 +0000\ncommitter Com Itter 222 +0100\n\nCommit message here`; const contents = [{ type: 1, content: commitContent }]; - const result = getCommitData(contents); + const result = getCommitData(contents as any); - expect(result).to.be.an('array').with.lengthOf(1); - expect(result[0]).to.deep.equal({ + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ tree: '123', parent: '456', author: 'Au Thor', @@ -945,69 +875,71 @@ describe('parsePackFile', () => { { type: 1, content: commit2 }, ]; - const result = getCommitData(contents); - expect(result).to.be.an('array').with.lengthOf(2); + const result = getCommitData(contents as any); + expect(result).toHaveLength(2); // Check first commit data - expect(result[0].message).to.equal('Msg1'); - expect(result[0].parent).to.equal('000'); - expect(result[0].author).to.equal('A1'); - expect(result[0].committer).to.equal('C1'); - expect(result[0].authorEmail).to.equal('a1@e.com'); - expect(result[0].commitTimestamp).to.equal('1678880002'); + expect(result[0].message).toBe('Msg1'); + expect(result[0].parent).toBe('000'); + expect(result[0].author).toBe('A1'); + expect(result[0].committer).toBe('C1'); + expect(result[0].authorEmail).toBe('a1@e.com'); + expect(result[0].commitTimestamp).toBe('1678880002'); // Check second commit data - expect(result[1].message).to.equal('Msg2'); - expect(result[1].parent).to.equal('111'); - expect(result[1].author).to.equal('A2'); - expect(result[1].committer).to.equal('C2'); - expect(result[1].authorEmail).to.equal('a2@e.com'); - expect(result[1].commitTimestamp).to.equal('1678880004'); + expect(result[1].message).toBe('Msg2'); + expect(result[1].parent).toBe('111'); + expect(result[1].author).toBe('A2'); + expect(result[1].committer).toBe('C2'); + expect(result[1].authorEmail).toBe('a2@e.com'); + expect(result[1].commitTimestamp).toBe('1678880004'); }); it('should default parent to zero hash if not present', () => { const commitContent = `tree 123\nauthor Au Thor 111 +0000\ncommitter Com Itter 222 +0100\n\nCommit message here`; const contents = [{ type: 1, content: commitContent }]; - const result = getCommitData(contents); - expect(result[0].parent).to.equal('0'.repeat(40)); + const result = getCommitData(contents as any); + expect(result[0].parent).toBe('0'.repeat(40)); }); it('should handle commit messages with multiple lines', () => { const commitContent = `tree 123\nparent 456\nauthor A 111 +0000\ncommitter C 222 +0100\n\nLine one\nLine two\n\nLine four`; const contents = [{ type: 1, content: commitContent }]; - const result = getCommitData(contents); - expect(result[0].message).to.equal('Line one\nLine two\n\nLine four'); + const result = getCommitData(contents as any); + expect(result[0].message).toBe('Line one\nLine two\n\nLine four'); }); it('should handle commits without a message body', () => { const commitContent = `tree 123\nparent 456\nauthor A 111 +0000\ncommitter C 222 +0100\n`; const contents = [{ type: 1, content: commitContent }]; - const result = getCommitData(contents); - expect(result[0].message).to.equal(''); + const result = getCommitData(contents as any); + expect(result[0].message).toBe(''); }); it('should throw error for invalid commit data (missing tree)', () => { const commitContent = `parent 456\nauthor A 1234567890 +0000\ncommitter C 1234567890 +0000\n\nMsg`; const contents = [{ type: 1, content: commitContent }]; - expect(() => getCommitData(contents)).to.throw('Invalid commit data: Missing tree'); + expect(() => getCommitData(contents as any)).toThrow('Invalid commit data: Missing tree'); }); it('should throw error for invalid commit data (missing author)', () => { const commitContent = `tree 123\nparent 456\ncommitter C 1234567890 +0000\n\nMsg`; const contents = [{ type: 1, content: commitContent }]; - expect(() => getCommitData(contents)).to.throw('Invalid commit data: Missing author'); + expect(() => getCommitData(contents as any)).toThrow('Invalid commit data: Missing author'); }); it('should throw error for invalid commit data (missing committer)', () => { const commitContent = `tree 123\nparent 456\nauthor A 1234567890 +0000\n\nMsg`; const contents = [{ type: 1, content: commitContent }]; - expect(() => getCommitData(contents)).to.throw('Invalid commit data: Missing committer'); + expect(() => getCommitData(contents as any)).toThrow( + 'Invalid commit data: Missing committer', + ); }); it('should throw error for invalid author line (missing timezone offset)', () => { const commitContent = `tree 123\nparent 456\nauthor A 1234567890\ncommitter C 1234567890 +0000\n\nMsg`; const contents = [{ type: 1, content: commitContent }]; - expect(() => getCommitData(contents)).to.throw('Failed to parse person line'); + expect(() => getCommitData(contents as any)).toThrow('Failed to parse person line'); }); it('should correctly parse a commit with a GPG signature header', () => { @@ -1043,29 +975,29 @@ describe('parsePackFile', () => { }, ]; - const result = getCommitData(contents); - expect(result).to.be.an('array').with.lengthOf(2); + const result = getCommitData(contents as any); + expect(result).toHaveLength(2); // Check the GPG signed commit data const gpgResult = result[0]; - expect(gpgResult.tree).to.equal('b4d3c0ffee1234567890abcdef1234567890aabbcc'); - expect(gpgResult.parent).to.equal('01dbeef9876543210fedcba9876543210fedcba'); - expect(gpgResult.author).to.equal('Test Author'); - expect(gpgResult.committer).to.equal('Test Committer'); - expect(gpgResult.authorEmail).to.equal('test.author@example.com'); - expect(gpgResult.commitTimestamp).to.equal('1744814610'); - expect(gpgResult.message).to.equal( + expect(gpgResult.tree).toBe('b4d3c0ffee1234567890abcdef1234567890aabbcc'); + expect(gpgResult.parent).toBe('01dbeef9876543210fedcba9876543210fedcba'); + expect(gpgResult.author).toBe('Test Author'); + expect(gpgResult.committer).toBe('Test Committer'); + expect(gpgResult.authorEmail).toBe('test.author@example.com'); + expect(gpgResult.commitTimestamp).toBe('1744814610'); + expect(gpgResult.message).toBe( `This is the commit message.\nIt can span multiple lines.\n\nAnd include blank lines internally.`, ); // Sanity check: the second commit should be the simple commit const simpleResult = result[1]; - expect(simpleResult.message).to.equal('Msg1'); - expect(simpleResult.parent).to.equal('000'); - expect(simpleResult.author).to.equal('A1'); - expect(simpleResult.committer).to.equal('C1'); - expect(simpleResult.authorEmail).to.equal('a1@e.com'); - expect(simpleResult.commitTimestamp).to.equal('1744814610'); + expect(simpleResult.message).toBe('Msg1'); + expect(simpleResult.parent).toBe('000'); + expect(simpleResult.author).toBe('A1'); + expect(simpleResult.committer).toBe('C1'); + expect(simpleResult.authorEmail).toBe('a1@e.com'); + expect(simpleResult.commitTimestamp).toBe('1744814610'); }); }); @@ -1076,24 +1008,24 @@ describe('parsePackFile', () => { const expectedOffset = buffer.length; // Should indicate the end of the buffer after flush packet const [parsedLines, offset] = parsePacketLines(buffer); - expect(parsedLines).to.deep.equal(lines); - expect(offset).to.equal(expectedOffset); + expect(parsedLines).toEqual(lines); + expect(offset).toBe(expectedOffset); }); it('should handle an empty input buffer', () => { const buffer = Buffer.alloc(0); const [parsedLines, offset] = parsePacketLines(buffer); - expect(parsedLines).to.deep.equal([]); - expect(offset).to.equal(0); + expect(parsedLines).toEqual([]); + expect(offset).toBe(0); }); it('should handle a buffer only with a flush packet', () => { const buffer = Buffer.from(FLUSH_PACKET); const [parsedLines, offset] = parsePacketLines(buffer); - expect(parsedLines).to.deep.equal([]); - expect(offset).to.equal(4); + expect(parsedLines).toEqual([]); + expect(offset).toBe(4); }); it('should handle lines with null characters correctly', () => { @@ -1102,8 +1034,8 @@ describe('parsePackFile', () => { const expectedOffset = buffer.length; const [parsedLines, offset] = parsePacketLines(buffer); - expect(parsedLines).to.deep.equal(lines); - expect(offset).to.equal(expectedOffset); + expect(parsedLines).toEqual(lines); + expect(offset).toBe(expectedOffset); }); it('should stop parsing at the first flush packet', () => { @@ -1117,33 +1049,33 @@ describe('parsePackFile', () => { const expectedOffset = buffer.length - extraData.length; const [parsedLines, offset] = parsePacketLines(buffer); - expect(parsedLines).to.deep.equal(lines); - expect(offset).to.equal(expectedOffset); + expect(parsedLines).toEqual(lines); + expect(offset).toBe(expectedOffset); }); it('should throw an error if a packet line length exceeds buffer bounds', () => { // 000A -> length 10, but actual line length is only 3 bytes const invalidLengthBuffer = Buffer.from('000Aabc'); - expect(() => parsePacketLines(invalidLengthBuffer)).to.throw( + expect(() => parsePacketLines(invalidLengthBuffer)).toThrow( /Invalid packet line length 000A/, ); }); it('should throw an error for non-hex length prefix (all non-hex)', () => { const invalidHexBuffer = Buffer.from('XXXXline'); - expect(() => parsePacketLines(invalidHexBuffer)).to.throw(/Invalid packet line length XXXX/); + expect(() => parsePacketLines(invalidHexBuffer)).toThrow(/Invalid packet line length XXXX/); }); it('should throw an error for non-hex length prefix (non-hex at the end)', () => { // Cover the quirk of parseInt returning 0 instead of NaN const invalidHexBuffer = Buffer.from('000zline'); - expect(() => parsePacketLines(invalidHexBuffer)).to.throw(/Invalid packet line length 000z/); + expect(() => parsePacketLines(invalidHexBuffer)).toThrow(/Invalid packet line length 000z/); }); it('should handle buffer ending exactly after a valid line length without content', () => { // 0008 -> length 8, but buffer ends after header (no content) const incompleteBuffer = Buffer.from('0008'); - expect(() => parsePacketLines(incompleteBuffer)).to.throw(/Invalid packet line length 0008/); + expect(() => parsePacketLines(incompleteBuffer)).toThrow(/Invalid packet line length 0008/); }); }); }); diff --git a/test/testProxy.test.js b/test/testProxy.test.js deleted file mode 100644 index 6927f25e1..000000000 --- a/test/testProxy.test.js +++ /dev/null @@ -1,308 +0,0 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const http = require('http'); -const https = require('https'); -const proxyquire = require('proxyquire'); - -const expect = chai.expect; - -describe('Proxy', () => { - let sandbox; - let Proxy; - let mockHttpServer; - let mockHttpsServer; - - beforeEach(() => { - sandbox = sinon.createSandbox(); - - mockHttpServer = { - listen: sandbox.stub().callsFake((port, callback) => { - if (callback) setImmediate(callback); - return mockHttpServer; - }), - close: sandbox.stub().callsFake((callback) => { - if (callback) setImmediate(callback); - return mockHttpServer; - }), - }; - - mockHttpsServer = { - listen: sandbox.stub().callsFake((port, callback) => { - if (callback) setImmediate(callback); - return mockHttpsServer; - }), - close: sandbox.stub().callsFake((callback) => { - if (callback) setImmediate(callback); - return mockHttpsServer; - }), - }; - - sandbox.stub(http, 'createServer').returns(mockHttpServer); - sandbox.stub(https, 'createServer').returns(mockHttpsServer); - - // deep mocking for express router - const mockRouter = sandbox.stub(); - mockRouter.use = sandbox.stub(); - mockRouter.get = sandbox.stub(); - mockRouter.post = sandbox.stub(); - mockRouter.stack = []; - - Proxy = proxyquire('../src/proxy/index', { - './routes': { - getRouter: sandbox.stub().resolves(mockRouter), - }, - '../config': { - getTLSEnabled: sandbox.stub().returns(false), - getTLSKeyPemPath: sandbox.stub().returns('/tmp/key.pem'), - getTLSCertPemPath: sandbox.stub().returns('/tmp/cert.pem'), - getPlugins: sandbox.stub().returns(['mock-plugin']), - getAuthorisedList: sandbox.stub().returns([{ project: 'test-proj', name: 'test-repo' }]), - }, - '../db': { - getRepos: sandbox.stub().resolves([]), - createRepo: sandbox.stub().resolves({ _id: 'mock-repo-id' }), - addUserCanPush: sandbox.stub().resolves(), - addUserCanAuthorise: sandbox.stub().resolves(), - }, - '../plugin': { - PluginLoader: sandbox.stub().returns({ - load: sandbox.stub().resolves(), - }), - }, - './chain': { - default: {}, - }, - '../config/env': { - serverConfig: { - GIT_PROXY_SERVER_PORT: 3000, - GIT_PROXY_HTTPS_SERVER_PORT: 3001, - }, - }, - fs: { - readFileSync: sandbox.stub().returns(Buffer.from('mock-cert')), - }, - }).default; - }); - - afterEach(() => { - sandbox.restore(); - }); - - describe('start()', () => { - it('should start HTTP server when TLS is disabled', async () => { - const proxy = new Proxy(); - - await proxy.start(); - - expect(http.createServer.calledOnce).to.be.true; - expect(https.createServer.called).to.be.false; - expect(mockHttpServer.listen.calledWith(3000)).to.be.true; - - await proxy.stop(); - }); - - it('should start both HTTP and HTTPS servers when TLS is enabled', async () => { - const mockRouterTLS = sandbox.stub(); - mockRouterTLS.use = sandbox.stub(); - mockRouterTLS.get = sandbox.stub(); - mockRouterTLS.post = sandbox.stub(); - mockRouterTLS.stack = []; - - const ProxyWithTLS = proxyquire('../src/proxy/index', { - './routes': { - getRouter: sandbox.stub().resolves(mockRouterTLS), - }, - '../config': { - getTLSEnabled: sandbox.stub().returns(true), // TLS enabled - getTLSKeyPemPath: sandbox.stub().returns('/tmp/key.pem'), - getTLSCertPemPath: sandbox.stub().returns('/tmp/cert.pem'), - getPlugins: sandbox.stub().returns(['mock-plugin']), - getAuthorisedList: sandbox.stub().returns([]), - }, - '../db': { - getRepos: sandbox.stub().resolves([]), - createRepo: sandbox.stub().resolves({ _id: 'mock-repo-id' }), - addUserCanPush: sandbox.stub().resolves(), - addUserCanAuthorise: sandbox.stub().resolves(), - }, - '../plugin': { - PluginLoader: sandbox.stub().returns({ - load: sandbox.stub().resolves(), - }), - }, - './chain': { - default: {}, - }, - '../config/env': { - serverConfig: { - GIT_PROXY_SERVER_PORT: 3000, - GIT_PROXY_HTTPS_SERVER_PORT: 3001, - }, - }, - fs: { - readFileSync: sandbox.stub().returns(Buffer.from('mock-cert')), - }, - }).default; - - const proxy = new ProxyWithTLS(); - - await proxy.start(); - - expect(http.createServer.calledOnce).to.be.true; - expect(https.createServer.calledOnce).to.be.true; - expect(mockHttpServer.listen.calledWith(3000)).to.be.true; - expect(mockHttpsServer.listen.calledWith(3001)).to.be.true; - - await proxy.stop(); - }); - - it('should set up express app after starting', async () => { - const proxy = new Proxy(); - expect(proxy.getExpressApp()).to.be.null; - - await proxy.start(); - - expect(proxy.getExpressApp()).to.not.be.null; - expect(proxy.getExpressApp()).to.be.a('function'); - - await proxy.stop(); - }); - }); - - describe('getExpressApp()', () => { - it('should return null before start() is called', () => { - const proxy = new Proxy(); - - expect(proxy.getExpressApp()).to.be.null; - }); - - it('should return express app after start() is called', async () => { - const proxy = new Proxy(); - - await proxy.start(); - - const app = proxy.getExpressApp(); - expect(app).to.not.be.null; - expect(app).to.be.a('function'); - expect(app.use).to.be.a('function'); - - await proxy.stop(); - }); - }); - - describe('stop()', () => { - it('should close HTTP server when running', async () => { - const proxy = new Proxy(); - await proxy.start(); - await proxy.stop(); - - expect(mockHttpServer.close.calledOnce).to.be.true; - }); - - it('should close both HTTP and HTTPS servers when both are running', async () => { - const mockRouterStop = sandbox.stub(); - mockRouterStop.use = sandbox.stub(); - mockRouterStop.get = sandbox.stub(); - mockRouterStop.post = sandbox.stub(); - mockRouterStop.stack = []; - - const ProxyWithTLS = proxyquire('../src/proxy/index', { - './routes': { - getRouter: sandbox.stub().resolves(mockRouterStop), - }, - '../config': { - getTLSEnabled: sandbox.stub().returns(true), - getTLSKeyPemPath: sandbox.stub().returns('/tmp/key.pem'), - getTLSCertPemPath: sandbox.stub().returns('/tmp/cert.pem'), - getPlugins: sandbox.stub().returns([]), - getAuthorisedList: sandbox.stub().returns([]), - }, - '../db': { - getRepos: sandbox.stub().resolves([]), - createRepo: sandbox.stub().resolves({ _id: 'mock-repo-id' }), - addUserCanPush: sandbox.stub().resolves(), - addUserCanAuthorise: sandbox.stub().resolves(), - }, - '../plugin': { - PluginLoader: sandbox.stub().returns({ - load: sandbox.stub().resolves(), - }), - }, - './chain': { - default: {}, - }, - '../config/env': { - serverConfig: { - GIT_PROXY_SERVER_PORT: 3000, - GIT_PROXY_HTTPS_SERVER_PORT: 3001, - }, - }, - fs: { - readFileSync: sandbox.stub().returns(Buffer.from('mock-cert')), - }, - }).default; - - const proxy = new ProxyWithTLS(); - await proxy.start(); - await proxy.stop(); - - expect(mockHttpServer.close.calledOnce).to.be.true; - expect(mockHttpsServer.close.calledOnce).to.be.true; - }); - - it('should resolve successfully when no servers are running', async () => { - const proxy = new Proxy(); - - await proxy.stop(); - - expect(mockHttpServer.close.called).to.be.false; - expect(mockHttpsServer.close.called).to.be.false; - }); - - it('should handle errors gracefully', async () => { - const proxy = new Proxy(); - await proxy.start(); - - // simulate error in server close - mockHttpServer.close.callsFake(() => { - throw new Error('Server close error'); - }); - - try { - await proxy.stop(); - expect.fail('Expected stop() to reject'); - } catch (error) { - expect(error.message).to.equal('Server close error'); - } - }); - }); - - describe('full lifecycle', () => { - it('should start and stop successfully', async () => { - const proxy = new Proxy(); - - await proxy.start(); - expect(proxy.getExpressApp()).to.not.be.null; - expect(mockHttpServer.listen.calledOnce).to.be.true; - - await proxy.stop(); - expect(mockHttpServer.close.calledOnce).to.be.true; - }); - - it('should handle multiple start/stop cycles', async () => { - const proxy = new Proxy(); - - await proxy.start(); - await proxy.stop(); - - mockHttpServer.listen.resetHistory(); - mockHttpServer.close.resetHistory(); - - await proxy.start(); - await proxy.stop(); - - expect(mockHttpServer.listen.calledOnce).to.be.true; - expect(mockHttpServer.close.calledOnce).to.be.true; - }); - }); -}); diff --git a/test/testProxy.test.ts b/test/testProxy.test.ts new file mode 100644 index 000000000..05a29a0b2 --- /dev/null +++ b/test/testProxy.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect, beforeEach, afterEach, vi, afterAll } from 'vitest'; + +vi.mock('http', async (importOriginal) => { + const actual: any = await importOriginal(); + return { + ...actual, + createServer: vi.fn(() => ({ + listen: vi.fn((port: number, cb: () => void) => { + cb(); + return { close: vi.fn((cb) => cb()) }; + }), + close: vi.fn((cb: () => void) => cb()), + })), + }; +}); + +vi.mock('https', async (importOriginal) => { + const actual: any = await importOriginal(); + return { + ...actual, + createServer: vi.fn(() => ({ + listen: vi.fn((port: number, cb: () => void) => { + cb(); + return { close: vi.fn((cb) => cb()) }; + }), + close: vi.fn((cb: () => void) => cb()), + })), + }; +}); + +vi.mock('../src/proxy/routes', () => ({ + getRouter: vi.fn(), +})); + +vi.mock('../src/config', () => ({ + getTLSEnabled: vi.fn(), + getTLSKeyPemPath: vi.fn(), + getTLSCertPemPath: vi.fn(), + getPlugins: vi.fn(), + getAuthorisedList: vi.fn(), +})); + +vi.mock('../src/db', () => ({ + getRepos: vi.fn(), + createRepo: vi.fn(), + addUserCanPush: vi.fn(), + addUserCanAuthorise: vi.fn(), +})); + +vi.mock('../src/plugin', () => ({ + PluginLoader: vi.fn(), +})); + +vi.mock('../src/proxy/chain', () => ({ + default: {}, +})); + +vi.mock('../src/config/env', () => ({ + serverConfig: { + GIT_PROXY_SERVER_PORT: 8001, + GIT_PROXY_HTTPS_SERVER_PORT: 8444, + }, +})); + +vi.mock('fs', async (importOriginal) => { + const actual: any = await importOriginal(); + return { + ...actual, + readFileSync: vi.fn(), + }; +}); + +// Import mocked modules +import * as http from 'http'; +import * as https from 'https'; +import * as routes from '../src/proxy/routes'; +import * as config from '../src/config'; +import * as db from '../src/db'; +import * as plugin from '../src/plugin'; +import * as fs from 'fs'; + +// Import the class under test +import Proxy from '../src/proxy/index'; + +interface MockServer { + listen: ReturnType; + close: ReturnType; +} + +interface MockRouter { + use: ReturnType; + get: ReturnType; + post: ReturnType; + stack: any[]; +} + +describe('Proxy', () => { + let proxy: Proxy; + let mockHttpServer: MockServer; + let mockHttpsServer: MockServer; + let mockRouter: MockRouter; + let mockPluginLoader: { load: ReturnType }; + + beforeEach(() => { + // Reset all mocks + vi.clearAllMocks(); + + proxy = new Proxy(); + + // Setup mock servers + mockHttpServer = { + listen: vi.fn().mockImplementation((port: number, callback?: () => void) => { + if (callback) setImmediate(callback); + return mockHttpServer; + }), + close: vi.fn().mockImplementation((callback?: () => void) => { + if (callback) setImmediate(callback); + return mockHttpServer; + }), + }; + + mockHttpsServer = { + listen: vi.fn().mockImplementation((port: number, callback?: () => void) => { + if (callback) setImmediate(callback); + return mockHttpsServer; + }), + close: vi.fn().mockImplementation((callback?: () => void) => { + if (callback) setImmediate(callback); + return mockHttpsServer; + }), + }; + + // Setup mock router - create a function that Express can use + const routerFunction = vi.fn(); + mockRouter = Object.assign(routerFunction, { + use: vi.fn(), + get: vi.fn(), + post: vi.fn(), + stack: [], + }); + + // Setup mock plugin loader + mockPluginLoader = { + load: vi.fn().mockResolvedValue(undefined), + }; + + // Configure mocks + vi.mocked(http.createServer).mockReturnValue(mockHttpServer as any); + vi.mocked(https.createServer).mockReturnValue(mockHttpsServer as any); + vi.mocked(routes.getRouter).mockResolvedValue(mockRouter as any); + vi.mocked(config.getTLSEnabled).mockReturnValue(false); + vi.mocked(config.getTLSKeyPemPath).mockReturnValue(undefined); + vi.mocked(config.getTLSCertPemPath).mockReturnValue(undefined); + vi.mocked(config.getPlugins).mockReturnValue(['mock-plugin']); + vi.mocked(config.getAuthorisedList).mockReturnValue([ + { project: 'test-proj', name: 'test-repo', url: 'test-url' }, + ]); + vi.mocked(db.getRepos).mockResolvedValue([]); + vi.mocked(db.createRepo).mockResolvedValue({ + _id: 'mock-repo-id', + project: 'test-proj', + name: 'test-repo', + url: 'test-url', + users: { canPush: [], canAuthorise: [] }, + }); + vi.mocked(db.addUserCanPush).mockResolvedValue(undefined); + vi.mocked(db.addUserCanAuthorise).mockResolvedValue(undefined); + vi.mocked(plugin.PluginLoader).mockReturnValue(mockPluginLoader as any); + vi.mocked(fs.readFileSync).mockReturnValue(Buffer.from('mock-cert')); + }); + + afterEach(() => { + vi.clearAllMocks(); + proxy.stop(); + }); + + afterAll(() => { + vi.resetModules(); + }); + + describe('start()', () => { + it('should start the HTTP server', async () => { + await proxy.start(); + const app = proxy.getExpressApp(); + expect(app).toBeTruthy(); + }); + + it('should set up express app after starting', async () => { + const proxy = new Proxy(); + expect(proxy.getExpressApp()).toBeNull(); + + await proxy.start(); + + expect(proxy.getExpressApp()).not.toBeNull(); + expect(proxy.getExpressApp()).toBeTypeOf('function'); + + await proxy.stop(); + }); + }); + + describe('getExpressApp()', () => { + it('should return null before start() is called', () => { + const proxy = new Proxy(); + + expect(proxy.getExpressApp()).toBeNull(); + }); + + it('should return express app after start() is called', async () => { + const proxy = new Proxy(); + + await proxy.start(); + + const app = proxy.getExpressApp(); + expect(app).not.toBeNull(); + expect(app).toBeTypeOf('function'); + expect((app as any).use).toBeTypeOf('function'); + + await proxy.stop(); + }); + }); + + describe('stop()', () => { + it('should stop without errors', async () => { + await proxy.start(); + await expect(proxy.stop()).resolves.toBeUndefined(); + }); + + it('should resolve successfully when no servers are running', async () => { + const proxy = new Proxy(); + + await proxy.stop(); + + expect(mockHttpServer.close).not.toHaveBeenCalled(); + expect(mockHttpsServer.close).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/test/testProxyRoute.test.js b/test/testProxyRoute.test.js deleted file mode 100644 index 47fd3b775..000000000 --- a/test/testProxyRoute.test.js +++ /dev/null @@ -1,562 +0,0 @@ -const { handleMessage, handleRefsErrorMessage, validGitRequest } = require('../src/proxy/routes'); -const chai = require('chai'); -const chaiHttp = require('chai-http'); -chai.use(chaiHttp); -chai.should(); -const expect = chai.expect; -const sinon = require('sinon'); -const express = require('express'); -const getRouter = require('../src/proxy/routes').getRouter; -const chain = require('../src/proxy/chain'); -const proxyquire = require('proxyquire'); -const { Action, Step } = require('../src/proxy/actions'); -const service = require('../src/service').default; -const db = require('../src/db'); - -import Proxy from '../src/proxy'; - -const TEST_DEFAULT_REPO = { - url: 'https://github.com/finos/git-proxy.git', - name: 'git-proxy', - project: 'finos/git-proxy', - host: 'github.com', - proxyUrlPrefix: '/github.com/finos/git-proxy.git', -}; - -const TEST_GITLAB_REPO = { - url: 'https://gitlab.com/gitlab-community/meta.git', - name: 'gitlab', - project: 'gitlab-community/meta', - host: 'gitlab.com', - proxyUrlPrefix: '/gitlab.com/gitlab-community/meta.git', -}; - -const TEST_UNKNOWN_REPO = { - url: 'https://github.com/finos/fdc3.git', - name: 'fdc3', - project: 'finos/fdc3', - host: 'github.com', - proxyUrlPrefix: '/github.com/finos/fdc3.git', - fallbackUrlPrefix: '/finos/fdc3.git', -}; - -describe('proxy route filter middleware', () => { - let app; - - beforeEach(async () => { - app = express(); - app.use('/', await getRouter()); - }); - - afterEach(() => { - sinon.restore(); - }); - - after(() => { - sinon.restore(); - }); - - it('should reject invalid git requests with 400', async () => { - const res = await chai - .request(app) - .get('/owner/repo.git/invalid/path') - .set('user-agent', 'git/2.42.0') - .set('accept', 'application/x-git-upload-pack-request'); - - expect(res).to.have.status(200); // status 200 is used to ensure error message is rendered by git client - expect(res.text).to.contain('Invalid request received'); - }); - - it('should handle blocked requests and return custom packet message', async () => { - sinon.stub(chain, 'executeChain').resolves({ - blocked: true, - blockedMessage: 'You shall not push!', - error: true, - }); - - const res = await chai - .request(app) - .post('/owner/repo.git/git-upload-pack') - .set('user-agent', 'git/2.42.0') - .set('accept', 'application/x-git-upload-pack-request') - .send(Buffer.from('0000')) - .buffer(); - - expect(res.status).to.equal(200); // status 200 is used to ensure error message is rendered by git client - expect(res.text).to.contain('You shall not push!'); - expect(res.headers['content-type']).to.include('application/x-git-receive-pack-result'); - expect(res.headers['x-frame-options']).to.equal('DENY'); - }); - - describe('when request is valid and not blocked', () => { - it('should return error if repo is not found', async () => { - sinon.stub(chain, 'executeChain').resolves({ - blocked: false, - blockedMessage: '', - error: false, - }); - - const res = await chai - .request(app) - .get('/owner/repo.git/info/refs?service=git-upload-pack') - .set('user-agent', 'git/2.42.0') - .set('accept', 'application/x-git-upload-pack-request') - .buffer(); - - expect(res.status).to.equal(401); - expect(res.text).to.equal('Repository not found.'); - }); - - it('should pass through if repo is found', async () => { - sinon.stub(chain, 'executeChain').resolves({ - blocked: false, - blockedMessage: '', - error: false, - }); - - const res = await chai - .request(app) - .get('/finos/git-proxy.git/info/refs?service=git-upload-pack') - .set('user-agent', 'git/2.42.0') - .set('accept', 'application/x-git-upload-pack-request') - .buffer(); - - expect(res.status).to.equal(200); - expect(res.text).to.contain('git-upload-pack'); - }); - }); -}); - -describe('proxy route helpers', () => { - describe('handleMessage', async () => { - it('should handle short messages', async function () { - const res = await handleMessage('one'); - expect(res).to.contain('one'); - }); - - it('should handle emoji messages', async function () { - const res = await handleMessage('❌ push failed: too many errors'); - expect(res).to.contain('❌'); - }); - }); - - describe('validGitRequest', () => { - it('should return true for /info/refs?service=git-upload-pack with valid user-agent', () => { - const res = validGitRequest('/info/refs?service=git-upload-pack', { - 'user-agent': 'git/2.30.1', - }); - expect(res).to.be.true; - }); - - it('should return true for /info/refs?service=git-receive-pack with valid user-agent', () => { - const res = validGitRequest('/info/refs?service=git-receive-pack', { - 'user-agent': 'git/1.9.1', - }); - expect(res).to.be.true; - }); - - it('should return false for /info/refs?service=git-upload-pack with missing user-agent', () => { - const res = validGitRequest('/info/refs?service=git-upload-pack', {}); - expect(res).to.be.false; - }); - - it('should return false for /info/refs?service=git-upload-pack with non-git user-agent', () => { - const res = validGitRequest('/info/refs?service=git-upload-pack', { - 'user-agent': 'curl/7.79.1', - }); - expect(res).to.be.false; - }); - - it('should return true for /git-upload-pack with valid user-agent and accept', () => { - const res = validGitRequest('/git-upload-pack', { - 'user-agent': 'git/2.40.0', - accept: 'application/x-git-upload-pack-request', - }); - expect(res).to.be.true; - }); - - it('should return false for /git-upload-pack with missing accept header', () => { - const res = validGitRequest('/git-upload-pack', { - 'user-agent': 'git/2.40.0', - }); - expect(res).to.be.false; - }); - - it('should return false for /git-upload-pack with wrong accept header', () => { - const res = validGitRequest('/git-upload-pack', { - 'user-agent': 'git/2.40.0', - accept: 'application/json', - }); - expect(res).to.be.false; - }); - - it('should return false for unknown paths', () => { - const res = validGitRequest('/not-a-valid-git-path', { - 'user-agent': 'git/2.40.0', - accept: 'application/x-git-upload-pack-request', - }); - expect(res).to.be.false; - }); - }); -}); - -describe('healthcheck route', () => { - let app; - - beforeEach(async () => { - app = express(); - app.use('/', await getRouter()); - }); - - it('returns 200 OK with no-cache headers', async () => { - const res = await chai.request(app).get('/healthcheck'); - - expect(res).to.have.status(200); - expect(res.text).to.equal('OK'); - - // Basic header checks (values defined in route) - expect(res).to.have.header( - 'cache-control', - 'no-cache, no-store, must-revalidate, proxy-revalidate', - ); - expect(res).to.have.header('pragma', 'no-cache'); - expect(res).to.have.header('expires', '0'); - expect(res).to.have.header('surrogate-control', 'no-store'); - }); -}); - -describe('proxyFilter function', async () => { - let proxyRoutes; - let req; - let res; - let actionToReturn; - let executeChainStub; - - beforeEach(async () => { - executeChainStub = sinon.stub(); - - // Re-import the proxy routes module and stub executeChain - proxyRoutes = proxyquire('../src/proxy/routes', { - '../chain': { executeChain: executeChainStub }, - }); - - req = { - url: '/github.com/finos/git-proxy.git/info/refs?service=git-receive-pack', - headers: { - host: 'dummyHost', - 'user-agent': 'git/dummy-git-client', - accept: 'application/x-git-receive-pack-request', - }, - }; - res = { - set: () => {}, - status: () => { - return { - send: () => {}, - }; - }, - }; - }); - - afterEach(() => { - sinon.restore(); - }); - - it('should return false for push requests that should be blocked', async function () { - // mock the executeChain function - actionToReturn = new Action( - 1234, - 'dummy', - 'dummy', - Date.now(), - '/github.com/finos/git-proxy.git', - ); - const step = new Step('dummy', false, null, true, 'test block', null); - actionToReturn.addStep(step); - executeChainStub.returns(actionToReturn); - const result = await proxyRoutes.proxyFilter(req, res); - expect(result).to.be.false; - }); - - it('should return false for push requests that produced errors', async function () { - // mock the executeChain function - actionToReturn = new Action( - 1234, - 'dummy', - 'dummy', - Date.now(), - '/github.com/finos/git-proxy.git', - ); - const step = new Step('dummy', true, 'test error', false, null, null); - actionToReturn.addStep(step); - executeChainStub.returns(actionToReturn); - const result = await proxyRoutes.proxyFilter(req, res); - expect(result).to.be.false; - }); - - it('should return false for invalid push requests', async function () { - // mock the executeChain function - actionToReturn = new Action( - 1234, - 'dummy', - 'dummy', - Date.now(), - '/github.com/finos/git-proxy.git', - ); - const step = new Step('dummy', true, 'test error', false, null, null); - actionToReturn.addStep(step); - executeChainStub.returns(actionToReturn); - - // create an invalid request - req = { - url: '/github.com/finos/git-proxy.git/invalidPath', - headers: { - host: 'dummyHost', - 'user-agent': 'git/dummy-git-client', - accept: 'application/x-git-receive-pack-request', - }, - }; - - const result = await proxyRoutes.proxyFilter(req, res); - expect(result).to.be.false; - }); - - it('should return true for push requests that are valid and pass the chain', async function () { - // mock the executeChain function - actionToReturn = new Action( - 1234, - 'dummy', - 'dummy', - Date.now(), - '/github.com/finos/git-proxy.git', - ); - const step = new Step('dummy', false, null, false, null, null); - actionToReturn.addStep(step); - executeChainStub.returns(actionToReturn); - const result = await proxyRoutes.proxyFilter(req, res); - expect(result).to.be.true; - }); - - it('should handle GET /info/refs with blocked action using Git protocol error format', async () => { - const req = { - url: '/proj/repo.git/info/refs?service=git-upload-pack', - method: 'GET', - headers: { - host: 'localhost', - 'user-agent': 'git/2.34.1', - }, - }; - const res = { - set: sinon.spy(), - status: sinon.stub().returnsThis(), - send: sinon.spy(), - }; - - const actionToReturn = { - blocked: true, - blockedMessage: 'Repository not in authorised list', - }; - - executeChainStub.returns(actionToReturn); - const result = await proxyRoutes.proxyFilter(req, res); - - expect(result).to.be.false; - - const expectedPacket = handleRefsErrorMessage('Repository not in authorised list'); - - expect(res.set.calledWith('content-type', 'application/x-git-upload-pack-advertisement')).to.be - .true; - expect(res.status.calledWith(200)).to.be.true; - expect(res.send.calledWith(expectedPacket)).to.be.true; - }); -}); - -describe('proxy express application', async () => { - let apiApp; - let cookie; - let proxy; - - const setCookie = function (res) { - res.headers['set-cookie'].forEach((x) => { - if (x.startsWith('connect')) { - const value = x.split(';')[0]; - cookie = value; - } - }); - }; - - const cleanupRepo = async (url) => { - const repo = await db.getRepoByUrl(url); - if (repo) { - await db.deleteRepo(repo._id); - } - }; - - before(async () => { - // start the API and proxy - proxy = new Proxy(); - apiApp = await service.start(proxy); - await proxy.start(); - - const res = await chai.request(apiApp).post('/api/auth/login').send({ - username: 'admin', - password: 'admin', - }); - expect(res).to.have.cookie('connect.sid'); - setCookie(res); - - // if our default repo is not set-up, create it - const repo = await db.getRepoByUrl(TEST_DEFAULT_REPO.url); - if (!repo) { - const res2 = await chai - .request(apiApp) - .post('/api/v1/repo') - .set('Cookie', `${cookie}`) - .send(TEST_DEFAULT_REPO); - res2.should.have.status(200); - } - }); - - after(async () => { - sinon.restore(); - await service.stop(); - await proxy.stop(); - await cleanupRepo(TEST_DEFAULT_REPO.url); - await cleanupRepo(TEST_GITLAB_REPO.url); - }); - - it('should proxy requests for the default GitHub repository', async function () { - // proxy a fetch request - const res = await chai - .request(proxy.getExpressApp()) - .get(`${TEST_DEFAULT_REPO.proxyUrlPrefix}/info/refs?service=git-upload-pack`) - .set('user-agent', 'git/2.42.0') - .set('accept', 'application/x-git-upload-pack-request') - .buffer(); - - expect(res.status).to.equal(200); - expect(res.text).to.contain('git-upload-pack'); - }); - - it('should proxy requests for the default GitHub repository using the fallback URL', async function () { - // proxy a fetch request using a fallback URL - const res = await chai - .request(proxy.getExpressApp()) - .get(`${TEST_DEFAULT_REPO.proxyUrlPrefix}/info/refs?service=git-upload-pack`) - .set('user-agent', 'git/2.42.0') - .set('accept', 'application/x-git-upload-pack-request') - .buffer(); - - expect(res.status).to.equal(200); - expect(res.text).to.contain('git-upload-pack'); - }); - - it('should be restarted by the api and proxy requests for a new host (e.g. gitlab.com) when a project at that host is ADDED via the API', async function () { - // Tests that the proxy restarts properly after a project with a URL at a new host is added - - // check that we don't have *any* repos at gitlab.com setup - const numExistingGitlabRepos = (await db.getRepos({ url: /https:\/\/gitlab\.com/ })).length; - expect( - numExistingGitlabRepos, - 'There is a GitLab that exists in the database already, which is NOT expected when running this test', - ).to.be.equal(0); - - // create the repo through the API, which should force the proxy to restart to handle the new domain - const res = await chai - .request(apiApp) - .post('/api/v1/repo') - .set('Cookie', `${cookie}`) - .send(TEST_GITLAB_REPO); - res.should.have.status(200); - - // confirm that the repo was created in the DB - const repo = await db.getRepoByUrl(TEST_GITLAB_REPO.url); - expect(repo).to.not.be.null; - - // and that our initial query for repos would have picked it up - const numCurrentGitlabRepos = (await db.getRepos({ url: /https:\/\/gitlab\.com/ })).length; - expect(numCurrentGitlabRepos).to.be.equal(1); - - // proxy a request to the new repo - const res2 = await chai - .request(proxy.getExpressApp()) - .get(`${TEST_GITLAB_REPO.proxyUrlPrefix}/info/refs?service=git-upload-pack`) - .set('user-agent', 'git/2.42.0') - .set('accept', 'application/x-git-upload-pack-request') - .buffer(); - - res2.should.have.status(200); - expect(res2.text).to.contain('git-upload-pack'); - }).timeout(5000); - - it('should be restarted by the api and stop proxying requests for a host (e.g. gitlab.com) when the last project at that host is DELETED via the API', async function () { - // We are testing that the proxy stops proxying requests for a particular origin - // The chain is stubbed and will always passthrough requests, hence, we are only checking what hosts are proxied. - - // the gitlab test repo should already exist - let repo = await db.getRepoByUrl(TEST_GITLAB_REPO.url); - expect(repo).to.not.be.null; - - // delete the gitlab test repo, which should force the proxy to restart and stop proxying gitlab.com - // We assume that there are no other gitlab.com repos present - const res = await chai - .request(apiApp) - .delete('/api/v1/repo/' + repo._id + '/delete') - .set('Cookie', `${cookie}`) - .send(); - res.should.have.status(200); - - // confirm that its gone from the DB - repo = await db.getRepoByUrl(TEST_GITLAB_REPO.url); - expect( - repo, - 'The GitLab repo still existed in the database after it should have been deleted...', - ).to.be.null; - - // give the proxy half a second to restart - await new Promise((resolve) => setTimeout(resolve, 500)); - - // try (and fail) to proxy a request to gitlab.com - const res2 = await chai - .request(proxy.getExpressApp()) - .get(`${TEST_GITLAB_REPO.proxyUrlPrefix}/info/refs?service=git-upload-pack`) - .set('user-agent', 'git/2.42.0') - .set('accept', 'application/x-git-upload-pack-request') - .buffer(); - - res2.should.have.status(200); // status 200 is used to ensure error message is rendered by git client - expect(res2.text).to.contain('Rejecting repo'); - }).timeout(5000); - - it('should not proxy requests for an unknown project', async function () { - // We are testing that the proxy stops proxying requests for a particular origin - // The chain is stubbed and will always passthrough requests, hence, we are only checking what hosts are proxied. - - // the gitlab test repo should already exist - const repo = await db.getRepoByUrl(TEST_UNKNOWN_REPO.url); - expect( - repo, - 'The unknown (but real) repo existed in the database which is not expected for this test', - ).to.be.null; - - // try (and fail) to proxy a request to the repo directly - const res = await chai - .request(proxy.getExpressApp()) - .get(`${TEST_UNKNOWN_REPO.proxyUrlPrefix}/info/refs?service=git-upload-pack`) - .set('user-agent', 'git/2.42.0') - .set('accept', 'application/x-git-upload-pack-request') - .buffer(); - res.should.have.status(200); // status 200 is used to ensure error message is rendered by git client - expect(res.text).to.contain('Rejecting repo'); - - // try (and fail) to proxy a request to the repo via the fallback URL directly - const res2 = await chai - .request(proxy.getExpressApp()) - .get(`${TEST_UNKNOWN_REPO.fallbackUrlPrefix}/info/refs?service=git-upload-pack`) - .set('user-agent', 'git/2.42.0') - .set('accept', 'application/x-git-upload-pack-request') - .buffer(); - res2.should.have.status(200); - expect(res2.text).to.contain('Rejecting repo'); - }).timeout(5000); -}); diff --git a/test/testProxyRoute.test.ts b/test/testProxyRoute.test.ts new file mode 100644 index 000000000..0299720b4 --- /dev/null +++ b/test/testProxyRoute.test.ts @@ -0,0 +1,710 @@ +import request from 'supertest'; +import express, { Express, Request, Response } from 'express'; +import { describe, it, beforeEach, afterEach, expect, vi, beforeAll, afterAll } from 'vitest'; + +import { Action, Step } from '../src/proxy/actions'; +import * as chain from '../src/proxy/chain'; +import * as helper from '../src/proxy/routes/helper'; +import Proxy from '../src/proxy'; +import { + handleMessage, + validGitRequest, + getRouter, + handleRefsErrorMessage, + proxyFilter, +} from '../src/proxy/routes'; + +import * as db from '../src/db'; +import service from '../src/service'; + +const TEST_DEFAULT_REPO = { + url: 'https://github.com/finos/git-proxy.git', + name: 'git-proxy', + project: 'finos/git-proxy', + host: 'github.com', + proxyUrlPrefix: '/github.com/finos/git-proxy.git', +}; + +const TEST_GITLAB_REPO = { + url: 'https://gitlab.com/gitlab-community/meta.git', + name: 'gitlab', + project: 'gitlab-community/meta', + host: 'gitlab.com', + proxyUrlPrefix: '/gitlab.com/gitlab-community/meta.git', +}; + +const TEST_UNKNOWN_REPO = { + url: 'https://github.com/finos/fdc3.git', + name: 'fdc3', + project: 'finos/fdc3', + host: 'github.com', + proxyUrlPrefix: '/github.com/finos/fdc3.git', + fallbackUrlPrefix: '/finos/fdc3.git', +}; + +afterAll(() => { + vi.resetModules(); +}); + +describe.skip('proxy express application', () => { + let apiApp: Express; + let proxy: Proxy; + let cookie: string; + + const setCookie = (res: request.Response) => { + const cookies = res.headers['set-cookie']; + if (cookies) { + for (const x of cookies) { + if (x.startsWith('connect')) { + cookie = x.split(';')[0]; + break; + } + } + } + }; + + const cleanupRepo = async (url: string) => { + const repo = await db.getRepoByUrl(url); + if (repo) { + await db.deleteRepo(repo._id!); + } + }; + + beforeAll(async () => { + // start the API and proxy + proxy = new Proxy(); + apiApp = await service.start(proxy); + await proxy.start(); + + const res = await request(apiApp) + .post('/api/auth/login') + .send({ username: 'admin', password: 'admin' }); + + expect(res.headers['set-cookie']).toBeDefined(); + setCookie(res); + + // if our default repo is not set-up, create it + const repo = await db.getRepoByUrl(TEST_DEFAULT_REPO.url); + if (!repo) { + const res2 = await request(apiApp) + .post('/api/v1/repo') + .set('Cookie', cookie) + .send(TEST_DEFAULT_REPO); + expect(res2.status).toBe(200); + } + }); + + afterAll(async () => { + vi.restoreAllMocks(); + await service.stop(); + await proxy.stop(); + await cleanupRepo(TEST_DEFAULT_REPO.url); + await cleanupRepo(TEST_GITLAB_REPO.url); + }); + + it('should proxy requests for the default GitHub repository', async () => { + // proxy a fetch request + const res = await request(proxy.getExpressApp()!) + .get(`${TEST_DEFAULT_REPO.proxyUrlPrefix}/info/refs?service=git-upload-pack`) + .set('user-agent', 'git/2.42.0') + .set('accept', 'application/x-git-upload-pack-request'); + + expect(res.status).toBe(200); + expect(res.text).toContain('git-upload-pack'); + }); + + it('should proxy requests for the default GitHub repository using the fallback URL', async () => { + // proxy a fetch request using a fallback URL + const res = await request(proxy.getExpressApp()!) + .get(`${TEST_DEFAULT_REPO.proxyUrlPrefix}/info/refs?service=git-upload-pack`) + .set('user-agent', 'git/2.42.0') + .set('accept', 'application/x-git-upload-pack-request'); + + expect(res.status).toBe(200); + expect(res.text).toContain('git-upload-pack'); + }); + + it('should restart and proxy for a new host when project is ADDED', async () => { + // Tests that the proxy restarts properly after a project with a URL at a new host is added + + // check that we don't have *any* repos at gitlab.com setup + const numExisting = (await db.getRepos({ url: /https:\/\/gitlab\.com/ as any })).length; + expect(numExisting).toBe(0); + + // create the repo through the API, which should force the proxy to restart to handle the new domain + const res = await request(apiApp) + .post('/api/v1/repo') + .set('Cookie', cookie) + .send(TEST_GITLAB_REPO); + expect(res.status).toBe(200); + + // confirm that the repo was created in the DB + const repo = await db.getRepoByUrl(TEST_GITLAB_REPO.url); + expect(repo).not.toBeNull(); + + // and that our initial query for repos would have picked it up + const numCurrent = (await db.getRepos({ url: /https:\/\/gitlab\.com/ as any })).length; + expect(numCurrent).toBe(1); + + // proxy a request to the new repo + const res2 = await request(proxy.getExpressApp()!) + .get(`${TEST_GITLAB_REPO.proxyUrlPrefix}/info/refs?service=git-upload-pack`) + .set('user-agent', 'git/2.42.0') + .set('accept', 'application/x-git-upload-pack-request'); + + expect(res2.status).toBe(200); + expect(res2.text).toContain('git-upload-pack'); + }, 5000); + + it('should restart and stop proxying for a host when project is DELETED', async () => { + // We are testing that the proxy stops proxying requests for a particular origin + // The chain is stubbed and will always passthrough requests, hence, we are only checking what hosts are proxied. + + // the gitlab test repo should already exist + let repo = await db.getRepoByUrl(TEST_GITLAB_REPO.url); + expect(repo).not.toBeNull(); + + // delete the gitlab test repo, which should force the proxy to restart and stop proxying gitlab.com + // We assume that there are no other gitlab.com repos present + const res = await request(apiApp) + .delete(`/api/v1/repo/${repo?._id}/delete`) + .set('Cookie', cookie); + expect(res.status).toBe(200); + + // confirm that its gone from the DB + repo = await db.getRepoByUrl(TEST_GITLAB_REPO.url); + expect(repo).toBeNull(); + + // give the proxy half a second to restart + await new Promise((r) => setTimeout(r, 500)); + + // try (and fail) to proxy a request to gitlab.com + const res2 = await request(proxy.getExpressApp()!) + .get(`${TEST_GITLAB_REPO.proxyUrlPrefix}/info/refs?service=git-upload-pack`) + .set('user-agent', 'git/2.42.0') + .set('accept', 'application/x-git-upload-pack-request'); + + expect(res2.status).toBe(200); // status 200 is used to ensure error message is rendered by git client + expect(res2.text).toContain('Rejecting repo'); + }, 5000); + + it('should not proxy requests for an unknown project', async () => { + // We are testing that the proxy stops proxying requests for a particular origin + // The chain is stubbed and will always passthrough requests, hence, we are only checking what hosts are proxied. + + // the unknown test repo should already exist + const repo = await db.getRepoByUrl(TEST_UNKNOWN_REPO.url); + expect(repo).toBeNull(); + + // try (and fail) to proxy a request to the repo directly + const res = await request(proxy.getExpressApp()!) + .get(`${TEST_UNKNOWN_REPO.proxyUrlPrefix}/info/refs?service=git-upload-pack`) + .set('user-agent', 'git/2.42.0') + .set('accept', 'application/x-git-upload-pack-request'); + + expect(res.status).toBe(200); // status 200 is used to ensure error message is rendered by git client + expect(res.text).toContain('Rejecting repo'); + + // try (and fail) to proxy a request to the repo via the fallback URL directly + const res2 = await request(proxy.getExpressApp()!) + .get(`${TEST_UNKNOWN_REPO.fallbackUrlPrefix}/info/refs?service=git-upload-pack`) + .set('user-agent', 'git/2.42.0') + .set('accept', 'application/x-git-upload-pack-request'); + + expect(res2.status).toBe(200); + expect(res2.text).toContain('Rejecting repo'); + }, 5000); +}); + +describe('handleRefsErrorMessage', () => { + it('should format refs error message correctly', () => { + const message = 'Repository not found'; + const result = handleRefsErrorMessage(message); + + expect(result).toMatch(/^[0-9a-f]{4}ERR /); + expect(result).toContain(message); + expect(result).toContain('\n0000'); + }); + + it('should calculate correct length for refs error', () => { + const message = 'Access denied'; + const result = handleRefsErrorMessage(message); + + const lengthHex = result.substring(0, 4); + const length = parseInt(lengthHex, 16); + + const errorBody = `ERR ${message}`; + expect(length).toBe(4 + Buffer.byteLength(errorBody)); + }); +}); + +describe('proxyFilter', () => { + let mockReq: Partial; + let mockRes: Partial; + let statusMock: ReturnType; + let sendMock: ReturnType; + let setMock: ReturnType; + + beforeEach(() => { + // setup mock response + statusMock = vi.fn().mockReturnThis(); + sendMock = vi.fn().mockReturnThis(); + setMock = vi.fn().mockReturnThis(); + + mockRes = { + status: statusMock, + send: sendMock, + set: setMock, + }; + + // setup mock request + mockReq = { + url: '/github.com/finos/git-proxy.git/info/refs?service=git-upload-pack', + method: 'GET', + headers: { + host: 'localhost:8080', + 'user-agent': 'git/2.30.0', + }, + }; + + // reduces console noise + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('Valid requests', () => { + it('should allow valid GET request to info/refs', async () => { + // mock helpers to return valid data + vi.spyOn(helper, 'processUrlPath').mockReturnValue({ + gitPath: '/finos/git-proxy.git/info/refs', + repoPath: 'github.com', + }); + vi.spyOn(helper, 'validGitRequest').mockReturnValue(true); + + // mock executeChain to return allowed action + vi.spyOn(chain, 'executeChain').mockResolvedValue({ + error: false, + blocked: false, + } as Action); + + const result = await proxyFilter?.(mockReq as Request, mockRes as Response); + + expect(result).toBe(true); + expect(statusMock).not.toHaveBeenCalled(); + expect(sendMock).not.toHaveBeenCalled(); + }); + + it('should allow valid POST request to git-receive-pack', async () => { + mockReq.method = 'POST'; + mockReq.url = '/github.com/finos/git-proxy.git/git-receive-pack'; + + vi.spyOn(helper, 'processUrlPath').mockReturnValue({ + gitPath: '/finos/git-proxy.git/git-receive-pack', + repoPath: 'github.com', + }); + vi.spyOn(helper, 'validGitRequest').mockReturnValue(true); + + vi.spyOn(chain, 'executeChain').mockResolvedValue({ + error: false, + blocked: false, + } as Action); + + const result = await proxyFilter?.(mockReq as Request, mockRes as Response); + + expect(result).toBe(true); + }); + + it('should handle bodyRaw for POST pack requests', async () => { + mockReq.method = 'POST'; + mockReq.url = '/github.com/finos/git-proxy.git/git-upload-pack'; + (mockReq as any).bodyRaw = Buffer.from('test data'); + + vi.spyOn(helper, 'processUrlPath').mockReturnValue({ + gitPath: '/finos/git-proxy.git/git-upload-pack', + repoPath: 'github.com', + }); + vi.spyOn(helper, 'validGitRequest').mockReturnValue(true); + + vi.spyOn(chain, 'executeChain').mockResolvedValue({ + error: false, + blocked: false, + } as Action); + + await proxyFilter?.(mockReq as Request, mockRes as Response); + + expect((mockReq as any).body).toEqual(Buffer.from('test data')); + expect((mockReq as any).bodyRaw).toBeUndefined(); + }); + }); + + describe('Invalid requests', () => { + it('should reject request with invalid URL components', async () => { + vi.spyOn(helper, 'processUrlPath').mockReturnValue(null); + + const result = await proxyFilter?.(mockReq as Request, mockRes as Response); + + expect(result).toBe(false); + expect(statusMock).toHaveBeenCalledWith(200); + expect(sendMock).toHaveBeenCalled(); + const sentMessage = sendMock.mock.calls[0][0]; + expect(sentMessage).toContain('Invalid request received'); + }); + + it('should reject request with empty gitPath', async () => { + vi.spyOn(helper, 'processUrlPath').mockReturnValue({ + gitPath: '', + repoPath: 'github.com', + }); + + const result = await proxyFilter?.(mockReq as Request, mockRes as Response); + + expect(result).toBe(false); + expect(statusMock).toHaveBeenCalledWith(200); + }); + + it('should reject invalid git request', async () => { + vi.spyOn(helper, 'processUrlPath').mockReturnValue({ + gitPath: '/finos/git-proxy.git/info/refs', + repoPath: 'github.com', + }); + vi.spyOn(helper, 'validGitRequest').mockReturnValue(false); + + const result = await proxyFilter?.(mockReq as Request, mockRes as Response); + + expect(result).toBe(false); + expect(statusMock).toHaveBeenCalledWith(200); + }); + }); + + describe('Blocked requests', () => { + it('should handle blocked request with message', async () => { + vi.spyOn(helper, 'processUrlPath').mockReturnValue({ + gitPath: '/finos/git-proxy.git/info/refs', + repoPath: 'github.com', + }); + vi.spyOn(helper, 'validGitRequest').mockReturnValue(true); + + vi.spyOn(chain, 'executeChain').mockResolvedValue({ + error: false, + blocked: true, + blockedMessage: 'Repository blocked by policy', + } as Action); + + const result = await proxyFilter?.(mockReq as Request, mockRes as Response); + + expect(result).toBe(false); + expect(statusMock).toHaveBeenCalledWith(200); + expect(setMock).toHaveBeenCalledWith( + 'content-type', + 'application/x-git-upload-pack-advertisement', + ); + const sentMessage = sendMock.mock.calls[0][0]; + expect(sentMessage).toContain('Repository blocked by policy'); + }); + + it('should handle blocked POST request', async () => { + mockReq.method = 'POST'; + mockReq.url = '/github.com/finos/git-proxy.git/git-receive-pack'; + + vi.spyOn(helper, 'processUrlPath').mockReturnValue({ + gitPath: '/finos/git-proxy.git/git-receive-pack', + repoPath: 'github.com', + }); + vi.spyOn(helper, 'validGitRequest').mockReturnValue(true); + + vi.spyOn(chain, 'executeChain').mockResolvedValue({ + error: false, + blocked: true, + blockedMessage: 'Push blocked', + } as Action); + + const result = await proxyFilter?.(mockReq as Request, mockRes as Response); + + expect(result).toBe(false); + expect(setMock).toHaveBeenCalledWith('content-type', 'application/x-git-receive-pack-result'); + }); + }); + + describe('Error handling', () => { + it('should handle error from executeChain', async () => { + vi.spyOn(helper, 'processUrlPath').mockReturnValue({ + gitPath: '/finos/git-proxy.git/info/refs', + repoPath: 'github.com', + }); + vi.spyOn(helper, 'validGitRequest').mockReturnValue(true); + + vi.spyOn(chain, 'executeChain').mockResolvedValue({ + error: true, + blocked: false, + errorMessage: 'Chain execution failed', + } as Action); + + const result = await proxyFilter?.(mockReq as Request, mockRes as Response); + + expect(result).toBe(false); + expect(statusMock).toHaveBeenCalledWith(200); + const sentMessage = sendMock.mock.calls[0][0]; + expect(sentMessage).toContain('Chain execution failed'); + }); + + it('should handle thrown exception', async () => { + vi.spyOn(helper, 'processUrlPath').mockReturnValue({ + gitPath: '/finos/git-proxy.git/info/refs', + repoPath: 'github.com', + }); + vi.spyOn(helper, 'validGitRequest').mockReturnValue(true); + + vi.spyOn(chain, 'executeChain').mockRejectedValue(new Error('Unexpected error')); + + const result = await proxyFilter?.(mockReq as Request, mockRes as Response); + + expect(result).toBe(false); + expect(statusMock).toHaveBeenCalledWith(200); + const sentMessage = sendMock.mock.calls[0][0]; + expect(sentMessage).toContain('Error occurred in proxy filter function'); + expect(sentMessage).toContain('Unexpected error'); + }); + + it('should use correct error format for GET /info/refs', async () => { + mockReq.method = 'GET'; + mockReq.url = '/github.com/finos/git-proxy.git/info/refs?service=git-upload-pack'; + + vi.spyOn(helper, 'processUrlPath').mockReturnValue({ + gitPath: '/finos/git-proxy.git/info/refs', + repoPath: 'github.com', + }); + vi.spyOn(helper, 'validGitRequest').mockReturnValue(true); + + vi.spyOn(chain, 'executeChain').mockResolvedValue({ + error: true, + blocked: false, + errorMessage: 'Test error', + } as Action); + + await proxyFilter?.(mockReq as Request, mockRes as Response); + + expect(setMock).toHaveBeenCalledWith( + 'content-type', + 'application/x-git-upload-pack-advertisement', + ); + const sentMessage = sendMock.mock.calls[0][0]; + + expect(sentMessage).toMatch(/^[0-9a-f]{4}ERR /); + }); + + it('should use standard error format for non-refs requests', async () => { + mockReq.method = 'POST'; + mockReq.url = '/github.com/finos/git-proxy.git/git-receive-pack'; + + vi.spyOn(helper, 'processUrlPath').mockReturnValue({ + gitPath: '/finos/git-proxy.git/git-receive-pack', + repoPath: 'github.com', + }); + vi.spyOn(helper, 'validGitRequest').mockReturnValue(true); + + vi.spyOn(chain, 'executeChain').mockResolvedValue({ + error: true, + blocked: false, + errorMessage: 'Test error', + } as Action); + + await proxyFilter?.(mockReq as Request, mockRes as Response); + + expect(setMock).toHaveBeenCalledWith('content-type', 'application/x-git-receive-pack-result'); + const sentMessage = sendMock.mock.calls[0][0]; + // should use handleMessage format + // eslint-disable-next-line no-control-regex + expect(sentMessage).toMatch(/^[0-9a-f]{4}\x02/); + }); + }); + + describe('Different git operations', () => { + it('should handle git-upload-pack request', async () => { + mockReq.method = 'POST'; + mockReq.url = '/gitlab.com/gitlab-community/meta.git/git-upload-pack'; + + vi.spyOn(helper, 'processUrlPath').mockReturnValue({ + gitPath: '/gitlab-community/meta.git/git-upload-pack', + repoPath: 'gitlab.com', + }); + vi.spyOn(helper, 'validGitRequest').mockReturnValue(true); + + vi.spyOn(chain, 'executeChain').mockResolvedValue({ + error: false, + blocked: false, + } as Action); + + const result = await proxyFilter?.(mockReq as Request, mockRes as Response); + + expect(result).toBe(true); + }); + + it('should handle different origins (GitLab)', async () => { + mockReq.url = '/gitlab.com/gitlab-community/meta.git/info/refs?service=git-upload-pack'; + mockReq.headers = { + ...mockReq.headers, + host: 'gitlab.com', + }; + + vi.spyOn(helper, 'processUrlPath').mockReturnValue({ + gitPath: '/gitlab-community/meta.git/info/refs', + repoPath: 'gitlab.com', + }); + vi.spyOn(helper, 'validGitRequest').mockReturnValue(true); + + vi.spyOn(chain, 'executeChain').mockResolvedValue({ + error: false, + blocked: false, + } as Action); + + const result = await proxyFilter?.(mockReq as Request, mockRes as Response); + + expect(result).toBe(true); + }); + }); +}); + +describe('proxy route helpers', () => { + describe('handleMessage', async () => { + it('should handle short messages', async () => { + const res = await handleMessage('one'); + expect(res).toContain('one'); + }); + + it('should handle emoji messages', async () => { + const res = await handleMessage('❌ push failed: too many errors'); + expect(res).toContain('❌'); + }); + }); + + describe('validGitRequest', () => { + it('should return true for /info/refs?service=git-upload-pack with valid user-agent', () => { + const res = validGitRequest('/info/refs?service=git-upload-pack', { + 'user-agent': 'git/2.30.1', + }); + expect(res).toBe(true); + }); + + it('should return true for /info/refs?service=git-receive-pack with valid user-agent', () => { + const res = validGitRequest('/info/refs?service=git-receive-pack', { + 'user-agent': 'git/1.9.1', + }); + expect(res).toBe(true); + }); + + it('should return false for /info/refs?service=git-upload-pack with missing user-agent', () => { + const res = validGitRequest('/info/refs?service=git-upload-pack', {}); + expect(res).toBe(false); + }); + + it('should return false for /info/refs?service=git-upload-pack with non-git user-agent', () => { + const res = validGitRequest('/info/refs?service=git-upload-pack', { + 'user-agent': 'curl/7.79.1', + }); + expect(res).toBe(false); + }); + + it('should return true for /git-upload-pack with valid user-agent and accept', () => { + const res = validGitRequest('/git-upload-pack', { + 'user-agent': 'git/2.40.0', + accept: 'application/x-git-upload-pack-request', + }); + expect(res).toBe(true); + }); + + it('should return false for /git-upload-pack with missing accept header', () => { + const res = validGitRequest('/git-upload-pack', { + 'user-agent': 'git/2.40.0', + }); + expect(res).toBe(false); + }); + + it('should return false for /git-upload-pack with wrong accept header', () => { + const res = validGitRequest('/git-upload-pack', { + 'user-agent': 'git/2.40.0', + accept: 'application/json', + }); + expect(res).toBe(false); + }); + + it('should return false for unknown paths', () => { + const res = validGitRequest('/not-a-valid-git-path', { + 'user-agent': 'git/2.40.0', + accept: 'application/x-git-upload-pack-request', + }); + expect(res).toBe(false); + }); + }); + + describe('handleMessage', () => { + it('should format error message correctly', () => { + const message = 'Test error message'; + const result = handleMessage(message); + + // eslint-disable-next-line no-control-regex + expect(result).toMatch(/^[0-9a-f]{4}\x02\t/); + expect(result).toContain(message); + expect(result).toContain('\n0000'); + }); + + it('should calculate correct length for message', () => { + const message = 'Error'; + const result = handleMessage(message); + + const lengthHex = result.substring(0, 4); + const length = parseInt(lengthHex, 16); + + const body = `\t${message}`; + expect(length).toBe(6 + Buffer.byteLength(body)); + }); + }); + + describe('handleRefsErrorMessage', () => { + it('should format refs error message correctly', () => { + const message = 'Repository not found'; + const result = handleRefsErrorMessage(message); + + expect(result).toMatch(/^[0-9a-f]{4}ERR /); + expect(result).toContain(message); + expect(result).toContain('\n0000'); + }); + + it('should calculate correct length for refs error', () => { + const message = 'Access denied'; + const result = handleRefsErrorMessage(message); + + const lengthHex = result.substring(0, 4); + const length = parseInt(lengthHex, 16); + + const errorBody = `ERR ${message}`; + expect(length).toBe(4 + Buffer.byteLength(errorBody)); + }); + }); +}); + +describe('healthcheck route', () => { + let app: Express; + + beforeEach(async () => { + app = express(); + app.use('/', await getRouter()); + }); + + it('returns 200 OK with no-cache headers', async () => { + const res = await request(app).get('/healthcheck'); + + expect(res.status).toBe(200); + expect(res.text).toBe('OK'); + + // basic header checks (values defined in route) + expect(res.headers['cache-control']).toBe( + 'no-cache, no-store, must-revalidate, proxy-revalidate', + ); + expect(res.headers['pragma']).toBe('no-cache'); + expect(res.headers['expires']).toBe('0'); + expect(res.headers['surrogate-control']).toBe('no-store'); + }); +}); diff --git a/test/testPush.test.js b/test/testPush.test.js deleted file mode 100644 index 696acafb0..000000000 --- a/test/testPush.test.js +++ /dev/null @@ -1,375 +0,0 @@ -// Import the dependencies for testing -const chai = require('chai'); -const chaiHttp = require('chai-http'); -const db = require('../src/db'); -const service = require('../src/service').default; - -chai.use(chaiHttp); -chai.should(); -const expect = chai.expect; - -// dummy repo -const TEST_ORG = 'finos'; -const TEST_REPO = 'test-push'; -const TEST_URL = 'https://github.com/finos/test-push.git'; -// approver user -const TEST_USERNAME_1 = 'push-test'; -const TEST_EMAIL_1 = 'push-test@test.com'; -const TEST_PASSWORD_1 = 'test1234'; -// committer user -const TEST_USERNAME_2 = 'push-test-2'; -const TEST_EMAIL_2 = 'push-test-2@test.com'; -const TEST_PASSWORD_2 = 'test5678'; -// unknown user -const TEST_USERNAME_3 = 'push-test-3'; -const TEST_EMAIL_3 = 'push-test-3@test.com'; - -const TEST_PUSH = { - steps: [], - error: false, - blocked: false, - allowPush: false, - authorised: false, - canceled: false, - rejected: false, - autoApproved: false, - autoRejected: false, - commitData: [], - id: '0000000000000000000000000000000000000000__1744380874110', - type: 'push', - method: 'get', - timestamp: 1744380903338, - project: TEST_ORG, - repoName: TEST_REPO + '.git', - url: TEST_URL, - repo: TEST_ORG + '/' + TEST_REPO + '.git', - user: TEST_USERNAME_2, - userEmail: TEST_EMAIL_2, - lastStep: null, - blockedMessage: - '\n\n\nGitProxy has received your push:\n\nhttp://localhost:8080/requests/0000000000000000000000000000000000000000__1744380874110\n\n\n', - _id: 'GIMEz8tU2KScZiTz', - attestation: null, -}; - -describe('auth', async () => { - let app; - let cookie; - let testRepo; - - const setCookie = function (res) { - res.headers['set-cookie'].forEach((x) => { - if (x.startsWith('connect')) { - const value = x.split(';')[0]; - cookie = value; - } - }); - }; - - const login = async function (username, password) { - console.log(`logging in as ${username}...`); - const res = await chai.request(app).post('/api/auth/login').send({ - username: username, - password: password, - }); - res.should.have.status(200); - expect(res).to.have.cookie('connect.sid'); - setCookie(res); - }; - - const loginAsApprover = () => login(TEST_USERNAME_1, TEST_PASSWORD_1); - const loginAsCommitter = () => login(TEST_USERNAME_2, TEST_PASSWORD_2); - const loginAsAdmin = () => login('admin', 'admin'); - - const logout = async function () { - const res = await chai.request(app).post('/api/auth/logout').set('Cookie', `${cookie}`); - res.should.have.status(200); - cookie = null; - }; - - before(async function () { - // remove existing repo and users if any - const oldRepo = await db.getRepoByUrl(TEST_URL); - if (oldRepo) { - await db.deleteRepo(oldRepo._id); - } - await db.deleteUser(TEST_USERNAME_1); - await db.deleteUser(TEST_USERNAME_2); - - app = await service.start(); - await loginAsAdmin(); - - // set up a repo, user and push to test against - testRepo = await db.createRepo({ - project: TEST_ORG, - name: TEST_REPO, - url: TEST_URL, - }); - - // Create a new user for the approver - console.log('creating approver'); - await db.createUser(TEST_USERNAME_1, TEST_PASSWORD_1, TEST_EMAIL_1, TEST_USERNAME_1, false); - await db.addUserCanAuthorise(testRepo._id, TEST_USERNAME_1); - - // create a new user for the committer - console.log('creating committer'); - await db.createUser(TEST_USERNAME_2, TEST_PASSWORD_2, TEST_EMAIL_2, TEST_USERNAME_2, false); - await db.addUserCanPush(testRepo._id, TEST_USERNAME_2); - - // logout of admin account - await logout(); - }); - - after(async function () { - await db.deleteRepo(testRepo._id); - await db.deleteUser(TEST_USERNAME_1); - await db.deleteUser(TEST_USERNAME_2); - }); - - describe('test push API', async function () { - afterEach(async function () { - await db.deletePush(TEST_PUSH.id); - await logout(); - }); - - it('should get 404 for unknown push', async function () { - await loginAsApprover(); - - const commitId = - '0000000000000000000000000000000000000000__79b4d8953cbc324bcc1eb53d6412ff89666c241f'; - const res = await chai - .request(app) - .get(`/api/v1/push/${commitId}`) - .set('Cookie', `${cookie}`); - res.should.have.status(404); - }); - - it('should allow an authorizer to approve a push', async function () { - await db.writeAudit(TEST_PUSH); - await loginAsApprover(); - const res = await chai - .request(app) - .post(`/api/v1/push/${TEST_PUSH.id}/authorise`) - .set('Cookie', `${cookie}`) - .set('content-type', 'application/x-www-form-urlencoded') - .send({ - params: { - attestation: [ - { - label: 'I am happy for this to be pushed to the upstream repository', - tooltip: { - text: 'Are you happy for this contribution to be pushed upstream?', - links: [], - }, - checked: true, - }, - ], - }, - }); - res.should.have.status(200); - }); - - it('should NOT allow an authorizer to approve if attestation is incomplete', async function () { - // make the approver also the committer - const testPush = { ...TEST_PUSH }; - testPush.user = TEST_USERNAME_1; - testPush.userEmail = TEST_EMAIL_1; - await db.writeAudit(testPush); - await loginAsApprover(); - const res = await chai - .request(app) - .post(`/api/v1/push/${TEST_PUSH.id}/authorise`) - .set('Cookie', `${cookie}`) - .set('content-type', 'application/x-www-form-urlencoded') - .send({ - params: { - attestation: [ - { - label: 'I am happy for this to be pushed to the upstream repository', - tooltip: { - text: 'Are you happy for this contribution to be pushed upstream?', - links: [], - }, - checked: false, - }, - ], - }, - }); - res.should.have.status(401); - }); - - it('should NOT allow an authorizer to approve if committer is unknown', async function () { - // make the approver also the committer - const testPush = { ...TEST_PUSH }; - testPush.user = TEST_USERNAME_3; - testPush.userEmail = TEST_EMAIL_3; - await db.writeAudit(testPush); - await loginAsApprover(); - const res = await chai - .request(app) - .post(`/api/v1/push/${TEST_PUSH.id}/authorise`) - .set('Cookie', `${cookie}`) - .set('content-type', 'application/x-www-form-urlencoded') - .send({ - params: { - attestation: [ - { - label: 'I am happy for this to be pushed to the upstream repository', - tooltip: { - text: 'Are you happy for this contribution to be pushed upstream?', - links: [], - }, - checked: true, - }, - ], - }, - }); - res.should.have.status(401); - }); - - it('should NOT allow an authorizer to approve their own push', async function () { - // make the approver also the committer - const testPush = { ...TEST_PUSH }; - testPush.user = TEST_USERNAME_1; - testPush.userEmail = TEST_EMAIL_1; - await db.writeAudit(testPush); - await loginAsApprover(); - const res = await chai - .request(app) - .post(`/api/v1/push/${TEST_PUSH.id}/authorise`) - .set('Cookie', `${cookie}`) - .set('content-type', 'application/x-www-form-urlencoded') - .send({ - params: { - attestation: [ - { - label: 'I am happy for this to be pushed to the upstream repository', - tooltip: { - text: 'Are you happy for this contribution to be pushed upstream?', - links: [], - }, - checked: true, - }, - ], - }, - }); - res.should.have.status(401); - }); - - it('should NOT allow a non-authorizer to approve a push', async function () { - await db.writeAudit(TEST_PUSH); - await loginAsCommitter(); - const res = await chai - .request(app) - .post(`/api/v1/push/${TEST_PUSH.id}/authorise`) - .set('Cookie', `${cookie}`) - .set('content-type', 'application/x-www-form-urlencoded') - .send({ - params: { - attestation: [ - { - label: 'I am happy for this to be pushed to the upstream repository', - tooltip: { - text: 'Are you happy for this contribution to be pushed upstream?', - links: [], - }, - checked: true, - }, - ], - }, - }); - res.should.have.status(401); - }); - - it('should allow an authorizer to reject a push', async function () { - await db.writeAudit(TEST_PUSH); - await loginAsApprover(); - const res = await chai - .request(app) - .post(`/api/v1/push/${TEST_PUSH.id}/reject`) - .set('Cookie', `${cookie}`); - res.should.have.status(200); - }); - - it('should NOT allow an authorizer to reject their own push', async function () { - // make the approver also the committer - const testPush = { ...TEST_PUSH }; - testPush.user = TEST_USERNAME_1; - testPush.userEmail = TEST_EMAIL_1; - await db.writeAudit(testPush); - await loginAsApprover(); - const res = await chai - .request(app) - .post(`/api/v1/push/${TEST_PUSH.id}/reject`) - .set('Cookie', `${cookie}`); - res.should.have.status(401); - }); - - it('should NOT allow a non-authorizer to reject a push', async function () { - await db.writeAudit(TEST_PUSH); - await loginAsCommitter(); - const res = await chai - .request(app) - .post(`/api/v1/push/${TEST_PUSH.id}/reject`) - .set('Cookie', `${cookie}`); - res.should.have.status(401); - }); - - it('should fetch all pushes', async function () { - await db.writeAudit(TEST_PUSH); - await loginAsApprover(); - const res = await chai.request(app).get('/api/v1/push').set('Cookie', `${cookie}`); - res.should.have.status(200); - res.body.should.be.an('array'); - - const push = res.body.find((push) => push.id === TEST_PUSH.id); - expect(push).to.exist; - expect(push).to.deep.equal(TEST_PUSH); - expect(push.canceled).to.be.false; - }); - - it('should allow a committer to cancel a push', async function () { - await db.writeAudit(TEST_PUSH); - await loginAsCommitter(); - const res = await chai - .request(app) - .post(`/api/v1/push/${TEST_PUSH.id}/cancel`) - .set('Cookie', `${cookie}`); - res.should.have.status(200); - - const pushes = await chai.request(app).get('/api/v1/push').set('Cookie', `${cookie}`); - const push = pushes.body.find((push) => push.id === TEST_PUSH.id); - - expect(push).to.exist; - expect(push.canceled).to.be.true; - }); - - it('should not allow a non-committer to cancel a push (even if admin)', async function () { - await db.writeAudit(TEST_PUSH); - await loginAsAdmin(); - const res = await chai - .request(app) - .post(`/api/v1/push/${TEST_PUSH.id}/cancel`) - .set('Cookie', `${cookie}`); - res.should.have.status(401); - - const pushes = await chai.request(app).get('/api/v1/push').set('Cookie', `${cookie}`); - const push = pushes.body.find((push) => push.id === TEST_PUSH.id); - - expect(push).to.exist; - expect(push.canceled).to.be.false; - }); - }); - - after(async function () { - const res = await chai.request(app).post('/api/auth/logout').set('Cookie', `${cookie}`); - res.should.have.status(200); - - await service.httpServer.close(); - - await db.deleteRepo(TEST_REPO); - await db.deleteUser(TEST_USERNAME_1); - await db.deleteUser(TEST_USERNAME_2); - await db.deletePush(TEST_PUSH.id); - }); -}); diff --git a/test/testPush.test.ts b/test/testPush.test.ts new file mode 100644 index 000000000..8e605ac60 --- /dev/null +++ b/test/testPush.test.ts @@ -0,0 +1,350 @@ +import request from 'supertest'; +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; +import * as db from '../src/db'; +import service from '../src/service'; +import Proxy from '../src/proxy'; +import { Express } from 'express'; + +// dummy repo +const TEST_ORG = 'finos'; +const TEST_REPO = 'test-push'; +const TEST_URL = 'https://github.com/finos/test-push.git'; +// approver user +const TEST_USERNAME_1 = 'push-test'; +const TEST_EMAIL_1 = 'push-test@test.com'; +const TEST_PASSWORD_1 = 'test1234'; +// committer user +const TEST_USERNAME_2 = 'push-test-2'; +const TEST_EMAIL_2 = 'push-test-2@test.com'; +const TEST_PASSWORD_2 = 'test5678'; +// unknown user +const TEST_USERNAME_3 = 'push-test-3'; +const TEST_EMAIL_3 = 'push-test-3@test.com'; + +const TEST_PUSH = { + steps: [], + error: false, + blocked: false, + allowPush: false, + authorised: false, + canceled: false, + rejected: false, + autoApproved: false, + autoRejected: false, + commitData: [], + id: '0000000000000000000000000000000000000000__1744380874110', + type: 'push', + method: 'get', + timestamp: 1744380903338, + project: TEST_ORG, + repoName: TEST_REPO + '.git', + url: TEST_URL, + repo: TEST_ORG + '/' + TEST_REPO + '.git', + user: TEST_USERNAME_2, + userEmail: TEST_EMAIL_2, + lastStep: null, + blockedMessage: + '\n\n\nGitProxy has received your push:\n\nhttp://localhost:8080/requests/0000000000000000000000000000000000000000__1744380874110\n\n\n', + _id: 'GIMEz8tU2KScZiTz', + attestation: null, +}; + +describe('Push API', () => { + let app: Express; + let cookie: string | null = null; + let testRepo: any; + + const setCookie = (res: any) => { + const cookies: string[] = res.headers['set-cookie'] ?? []; + for (const x of cookies) { + if (x.startsWith('connect')) { + cookie = x.split(';')[0]; + } + } + }; + + const login = async (username: string, password: string) => { + const res = await request(app).post('/api/auth/login').send({ username, password }); + expect(res.status).toBe(200); + setCookie(res); + }; + + const loginAsApprover = () => login(TEST_USERNAME_1, TEST_PASSWORD_1); + const loginAsCommitter = () => login(TEST_USERNAME_2, TEST_PASSWORD_2); + const loginAsAdmin = () => login('admin', 'admin'); + + const logout = async () => { + const res = await request(app).post('/api/auth/logout').set('Cookie', `${cookie}`); + expect(res.status).toBe(200); + cookie = null; + }; + + beforeAll(async () => { + // remove existing repo and users if any + const oldRepo = await db.getRepoByUrl(TEST_URL); + if (oldRepo) { + await db.deleteRepo(oldRepo._id!); + } + await db.deleteUser(TEST_USERNAME_1); + await db.deleteUser(TEST_USERNAME_2); + + const proxy = new Proxy(); + app = await service.start(proxy); + await loginAsAdmin(); + + // set up a repo, user and push to test against + testRepo = await db.createRepo({ + project: TEST_ORG, + name: TEST_REPO, + url: TEST_URL, + }); + + // Create a new user for the approver + await db.createUser(TEST_USERNAME_1, TEST_PASSWORD_1, TEST_EMAIL_1, TEST_USERNAME_1, false); + await db.addUserCanAuthorise(testRepo._id, TEST_USERNAME_1); + + // create a new user for the committer + await db.createUser(TEST_USERNAME_2, TEST_PASSWORD_2, TEST_EMAIL_2, TEST_USERNAME_2, false); + await db.addUserCanPush(testRepo._id, TEST_USERNAME_2); + + // logout of admin account + await logout(); + }); + + afterAll(async () => { + await db.deleteRepo(testRepo._id); + await db.deleteUser(TEST_USERNAME_1); + await db.deleteUser(TEST_USERNAME_2); + + vi.resetModules(); + service.httpServer.close(); + }); + + describe('test push API', () => { + afterEach(async () => { + await db.deletePush(TEST_PUSH.id); + if (cookie) await logout(); + }); + + it('should get 404 for unknown push', async () => { + await loginAsApprover(); + const commitId = + '0000000000000000000000000000000000000000__79b4d8953cbc324bcc1eb53d6412ff89666c241f'; + const res = await request(app).get(`/api/v1/push/${commitId}`).set('Cookie', `${cookie}`); + expect(res.status).toBe(404); + }); + + it('should allow an authorizer to approve a push', async () => { + await db.writeAudit(TEST_PUSH as any); + await loginAsApprover(); + const res = await request(app) + .post(`/api/v1/push/${TEST_PUSH.id}/authorise`) + .set('Cookie', `${cookie}`) + .set('content-type', 'application/json') // must use JSON format to send arrays + .send({ + params: { + attestation: [ + { + label: 'I am happy for this to be pushed to the upstream repository', + tooltip: { + text: 'Are you happy for this contribution to be pushed upstream?', + links: [], + }, + checked: true, + }, + ], + }, + }); + expect(res.status).toBe(200); + }); + + it('should NOT allow an authorizer to approve if attestation is incomplete', async () => { + // make the approver also the committer + const testPush = { ...TEST_PUSH, user: TEST_USERNAME_1, userEmail: TEST_EMAIL_1 }; + await db.writeAudit(testPush as any); + await loginAsApprover(); + const res = await request(app) + .post(`/api/v1/push/${TEST_PUSH.id}/authorise`) + .set('Cookie', `${cookie}`) + .set('content-type', 'application/json') + .send({ + params: { + attestation: [ + { + label: 'I am happy for this to be pushed to the upstream repository', + tooltip: { + text: 'Are you happy for this contribution to be pushed upstream?', + links: [], + }, + checked: false, + }, + ], + }, + }); + expect(res.status).toBe(401); + }); + + it('should NOT allow an authorizer to approve if committer is unknown', async () => { + // make the approver also the committer + const testPush = { ...TEST_PUSH, user: TEST_USERNAME_3, userEmail: TEST_EMAIL_3 }; + await db.writeAudit(testPush as any); + await loginAsApprover(); + const res = await request(app) + .post(`/api/v1/push/${TEST_PUSH.id}/authorise`) + .set('Cookie', `${cookie}`) + .set('content-type', 'application/json') + .send({ + params: { + attestation: [ + { + label: 'I am happy for this to be pushed to the upstream repository', + tooltip: { + text: 'Are you happy for this contribution to be pushed upstream?', + links: [], + }, + checked: true, + }, + ], + }, + }); + expect(res.status).toBe(401); + }); + }); + + it('should NOT allow an authorizer to approve their own push', async () => { + // make the approver also the committer + const testPush = { ...TEST_PUSH }; + testPush.user = TEST_USERNAME_1; + testPush.userEmail = TEST_EMAIL_1; + await db.writeAudit(testPush as any); + await loginAsApprover(); + const res = await request(app) + .post(`/api/v1/push/${TEST_PUSH.id}/authorise`) + .set('Cookie', `${cookie}`) + .set('Content-Type', 'application/json') + .send({ + params: { + attestation: [ + { + label: 'I am happy for this to be pushed to the upstream repository', + tooltip: { + text: 'Are you happy for this contribution to be pushed upstream?', + links: [], + }, + checked: true, + }, + ], + }, + }); + expect(res.status).toBe(401); + }); + + it('should NOT allow a non-authorizer to approve a push', async () => { + await db.writeAudit(TEST_PUSH as any); + await loginAsCommitter(); + const res = await request(app) + .post(`/api/v1/push/${TEST_PUSH.id}/authorise`) + .set('Cookie', `${cookie}`) + .set('Content-Type', 'application/json') + .send({ + params: { + attestation: [ + { + label: 'I am happy for this to be pushed to the upstream repository', + tooltip: { + text: 'Are you happy for this contribution to be pushed upstream?', + links: [], + }, + checked: true, + }, + ], + }, + }); + expect(res.status).toBe(401); + }); + + it('should allow an authorizer to reject a push', async () => { + await db.writeAudit(TEST_PUSH as any); + await loginAsApprover(); + const res = await request(app) + .post(`/api/v1/push/${TEST_PUSH.id}/reject`) + .set('Cookie', `${cookie}`); + expect(res.status).toBe(200); + }); + + it('should NOT allow an authorizer to reject their own push', async () => { + // make the approver also the committer + const testPush = { ...TEST_PUSH }; + testPush.user = TEST_USERNAME_1; + testPush.userEmail = TEST_EMAIL_1; + await db.writeAudit(testPush as any); + await loginAsApprover(); + const res = await request(app) + .post(`/api/v1/push/${TEST_PUSH.id}/reject`) + .set('Cookie', `${cookie}`); + expect(res.status).toBe(401); + }); + + it('should NOT allow a non-authorizer to reject a push', async () => { + await db.writeAudit(TEST_PUSH as any); + await loginAsCommitter(); + const res = await request(app) + .post(`/api/v1/push/${TEST_PUSH.id}/reject`) + .set('Cookie', `${cookie}`); + expect(res.status).toBe(401); + }); + + it('should fetch all pushes', async () => { + await db.writeAudit(TEST_PUSH as any); + await loginAsApprover(); + const res = await request(app).get('/api/v1/push').set('Cookie', `${cookie}`); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + + const push = res.body.find((p: any) => p.id === TEST_PUSH.id); + expect(push).toBeDefined(); + expect(push).toEqual(TEST_PUSH); + expect(push.canceled).toBe(false); + }); + + it('should allow a committer to cancel a push', async () => { + await db.writeAudit(TEST_PUSH as any); + await loginAsCommitter(); + const res = await request(app) + .post(`/api/v1/push/${TEST_PUSH.id}/cancel`) + .set('Cookie', `${cookie}`); + expect(res.status).toBe(200); + + const pushes = await request(app).get('/api/v1/push').set('Cookie', `${cookie}`); + const push = pushes.body.find((p: any) => p.id === TEST_PUSH.id); + + expect(push).toBeDefined(); + expect(push.canceled).toBe(true); + }); + + it('should not allow a non-committer to cancel a push (even if admin)', async () => { + await db.writeAudit(TEST_PUSH as any); + await loginAsAdmin(); + const res = await request(app) + .post(`/api/v1/push/${TEST_PUSH.id}/cancel`) + .set('Cookie', `${cookie}`); + expect(res.status).toBe(401); + + const pushes = await request(app).get('/api/v1/push').set('Cookie', `${cookie}`); + const push = pushes.body.find((p: any) => p.id === TEST_PUSH.id); + + expect(push).toBeDefined(); + expect(push.canceled).toBe(false); + }); + + afterAll(async () => { + const res = await request(app).post('/api/auth/logout').set('Cookie', `${cookie}`); + expect(res.status).toBe(200); + + await service.httpServer.close(); + await db.deleteRepo(TEST_REPO); + await db.deleteUser(TEST_USERNAME_1); + await db.deleteUser(TEST_USERNAME_2); + await db.deletePush(TEST_PUSH.id); + }); +}); diff --git a/test/testRepoApi.test.js b/test/testRepoApi.test.js deleted file mode 100644 index 8c06cf79b..000000000 --- a/test/testRepoApi.test.js +++ /dev/null @@ -1,340 +0,0 @@ -// Import the dependencies for testing -const chai = require('chai'); -const chaiHttp = require('chai-http'); -const db = require('../src/db'); -const service = require('../src/service').default; -const { getAllProxiedHosts } = require('../src/proxy/routes/helper'); - -import Proxy from '../src/proxy'; - -chai.use(chaiHttp); -chai.should(); -const expect = chai.expect; - -const TEST_REPO = { - url: 'https://github.com/finos/test-repo.git', - name: 'test-repo', - project: 'finos', - host: 'github.com', -}; - -const TEST_REPO_NON_GITHUB = { - url: 'https://gitlab.com/org/sub-org/test-repo2.git', - name: 'test-repo2', - project: 'org/sub-org', - host: 'gitlab.com', -}; - -const TEST_REPO_NAKED = { - url: 'https://123.456.789:80/test-repo3.git', - name: 'test-repo3', - project: '', - host: '123.456.789:80', -}; - -const cleanupRepo = async (url) => { - const repo = await db.getRepoByUrl(url); - if (repo) { - await db.deleteRepo(repo._id); - } -}; - -describe('add new repo', async () => { - let app; - let proxy; - let cookie; - const repoIds = []; - - const setCookie = function (res) { - res.headers['set-cookie'].forEach((x) => { - if (x.startsWith('connect')) { - const value = x.split(';')[0]; - cookie = value; - } - }); - }; - - before(async function () { - proxy = new Proxy(); - app = await service.start(proxy); - // Prepare the data. - // _id is autogenerated by the DB so we need to retrieve it before we can use it - cleanupRepo(TEST_REPO.url); - cleanupRepo(TEST_REPO_NON_GITHUB.url); - cleanupRepo(TEST_REPO_NAKED.url); - - await db.deleteUser('u1'); - await db.deleteUser('u2'); - await db.createUser('u1', 'abc', 'test@test.com', 'test', true); - await db.createUser('u2', 'abc', 'test2@test.com', 'test', true); - }); - - it('login', async function () { - const res = await chai.request(app).post('/api/auth/login').send({ - username: 'admin', - password: 'admin', - }); - expect(res).to.have.cookie('connect.sid'); - setCookie(res); - }); - - it('create a new repo', async function () { - const res = await chai - .request(app) - .post('/api/v1/repo') - .set('Cookie', `${cookie}`) - .send(TEST_REPO); - res.should.have.status(200); - - const repo = await db.getRepoByUrl(TEST_REPO.url); - // save repo id for use in subsequent tests - repoIds[0] = repo._id; - - repo.project.should.equal(TEST_REPO.project); - repo.name.should.equal(TEST_REPO.name); - repo.url.should.equal(TEST_REPO.url); - repo.users.canPush.length.should.equal(0); - repo.users.canAuthorise.length.should.equal(0); - }); - - it('get a repo', async function () { - const res = await chai - .request(app) - .get('/api/v1/repo/' + repoIds[0]) - .set('Cookie', `${cookie}`) - .send(); - res.should.have.status(200); - - expect(res.body.url).to.equal(TEST_REPO.url); - expect(res.body.name).to.equal(TEST_REPO.name); - expect(res.body.project).to.equal(TEST_REPO.project); - }); - - it('return a 409 error if the repo already exists', async function () { - const res = await chai - .request(app) - .post('/api/v1/repo') - .set('Cookie', `${cookie}`) - .send(TEST_REPO); - res.should.have.status(409); - res.body.message.should.equal('Repository ' + TEST_REPO.url + ' already exists!'); - }); - - it('filter repos', async function () { - const res = await chai - .request(app) - .get('/api/v1/repo') - .set('Cookie', `${cookie}`) - .query({ url: TEST_REPO.url }); - res.should.have.status(200); - res.body[0].project.should.equal(TEST_REPO.project); - res.body[0].name.should.equal(TEST_REPO.name); - res.body[0].url.should.equal(TEST_REPO.url); - }); - - it('add 1st can push user', async function () { - const res = await chai - .request(app) - .patch(`/api/v1/repo/${repoIds[0]}/user/push`) - .set('Cookie', `${cookie}`) - .send({ - username: 'u1', - }); - - res.should.have.status(200); - const repo = await db.getRepoById(repoIds[0]); - repo.users.canPush.length.should.equal(1); - repo.users.canPush[0].should.equal('u1'); - }); - - it('add 2nd can push user', async function () { - const res = await chai - .request(app) - .patch(`/api/v1/repo/${repoIds[0]}/user/push`) - .set('Cookie', `${cookie}`) - .send({ - username: 'u2', - }); - - res.should.have.status(200); - const repo = await db.getRepoById(repoIds[0]); - repo.users.canPush.length.should.equal(2); - repo.users.canPush[1].should.equal('u2'); - }); - - it('add push user that does not exist', async function () { - const res = await chai - .request(app) - .patch(`/api/v1/repo/${repoIds[0]}/user/push`) - .set('Cookie', `${cookie}`) - .send({ - username: 'u3', - }); - - res.should.have.status(400); - const repo = await db.getRepoById(repoIds[0]); - repo.users.canPush.length.should.equal(2); - }); - - it('delete user u2 from push', async function () { - const res = await chai - .request(app) - .delete(`/api/v1/repo/${repoIds[0]}/user/push/u2`) - .set('Cookie', `${cookie}`) - .send({}); - - res.should.have.status(200); - const repo = await db.getRepoById(repoIds[0]); - repo.users.canPush.length.should.equal(1); - }); - - it('add 1st can authorise user', async function () { - const res = await chai - .request(app) - .patch(`/api/v1/repo/${repoIds[0]}/user/authorise`) - .set('Cookie', `${cookie}`) - .send({ - username: 'u1', - }); - - res.should.have.status(200); - const repo = await db.getRepoById(repoIds[0]); - repo.users.canAuthorise.length.should.equal(1); - repo.users.canAuthorise[0].should.equal('u1'); - }); - - it('add 2nd can authorise user', async function () { - const res = await chai - .request(app) - .patch(`/api/v1/repo/${repoIds[0]}/user/authorise`) - .set('Cookie', `${cookie}`) - .send({ - username: 'u2', - }); - - res.should.have.status(200); - const repo = await db.getRepoById(repoIds[0]); - repo.users.canAuthorise.length.should.equal(2); - repo.users.canAuthorise[1].should.equal('u2'); - }); - - it('add authorise user that does not exist', async function () { - const res = await chai - .request(app) - .patch(`/api/v1/repo/${repoIds[0]}/user/authorise`) - .set('Cookie', `${cookie}`) - .send({ - username: 'u3', - }); - - res.should.have.status(400); - const repo = await db.getRepoById(repoIds[0]); - repo.users.canAuthorise.length.should.equal(2); - }); - - it('Can delete u2 user', async function () { - const res = await chai - .request(app) - .delete(`/api/v1/repo/${repoIds[0]}/user/authorise/u2`) - .set('Cookie', `${cookie}`) - .send({}); - - res.should.have.status(200); - const repo = await db.getRepoById(repoIds[0]); - repo.users.canAuthorise.length.should.equal(1); - }); - - it('Valid user push permission on repo', async function () { - const res = await chai - .request(app) - .patch(`/api/v1/repo/${repoIds[0]}/user/authorise`) - .set('Cookie', `${cookie}`) - .send({ username: 'u2' }); - - res.should.have.status(200); - const isAllowed = await db.isUserPushAllowed(TEST_REPO.url, 'u2'); - expect(isAllowed).to.be.true; - }); - - it('Invalid user push permission on repo', async function () { - const isAllowed = await db.isUserPushAllowed(TEST_REPO.url, 'test1234'); - expect(isAllowed).to.be.false; - }); - - it('Proxy route helpers should return the proxied origin', async function () { - const origins = await getAllProxiedHosts(); - expect(origins).to.eql([TEST_REPO.host]); - }); - - it('Proxy route helpers should return the new proxied origins when new repos are added', async function () { - const res = await chai - .request(app) - .post('/api/v1/repo') - .set('Cookie', `${cookie}`) - .send(TEST_REPO_NON_GITHUB); - res.should.have.status(200); - - const repo = await db.getRepoByUrl(TEST_REPO_NON_GITHUB.url); - // save repo id for use in subsequent tests - repoIds[1] = repo._id; - - repo.project.should.equal(TEST_REPO_NON_GITHUB.project); - repo.name.should.equal(TEST_REPO_NON_GITHUB.name); - repo.url.should.equal(TEST_REPO_NON_GITHUB.url); - repo.users.canPush.length.should.equal(0); - repo.users.canAuthorise.length.should.equal(0); - - const origins = await getAllProxiedHosts(); - expect(origins).to.have.members([TEST_REPO.host, TEST_REPO_NON_GITHUB.host]); - - const res2 = await chai - .request(app) - .post('/api/v1/repo') - .set('Cookie', `${cookie}`) - .send(TEST_REPO_NAKED); - res2.should.have.status(200); - const repo2 = await db.getRepoByUrl(TEST_REPO_NAKED.url); - repoIds[2] = repo2._id; - - const origins2 = await getAllProxiedHosts(); - expect(origins2).to.have.members([ - TEST_REPO.host, - TEST_REPO_NON_GITHUB.host, - TEST_REPO_NAKED.host, - ]); - }); - - it('delete a repo', async function () { - const res = await chai - .request(app) - .delete('/api/v1/repo/' + repoIds[1] + '/delete') - .set('Cookie', `${cookie}`) - .send(); - res.should.have.status(200); - - const repo = await db.getRepoByUrl(TEST_REPO_NON_GITHUB.url); - expect(repo).to.be.null; - - const res2 = await chai - .request(app) - .delete('/api/v1/repo/' + repoIds[2] + '/delete') - .set('Cookie', `${cookie}`) - .send(); - res2.should.have.status(200); - - const repo2 = await db.getRepoByUrl(TEST_REPO_NAKED.url); - expect(repo2).to.be.null; - }); - - after(async function () { - await service.httpServer.close(); - - // don't clean up data as cypress tests rely on it being present - // await cleanupRepo(TEST_REPO.url); - // await db.deleteUser('u1'); - // await db.deleteUser('u2'); - - await cleanupRepo(TEST_REPO_NON_GITHUB.url); - await cleanupRepo(TEST_REPO_NAKED.url); - }); -}); diff --git a/test/testRepoApi.test.ts b/test/testRepoApi.test.ts new file mode 100644 index 000000000..83d12f71c --- /dev/null +++ b/test/testRepoApi.test.ts @@ -0,0 +1,300 @@ +import request from 'supertest'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as db from '../src/db'; +import service from '../src/service'; +import { getAllProxiedHosts } from '../src/proxy/routes/helper'; + +import Proxy from '../src/proxy'; + +const TEST_REPO = { + url: 'https://github.com/finos/test-repo.git', + name: 'test-repo', + project: 'finos', + host: 'github.com', +}; + +const TEST_REPO_NON_GITHUB = { + url: 'https://gitlab.com/org/sub-org/test-repo2.git', + name: 'test-repo2', + project: 'org/sub-org', + host: 'gitlab.com', +}; + +const TEST_REPO_NAKED = { + url: 'https://123.456.789:80/test-repo3.git', + name: 'test-repo3', + project: '', + host: '123.456.789:80', +}; + +const cleanupRepo = async (url: string) => { + const repo = await db.getRepoByUrl(url); + if (repo) { + await db.deleteRepo(repo._id!); + } +}; + +const fetchRepoOrThrow = async (url: string) => { + const repo = await db.getRepoByUrl(url); + if (!repo) { + throw new Error('Repo not found'); + } + return repo; +}; + +describe('add new repo', () => { + let app: any; + let proxy: any; + let cookie: string; + const repoIds: string[] = []; + + const setCookie = function (res: any) { + res.headers['set-cookie'].forEach((x: string) => { + if (x.startsWith('connect')) { + const value = x.split(';')[0]; + cookie = value; + } + }); + }; + + beforeAll(async () => { + proxy = new Proxy(); + app = await service.start(proxy); + // Prepare the data. + // _id is autogenerated by the DB so we need to retrieve it before we can use it + await cleanupRepo(TEST_REPO.url); + await cleanupRepo(TEST_REPO_NON_GITHUB.url); + await cleanupRepo(TEST_REPO_NAKED.url); + + await db.deleteUser('u1'); + await db.deleteUser('u2'); + await db.createUser('u1', 'abc', 'test@test.com', 'test', true); + await db.createUser('u2', 'abc', 'test2@test.com', 'test', true); + }); + + it('login', async () => { + const res = await request(app).post('/api/auth/login').send({ + username: 'admin', + password: 'admin', + }); + expect(res.headers['set-cookie']).toBeDefined(); + setCookie(res); + }); + + it('create a new repo', async () => { + const res = await request(app).post('/api/v1/repo').set('Cookie', `${cookie}`).send(TEST_REPO); + expect(res.status).toBe(200); + + const repo = await fetchRepoOrThrow(TEST_REPO.url); + + // save repo id for use in subsequent tests + repoIds[0] = repo._id!; + + expect(repo.project).toBe(TEST_REPO.project); + expect(repo.name).toBe(TEST_REPO.name); + expect(repo.url).toBe(TEST_REPO.url); + expect(repo.users.canPush.length).toBe(0); + expect(repo.users.canAuthorise.length).toBe(0); + }); + + it('get a repo', async () => { + const res = await request(app) + .get('/api/v1/repo/' + repoIds[0]) + .set('Cookie', `${cookie}`); + expect(res.status).toBe(200); + + expect(res.body.url).toBe(TEST_REPO.url); + expect(res.body.name).toBe(TEST_REPO.name); + expect(res.body.project).toBe(TEST_REPO.project); + }); + + it('return a 409 error if the repo already exists', async () => { + const res = await request(app).post('/api/v1/repo').set('Cookie', `${cookie}`).send(TEST_REPO); + expect(res.status).toBe(409); + expect(res.body.message).toBe('Repository ' + TEST_REPO.url + ' already exists!'); + }); + + it('filter repos', async () => { + const res = await request(app) + .get('/api/v1/repo') + .set('Cookie', `${cookie}`) + .query({ url: TEST_REPO.url }); + expect(res.status).toBe(200); + expect(res.body[0].project).toBe(TEST_REPO.project); + expect(res.body[0].name).toBe(TEST_REPO.name); + expect(res.body[0].url).toBe(TEST_REPO.url); + }); + + it('add 1st can push user', async () => { + const res = await request(app) + .patch(`/api/v1/repo/${repoIds[0]}/user/push`) + .set('Cookie', `${cookie}`) + .send({ username: 'u1' }); + + expect(res.status).toBe(200); + const repo = await fetchRepoOrThrow(TEST_REPO.url); + expect(repo.users.canPush.length).toBe(1); + expect(repo.users.canPush[0]).toBe('u1'); + }); + + it('add 2nd can push user', async () => { + const res = await request(app) + .patch(`/api/v1/repo/${repoIds[0]}/user/push`) + .set('Cookie', `${cookie}`) + .send({ username: 'u2' }); + + expect(res.status).toBe(200); + const repo = await fetchRepoOrThrow(TEST_REPO.url); + expect(repo.users.canPush.length).toBe(2); + expect(repo.users.canPush[1]).toBe('u2'); + }); + + it('add push user that does not exist', async () => { + const res = await request(app) + .patch(`/api/v1/repo/${repoIds[0]}/user/push`) + .set('Cookie', `${cookie}`) + .send({ username: 'u3' }); + + expect(res.status).toBe(400); + const repo = await fetchRepoOrThrow(TEST_REPO.url); + expect(repo.users.canPush.length).toBe(2); + }); + + it('delete user u2 from push', async () => { + const res = await request(app) + .delete(`/api/v1/repo/${repoIds[0]}/user/push/u2`) + .set('Cookie', `${cookie}`) + .send({}); + + expect(res.status).toBe(200); + const repo = await fetchRepoOrThrow(TEST_REPO.url); + expect(repo.users.canPush.length).toBe(1); + }); + + it('add 1st can authorise user', async () => { + const res = await request(app) + .patch(`/api/v1/repo/${repoIds[0]}/user/authorise`) + .set('Cookie', `${cookie}`) + .send({ username: 'u1' }); + + expect(res.status).toBe(200); + const repo = await fetchRepoOrThrow(TEST_REPO.url); + expect(repo.users.canAuthorise.length).toBe(1); + expect(repo.users.canAuthorise[0]).toBe('u1'); + }); + + it('add 2nd can authorise user', async () => { + const res = await request(app) + .patch(`/api/v1/repo/${repoIds[0]}/user/authorise`) + .set('Cookie', cookie) + .send({ username: 'u2' }); + + expect(res.status).toBe(200); + const repo = await fetchRepoOrThrow(TEST_REPO.url); + expect(repo.users.canAuthorise.length).toBe(2); + expect(repo.users.canAuthorise[1]).toBe('u2'); + }); + + it('add authorise user that does not exist', async () => { + const res = await request(app) + .patch(`/api/v1/repo/${repoIds[0]}/user/authorise`) + .set('Cookie', cookie) + .send({ username: 'u3' }); + + expect(res.status).toBe(400); + const repo = await fetchRepoOrThrow(TEST_REPO.url); + expect(repo.users.canAuthorise.length).toBe(2); + }); + + it('Can delete u2 user', async () => { + const res = await request(app) + .delete(`/api/v1/repo/${repoIds[0]}/user/authorise/u2`) + .set('Cookie', cookie) + .send(); + + expect(res.status).toBe(200); + const repo = await fetchRepoOrThrow(TEST_REPO.url); + expect(repo.users.canAuthorise.length).toBe(1); + }); + + it('Valid user push permission on repo', async () => { + const res = await request(app) + .patch(`/api/v1/repo/${repoIds[0]}/user/authorise`) + .set('Cookie', cookie) + .send({ username: 'u2' }); + + expect(res.status).toBe(200); + const isAllowed = await db.isUserPushAllowed(TEST_REPO.url, 'u2'); + expect(isAllowed).toBe(true); + }); + + it('Invalid user push permission on repo', async () => { + const isAllowed = await db.isUserPushAllowed(TEST_REPO.url, 'test1234'); + expect(isAllowed).toBe(false); + }); + + it('Proxy route helpers should return the proxied origin', async () => { + const origins = await getAllProxiedHosts(); + expect(origins).toEqual([TEST_REPO.host]); + }); + + it('Proxy route helpers should return the new proxied origins when new repos are added', async () => { + const res = await request(app) + .post('/api/v1/repo') + .set('Cookie', cookie) + .send(TEST_REPO_NON_GITHUB); + + expect(res.status).toBe(200); + const repo = await fetchRepoOrThrow(TEST_REPO_NON_GITHUB.url); + repoIds[1] = repo._id!; + + expect(repo.project).toBe(TEST_REPO_NON_GITHUB.project); + expect(repo.name).toBe(TEST_REPO_NON_GITHUB.name); + expect(repo.url).toBe(TEST_REPO_NON_GITHUB.url); + expect(repo.users.canPush.length).toBe(0); + expect(repo.users.canAuthorise.length).toBe(0); + + const origins = await getAllProxiedHosts(); + expect(origins).toEqual(expect.arrayContaining([TEST_REPO.host, TEST_REPO_NON_GITHUB.host])); + + const res2 = await request(app) + .post('/api/v1/repo') + .set('Cookie', cookie) + .send(TEST_REPO_NAKED); + + expect(res2.status).toBe(200); + const repo2 = await fetchRepoOrThrow(TEST_REPO_NAKED.url); + repoIds[2] = repo2._id!; + + const origins2 = await getAllProxiedHosts(); + expect(origins2).toEqual( + expect.arrayContaining([TEST_REPO.host, TEST_REPO_NON_GITHUB.host, TEST_REPO_NAKED.host]), + ); + }); + + it('delete a repo', async () => { + const res = await request(app) + .delete(`/api/v1/repo/${repoIds[1]}/delete`) + .set('Cookie', cookie) + .send(); + + expect(res.status).toBe(200); + const repo = await db.getRepoByUrl(TEST_REPO_NON_GITHUB.url); + expect(repo).toBeNull(); + + const res2 = await request(app) + .delete(`/api/v1/repo/${repoIds[2]}/delete`) + .set('Cookie', cookie) + .send(); + + expect(res2.status).toBe(200); + const repo2 = await db.getRepoByUrl(TEST_REPO_NAKED.url); + expect(repo2).toBeNull(); + }); + + afterAll(async () => { + await service.httpServer.close(); + await cleanupRepo(TEST_REPO_NON_GITHUB.url); + await cleanupRepo(TEST_REPO_NAKED.url); + }); +}); diff --git a/test/testRouteFilter.test.js b/test/testRouteFilter.test.ts similarity index 73% rename from test/testRouteFilter.test.js rename to test/testRouteFilter.test.ts index d2bcb1ef4..2b1b7cec1 100644 --- a/test/testRouteFilter.test.js +++ b/test/testRouteFilter.test.ts @@ -1,4 +1,4 @@ -import * as chai from 'chai'; +import { describe, it, expect } from 'vitest'; import { validGitRequest, processUrlPath, @@ -6,82 +6,79 @@ import { processGitURLForNameAndOrg, } from '../src/proxy/routes/helper'; -chai.should(); - -const expect = chai.expect; - const VERY_LONG_PATH = - '/a/very/very/very/very/very//very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/long/path'; + '/a/very/very/very/very/very//very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/long/path'; -describe('url helpers and filter functions used in the proxy', function () { - it('processUrlPath should return breakdown of a proxied path, separating the path to repository from the git operation path', function () { +describe('url helpers and filter functions used in the proxy', () => { + it('processUrlPath should return breakdown of a proxied path, separating the path to repository from the git operation path', () => { expect( processUrlPath('/github.com/octocat/hello-world.git/info/refs?service=git-upload-pack'), - ).to.deep.eq({ + ).toEqual({ repoPath: '/github.com/octocat/hello-world.git', gitPath: '/info/refs?service=git-upload-pack', }); expect( processUrlPath('/gitlab.com/org/sub-org/hello-world.git/info/refs?service=git-upload-pack'), - ).to.deep.eq({ + ).toEqual({ repoPath: '/gitlab.com/org/sub-org/hello-world.git', gitPath: '/info/refs?service=git-upload-pack', }); expect( processUrlPath('/123.456.789/hello-world.git/info/refs?service=git-upload-pack'), - ).to.deep.eq({ + ).toEqual({ repoPath: '/123.456.789/hello-world.git', gitPath: '/info/refs?service=git-upload-pack', }); }); - it('processUrlPath should return breakdown of a legacy proxy path, separating the path to repository from the git operation path', function () { - expect(processUrlPath('/octocat/hello-world.git/info/refs?service=git-upload-pack')).to.deep.eq( - { repoPath: '/octocat/hello-world.git', gitPath: '/info/refs?service=git-upload-pack' }, - ); + it('processUrlPath should return breakdown of a legacy proxy path, separating the path to repository from the git operation path', () => { + expect(processUrlPath('/octocat/hello-world.git/info/refs?service=git-upload-pack')).toEqual({ + repoPath: '/octocat/hello-world.git', + gitPath: '/info/refs?service=git-upload-pack', + }); }); - it('processUrlPath should return breakdown of a legacy proxy path, separating the path to repository when git path is just /', function () { - expect(processUrlPath('/octocat/hello-world.git/')).to.deep.eq({ + it('processUrlPath should return breakdown of a legacy proxy path, separating the path to repository when git path is just /', () => { + expect(processUrlPath('/octocat/hello-world.git/')).toEqual({ repoPath: '/octocat/hello-world.git', gitPath: '/', }); }); - it('processUrlPath should return breakdown of a legacy proxy path, separating the path to repository when no path is present', function () { - expect(processUrlPath('/octocat/hello-world.git')).to.deep.eq({ + it('processUrlPath should return breakdown of a legacy proxy path, separating the path to repository when no path is present', () => { + expect(processUrlPath('/octocat/hello-world.git')).toEqual({ repoPath: '/octocat/hello-world.git', gitPath: '/', }); }); - it("processUrlPath should return null if the url couldn't be parsed", function () { - expect(processUrlPath('/octocat/hello-world')).to.be.null; - expect(processUrlPath(VERY_LONG_PATH)).to.be.null; + it("processUrlPath should return null if it can't be parsed", () => { + expect(processUrlPath('/octocat/hello-world')).toBeNull(); + expect(processUrlPath(VERY_LONG_PATH)).toBeNull(); }); - it('processGitUrl should return breakdown of a git URL separating out the protocol, host and repository path', function () { - expect(processGitUrl('https://somegithost.com/octocat/hello-world.git')).to.deep.eq({ + it('processGitUrl should return breakdown of a git URL separating out the protocol, host and repository path', () => { + expect(processGitUrl('https://somegithost.com/octocat/hello-world.git')).toEqual({ protocol: 'https://', host: 'somegithost.com', repoPath: '/octocat/hello-world.git', }); - expect(processGitUrl('https://123.456.789:1234/hello-world.git')).to.deep.eq({ + expect(processGitUrl('https://123.456.789:1234/hello-world.git')).toEqual({ protocol: 'https://', host: '123.456.789:1234', repoPath: '/hello-world.git', }); }); - it('processGitUrl should return breakdown of a git URL separating out the protocol, host and repository path and discard any git operation path', function () { + it('processGitUrl should return breakdown of a git URL separating out the protocol, host and repository path and discard any git operation path', () => { expect( processGitUrl( 'https://somegithost.com:1234/octocat/hello-world.git/info/refs?service=git-upload-pack', ), - ).to.deep.eq({ + ).toEqual({ protocol: 'https://', host: 'somegithost.com:1234', repoPath: '/octocat/hello-world.git', @@ -89,40 +86,41 @@ describe('url helpers and filter functions used in the proxy', function () { expect( processGitUrl('https://123.456.789/hello-world.git/info/refs?service=git-upload-pack'), - ).to.deep.eq({ + ).toEqual({ protocol: 'https://', host: '123.456.789', repoPath: '/hello-world.git', }); }); - it('processGitUrl should return null for a url it cannot parse', function () { - expect(processGitUrl('somegithost.com:1234/octocat/hello-world.git')).to.be.null; - expect(processUrlPath('somegithost.com:1234' + VERY_LONG_PATH + '.git')).to.be.null; + it('processGitUrl should return null for a url it cannot parse', () => { + expect(processGitUrl('somegithost.com:1234/octocat/hello-world.git')).toBeNull(); + expect(processUrlPath('somegithost.com:1234' + VERY_LONG_PATH + '.git')).toBeNull(); }); - it('processGitURLForNameAndOrg should return breakdown of a git URL path separating out the protocol, origin and repository path', function () { - expect(processGitURLForNameAndOrg('github.com/octocat/hello-world.git')).to.deep.eq({ + it('processGitURLForNameAndOrg should return breakdown of a git URL path separating out the protocol, origin and repository path', () => { + expect(processGitURLForNameAndOrg('github.com/octocat/hello-world.git')).toEqual({ project: 'octocat', repoName: 'hello-world.git', }); }); - it('processGitURLForNameAndOrg should return breakdown of a git repository URL separating out the project (organisation) and repository name', function () { - expect(processGitURLForNameAndOrg('https://github.com:80/octocat/hello-world.git')).to.deep.eq({ + it('processGitURLForNameAndOrg should return breakdown of a git repository URL separating out the project (organisation) and repository name', () => { + expect(processGitURLForNameAndOrg('https://github.com:80/octocat/hello-world.git')).toEqual({ project: 'octocat', repoName: 'hello-world.git', }); }); - it("processGitURLForNameAndOrg should return null for a git repository URL it can't parse", function () { - expect(processGitURLForNameAndOrg('someGitHost.com/repo')).to.be.null; - expect(processGitURLForNameAndOrg('https://someGitHost.com/repo')).to.be.null; - expect(processGitURLForNameAndOrg('https://somegithost.com:1234' + VERY_LONG_PATH + '.git')).to - .be.null; + it("processGitURLForNameAndOrg should return null for a git repository URL it can't parse", () => { + expect(processGitURLForNameAndOrg('someGitHost.com/repo')).toBeNull(); + expect(processGitURLForNameAndOrg('https://someGitHost.com/repo')).toBeNull(); + expect( + processGitURLForNameAndOrg('https://somegithost.com:1234' + VERY_LONG_PATH + '.git'), + ).toBeNull(); }); - it('validGitRequest should return true for safe requests on expected URLs', function () { + it('validGitRequest should return true for safe requests', () => { [ '/info/refs?service=git-upload-pack', '/info/refs?service=git-receive-pack', @@ -134,56 +132,54 @@ describe('url helpers and filter functions used in the proxy', function () { 'user-agent': 'git/2.30.0', accept: 'application/x-git-upload-pack-request', }), - ).true; + ).toBe(true); }); }); - it('validGitRequest should return false for unsafe URLs', function () { + it('validGitRequest should return false for unsafe URLs', () => { ['/', '/foo'].forEach((url) => { expect( validGitRequest(url, { 'user-agent': 'git/2.30.0', accept: 'application/x-git-upload-pack-request', }), - ).false; + ).toBe(false); }); }); - it('validGitRequest should return false for a browser request', function () { + it('validGitRequest should return false for a browser request', () => { expect( validGitRequest('/', { 'user-agent': 'Mozilla/5.0', accept: '*/*', }), - ).false; + ).toBe(false); }); - it('validGitRequest should return false for unexpected combinations of headers & URLs', function () { - // expected Accept=application/x-git-upload-pack + it('validGitRequest should return false for unexpected headers', () => { expect( validGitRequest('/git-upload-pack', { 'user-agent': 'git/2.30.0', accept: '*/*', }), - ).false; + ).toBe(false); - // expected User-Agent=git/* expect( validGitRequest('/info/refs?service=git-upload-pack', { 'user-agent': 'Mozilla/5.0', accept: '*/*', }), - ).false; + ).toBe(false); }); - it('validGitRequest should return false for unexpected content-type on certain URLs', function () { - ['application/json', 'text/html', '*/*'].map((accept) => { + it('validGitRequest should return false for unexpected content-type', () => { + ['application/json', 'text/html', '*/*'].forEach((accept) => { expect( validGitRequest('/git-upload-pack', { 'user-agent': 'git/2.30.0', - accept: accept, + accept, }), - ).false; + ).toBe(false); }); }); }); diff --git a/test/ui/apiBase.test.js b/test/ui/apiBase.test.js deleted file mode 100644 index b339a9388..000000000 --- a/test/ui/apiBase.test.js +++ /dev/null @@ -1,51 +0,0 @@ -const { expect } = require('chai'); - -// Helper to reload the module fresh each time -function loadApiBase() { - delete require.cache[require.resolve('../../src/ui/apiBase')]; - return require('../../src/ui/apiBase'); -} - -describe('apiBase', () => { - let originalEnv; - - before(() => { - global.location = { origin: 'https://lovely-git-proxy.com' }; - }); - - after(() => { - delete global.location; - }); - - beforeEach(() => { - originalEnv = process.env.VITE_API_URI; - delete process.env.VITE_API_URI; - delete require.cache[require.resolve('../../src/ui/apiBase')]; - }); - - afterEach(() => { - if (typeof originalEnv === 'undefined') { - delete process.env.VITE_API_URI; - } else { - process.env.VITE_API_URI = originalEnv; - } - delete require.cache[require.resolve('../../src/ui/apiBase')]; - }); - - it('uses the location origin when VITE_API_URI is not set', () => { - const { API_BASE } = loadApiBase(); - expect(API_BASE).to.equal('https://lovely-git-proxy.com'); - }); - - it('returns the exact value when no trailing slash', () => { - process.env.VITE_API_URI = 'https://example.com'; - const { API_BASE } = loadApiBase(); - expect(API_BASE).to.equal('https://example.com'); - }); - - it('strips trailing slashes from VITE_API_URI', () => { - process.env.VITE_API_URI = 'https://example.com////'; - const { API_BASE } = loadApiBase(); - expect(API_BASE).to.equal('https://example.com'); - }); -}); diff --git a/test/ui/apiBase.test.ts b/test/ui/apiBase.test.ts new file mode 100644 index 000000000..da34dbc30 --- /dev/null +++ b/test/ui/apiBase.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; + +async function loadApiBase() { + const path = '../../src/ui/apiBase.ts'; + const modulePath = await import(path + '?update=' + Date.now()); // forces reload + return modulePath; +} + +describe('apiBase', () => { + let originalEnv: string | undefined; + const originalLocation = globalThis.location; + + beforeAll(() => { + globalThis.location = { origin: 'https://lovely-git-proxy.com' } as any; + }); + + afterAll(() => { + globalThis.location = originalLocation; + }); + + beforeEach(() => { + originalEnv = process.env.VITE_API_URI; + delete process.env.VITE_API_URI; + }); + + afterEach(() => { + if (typeof originalEnv === 'undefined') { + delete process.env.VITE_API_URI; + } else { + process.env.VITE_API_URI = originalEnv; + } + }); + + it('uses the location origin when VITE_API_URI is not set', async () => { + const { API_BASE } = await loadApiBase(); + expect(API_BASE).toBe('https://lovely-git-proxy.com'); + }); + + it('returns the exact value when no trailing slash', async () => { + process.env.VITE_API_URI = 'https://example.com'; + const { API_BASE } = await loadApiBase(); + expect(API_BASE).toBe('https://example.com'); + }); + + it('strips trailing slashes from VITE_API_URI', async () => { + process.env.VITE_API_URI = 'https://example.com////'; + const { API_BASE } = await loadApiBase(); + expect(API_BASE).toBe('https://example.com'); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 000000000..3e8b1ac1c --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,35 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + pool: 'forks', + poolOptions: { + forks: { + singleFork: true, // Run all tests in a single process + }, + }, + coverage: { + provider: 'v8', + reportsDirectory: './coverage', + reporter: ['text', 'lcov'], + include: ['src/**/*.ts'], + exclude: [ + 'dist', + 'experimental', + 'packages', + 'plugins', + 'scripts', + 'src/**/types.ts', + 'src/config/generated', + 'src/constants', + 'src/contents', + 'src/types', + 'src/ui', + 'website', + ], + thresholds: { + lines: 80, + }, + }, + }, +});