diff --git a/.env-example b/.env-example index 730b64211..173d00818 100644 --- a/.env-example +++ b/.env-example @@ -41,6 +41,7 @@ LDAP_BIND_USER = LDAP_BIND_PW = LDAP_USER_FILTER = LDAP_SHARED_ACCOUNT_FILTER = +LDAP_SERVICE_ACCOUNT_FILTER = LDAP_ROLE_FILTER = LDAP_USER_BASE = ENABLE_LDAP = false diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b1baa2470..6a51a9fc7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,10 +22,10 @@ jobs: container: image: node:18 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Cache and restore node_modules id: cache-node - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ./node_modules key: ${{ runner.os }}-node-${{ hashFiles('./package-lock.json') }} @@ -38,10 +38,10 @@ jobs: container: image: node:18 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Cache and restore node_modules id: cache-node - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ./node_modules key: ${{ runner.os }}-node-${{ hashFiles('./package-lock.json') }} @@ -117,12 +117,12 @@ jobs: STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_TEST_SECRET }} SKIP_SQLITE_DEFAULTS: true steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Cache and restore node_modules id: cache-node - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ./node_modules key: ${{ runner.os }}-node-${{ hashFiles('./package-lock.json') }} @@ -131,7 +131,7 @@ jobs: - run: openssl genrsa -out ./config/jwt.key 2048 && chmod 0777 ./config/jwt.key - run: npm run swagger:validate - run: npm run coverage-ci # Separate command to limit the number of workers to prevent timeouts - - run: git config --global --add safe.directory $GITHUB_WORKSPACE # To avoid dubious ownership + - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" # To avoid dubious ownership if: ${{ matrix.typeorm-connection == 'mariadb' }} - name: "Cannot commit code coverage cross-fork" @@ -206,12 +206,12 @@ jobs: MARIADB_USER: sudosos-ci MARIADB_PASSWORD: sudosos-ci steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Cache and restore node_modules id: cache-node - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ./node_modules key: ${{ runner.os }}-node-${{ hashFiles('./package-lock.json') }} diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index b38eaf47e..9a9fbeed2 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -15,20 +15,21 @@ jobs: image: docker:dind steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Determine Docker tag id: tag run: | - if [[ "${{ github.actor }}" == "dependabot[bot]" ]]; then - echo "docker_actor=dependabot" >> $GITHUB_ENV + ACTOR="${{ github.actor }}" + if [[ "$ACTOR" == "dependabot\[bot\]" ]]; then + echo "docker_actor=dependabot" >> "$GITHUB_ENV" else - echo "docker_actor=${{ github.actor }}" >> $GITHUB_ENV + echo "docker_actor=$ACTOR" >> "$GITHUB_ENV" fi - name: Get Docker meta across forks id: meta - uses: docker/metadata-action@v4 + uses: docker/metadata-action@v5 with: images: | ${{ env.docker_actor }}/${{ github.repository }} @@ -36,11 +37,11 @@ jobs: type=ref,event=pr - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 # Build and push Docker image with Buildx (don't push on PR) - name: Build and push - uses: docker/build-push-action@v3 + uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64 #SudoSOS does not run on linux/arm64 diff --git a/.github/workflows/docker-push.yml b/.github/workflows/docker-push.yml index 6739cd253..05b0c1d9b 100644 --- a/.github/workflows/docker-push.yml +++ b/.github/workflows/docker-push.yml @@ -17,11 +17,11 @@ jobs: image: docker:dind steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Get Docker meta (for tags) id: meta - uses: docker/metadata-action@v4 + uses: docker/metadata-action@v5 with: images: | ${{ vars.DOCKER_REGISTRY }}/${{ vars.DOCKER_TAG }} @@ -32,7 +32,7 @@ jobs: type=semver,pattern={{version}} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Login to Registry (GitHub) uses: docker/login-action@v3 @@ -52,7 +52,7 @@ jobs: - name: Login to SudoSOS Container Registry if: github.event_name != 'pull_request' - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ${{ vars.DOCKER_REGISTRY }} username: ${{ secrets.SVC_GH_SUDOSOS_USERNAME }} @@ -60,7 +60,7 @@ jobs: # Build and push Docker image with Buildx (don't push on PR) - name: Build and push - uses: docker/build-push-action@v3 + uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64 #SudoSOS does not run on linux/arm64 diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 1e1dbe112..9f3070e7d 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -17,11 +17,11 @@ jobs: image: docker:dind steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Get Docker meta (for tags) id: meta - uses: docker/metadata-action@v4 + uses: docker/metadata-action@v5 with: images: | ${{ vars.DOCKER_REGISTRY }}/${{ vars.DOCKER_DOCS_TAG }} @@ -32,17 +32,17 @@ jobs: type=semver,pattern={{version}} - name: Log in to Docker Hub - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ${{ vars.DOCKER_REGISTRY }} username: ${{ secrets.SVC_GH_SUDOSOS_USERNAME }} password: ${{ secrets.SVC_GH_SUDOSOS_PWD }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Build and push - uses: docker/build-push-action@v3 + uses: docker/build-push-action@v6 with: context: . file: ./Dockerfile-docs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a019de7be..ebce2139e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,11 +41,11 @@ jobs: image: docker:dind steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Get Docker meta (for tags) id: meta - uses: docker/metadata-action@v4 + uses: docker/metadata-action@v5 with: images: | ${{ vars.DOCKER_REGISTRY }}/${{ vars.DOCKER_TAG }} @@ -53,10 +53,10 @@ jobs: ${{ needs.versioning.outputs.next_version }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Login to SudoSOS Container Registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ${{ vars.DOCKER_REGISTRY }} username: ${{ secrets.SVC_GH_SUDOSOS_USERNAME }} @@ -64,7 +64,7 @@ jobs: - name: Build and push - uses: docker/build-push-action@v3 + uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64 diff --git a/init_scripts/start.sh b/init_scripts/start.sh index e00710af2..11d9cb1aa 100644 --- a/init_scripts/start.sh +++ b/init_scripts/start.sh @@ -3,4 +3,5 @@ chmod +x /app/init_scripts/00_make_sudosos_data_dirs.sh chmod +x /app/init_scripts/00_regen_sudosos_secrets.sh sh /app/init_scripts/00_make_sudosos_data_dirs.sh sh /app/init_scripts/00_regen_sudosos_secrets.sh -pm2 start /app/pm2.json --attach +pm2 start /app/pm2.json +pm2 logs diff --git a/package-lock.json b/package-lock.json index 39d207aca..539b64a95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,12 +20,12 @@ "gewisdb-ts-client": "github:GEWIS/gewisdb-ts-client#26575e8", "jsonwebtoken": "^9.0.2", "ldap-escape": "^2.0.6", - "ldapts": "^7.2.1", + "ldapts": "^7.3.1", "log4js": "^6.9.1", "mime-types": "^2.1.35", "mysql2": "^3.12.0", "node-cron": "^3.0.3", - "nodemailer": "^6.9.16", + "nodemailer": "^6.10.0", "pdf-generator-client": "github:GEWIS/pdf-generator#04e040b", "reflect-metadata": "^0.2.2", "sqlite3": "^5.1.7", @@ -48,10 +48,10 @@ "@types/dinero.js": "^1.9.4", "@types/express": "^4.17.21", "@types/express-fileupload": "^1.5.1", - "@types/jsonwebtoken": "^9.0.7", + "@types/jsonwebtoken": "^9.0.8", "@types/mime-types": "^2.1.4", "@types/mocha": "^10.0.10", - "@types/node": "^20.17.16", + "@types/node": "^20.17.17", "@types/node-cron": "^3.0.11", "@types/nodemailer": "^6.4.17", "@types/sinon": "^17.0.3", @@ -85,46 +85,46 @@ "sinon": "^18.0.1", "sinon-chai": "^3.7.0", "ts-node": "^10.9.2", - "typedoc": "^0.27.6", - "typedoc-plugin-markdown": "^4.4.1", + "typedoc": "^0.27.7", + "typedoc-plugin-markdown": "^4.4.2", "typedoc-plugin-merge-modules": "^6.1.0", "typedoc-vitepress-theme": "^1.1.2", "typescript": "^5.6.3", - "vitepress": "^1.5.0" + "vitepress": "^1.6.3" } }, "node_modules/@algolia/autocomplete-core": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.9.3.tgz", - "integrity": "sha512-009HdfugtGCdC4JdXUbVJClA0q0zh24yyePn+KUGk3rP7j8FEe/m5Yo/z65gn6nP/cM39PxpzqKrL7A6fP6PPw==", + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.17.7.tgz", + "integrity": "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/autocomplete-plugin-algolia-insights": "1.9.3", - "@algolia/autocomplete-shared": "1.9.3" + "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", + "@algolia/autocomplete-shared": "1.17.7" } }, "node_modules/@algolia/autocomplete-plugin-algolia-insights": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.9.3.tgz", - "integrity": "sha512-a/yTUkcO/Vyy+JffmAnTWbr4/90cLzw+CC3bRbhnULr/EM0fGNvM13oQQ14f2moLMcVDyAx/leczLlAOovhSZg==", + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.17.7.tgz", + "integrity": "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/autocomplete-shared": "1.9.3" + "@algolia/autocomplete-shared": "1.17.7" }, "peerDependencies": { "search-insights": ">= 1 < 3" } }, "node_modules/@algolia/autocomplete-preset-algolia": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.9.3.tgz", - "integrity": "sha512-d4qlt6YmrLMYy95n5TB52wtNDr6EgAIPH81dvvvW8UmuWRgxEtY0NJiPwl/h95JtG2vmRM804M0DSwMCNZlzRA==", + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.17.7.tgz", + "integrity": "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/autocomplete-shared": "1.9.3" + "@algolia/autocomplete-shared": "1.17.7" }, "peerDependencies": { "@algolia/client-search": ">= 4.9.1 < 6", @@ -132,9 +132,9 @@ } }, "node_modules/@algolia/autocomplete-shared": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.9.3.tgz", - "integrity": "sha512-Wnm9E4Ye6Rl6sTTqjoymD+l8DjSTHsHboVRYrKgEt8Q7UHm9nYbqhN/i0fhUYA3OAEH7WA8x3jfpnmJm3rKvaQ==", + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.7.tgz", + "integrity": "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -142,296 +142,199 @@ "algoliasearch": ">= 4.9.1 < 6" } }, - "node_modules/@algolia/cache-browser-local-storage": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@algolia/cache-browser-local-storage/-/cache-browser-local-storage-4.24.0.tgz", - "integrity": "sha512-t63W9BnoXVrGy9iYHBgObNXqYXM3tYXCjDSHeNwnsc324r4o5UiVKUiAB4THQ5z9U5hTj6qUvwg/Ez43ZD85ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/cache-common": "4.24.0" - } - }, - "node_modules/@algolia/cache-common": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@algolia/cache-common/-/cache-common-4.24.0.tgz", - "integrity": "sha512-emi+v+DmVLpMGhp0V9q9h5CdkURsNmFC+cOS6uK9ndeJm9J4TiqSvPYVu+THUP8P/S08rxf5x2P+p3CfID0Y4g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@algolia/cache-in-memory": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@algolia/cache-in-memory/-/cache-in-memory-4.24.0.tgz", - "integrity": "sha512-gDrt2so19jW26jY3/MkFg5mEypFIPbPoXsQGQWAi6TrCPsNOSEYepBMPlucqWigsmEy/prp5ug2jy/N3PVG/8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/cache-common": "4.24.0" - } - }, - "node_modules/@algolia/client-account": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@algolia/client-account/-/client-account-4.24.0.tgz", - "integrity": "sha512-adcvyJ3KjPZFDybxlqnf+5KgxJtBjwTPTeyG2aOyoJvx0Y8dUQAEOEVOJ/GBxX0WWNbmaSrhDURMhc+QeevDsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "4.24.0", - "@algolia/client-search": "4.24.0", - "@algolia/transporter": "4.24.0" - } - }, - "node_modules/@algolia/client-account/node_modules/@algolia/client-common": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-4.24.0.tgz", - "integrity": "sha512-bc2ROsNL6w6rqpl5jj/UywlIYC21TwSSoFHKl01lYirGMW+9Eek6r02Tocg4gZ8HAw3iBvu6XQiM3BEbmEMoiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/requester-common": "4.24.0", - "@algolia/transporter": "4.24.0" - } - }, - "node_modules/@algolia/client-account/node_modules/@algolia/client-search": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-4.24.0.tgz", - "integrity": "sha512-uRW6EpNapmLAD0mW47OXqTP8eiIx5F6qN9/x/7HHO6owL3N1IXqydGwW5nhDFBrV+ldouro2W1VX3XlcUXEFCA==", + "node_modules/@algolia/client-abtesting": { + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.20.0.tgz", + "integrity": "sha512-YaEoNc1Xf2Yk6oCfXXkZ4+dIPLulCx8Ivqj0OsdkHWnsI3aOJChY5qsfyHhDBNSOhqn2ilgHWxSfyZrjxBcAww==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "4.24.0", - "@algolia/requester-common": "4.24.0", - "@algolia/transporter": "4.24.0" + "@algolia/client-common": "5.20.0", + "@algolia/requester-browser-xhr": "5.20.0", + "@algolia/requester-fetch": "5.20.0", + "@algolia/requester-node-http": "5.20.0" + }, + "engines": { + "node": ">= 14.0.0" } }, "node_modules/@algolia/client-analytics": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-4.24.0.tgz", - "integrity": "sha512-y8jOZt1OjwWU4N2qr8G4AxXAzaa8DBvyHTWlHzX/7Me1LX8OayfgHexqrsL4vSBcoMmVw2XnVW9MhL+Y2ZDJXg==", + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.20.0.tgz", + "integrity": "sha512-CIT9ni0+5sYwqehw+t5cesjho3ugKQjPVy/iPiJvtJX4g8Cdb6je6SPt2uX72cf2ISiXCAX9U3cY0nN0efnRDw==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "4.24.0", - "@algolia/client-search": "4.24.0", - "@algolia/requester-common": "4.24.0", - "@algolia/transporter": "4.24.0" + "@algolia/client-common": "5.20.0", + "@algolia/requester-browser-xhr": "5.20.0", + "@algolia/requester-fetch": "5.20.0", + "@algolia/requester-node-http": "5.20.0" + }, + "engines": { + "node": ">= 14.0.0" } }, - "node_modules/@algolia/client-analytics/node_modules/@algolia/client-common": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-4.24.0.tgz", - "integrity": "sha512-bc2ROsNL6w6rqpl5jj/UywlIYC21TwSSoFHKl01lYirGMW+9Eek6r02Tocg4gZ8HAw3iBvu6XQiM3BEbmEMoiA==", + "node_modules/@algolia/client-common": { + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.20.0.tgz", + "integrity": "sha512-iSTFT3IU8KNpbAHcBUJw2HUrPnMXeXLyGajmCL7gIzWOsYM4GabZDHXOFx93WGiXMti1dymz8k8R+bfHv1YZmA==", "dev": true, "license": "MIT", - "dependencies": { - "@algolia/requester-common": "4.24.0", - "@algolia/transporter": "4.24.0" + "engines": { + "node": ">= 14.0.0" } }, - "node_modules/@algolia/client-analytics/node_modules/@algolia/client-search": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-4.24.0.tgz", - "integrity": "sha512-uRW6EpNapmLAD0mW47OXqTP8eiIx5F6qN9/x/7HHO6owL3N1IXqydGwW5nhDFBrV+ldouro2W1VX3XlcUXEFCA==", + "node_modules/@algolia/client-insights": { + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.20.0.tgz", + "integrity": "sha512-w9RIojD45z1csvW1vZmAko82fqE/Dm+Ovsy2ElTsjFDB0HMAiLh2FO86hMHbEXDPz6GhHKgGNmBRiRP8dDPgJg==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "4.24.0", - "@algolia/requester-common": "4.24.0", - "@algolia/transporter": "4.24.0" - } - }, - "node_modules/@algolia/client-common": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.7.0.tgz", - "integrity": "sha512-hrYlN9yNQukmNj8bBlw9PCXi9jmRQqNUXaG6MXH1aDabjO6YD1WPVqTvaELbIBgTbDJzCn0R2owms0uaxQkjUg==", - "dev": true, - "license": "MIT", - "peer": true, + "@algolia/client-common": "5.20.0", + "@algolia/requester-browser-xhr": "5.20.0", + "@algolia/requester-fetch": "5.20.0", + "@algolia/requester-node-http": "5.20.0" + }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-personalization": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-4.24.0.tgz", - "integrity": "sha512-l5FRFm/yngztweU0HdUzz1rC4yoWCFo3IF+dVIVTfEPg906eZg5BOd1k0K6rZx5JzyyoP4LdmOikfkfGsKVE9w==", + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.20.0.tgz", + "integrity": "sha512-p/hftHhrbiHaEcxubYOzqVV4gUqYWLpTwK+nl2xN3eTrSW9SNuFlAvUBFqPXSVBqc6J5XL9dNKn3y8OA1KElSQ==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "4.24.0", - "@algolia/requester-common": "4.24.0", - "@algolia/transporter": "4.24.0" + "@algolia/client-common": "5.20.0", + "@algolia/requester-browser-xhr": "5.20.0", + "@algolia/requester-fetch": "5.20.0", + "@algolia/requester-node-http": "5.20.0" + }, + "engines": { + "node": ">= 14.0.0" } }, - "node_modules/@algolia/client-personalization/node_modules/@algolia/client-common": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-4.24.0.tgz", - "integrity": "sha512-bc2ROsNL6w6rqpl5jj/UywlIYC21TwSSoFHKl01lYirGMW+9Eek6r02Tocg4gZ8HAw3iBvu6XQiM3BEbmEMoiA==", + "node_modules/@algolia/client-query-suggestions": { + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.20.0.tgz", + "integrity": "sha512-m4aAuis5vZi7P4gTfiEs6YPrk/9hNTESj3gEmGFgfJw3hO2ubdS4jSId1URd6dGdt0ax2QuapXufcrN58hPUcw==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/requester-common": "4.24.0", - "@algolia/transporter": "4.24.0" + "@algolia/client-common": "5.20.0", + "@algolia/requester-browser-xhr": "5.20.0", + "@algolia/requester-fetch": "5.20.0", + "@algolia/requester-node-http": "5.20.0" + }, + "engines": { + "node": ">= 14.0.0" } }, "node_modules/@algolia/client-search": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.7.0.tgz", - "integrity": "sha512-0Frfjt4oxvVP2qsTQAjwdaG5SvJ3TbHBkBrS6M7cG5RDrgHqOrhBnBGCFT+YO3CeNK54r+d57oB1VcD2F1lHuQ==", + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.20.0.tgz", + "integrity": "sha512-KL1zWTzrlN4MSiaK1ea560iCA/UewMbS4ZsLQRPoDTWyrbDKVbztkPwwv764LAqgXk0fvkNZvJ3IelcK7DqhjQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@algolia/client-common": "5.7.0", - "@algolia/requester-browser-xhr": "5.7.0", - "@algolia/requester-fetch": "5.7.0", - "@algolia/requester-node-http": "5.7.0" + "@algolia/client-common": "5.20.0", + "@algolia/requester-browser-xhr": "5.20.0", + "@algolia/requester-fetch": "5.20.0", + "@algolia/requester-node-http": "5.20.0" }, "engines": { "node": ">= 14.0.0" } }, - "node_modules/@algolia/logger-common": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@algolia/logger-common/-/logger-common-4.24.0.tgz", - "integrity": "sha512-LLUNjkahj9KtKYrQhFKCzMx0BY3RnNP4FEtO+sBybCjJ73E8jNdaKJ/Dd8A/VA4imVHP5tADZ8pn5B8Ga/wTMA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@algolia/logger-console": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@algolia/logger-console/-/logger-console-4.24.0.tgz", - "integrity": "sha512-X4C8IoHgHfiUROfoRCV+lzSy+LHMgkoEEU1BbKcsfnV0i0S20zyy0NLww9dwVHUWNfPPxdMU+/wKmLGYf96yTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/logger-common": "4.24.0" - } - }, - "node_modules/@algolia/recommend": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-4.24.0.tgz", - "integrity": "sha512-P9kcgerfVBpfYHDfVZDvvdJv0lEoCvzNlOy2nykyt5bK8TyieYyiD0lguIJdRZZYGre03WIAFf14pgE+V+IBlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/cache-browser-local-storage": "4.24.0", - "@algolia/cache-common": "4.24.0", - "@algolia/cache-in-memory": "4.24.0", - "@algolia/client-common": "4.24.0", - "@algolia/client-search": "4.24.0", - "@algolia/logger-common": "4.24.0", - "@algolia/logger-console": "4.24.0", - "@algolia/requester-browser-xhr": "4.24.0", - "@algolia/requester-common": "4.24.0", - "@algolia/requester-node-http": "4.24.0", - "@algolia/transporter": "4.24.0" - } - }, - "node_modules/@algolia/recommend/node_modules/@algolia/client-common": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-4.24.0.tgz", - "integrity": "sha512-bc2ROsNL6w6rqpl5jj/UywlIYC21TwSSoFHKl01lYirGMW+9Eek6r02Tocg4gZ8HAw3iBvu6XQiM3BEbmEMoiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/requester-common": "4.24.0", - "@algolia/transporter": "4.24.0" - } - }, - "node_modules/@algolia/recommend/node_modules/@algolia/client-search": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-4.24.0.tgz", - "integrity": "sha512-uRW6EpNapmLAD0mW47OXqTP8eiIx5F6qN9/x/7HHO6owL3N1IXqydGwW5nhDFBrV+ldouro2W1VX3XlcUXEFCA==", + "node_modules/@algolia/ingestion": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.20.0.tgz", + "integrity": "sha512-shj2lTdzl9un4XJblrgqg54DoK6JeKFO8K8qInMu4XhE2JuB8De6PUuXAQwiRigZupbI0xq8aM0LKdc9+qiLQA==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "4.24.0", - "@algolia/requester-common": "4.24.0", - "@algolia/transporter": "4.24.0" + "@algolia/client-common": "5.20.0", + "@algolia/requester-browser-xhr": "5.20.0", + "@algolia/requester-fetch": "5.20.0", + "@algolia/requester-node-http": "5.20.0" + }, + "engines": { + "node": ">= 14.0.0" } }, - "node_modules/@algolia/recommend/node_modules/@algolia/requester-browser-xhr": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-4.24.0.tgz", - "integrity": "sha512-Z2NxZMb6+nVXSjF13YpjYTdvV3032YTBSGm2vnYvYPA6mMxzM3v5rsCiSspndn9rzIW4Qp1lPHBvuoKJV6jnAA==", + "node_modules/@algolia/monitoring": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.20.0.tgz", + "integrity": "sha512-aF9blPwOhKtWvkjyyXh9P5peqmhCA1XxLBRgItT+K6pbT0q4hBDQrCid+pQZJYy4HFUKjB/NDDwyzFhj/rwKhw==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/requester-common": "4.24.0" + "@algolia/client-common": "5.20.0", + "@algolia/requester-browser-xhr": "5.20.0", + "@algolia/requester-fetch": "5.20.0", + "@algolia/requester-node-http": "5.20.0" + }, + "engines": { + "node": ">= 14.0.0" } }, - "node_modules/@algolia/recommend/node_modules/@algolia/requester-node-http": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-4.24.0.tgz", - "integrity": "sha512-JF18yTjNOVYvU/L3UosRcvbPMGT9B+/GQWNWnenIImglzNVGpyzChkXLnrSf6uxwVNO6ESGu6oN8MqcGQcjQJw==", + "node_modules/@algolia/recommend": { + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.20.0.tgz", + "integrity": "sha512-T6B/WPdZR3b89/F9Vvk6QCbt/wrLAtrGoL8z4qPXDFApQ8MuTFWbleN/4rHn6APWO3ps+BUePIEbue2rY5MlRw==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/requester-common": "4.24.0" + "@algolia/client-common": "5.20.0", + "@algolia/requester-browser-xhr": "5.20.0", + "@algolia/requester-fetch": "5.20.0", + "@algolia/requester-node-http": "5.20.0" + }, + "engines": { + "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-browser-xhr": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.7.0.tgz", - "integrity": "sha512-ohtIp+lyTGM3agrHyedC3w7ijfdUvSN6wmGuKqUezrNzd0nCkFoLW0OINlyv1ODrTEVnL8PAM/Zqubjafxd/Ww==", + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.20.0.tgz", + "integrity": "sha512-t6//lXsq8E85JMenHrI6mhViipUT5riNhEfCcvtRsTV+KIBpC6Od18eK864dmBhoc5MubM0f+sGpKOqJIlBSCg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@algolia/client-common": "5.7.0" + "@algolia/client-common": "5.20.0" }, "engines": { "node": ">= 14.0.0" } }, - "node_modules/@algolia/requester-common": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-common/-/requester-common-4.24.0.tgz", - "integrity": "sha512-k3CXJ2OVnvgE3HMwcojpvY6d9kgKMPRxs/kVohrwF5WMr2fnqojnycZkxPoEg+bXm8fi5BBfFmOqgYztRtHsQA==", - "dev": true, - "license": "MIT" - }, "node_modules/@algolia/requester-fetch": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.7.0.tgz", - "integrity": "sha512-Eg8cBhNg2QNnDDldyK77aXvg3wIc5qnpCDCAJXQ2oaqZwwvvYaTgnP1ofznNG6+klri4Fk1YAaC9wyDBhByWIA==", + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.20.0.tgz", + "integrity": "sha512-FHxYGqRY+6bgjKsK4aUsTAg6xMs2S21elPe4Y50GB0Y041ihvw41Vlwy2QS6K9ldoftX4JvXodbKTcmuQxywdQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@algolia/client-common": "5.7.0" + "@algolia/client-common": "5.20.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-node-http": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.7.0.tgz", - "integrity": "sha512-8BDssYEkcp1co06KtHO9b37H+5zVM/h+5kyesJb2C2EHFO3kgzLHWl/JyXOVtYlKQBkmdObYOI0s6JaXRy2yQA==", + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.20.0.tgz", + "integrity": "sha512-kmtQClq/w3vtPteDSPvaW9SPZL/xrIgMrxZyAgsFwrJk0vJxqyC5/hwHmrCraDnStnGSADnLpBf4SpZnwnkwWw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@algolia/client-common": "5.7.0" + "@algolia/client-common": "5.20.0" }, "engines": { "node": ">= 14.0.0" } }, - "node_modules/@algolia/transporter": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@algolia/transporter/-/transporter-4.24.0.tgz", - "integrity": "sha512-86nI7w6NzWxd1Zp9q3413dRshDqAzSbsQjhcDhPIatEFiZrL1/TjnHL8S7jVKFePlIMzDsZWXAXwXzcok9c5oA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/cache-common": "4.24.0", - "@algolia/logger-common": "4.24.0", - "@algolia/requester-common": "4.24.0" - } - }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -931,34 +834,34 @@ } }, "node_modules/@docsearch/css": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.6.2.tgz", - "integrity": "sha512-vKNZepO2j7MrYBTZIGXvlUOIR+v9KRf70FApRgovWrj3GTs1EITz/Xb0AOlm1xsQBp16clVZj1SY/qaOJbQtZw==", + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.8.2.tgz", + "integrity": "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==", "dev": true, "license": "MIT" }, "node_modules/@docsearch/js": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-3.6.2.tgz", - "integrity": "sha512-pS4YZF+VzUogYrkblCucQ0Oy2m8Wggk8Kk7lECmZM60hTbaydSIhJTTiCrmoxtBqV8wxORnOqcqqOfbmkkQEcA==", + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-3.8.2.tgz", + "integrity": "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==", "dev": true, "license": "MIT", "dependencies": { - "@docsearch/react": "3.6.2", + "@docsearch/react": "3.8.2", "preact": "^10.0.0" } }, "node_modules/@docsearch/react": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.6.2.tgz", - "integrity": "sha512-rtZce46OOkVflCQH71IdbXSFK+S8iJZlUF56XBW5rIgx/eG5qoomC7Ag3anZson1bBac/JFQn7XOBfved/IMRA==", + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.8.2.tgz", + "integrity": "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/autocomplete-core": "1.9.3", - "@algolia/autocomplete-preset-algolia": "1.9.3", - "@docsearch/css": "3.6.2", - "algoliasearch": "^4.19.1" + "@algolia/autocomplete-core": "1.17.7", + "@algolia/autocomplete-preset-algolia": "1.17.7", + "@docsearch/css": "3.8.2", + "algoliasearch": "^5.14.2" }, "peerDependencies": { "@types/react": ">= 16.8.0 < 19.0.0", @@ -1567,12 +1470,6 @@ "@types/hast": "^3.0.4" } }, - "node_modules/@gerrit0/mini-shiki/node_modules/@shikijs/vscode-textmate": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.1.tgz", - "integrity": "sha512-fTIQwLF+Qhuws31iw7Ncl1R3HUDtGwIipiJ9iU+UsDUwMhegFcQKQHd51nZjb7CArq0MvON8rbgCGQYWHUKAdg==", - "dev": true - }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -1609,10 +1506,11 @@ "dev": true }, "node_modules/@iconify-json/simple-icons": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.10.tgz", - "integrity": "sha512-9OK1dsSjXlH36lhu5n+BlSoXuqFjHUErGLtNdzHpq0vHq4YFBuGYWtZ+vZTHLreRQ8ijPRv/6EsgkV+nf6AReQ==", + "version": "1.2.22", + "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.22.tgz", + "integrity": "sha512-0UzThRMwHuOJfgpp+tyV/y2uEBLjFVrxC4igv9iWjSEQEBK4tNjWZNTRCBCYyv/FwWVYyKAsA8tZQ8vUYzvFnw==", "dev": true, + "license": "CC0-1.0", "dependencies": { "@iconify/types": "*" } @@ -1621,7 +1519,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@internationalized/date": { "version": "3.5.6", @@ -1993,9 +1892,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.24.0.tgz", - "integrity": "sha512-Q6HJd7Y6xdB48x8ZNVDOqsbh2uByBhgK8PiQgPhwkIw/HC/YX5Ghq2mQY5sRMZWHb3VsFkWooUVOZHKr7DmDIA==", + "version": "4.32.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.32.0.tgz", + "integrity": "sha512-G2fUQQANtBPsNwiVFg4zKiPQyjVKZCUdQUol53R8E71J7AsheRMV/Yv/nB8giOcOVqP7//eB5xPqieBYZe9bGg==", "cpu": [ "arm" ], @@ -2007,9 +1906,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.24.0.tgz", - "integrity": "sha512-ijLnS1qFId8xhKjT81uBHuuJp2lU4x2yxa4ctFPtG+MqEE6+C5f/+X/bStmxapgmwLwiL3ih122xv8kVARNAZA==", + "version": "4.32.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.32.0.tgz", + "integrity": "sha512-qhFwQ+ljoymC+j5lXRv8DlaJYY/+8vyvYmVx074zrLsu5ZGWYsJNLjPPVJJjhZQpyAKUGPydOq9hRLLNvh1s3A==", "cpu": [ "arm64" ], @@ -2021,9 +1920,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.24.0.tgz", - "integrity": "sha512-bIv+X9xeSs1XCk6DVvkO+S/z8/2AMt/2lMqdQbMrmVpgFvXlmde9mLcbQpztXm1tajC3raFDqegsH18HQPMYtA==", + "version": "4.32.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.32.0.tgz", + "integrity": "sha512-44n/X3lAlWsEY6vF8CzgCx+LQaoqWGN7TzUfbJDiTIOjJm4+L2Yq+r5a8ytQRGyPqgJDs3Rgyo8eVL7n9iW6AQ==", "cpu": [ "arm64" ], @@ -2035,9 +1934,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.24.0.tgz", - "integrity": "sha512-X6/nOwoFN7RT2svEQWUsW/5C/fYMBe4fnLK9DQk4SX4mgVBiTA9h64kjUYPvGQ0F/9xwJ5U5UfTbl6BEjaQdBQ==", + "version": "4.32.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.32.0.tgz", + "integrity": "sha512-F9ct0+ZX5Np6+ZDztxiGCIvlCaW87HBdHcozUfsHnj1WCUTBUubAoanhHUfnUHZABlElyRikI0mgcw/qdEm2VQ==", "cpu": [ "x64" ], @@ -2048,10 +1947,38 @@ "darwin" ] }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.32.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.32.0.tgz", + "integrity": "sha512-JpsGxLBB2EFXBsTLHfkZDsXSpSmKD3VxXCgBQtlPcuAqB8TlqtLcbeMhxXQkCDv1avgwNjF8uEIbq5p+Cee0PA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.32.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.32.0.tgz", + "integrity": "sha512-wegiyBT6rawdpvnD9lmbOpx5Sph+yVZKHbhnSP9MqUEDX08G4UzMU+D87jrazGE7lRSyTRs6NEYHtzfkJ3FjjQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.24.0.tgz", - "integrity": "sha512-0KXvIJQMOImLCVCz9uvvdPgfyWo93aHHp8ui3FrtOP57svqrF/roSSR5pjqL2hcMp0ljeGlU4q9o/rQaAQ3AYA==", + "version": "4.32.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.32.0.tgz", + "integrity": "sha512-3pA7xecItbgOs1A5H58dDvOUEboG5UfpTq3WzAdF54acBbUM+olDJAPkgj1GRJ4ZqE12DZ9/hNS2QZk166v92A==", "cpu": [ "arm" ], @@ -2063,9 +1990,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.24.0.tgz", - "integrity": "sha512-it2BW6kKFVh8xk/BnHfakEeoLPv8STIISekpoF+nBgWM4d55CZKc7T4Dx1pEbTnYm/xEKMgy1MNtYuoA8RFIWw==", + "version": "4.32.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.32.0.tgz", + "integrity": "sha512-Y7XUZEVISGyge51QbYyYAEHwpGgmRrAxQXO3siyYo2kmaj72USSG8LtlQQgAtlGfxYiOwu+2BdbPjzEpcOpRmQ==", "cpu": [ "arm" ], @@ -2077,9 +2004,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.24.0.tgz", - "integrity": "sha512-i0xTLXjqap2eRfulFVlSnM5dEbTVque/3Pi4g2y7cxrs7+a9De42z4XxKLYJ7+OhE3IgxvfQM7vQc43bwTgPwA==", + "version": "4.32.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.32.0.tgz", + "integrity": "sha512-r7/OTF5MqeBrZo5omPXcTnjvv1GsrdH8a8RerARvDFiDwFpDVDnJyByYM/nX+mvks8XXsgPUxkwe/ltaX2VH7w==", "cpu": [ "arm64" ], @@ -2091,9 +2018,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.24.0.tgz", - "integrity": "sha512-9E6MKUJhDuDh604Qco5yP/3qn3y7SLXYuiC0Rpr89aMScS2UAmK1wHP2b7KAa1nSjWJc/f/Lc0Wl1L47qjiyQw==", + "version": "4.32.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.32.0.tgz", + "integrity": "sha512-HJbifC9vex9NqnlodV2BHVFNuzKL5OnsV2dvTw6e1dpZKkNjPG6WUq+nhEYV6Hv2Bv++BXkwcyoGlXnPrjAKXw==", "cpu": [ "arm64" ], @@ -2104,10 +2031,24 @@ "linux" ] }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.32.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.32.0.tgz", + "integrity": "sha512-VAEzZTD63YglFlWwRj3taofmkV1V3xhebDXffon7msNz4b14xKsz7utO6F8F4cqt8K/ktTl9rm88yryvDpsfOw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.24.0.tgz", - "integrity": "sha512-2XFFPJ2XMEiF5Zi2EBf4h73oR1V/lycirxZxHZNc93SqDN/IWhYYSYj8I9381ikUFXZrz2v7r2tOVk2NBwxrWw==", + "version": "4.32.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.32.0.tgz", + "integrity": "sha512-Sts5DST1jXAc9YH/iik1C9QRsLcCoOScf3dfbY5i4kH9RJpKxiTBXqm7qU5O6zTXBTEZry69bGszr3SMgYmMcQ==", "cpu": [ "ppc64" ], @@ -2119,9 +2060,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.24.0.tgz", - "integrity": "sha512-M3Dg4hlwuntUCdzU7KjYqbbd+BLq3JMAOhCKdBE3TcMGMZbKkDdJ5ivNdehOssMCIokNHFOsv7DO4rlEOfyKpg==", + "version": "4.32.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.32.0.tgz", + "integrity": "sha512-qhlXeV9AqxIyY9/R1h1hBD6eMvQCO34ZmdYvry/K+/MBs6d1nRFLm6BOiITLVI+nFAAB9kUB6sdJRKyVHXnqZw==", "cpu": [ "riscv64" ], @@ -2133,9 +2074,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.24.0.tgz", - "integrity": "sha512-mjBaoo4ocxJppTorZVKWFpy1bfFj9FeCMJqzlMQGjpNPY9JwQi7OuS1axzNIk0nMX6jSgy6ZURDZ2w0QW6D56g==", + "version": "4.32.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.32.0.tgz", + "integrity": "sha512-8ZGN7ExnV0qjXa155Rsfi6H8M4iBBwNLBM9lcVS+4NcSzOFaNqmt7djlox8pN1lWrRPMRRQ8NeDlozIGx3Omsw==", "cpu": [ "s390x" ], @@ -2147,9 +2088,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.24.0.tgz", - "integrity": "sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A==", + "version": "4.32.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.32.0.tgz", + "integrity": "sha512-VDzNHtLLI5s7xd/VubyS10mq6TxvZBp+4NRWoW+Hi3tgV05RtVm4qK99+dClwTN1McA6PHwob6DEJ6PlXbY83A==", "cpu": [ "x64" ], @@ -2161,9 +2102,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.24.0.tgz", - "integrity": "sha512-w1i+L7kAXZNdYl+vFvzSZy8Y1arS7vMgIy8wusXJzRrPyof5LAb02KGr1PD2EkRcl73kHulIID0M501lN+vobQ==", + "version": "4.32.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.32.0.tgz", + "integrity": "sha512-qcb9qYDlkxz9DxJo7SDhWxTWV1gFuwznjbTiov289pASxlfGbaOD54mgbs9+z94VwrXtKTu+2RqwlSTbiOqxGg==", "cpu": [ "x64" ], @@ -2175,9 +2116,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.24.0.tgz", - "integrity": "sha512-VXBrnPWgBpVDCVY6XF3LEW0pOU51KbaHhccHw6AS6vBWIC60eqsH19DAeeObl+g8nKAz04QFdl/Cefta0xQtUQ==", + "version": "4.32.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.32.0.tgz", + "integrity": "sha512-pFDdotFDMXW2AXVbfdUEfidPAk/OtwE/Hd4eYMTNVVaCQ6Yl8et0meDaKNL63L44Haxv4UExpv9ydSf3aSayDg==", "cpu": [ "arm64" ], @@ -2189,9 +2130,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.24.0.tgz", - "integrity": "sha512-xrNcGDU0OxVcPTH/8n/ShH4UevZxKIO6HJFK0e15XItZP2UcaiLFd5kiX7hJnqCbSztUF8Qot+JWBC/QXRPYWQ==", + "version": "4.32.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.32.0.tgz", + "integrity": "sha512-/TG7WfrCAjeRNDvI4+0AAMoHxea/USWhAzf9PVDFHbcqrQ7hMMKp4jZIy4VEjk72AAfN5k4TiSMRXRKf/0akSw==", "cpu": [ "ia32" ], @@ -2203,9 +2144,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.24.0.tgz", - "integrity": "sha512-fbMkAF7fufku0N2dE5TBXcNlg0pt0cJue4xBRE2Qc5Vqikxr4VCgKj/ht6SMdFcOacVA9rqF70APJ8RN/4vMJw==", + "version": "4.32.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.32.0.tgz", + "integrity": "sha512-5hqO5S3PTEO2E5VjCePxv40gIgyS2KvO7E7/vvC/NbIW4SIRamkMr1hqj+5Y67fbBWv/bQLB6KelBQmXlyCjWA==", "cpu": [ "x64" ], @@ -2223,63 +2164,89 @@ "dev": true }, "node_modules/@shikijs/core": { - "version": "1.22.2", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-1.22.2.tgz", - "integrity": "sha512-bvIQcd8BEeR1yFvOYv6HDiyta2FFVePbzeowf5pPS1avczrPK+cjmaxxh0nx5QzbON7+Sv0sQfQVciO7bN72sg==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-2.1.0.tgz", + "integrity": "sha512-v795KDmvs+4oV0XD05YLzfDMe9ISBgNjtFxP4PAEv5DqyeghO1/TwDqs9ca5/E6fuO95IcAcWqR6cCX9TnqLZA==", "dev": true, + "license": "MIT", "dependencies": { - "@shikijs/engine-javascript": "1.22.2", - "@shikijs/engine-oniguruma": "1.22.2", - "@shikijs/types": "1.22.2", - "@shikijs/vscode-textmate": "^9.3.0", + "@shikijs/engine-javascript": "2.1.0", + "@shikijs/engine-oniguruma": "2.1.0", + "@shikijs/types": "2.1.0", + "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4", - "hast-util-to-html": "^9.0.3" + "hast-util-to-html": "^9.0.4" } }, "node_modules/@shikijs/engine-javascript": { - "version": "1.22.2", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-1.22.2.tgz", - "integrity": "sha512-iOvql09ql6m+3d1vtvP8fLCVCK7BQD1pJFmHIECsujB0V32BJ0Ab6hxk1ewVSMFA58FI0pR2Had9BKZdyQrxTw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-2.1.0.tgz", + "integrity": "sha512-cgIUdAliOsoaa0rJz/z+jvhrpRd+fVAoixVFEVxUq5FA+tHgBZAIfVJSgJNVRj2hs/wZ1+4hMe82eKAThVh0nQ==", "dev": true, + "license": "MIT", "dependencies": { - "@shikijs/types": "1.22.2", - "@shikijs/vscode-textmate": "^9.3.0", - "oniguruma-to-js": "0.4.3" + "@shikijs/types": "2.1.0", + "@shikijs/vscode-textmate": "^10.0.1", + "oniguruma-to-es": "^2.3.0" } }, "node_modules/@shikijs/engine-oniguruma": { - "version": "1.22.2", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-1.22.2.tgz", - "integrity": "sha512-GIZPAGzQOy56mGvWMoZRPggn0dTlBf1gutV5TdceLCZlFNqWmuc7u+CzD0Gd9vQUTgLbrt0KLzz6FNprqYAxlA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-2.1.0.tgz", + "integrity": "sha512-Ujik33wEDqgqY2WpjRDUBECGcKPv3eGGkoXPujIXvokLaRmGky8NisSk8lHUGeSFxo/Cz5sgFej9sJmA9yeepg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.1.0", + "@shikijs/vscode-textmate": "^10.0.1" + } + }, + "node_modules/@shikijs/langs": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-2.1.0.tgz", + "integrity": "sha512-Jn0gS4rPgerMDPj1ydjgFzZr5fAIoMYz4k7ZT3LJxWWBWA6lokK0pumUwVtb+MzXtlpjxOaQejLprmLbvMZyww==", "dev": true, + "license": "MIT", "dependencies": { - "@shikijs/types": "1.22.2", - "@shikijs/vscode-textmate": "^9.3.0" + "@shikijs/types": "2.1.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-2.1.0.tgz", + "integrity": "sha512-oS2mU6+bz+8TKutsjBxBA7Z3vrQk21RCmADLpnu8cy3tZD6Rw0FKqDyXNtwX52BuIDKHxZNmRlTdG3vtcYv3NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.1.0" } }, "node_modules/@shikijs/transformers": { - "version": "1.22.2", - "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-1.22.2.tgz", - "integrity": "sha512-8f78OiBa6pZDoZ53lYTmuvpFPlWtevn23bzG+azpPVvZg7ITax57o/K3TC91eYL3OMJOO0onPbgnQyZjRos8XQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-2.1.0.tgz", + "integrity": "sha512-3sfvh6OKUVkT5wZFU1xxiq1qqNIuCwUY3yOb9ZGm19y80UZ/eoroLE2orGNzfivyTxR93GfXXZC/ghPR0/SBow==", "dev": true, + "license": "MIT", "dependencies": { - "shiki": "1.22.2" + "@shikijs/core": "2.1.0", + "@shikijs/types": "2.1.0" } }, "node_modules/@shikijs/types": { - "version": "1.22.2", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-1.22.2.tgz", - "integrity": "sha512-NCWDa6LGZqTuzjsGfXOBWfjS/fDIbDdmVDug+7ykVe1IKT4c1gakrvlfFYp5NhAXH/lyqLM8wsAPo5wNy73Feg==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-2.1.0.tgz", + "integrity": "sha512-OFOdHA6VEVbiQvepJ8yqicC6VmBrKxFFhM2EsHHrZESqLVAXOSeRDiuSYV185lIgp15TVic5vYBYNhTsk1xHLg==", "dev": true, + "license": "MIT", "dependencies": { - "@shikijs/vscode-textmate": "^9.3.0", + "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "node_modules/@shikijs/vscode-textmate": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-9.3.0.tgz", - "integrity": "sha512-jn7/7ky30idSkd/O5yDBfAnVt+JJpepofP/POZ1iMOxK59cOfqIgg/Dj0eFsjOTMw+4ycJN0uhZH/Eb0bs/EUA==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.1.tgz", + "integrity": "sha512-fTIQwLF+Qhuws31iw7Ncl1R3HUDtGwIipiJ9iU+UsDUwMhegFcQKQHd51nZjb7CArq0MvON8rbgCGQYWHUKAdg==", "dev": true, "license": "MIT" }, @@ -2562,11 +2529,13 @@ "dev": true }, "node_modules/@types/jsonwebtoken": { - "version": "9.0.7", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.7.tgz", - "integrity": "sha512-ugo316mmTYBl2g81zDFnZ7cfxlut3o+/EQdaP7J8QN2kY6lJ22hmQYCK5EHcJHbrW+dkCGSCPgbG8JtYj6qSrg==", + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.8.tgz", + "integrity": "sha512-7fx54m60nLFUVYlxAB1xpe9CBWX2vSrk50Y6ogRJ1v5xxtba7qXTg5BgYDN5dq+yuQQ9HaVlHJyAAt1/mxryFg==", "dev": true, + "license": "MIT", "dependencies": { + "@types/ms": "*", "@types/node": "*" } }, @@ -2629,10 +2598,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { - "version": "20.17.16", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.16.tgz", - "integrity": "sha512-vOTpLduLkZXePLxHiHsBLp98mHGnl8RptV4YAO3HfKO5UHjDvySGbxKtpYfy8Sx5+WKcgc45qNreJJRVM3L6mw==", + "version": "20.17.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.17.tgz", + "integrity": "sha512-/WndGO4kIfMicEQLTi/mDANUu/iVUhT7KboZPdEqqHQ4aTS+3qT3U5gIqWDFV+XouorjfgGqvKILJeHhuQgFYg==", "license": "MIT", "dependencies": { "undici-types": "~6.19.2" @@ -2995,81 +2971,87 @@ "dev": true }, "node_modules/@vitejs/plugin-vue": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.1.4.tgz", - "integrity": "sha512-N2XSI2n3sQqp5w7Y/AN/L2XDjBIRGqXko+eDp42sydYSBeJuSm5a1sLf8zakmo8u7tA8NmBgoDLA1HeOESjp9A==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.1.tgz", + "integrity": "sha512-cxh314tzaWwOLqVes2gnnCtvBDcM1UMdn+iFR+UjAn411dPT3tOmqrJjbMd7koZpMAmBM/GqeV4n9ge7JSiJJQ==", "dev": true, "license": "MIT", "engines": { "node": "^18.0.0 || >=20.0.0" }, "peerDependencies": { - "vite": "^5.0.0", + "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "node_modules/@vue/compiler-core": { - "version": "3.5.12", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.12.tgz", - "integrity": "sha512-ISyBTRMmMYagUxhcpyEH0hpXRd/KqDU4ymofPgl2XAkY9ZhQ+h0ovEZJIiPop13UmR/54oA2cgMDjgroRelaEw==", + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.13.tgz", + "integrity": "sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==", + "license": "MIT", "dependencies": { "@babel/parser": "^7.25.3", - "@vue/shared": "3.5.12", + "@vue/shared": "3.5.13", "entities": "^4.5.0", "estree-walker": "^2.0.2", "source-map-js": "^1.2.0" } }, "node_modules/@vue/compiler-dom": { - "version": "3.5.12", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.12.tgz", - "integrity": "sha512-9G6PbJ03uwxLHKQ3P42cMTi85lDRvGLB2rSGOiQqtXELat6uI4n8cNz9yjfVHRPIu+MsK6TE418Giruvgptckg==", + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz", + "integrity": "sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==", + "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.12", - "@vue/shared": "3.5.12" + "@vue/compiler-core": "3.5.13", + "@vue/shared": "3.5.13" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.5.12", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.12.tgz", - "integrity": "sha512-2k973OGo2JuAa5+ZlekuQJtitI5CgLMOwgl94BzMCsKZCX/xiqzJYzapl4opFogKHqwJk34vfsaKpfEhd1k5nw==", + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.13.tgz", + "integrity": "sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ==", + "license": "MIT", "dependencies": { "@babel/parser": "^7.25.3", - "@vue/compiler-core": "3.5.12", - "@vue/compiler-dom": "3.5.12", - "@vue/compiler-ssr": "3.5.12", - "@vue/shared": "3.5.12", + "@vue/compiler-core": "3.5.13", + "@vue/compiler-dom": "3.5.13", + "@vue/compiler-ssr": "3.5.13", + "@vue/shared": "3.5.13", "estree-walker": "^2.0.2", "magic-string": "^0.30.11", - "postcss": "^8.4.47", + "postcss": "^8.4.48", "source-map-js": "^1.2.0" } }, "node_modules/@vue/compiler-ssr": { - "version": "3.5.12", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.12.tgz", - "integrity": "sha512-eLwc7v6bfGBSM7wZOGPmRavSWzNFF6+PdRhE+VFJhNCgHiF8AM7ccoqcv5kBXA2eWUfigD7byekvf/JsOfKvPA==", + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.13.tgz", + "integrity": "sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==", + "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.12", - "@vue/shared": "3.5.12" + "@vue/compiler-dom": "3.5.13", + "@vue/shared": "3.5.13" } }, "node_modules/@vue/devtools-api": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.6.2.tgz", - "integrity": "sha512-NCT0ujqlwAhoFvCsAG7G5qS8w/A/dhvFSt2BhmNxyqgpYDrf9CG1zYyWLQkE3dsZ+5lCT6ULUic2VKNaE07Vzg==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.1.tgz", + "integrity": "sha512-Cexc8GimowoDkJ6eNelOPdYIzsu2mgNyp0scOQ3tiaYSb9iok6LOESSsJvHaI+ib3joRfqRJNLkHFjhNuWA5dg==", "dev": true, + "license": "MIT", "dependencies": { - "@vue/devtools-kit": "^7.6.2" + "@vue/devtools-kit": "^7.7.1" } }, "node_modules/@vue/devtools-kit": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.6.2.tgz", - "integrity": "sha512-k61BxHRmcTtIQZFouF9QWt9nCCNtSdw12lhg8VNtHq5/XOBGD+ewiK27a40UJ8UPYoCJvi80hbvbYr5E/Zeu1g==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.1.tgz", + "integrity": "sha512-yhZ4NPnK/tmxGtLNQxmll90jIIXdb2jAhPF76anvn5M/UkZCiLJy28bYgPIACKZ7FCosyKoaope89/RsFJll1w==", "dev": true, + "license": "MIT", "dependencies": { - "@vue/devtools-shared": "^7.6.2", + "@vue/devtools-shared": "^7.7.1", "birpc": "^0.2.19", "hookable": "^5.5.3", "mitt": "^3.0.1", @@ -3079,112 +3061,91 @@ } }, "node_modules/@vue/devtools-shared": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.6.2.tgz", - "integrity": "sha512-lcjyJ7hCC0W0kNwnCGMLVTMvDLoZgjcq9BvboPgS+6jQyDul7fpzRSKTGtGhCHoxrDox7qBAKGbAl2Rcf7GE1A==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.1.tgz", + "integrity": "sha512-BtgF7kHq4BHG23Lezc/3W2UhK2ga7a8ohAIAGJMBr4BkxUFzhqntQtCiuL1ijo2ztWnmusymkirgqUrXoQKumA==", "dev": true, + "license": "MIT", "dependencies": { "rfdc": "^1.4.1" } }, "node_modules/@vue/reactivity": { - "version": "3.5.12", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.12.tgz", - "integrity": "sha512-UzaN3Da7xnJXdz4Okb/BGbAaomRHc3RdoWqTzlvd9+WBR5m3J39J1fGcHes7U3za0ruYn/iYy/a1euhMEHvTAg==", + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.13.tgz", + "integrity": "sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==", + "license": "MIT", "dependencies": { - "@vue/shared": "3.5.12" + "@vue/shared": "3.5.13" } }, "node_modules/@vue/runtime-core": { - "version": "3.5.12", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.12.tgz", - "integrity": "sha512-hrMUYV6tpocr3TL3Ad8DqxOdpDe4zuQY4HPY3X/VRh+L2myQO8MFXPAMarIOSGNu0bFAjh1yBkMPXZBqCk62Uw==", + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.13.tgz", + "integrity": "sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw==", + "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.12", - "@vue/shared": "3.5.12" + "@vue/reactivity": "3.5.13", + "@vue/shared": "3.5.13" } }, "node_modules/@vue/runtime-dom": { - "version": "3.5.12", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.12.tgz", - "integrity": "sha512-q8VFxR9A2MRfBr6/55Q3umyoN7ya836FzRXajPB6/Vvuv0zOPL+qltd9rIMzG/DbRLAIlREmnLsplEF/kotXKA==", + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.13.tgz", + "integrity": "sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog==", + "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.12", - "@vue/runtime-core": "3.5.12", - "@vue/shared": "3.5.12", + "@vue/reactivity": "3.5.13", + "@vue/runtime-core": "3.5.13", + "@vue/shared": "3.5.13", "csstype": "^3.1.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.5.12", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.12.tgz", - "integrity": "sha512-I3QoeDDeEPZm8yR28JtY+rk880Oqmj43hreIBVTicisFTx/Dl7JpG72g/X7YF8hnQD3IFhkky5i2bPonwrTVPg==", + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.13.tgz", + "integrity": "sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA==", + "license": "MIT", "dependencies": { - "@vue/compiler-ssr": "3.5.12", - "@vue/shared": "3.5.12" + "@vue/compiler-ssr": "3.5.13", + "@vue/shared": "3.5.13" }, "peerDependencies": { - "vue": "3.5.12" + "vue": "3.5.13" } }, "node_modules/@vue/shared": { - "version": "3.5.12", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.12.tgz", - "integrity": "sha512-L2RPSAwUFbgZH20etwrXyVyCBu9OxRSi8T/38QsvnkJyvq2LufW2lDCOzm7t/U9C1mkhJGWYfCuFBCmIuNivrg==" + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz", + "integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==", + "license": "MIT" }, "node_modules/@vueuse/core": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-11.1.0.tgz", - "integrity": "sha512-P6dk79QYA6sKQnghrUz/1tHi0n9mrb/iO1WTMk/ElLmTyNqgDeSZ3wcDf6fRBGzRJbeG1dxzEOvLENMjr+E3fg==", + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.5.0.tgz", + "integrity": "sha512-GVyH1iYqNANwcahAx8JBm6awaNgvR/SwZ1fjr10b8l1HIgDp82ngNbfzJUgOgWEoxjL+URAggnlilAEXwCOZtg==", "dev": true, "license": "MIT", "dependencies": { "@types/web-bluetooth": "^0.0.20", - "@vueuse/metadata": "11.1.0", - "@vueuse/shared": "11.1.0", - "vue-demi": ">=0.14.10" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@vueuse/core/node_modules/vue-demi": { - "version": "0.14.10", - "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", - "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "vue-demi-fix": "bin/vue-demi-fix.js", - "vue-demi-switch": "bin/vue-demi-switch.js" - }, - "engines": { - "node": ">=12" + "@vueuse/metadata": "12.5.0", + "@vueuse/shared": "12.5.0", + "vue": "^3.5.13" }, "funding": { "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "@vue/composition-api": "^1.0.0-rc.1", - "vue": "^3.0.0-0 || ^2.6.0" - }, - "peerDependenciesMeta": { - "@vue/composition-api": { - "optional": true - } } }, "node_modules/@vueuse/integrations": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-11.1.0.tgz", - "integrity": "sha512-O2ZgrAGPy0qAjpoI2YR3egNgyEqwG85fxfwmA9BshRIGjV4G6yu6CfOPpMHAOoCD+UfsIl7Vb1bXJ6ifrHYDDA==", + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-12.5.0.tgz", + "integrity": "sha512-HYLt8M6mjUfcoUOzyBcX2RjpfapIwHPBmQJtTmXOQW845Y/Osu9VuTJ5kPvnmWJ6IUa05WpblfOwZ+P0G4iZsQ==", "dev": true, "license": "MIT", "dependencies": { - "@vueuse/core": "11.1.0", - "@vueuse/shared": "11.1.0", - "vue-demi": ">=0.14.10" + "@vueuse/core": "12.5.0", + "@vueuse/shared": "12.5.0", + "vue": "^3.5.13" }, "funding": { "url": "https://github.com/sponsors/antfu" @@ -3242,37 +3203,10 @@ } } }, - "node_modules/@vueuse/integrations/node_modules/vue-demi": { - "version": "0.14.10", - "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", - "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "vue-demi-fix": "bin/vue-demi-fix.js", - "vue-demi-switch": "bin/vue-demi-switch.js" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "@vue/composition-api": "^1.0.0-rc.1", - "vue": "^3.0.0-0 || ^2.6.0" - }, - "peerDependenciesMeta": { - "@vue/composition-api": { - "optional": true - } - } - }, "node_modules/@vueuse/metadata": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-11.1.0.tgz", - "integrity": "sha512-l9Q502TBTaPYGanl1G+hPgd3QX5s4CGnpXriVBR5fEZ/goI6fvDaVmIl3Td8oKFurOxTmbXvBPSsgrd6eu6HYg==", + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.5.0.tgz", + "integrity": "sha512-Ui7Lo2a7AxrMAXRF+fAp9QsXuwTeeZ8fIB9wsLHqzq9MQk+2gMYE2IGJW48VMJ8ecvCB3z3GsGLKLbSasQ5Qlg==", "dev": true, "license": "MIT", "funding": { @@ -3280,45 +3214,18 @@ } }, "node_modules/@vueuse/shared": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-11.1.0.tgz", - "integrity": "sha512-YUtIpY122q7osj+zsNMFAfMTubGz0sn5QzE5gPzAIiCmtt2ha3uQUY1+JPyL4gRCTsLPX82Y9brNbo/aqlA91w==", + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.5.0.tgz", + "integrity": "sha512-vMpcL1lStUU6O+kdj6YdHDixh0odjPAUM15uJ9f7MY781jcYkIwFA4iv2EfoIPO6vBmvutI1HxxAwmf0cx5ISQ==", "dev": true, "license": "MIT", "dependencies": { - "vue-demi": ">=0.14.10" + "vue": "^3.5.13" }, "funding": { "url": "https://github.com/sponsors/antfu" } }, - "node_modules/@vueuse/shared/node_modules/vue-demi": { - "version": "0.14.10", - "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", - "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "vue-demi-fix": "bin/vue-demi-fix.js", - "vue-demi-switch": "bin/vue-demi-switch.js" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "@vue/composition-api": "^1.0.0-rc.1", - "vue": "^3.0.0-0 || ^2.6.0" - }, - "peerDependenciesMeta": { - "@vue/composition-api": { - "optional": true - } - } - }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -3434,70 +3341,28 @@ } }, "node_modules/algoliasearch": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-4.24.0.tgz", - "integrity": "sha512-bf0QV/9jVejssFBmz2HQLxUadxk574t4iwjCKp5E7NBzwKkrDEhKPISIIjAU/p6K5qDx3qoeh4+26zWN1jmw3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/cache-browser-local-storage": "4.24.0", - "@algolia/cache-common": "4.24.0", - "@algolia/cache-in-memory": "4.24.0", - "@algolia/client-account": "4.24.0", - "@algolia/client-analytics": "4.24.0", - "@algolia/client-common": "4.24.0", - "@algolia/client-personalization": "4.24.0", - "@algolia/client-search": "4.24.0", - "@algolia/logger-common": "4.24.0", - "@algolia/logger-console": "4.24.0", - "@algolia/recommend": "4.24.0", - "@algolia/requester-browser-xhr": "4.24.0", - "@algolia/requester-common": "4.24.0", - "@algolia/requester-node-http": "4.24.0", - "@algolia/transporter": "4.24.0" - } - }, - "node_modules/algoliasearch/node_modules/@algolia/client-common": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-4.24.0.tgz", - "integrity": "sha512-bc2ROsNL6w6rqpl5jj/UywlIYC21TwSSoFHKl01lYirGMW+9Eek6r02Tocg4gZ8HAw3iBvu6XQiM3BEbmEMoiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/requester-common": "4.24.0", - "@algolia/transporter": "4.24.0" - } - }, - "node_modules/algoliasearch/node_modules/@algolia/client-search": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-4.24.0.tgz", - "integrity": "sha512-uRW6EpNapmLAD0mW47OXqTP8eiIx5F6qN9/x/7HHO6owL3N1IXqydGwW5nhDFBrV+ldouro2W1VX3XlcUXEFCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "4.24.0", - "@algolia/requester-common": "4.24.0", - "@algolia/transporter": "4.24.0" - } - }, - "node_modules/algoliasearch/node_modules/@algolia/requester-browser-xhr": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-4.24.0.tgz", - "integrity": "sha512-Z2NxZMb6+nVXSjF13YpjYTdvV3032YTBSGm2vnYvYPA6mMxzM3v5rsCiSspndn9rzIW4Qp1lPHBvuoKJV6jnAA==", + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.20.0.tgz", + "integrity": "sha512-groO71Fvi5SWpxjI9Ia+chy0QBwT61mg6yxJV27f5YFf+Mw+STT75K6SHySpP8Co5LsCrtsbCH5dJZSRtkSKaQ==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/requester-common": "4.24.0" - } - }, - "node_modules/algoliasearch/node_modules/@algolia/requester-node-http": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-4.24.0.tgz", - "integrity": "sha512-JF18yTjNOVYvU/L3UosRcvbPMGT9B+/GQWNWnenIImglzNVGpyzChkXLnrSf6uxwVNO6ESGu6oN8MqcGQcjQJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/requester-common": "4.24.0" + "@algolia/client-abtesting": "5.20.0", + "@algolia/client-analytics": "5.20.0", + "@algolia/client-common": "5.20.0", + "@algolia/client-insights": "5.20.0", + "@algolia/client-personalization": "5.20.0", + "@algolia/client-query-suggestions": "5.20.0", + "@algolia/client-search": "5.20.0", + "@algolia/ingestion": "1.20.0", + "@algolia/monitoring": "1.20.0", + "@algolia/recommend": "5.20.0", + "@algolia/requester-browser-xhr": "5.20.0", + "@algolia/requester-fetch": "5.20.0", + "@algolia/requester-node-http": "5.20.0" + }, + "engines": { + "node": ">= 14.0.0" } }, "node_modules/ansi-colors": { @@ -3883,6 +3748,7 @@ "resolved": "https://registry.npmjs.org/birpc/-/birpc-0.2.19.tgz", "integrity": "sha512-5WeXXAvTmitV1RqJFppT5QtUiz2p1mRSYU000Jkft5ZUCLJIk4uQriYNO50HknxKwM6jd8utNc66K1qGIwwWBQ==", "dev": true, + "license": "MIT", "funding": { "url": "https://github.com/sponsors/antfu" } @@ -4732,6 +4598,7 @@ "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz", "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==", "dev": true, + "license": "MIT", "dependencies": { "is-what": "^4.1.8" }, @@ -4791,7 +4658,8 @@ "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" }, "node_modules/data-view-buffer": { "version": "1.0.1", @@ -4858,9 +4726,10 @@ "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==" }, "node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", "dependencies": { "ms": "^2.1.3" }, @@ -5170,6 +5039,13 @@ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, + "node_modules/emoji-regex-xs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", + "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", + "dev": true, + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -5837,7 +5713,8 @@ "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" }, "node_modules/esutils": { "version": "2.0.3", @@ -6153,9 +6030,9 @@ "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==" }, "node_modules/focus-trap": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.0.tgz", - "integrity": "sha512-1td0l3pMkWJLFipobUcGaf+5DTY4PLDDrcqoSaKP8ediO/CoWCCYk/fT/Y2A4e6TNB+Sh6clRJCjOPPnKoNHnQ==", + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.4.tgz", + "integrity": "sha512-xx560wGBk7seZ6y933idtjJQc1l+ck+pI3sKvhKozdBV1dRZoKhkW5xoCaFv9tQiX5RH1xfSxjuNu6g+lmN/gw==", "dev": true, "license": "MIT", "dependencies": { @@ -6689,9 +6566,9 @@ } }, "node_modules/hast-util-to-html": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.3.tgz", - "integrity": "sha512-M17uBDzMJ9RPCqLMO92gNNUDuBSq10a25SDBI08iCCxmorf4Yy6sYHK57n9WAbRAAaU+DuR4W6GN9K4DFZesYg==", + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.4.tgz", + "integrity": "sha512-wxQzXtdbhiwGAUKrnQJXlOPmHnEehzphwkK7aluUPQ+lEc1xefC8pblMgpp2w5ldBTEfveRIrADcrhGIWrlTDA==", "dev": true, "license": "MIT", "dependencies": { @@ -6756,7 +6633,8 @@ "version": "5.5.3", "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/html-escaper": { "version": "2.0.2", @@ -7352,6 +7230,7 @@ "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz", "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==", "dev": true, + "license": "MIT", "engines": { "node": ">=12.13" }, @@ -7708,16 +7587,17 @@ "integrity": "sha512-M0mZojh0QIDSDkA0+M5Zopqz3Ku5DNs1/Q8VqWO5l3Pjx1J2p71c9WksQIDQQKmd1XkV3N2NZFwcBFLJSm1l1w==" }, "node_modules/ldapts": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/ldapts/-/ldapts-7.2.1.tgz", - "integrity": "sha512-2NSA9drjHdRiApF+TO18c+Hy/uyBLs96OS6Gia4+dPQWPxvqDbu3Ji2beCbNCXTvvgxDj4cLZ0WoOZLt5ojfAg==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/ldapts/-/ldapts-7.3.1.tgz", + "integrity": "sha512-g8mxobOSeuxVkXRT9JZBGUvfDjXIpQPEHH5kYG9UjrIlWV5Rqxq+MMmqzlSh4OqSXh+3lFvzyYu+lsJldoZvvA==", + "license": "MIT", "dependencies": { "@types/asn1": ">=0.2.4", "asn1": "~0.2.6", - "debug": "~4.3.7", + "debug": "~4.4.0", "strict-event-emitter-types": "~2.0.0", - "uuid": "~10.0.0", - "whatwg-url": "~14.0.0" + "uuid": "~11.0.4", + "whatwg-url": "~14.1.0" }, "engines": { "node": ">=18" @@ -7735,6 +7615,19 @@ "node": ">=18" } }, + "node_modules/ldapts/node_modules/uuid": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz", + "integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/ldapts/node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -7745,9 +7638,9 @@ } }, "node_modules/ldapts/node_modules/whatwg-url": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.0.0.tgz", - "integrity": "sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==", + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.1.0.tgz", + "integrity": "sha512-jlf/foYIKywAt3x/XWKZ/3rz8OSJPiWktjmk891alJUEjiVxKX9LEO92qH3hv4aJ0mN3MWPvGMCy8jQi95xK4w==", "license": "MIT", "dependencies": { "tr46": "^5.0.0", @@ -7958,9 +7851,10 @@ "license": "MIT" }, "node_modules/magic-string": { - "version": "0.30.12", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.12.tgz", - "integrity": "sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==", + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } @@ -8137,9 +8031,9 @@ } }, "node_modules/micromark-util-character": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", - "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", "dev": true, "funding": [ { @@ -8158,9 +8052,9 @@ } }, "node_modules/micromark-util-encode": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.0.tgz", - "integrity": "sha512-pS+ROfCXAGLWCOc8egcBvT0kf27GoWMqtdarNfDcjb6YLuV5cM3ioG45Ys2qOVqeqSbjaKg72vU+Wby3eddPsA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", "dev": true, "funding": [ { @@ -8175,9 +8069,9 @@ "license": "MIT" }, "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.0.tgz", - "integrity": "sha512-WhYv5UEcZrbAtlsnPuChHUAsu/iBPOVaEVsntLBIdpibO0ddy8OzavZz3iL2xVvBZOpolujSliP65Kq0/7KIYw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", "dev": true, "funding": [ { @@ -8197,9 +8091,9 @@ } }, "node_modules/micromark-util-symbol": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", - "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", "dev": true, "funding": [ { @@ -8214,9 +8108,9 @@ "license": "MIT" }, "node_modules/micromark-util-types": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.0.tgz", - "integrity": "sha512-oNh6S2WMHWRZrmutsRmDDfkzKtxF+bc2VxLC9dvtrDIRFln627VsFP6fLMgTryGDljgLPjkrzQSDcPrjPyDJ5w==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.1.tgz", + "integrity": "sha512-534m2WhVTddrcKVepwmVEVnUAmtrx9bfIjNoQHRqfnvdaHQiFytEhJoTgpWJvDEXCO5gLTQh3wYC1PgOJA4NSQ==", "dev": true, "funding": [ { @@ -8391,9 +8285,9 @@ } }, "node_modules/minisearch": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.1.0.tgz", - "integrity": "sha512-tv7c/uefWdEhcu6hvrfTihflgeEi2tN6VV7HJnCjK6VxM75QQJh4t9FwJCsA2EsRS8LCnu3W87CuGPWMocOLCA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.1.1.tgz", + "integrity": "sha512-b3YZEYCEH4EdCAtYP7OlDyx7FdPwNzuNwLQ34SfJpM9dlbBZzeXndGavTrC+VCiRWomL21SWfMc6SCKO/U2ZNw==", "dev": true, "license": "MIT" }, @@ -8413,7 +8307,8 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/mkdirp": { "version": "3.0.1", @@ -8712,9 +8607,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "funding": [ { "type": "github", @@ -9308,9 +9203,10 @@ "license": "MIT" }, "node_modules/nodemailer": { - "version": "6.9.16", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.16.tgz", - "integrity": "sha512-psAuZdTIRN08HKVd/E8ObdV6NO7NTBY3KsC30F7M4H1OnmLCUNaS56FpYxyb26zWLSyYF9Ozch9KYHhHegsiOQ==", + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.0.tgz", + "integrity": "sha512-SQ3wZCExjeSatLE/HBaXS5vqUOQk6GtBdIIKxiFdmm01mOQZX/POJkO3SUX1wDiYcwUOJwT23scFSC9fY2H8IA==", + "license": "MIT-0", "engines": { "node": ">=6.0.0" } @@ -9715,16 +9611,16 @@ "wrappy": "1" } }, - "node_modules/oniguruma-to-js": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/oniguruma-to-js/-/oniguruma-to-js-0.4.3.tgz", - "integrity": "sha512-X0jWUcAlxORhOqqBREgPMgnshB7ZGYszBNspP+tS9hPD3l13CdaXcHbgImoHUHlrvGx/7AvFEkTRhAGYh+jzjQ==", + "node_modules/oniguruma-to-es": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-2.3.0.tgz", + "integrity": "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g==", "dev": true, + "license": "MIT", "dependencies": { - "regex": "^4.3.2" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" + "emoji-regex-xs": "^1.0.0", + "regex": "^5.1.1", + "regex-recursion": "^5.1.1" } }, "node_modules/openapi-types": { @@ -9991,12 +9887,13 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/picocolors": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", - "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, "node_modules/picomatch": { @@ -10104,9 +10001,9 @@ } }, "node_modules/postcss": { - "version": "8.4.47", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", - "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.1.tgz", + "integrity": "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==", "funding": [ { "type": "opencollective", @@ -10123,8 +10020,8 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.1.0", + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, "engines": { @@ -10266,9 +10163,9 @@ "peer": true }, "node_modules/preact": { - "version": "10.24.2", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.2.tgz", - "integrity": "sha512-1cSoF0aCC8uaARATfrlz4VCBqE8LwZwRfLgkxJOQwAlQt6ayTmi0D9OF7nXid1POI5SZidFuG9CnlXbDfLqY/Q==", + "version": "10.25.4", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.25.4.tgz", + "integrity": "sha512-jLdZDb+Q+odkHJ+MpW/9U5cODzqnB+fy2EiHSZES7ldV5LK7yjlVzTp7R8Xy6W6y75kfK8iWYtFVH7lvjwrCMA==", "dev": true, "license": "MIT", "funding": { @@ -10694,10 +10591,32 @@ "license": "Apache-2.0" }, "node_modules/regex": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/regex/-/regex-4.4.0.tgz", - "integrity": "sha512-uCUSuobNVeqUupowbdZub6ggI5/JZkYyJdDogddJr60L764oxC2pMZov1fQ3wM9bdyzUILDG+Sqx6NAKAz9rKQ==", - "dev": true + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/regex/-/regex-5.1.1.tgz", + "integrity": "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-5.1.1.tgz", + "integrity": "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex": "^5.1.1", + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "dev": true, + "license": "MIT" }, "node_modules/regexp.prototype.flags": { "version": "1.5.2", @@ -10856,9 +10775,9 @@ } }, "node_modules/rollup": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.24.0.tgz", - "integrity": "sha512-DOmrlGSXNk1DM0ljiQA+i+o0rSLhtii1je5wgk60j49d1jHT5YYttBv1iWOnYSTG+fZZESUOSNiAl89SIet+Cg==", + "version": "4.32.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.32.0.tgz", + "integrity": "sha512-JmrhfQR31Q4AuNBjjAX4s+a/Pu/Q8Q9iwjWBsjRH1q52SPFE2NqRMK6fUZKKnvKO6id+h7JIRf0oYsph53eATg==", "dev": true, "license": "MIT", "dependencies": { @@ -10872,22 +10791,25 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.24.0", - "@rollup/rollup-android-arm64": "4.24.0", - "@rollup/rollup-darwin-arm64": "4.24.0", - "@rollup/rollup-darwin-x64": "4.24.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.24.0", - "@rollup/rollup-linux-arm-musleabihf": "4.24.0", - "@rollup/rollup-linux-arm64-gnu": "4.24.0", - "@rollup/rollup-linux-arm64-musl": "4.24.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.24.0", - "@rollup/rollup-linux-riscv64-gnu": "4.24.0", - "@rollup/rollup-linux-s390x-gnu": "4.24.0", - "@rollup/rollup-linux-x64-gnu": "4.24.0", - "@rollup/rollup-linux-x64-musl": "4.24.0", - "@rollup/rollup-win32-arm64-msvc": "4.24.0", - "@rollup/rollup-win32-ia32-msvc": "4.24.0", - "@rollup/rollup-win32-x64-msvc": "4.24.0", + "@rollup/rollup-android-arm-eabi": "4.32.0", + "@rollup/rollup-android-arm64": "4.32.0", + "@rollup/rollup-darwin-arm64": "4.32.0", + "@rollup/rollup-darwin-x64": "4.32.0", + "@rollup/rollup-freebsd-arm64": "4.32.0", + "@rollup/rollup-freebsd-x64": "4.32.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.32.0", + "@rollup/rollup-linux-arm-musleabihf": "4.32.0", + "@rollup/rollup-linux-arm64-gnu": "4.32.0", + "@rollup/rollup-linux-arm64-musl": "4.32.0", + "@rollup/rollup-linux-loongarch64-gnu": "4.32.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.32.0", + "@rollup/rollup-linux-riscv64-gnu": "4.32.0", + "@rollup/rollup-linux-s390x-gnu": "4.32.0", + "@rollup/rollup-linux-x64-gnu": "4.32.0", + "@rollup/rollup-linux-x64-musl": "4.32.0", + "@rollup/rollup-win32-arm64-msvc": "4.32.0", + "@rollup/rollup-win32-ia32-msvc": "4.32.0", + "@rollup/rollup-win32-x64-msvc": "4.32.0", "fsevents": "~2.3.2" } }, @@ -10979,9 +10901,9 @@ "dev": true }, "node_modules/search-insights": { - "version": "2.17.2", - "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.2.tgz", - "integrity": "sha512-zFNpOpUO+tY2D85KrxJ+aqwnIfdEGi06UH2+xEb+Bp9Mwznmauqc9djbnBibJO5mpfUPPa8st6Sx65+vbeO45g==", + "version": "2.17.3", + "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", + "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", "dev": true, "license": "MIT", "peer": true @@ -11142,16 +11064,19 @@ } }, "node_modules/shiki": { - "version": "1.22.2", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-1.22.2.tgz", - "integrity": "sha512-3IZau0NdGKXhH2bBlUk4w1IHNxPh6A5B2sUpyY+8utLu2j/h1QpFkAaUA1bAMxOWWGtTWcAh531vnS4NJKS/lA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-2.1.0.tgz", + "integrity": "sha512-yvKPdNGLXZv7WC4bl7JBbU3CEcUxnBanvMez8MG3gZXKpClGL4bHqFyLhTx+2zUvbjClUANs/S22HXb7aeOgmA==", "dev": true, + "license": "MIT", "dependencies": { - "@shikijs/core": "1.22.2", - "@shikijs/engine-javascript": "1.22.2", - "@shikijs/engine-oniguruma": "1.22.2", - "@shikijs/types": "1.22.2", - "@shikijs/vscode-textmate": "^9.3.0", + "@shikijs/core": "2.1.0", + "@shikijs/engine-javascript": "2.1.0", + "@shikijs/engine-oniguruma": "2.1.0", + "@shikijs/langs": "2.1.0", + "@shikijs/themes": "2.1.0", + "@shikijs/types": "2.1.0", + "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, @@ -11400,6 +11325,7 @@ "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -12080,10 +12006,11 @@ } }, "node_modules/superjson": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.1.tgz", - "integrity": "sha512-8iGv75BYOa0xRJHK5vRLEjE2H/i4lulTjzpUXic3Eg8akftYjkmQDa8JARQ42rlczXyFR3IeRoeFCc7RxHsYZA==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.2.tgz", + "integrity": "sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==", "dev": true, + "license": "MIT", "dependencies": { "copy-anything": "^3.0.2" }, @@ -12723,10 +12650,11 @@ } }, "node_modules/typedoc": { - "version": "0.27.6", - "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.27.6.tgz", - "integrity": "sha512-oBFRoh2Px6jFx366db0lLlihcalq/JzyCVp7Vaq1yphL/tbgx2e+bkpkCgJPunaPvPwoTOXSwasfklWHm7GfAw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.27.7.tgz", + "integrity": "sha512-K/JaUPX18+61W3VXek1cWC5gwmuLvYTOXJzBvD9W7jFvbPnefRnCHQCEPw7MSNrP/Hj7JJrhZtDDLKdcYm6ucg==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@gerrit0/mini-shiki": "^1.24.0", "lunr": "^2.3.9", @@ -12745,10 +12673,11 @@ } }, "node_modules/typedoc-plugin-markdown": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/typedoc-plugin-markdown/-/typedoc-plugin-markdown-4.4.1.tgz", - "integrity": "sha512-fx23nSCvewI9IR8lzIYtzDphETcgTDuxKcmHKGD4lo36oexC+B1k4NaCOY58Snqb4OlE8OXDAGVcQXYYuLRCNw==", + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/typedoc-plugin-markdown/-/typedoc-plugin-markdown-4.4.2.tgz", + "integrity": "sha512-kJVkU2Wd+AXQpyL6DlYXXRrfNrHrEIUgiABWH8Z+2Lz5Sq6an4dQ/hfvP75bbokjNDUskOdFlEEm/0fSVyC7eg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 18" }, @@ -13382,10 +13311,11 @@ } }, "node_modules/vite": { - "version": "5.4.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.10.tgz", - "integrity": "sha512-1hvaPshuPUtxeQ0hsVH3Mud0ZanOLwVTneA1EgbAM5LhaZEqyPWGRQ7BtaMvUrTDeEaC8pxtj6a6jku3x4z6SQ==", + "version": "5.4.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.14.tgz", + "integrity": "sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==", "dev": true, + "license": "MIT", "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -13441,29 +13371,30 @@ } }, "node_modules/vitepress": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.5.0.tgz", - "integrity": "sha512-q4Q/G2zjvynvizdB3/bupdYkCJe2umSAMv9Ju4d92E6/NXJ59z70xB0q5p/4lpRyAwflDsbwy1mLV9Q5+nlB+g==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.3.tgz", + "integrity": "sha512-fCkfdOk8yRZT8GD9BFqusW3+GggWYZ/rYncOfmgcDtP3ualNHCAg+Robxp2/6xfH1WwPHtGpPwv7mbA3qomtBw==", "dev": true, + "license": "MIT", "dependencies": { - "@docsearch/css": "^3.6.2", - "@docsearch/js": "^3.6.2", - "@iconify-json/simple-icons": "^1.2.10", - "@shikijs/core": "^1.22.2", - "@shikijs/transformers": "^1.22.2", - "@shikijs/types": "^1.22.2", + "@docsearch/css": "3.8.2", + "@docsearch/js": "3.8.2", + "@iconify-json/simple-icons": "^1.2.21", + "@shikijs/core": "^2.1.0", + "@shikijs/transformers": "^2.1.0", + "@shikijs/types": "^2.1.0", "@types/markdown-it": "^14.1.2", - "@vitejs/plugin-vue": "^5.1.4", - "@vue/devtools-api": "^7.5.4", - "@vue/shared": "^3.5.12", - "@vueuse/core": "^11.1.0", - "@vueuse/integrations": "^11.1.0", - "focus-trap": "^7.6.0", + "@vitejs/plugin-vue": "^5.2.1", + "@vue/devtools-api": "^7.7.0", + "@vue/shared": "^3.5.13", + "@vueuse/core": "^12.4.0", + "@vueuse/integrations": "^12.4.0", + "focus-trap": "^7.6.4", "mark.js": "8.11.1", - "minisearch": "^7.1.0", - "shiki": "^1.22.2", - "vite": "^5.4.10", - "vue": "^3.5.12" + "minisearch": "^7.1.1", + "shiki": "^2.1.0", + "vite": "^5.4.14", + "vue": "^3.5.13" }, "bin": { "vitepress": "bin/vitepress.js" @@ -13588,15 +13519,16 @@ } }, "node_modules/vue": { - "version": "3.5.12", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.12.tgz", - "integrity": "sha512-CLVZtXtn2ItBIi/zHZ0Sg1Xkb7+PU32bJJ8Bmy7ts3jxXTcbfsEfBivFYYWz1Hur+lalqGAh65Coin0r+HRUfg==", - "dependencies": { - "@vue/compiler-dom": "3.5.12", - "@vue/compiler-sfc": "3.5.12", - "@vue/runtime-dom": "3.5.12", - "@vue/server-renderer": "3.5.12", - "@vue/shared": "3.5.12" + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.13.tgz", + "integrity": "sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.13", + "@vue/compiler-sfc": "3.5.13", + "@vue/runtime-dom": "3.5.13", + "@vue/server-renderer": "3.5.13", + "@vue/shared": "3.5.13" }, "peerDependencies": { "typescript": "*" diff --git a/package.json b/package.json index 8b54a58aa..f7bfd3ca2 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,6 @@ "lint": "eslint package.json src test --ext .js --ext .ts", "lint-fix": "npm run lint -- --fix", "typeorm": "node --require ts-node/register ./node_modules/typeorm/cli.js", - "cli": "ts-node ./src/gewis/service/cli-service.ts", "predocs:dev": "typedoc", "typedoc": "typedoc", "docs:dev": "vitepress dev docs", @@ -43,12 +42,12 @@ "gewisdb-ts-client": "github:GEWIS/gewisdb-ts-client#26575e8", "jsonwebtoken": "^9.0.2", "ldap-escape": "^2.0.6", - "ldapts": "^7.2.1", + "ldapts": "^7.3.1", "log4js": "^6.9.1", "mime-types": "^2.1.35", "mysql2": "^3.12.0", "node-cron": "^3.0.3", - "nodemailer": "^6.9.16", + "nodemailer": "^6.10.0", "pdf-generator-client": "github:GEWIS/pdf-generator#04e040b", "reflect-metadata": "^0.2.2", "sqlite3": "^5.1.7", @@ -71,10 +70,10 @@ "@types/dinero.js": "^1.9.4", "@types/express": "^4.17.21", "@types/express-fileupload": "^1.5.1", - "@types/jsonwebtoken": "^9.0.7", + "@types/jsonwebtoken": "^9.0.8", "@types/mime-types": "^2.1.4", "@types/mocha": "^10.0.10", - "@types/node": "^20.17.16", + "@types/node": "^20.17.17", "@types/node-cron": "^3.0.11", "@types/nodemailer": "^6.4.17", "@types/sinon": "^17.0.3", @@ -108,11 +107,11 @@ "sinon": "^18.0.1", "sinon-chai": "^3.7.0", "ts-node": "^10.9.2", - "typedoc": "^0.27.6", - "typedoc-plugin-markdown": "^4.4.1", + "typedoc": "^0.27.7", + "typedoc-plugin-markdown": "^4.4.2", "typedoc-plugin-merge-modules": "^6.1.0", "typedoc-vitepress-theme": "^1.1.2", "typescript": "^5.6.3", - "vitepress": "^1.5.0" + "vitepress": "^1.6.3" } } diff --git a/src/cron.ts b/src/cron.ts index 3914d2ebe..91c7dfbe7 100644 --- a/src/cron.ts +++ b/src/cron.ts @@ -30,12 +30,16 @@ import dinero, { Currency } from 'dinero.js'; import { DataSource } from 'typeorm'; import cron from 'node-cron'; import BalanceService from './service/balance-service'; -import ADService from './service/ad-service'; import RoleManager from './rbac/role-manager'; import Gewis from './gewis/gewis'; -import GewisDBService from './gewis/service/gewisdb-service'; import EventService from './service/event-service'; import DefaultRoles from './rbac/default-roles'; +import LdapSyncService from './service/sync/user/ldap-sync-service'; +import { UserSyncService } from './service/sync/user/user-sync-service'; +import UserSyncManager from './service/sync/user/user-sync-manager'; +import GewisDBSyncService from './gewis/service/gewisdb-sync-service'; +import getAppLogger from './helpers/logging'; +import ServerSettingsStore from './server-settings/server-settings-store'; class CronApplication { logger: Logger; @@ -60,7 +64,7 @@ async function createCronTasks(): Promise { application.logger.level = process.env.LOG_LEVEL; application.logger.info('Starting cron tasks...'); - const logger = log4js.getLogger('Console (cron)'); + const logger = getAppLogger('Console (cron)'); logger.level = process.env.LOG_LEVEL; console.log = (message: any, ...additional: any[]) => logger.debug(message, ...additional); @@ -68,6 +72,10 @@ async function createCronTasks(): Promise { dinero.defaultCurrency = process.env.CURRENCY_CODE as Currency; dinero.defaultPrecision = parseInt(process.env.CURRENCY_PRECISION, 10); + // Initialize database-stored settings + const store = ServerSettingsStore.getInstance(); + if (!store.initialized) await store.initialize(); + // Setup RBAC. application.roleManager = await new RoleManager().initialize(); @@ -101,31 +109,34 @@ async function createCronTasks(): Promise { // INJECT GEWIS BINDINGS Gewis.overwriteBindings(); + const syncServices: UserSyncService[] = []; + if (process.env.ENABLE_LDAP === 'true') { - const adService = new ADService(); - await adService.syncUsers(); - await adService.syncSharedAccounts().then( - () => adService.syncUserRoles(application.roleManager), - ); - const syncADGroups = cron.schedule('*/10 * * * *', async () => { - logger.debug('Syncing AD.'); - await adService.syncSharedAccounts().then( - () => adService.syncUserRoles(application.roleManager), - ); - logger.debug('Synced AD'); - }); - application.tasks.push(syncADGroups); + const ldapSyncService = new LdapSyncService(application.roleManager); + syncServices.push(ldapSyncService); + } + + if (process.env.GEWISDB_API_KEY && process.env.GEWISDB_API_URL) { + const gewisDBSyncService = new GewisDBSyncService(); + syncServices.push(gewisDBSyncService); } - if (process.env.GEWISDB_API_KEY && process.env.GEWISDB_API_URL && process.env.ENABLE_GEWISDB_SYNC) { - await GewisDBService.syncAll(); - const syncGewis = cron.schedule('41 4 * * *', async () => { - logger.debug('Syncing users with GEWISDB.'); - await GewisDBService.syncAll(); - logger.debug('Synced users with GEWISDB.'); + if (syncServices.length !== 0) { + const syncManager = new UserSyncManager(syncServices); + + const userSyncer = cron.schedule('41 1 * * *', async () => { + logger.debug('Syncing users.'); + await syncManager.run(); + }); + application.tasks.push(userSyncer); + + const userFetcher = cron.schedule('*/15 * * * *', async () => { + logger.debug('Fetching users.'); + await syncManager.fetch(); }); - application.tasks.push(syncGewis); + application.tasks.push(userFetcher); } + application.logger.info('Tasks registered'); } diff --git a/src/entity/server-setting.ts b/src/entity/server-setting.ts index c1dbf2216..cee0cb50b 100644 --- a/src/entity/server-setting.ts +++ b/src/entity/server-setting.ts @@ -32,6 +32,7 @@ export interface ISettings { jwtExpiryDefault: number; jwtExpiryPointOfSale: number; maintenanceMode: boolean; + allowGewisSyncDelete: boolean; } /** diff --git a/src/entity/user/user.ts b/src/entity/user/user.ts index c8184272f..6766e2417 100644 --- a/src/entity/user/user.ts +++ b/src/entity/user/user.ts @@ -46,6 +46,7 @@ export enum UserType { LOCAL_ADMIN = 'LOCAL_ADMIN', INVOICE = 'INVOICE', POINT_OF_SALE = 'POINT_OF_SALE', + INTEGRATION = 'INTEGRATION', } /** diff --git a/src/gewis/gewis.ts b/src/gewis/gewis.ts index eada5476d..cbe5d4e4e 100644 --- a/src/gewis/gewis.ts +++ b/src/gewis/gewis.ts @@ -135,12 +135,14 @@ export default class Gewis extends WithManager { return gewisUser; } + public static ldapUserCreation: () => (ADUser: LDAPUser) => Promise = () => { + const service = new Gewis(); + return service.findOrCreateGEWISUserAndBind.bind(service); + }; + // eslint-disable-next-line class-methods-use-this static overwriteBindings() { - Bindings.ldapUserCreation = () => { - const service = new Gewis(); - return service.findOrCreateGEWISUserAndBind.bind(service); - }; + Bindings.onNewUserCreate = Gewis.ldapUserCreation; Bindings.Users = { parseToResponse: Gewis.parseRawUserToGewisResponse, getBuilder: Gewis.getUserBuilder, diff --git a/src/gewis/service/cli-service.ts b/src/gewis/service/cli-service.ts deleted file mode 100644 index ddd513e48..000000000 --- a/src/gewis/service/cli-service.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * SudoSOS back-end API service. - * Copyright (C) 2024 Study association GEWIS - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * @license - */ - -/** - * This is the module page of the cli-service. - * - * @module GEWIS/cli-service - */ - -import 'reflect-metadata'; -import { Command } from 'commander'; -import log4js from 'log4js'; -import database from '../../database/database'; -import GewisDBService from './gewisdb-service'; -import { DataSource } from 'typeorm'; - -// Load environment variables -require('dotenv').config(); - -// Logger setup -const logger = log4js.getLogger('CLIService'); -logger.level = 'info'; - - -// Define the CLI program -const program = new Command(); - -async function dryRunSyncAll() { - let dataSource: DataSource; - try { - // Initialize the datasource - dataSource = await database.initialize(); - logger.info('Datasource initialized successfully.'); - - // Call syncAll and log the results - const updatedUsers = await GewisDBService.syncAll(false); - logger.info('Updated users:', updatedUsers); - } catch (error) { - logger.error('Error during dry-run sync:', error); - } finally { - // Close the datasource connection - await dataSource?.destroy(); - logger.info('Datasource connection closed.'); - } -} -program - .command('db-sync') - .description('Dry ryun sync users with GEWIS DB') - .action(async () => { - await dryRunSyncAll(); - }); - - -// Parse the CLI arguments -program.parse(process.argv); diff --git a/src/gewis/service/gewisdb-service.ts b/src/gewis/service/gewisdb-service.ts deleted file mode 100644 index efdd15856..000000000 --- a/src/gewis/service/gewisdb-service.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** - * SudoSOS back-end API service. - * Copyright (C) 2024 Study association GEWIS - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * @license - */ - -/** - * This is the module page of gewis-db-service. - * - * @module GEWIS/gewisdb - */ - -import GewisUser from '../entity/gewis-user'; -import { BasicApi, Configuration, Health, MembersApi } from 'gewisdb-ts-client'; -import log4js, { getLogger, Logger } from 'log4js'; -import { webResponseToUpdate } from '../helpers/gewis-helper'; -import UserService from '../../service/user-service'; -import { UserResponse } from '../../controller/response/user-response'; -import Mailer from '../../mailer'; -import MembershipExpiryNotification from '../../mailer/messages/membership-expiry-notification'; -import DineroTransformer from '../../entity/transformer/dinero-transformer'; -import { Language } from '../../mailer/mail-message'; -import BalanceService from '../../service/balance-service'; - -const GEWISDB_API_URL = process.env.GEWISDB_API_URL; -const GEWISDB_API_KEY = process.env.GEWISDB_API_KEY; - -// Configuration for the API access -const configuration = new Configuration({ basePath: GEWISDB_API_URL, accessToken: () => GEWISDB_API_KEY }); -const api = new MembersApi(configuration); -const pinger = new BasicApi(configuration); - -// Logger setup -const logger: Logger = log4js.getLogger('GewisDBService'); -logger.level = process.env.LOG_LEVEL; - -export default class GewisDBService { - - public static api = api; - - public static pinger = pinger; - - /** - * Synchronizes ALL users with GEWIS DB user data. - * This method only returns users that were actually updated during the synchronization process. - * @param {boolean} commit - Whether to commit the changes to the database. - * @returns {Promise} A promise that resolves with an array of UserResponses for users that were updated. Returns null if the API is unhealthy. - */ - public static async syncAll(commit: boolean = true): Promise { - const gewisUsers = await GewisUser.find({ where: { user: { deleted: false } }, relations: ['user'] }); - return this.sync(gewisUsers, commit); - } - - /** - * Synchronizes users with GEWIS DB user data. - * This method only returns users that were actually updated during the synchronization process. - * @param {GewisUser[]} gewisUsers - Array of users to sync. - * @param {boolean} commit - Whether to commit the changes to the database. - * @returns {Promise} A promise that resolves with an array of UserResponses for users that were updated. Returns null if the API is unhealthy. - */ - public static async sync(gewisUsers: GewisUser[], commit: boolean = true): Promise { - let ping: Health; - try { - ping = await GewisDBService.pinger.healthGet().then(health => health.data); - } catch (error) { - logger.warn('Failed to ping GEWIS DB', error); - return null; - } - - if (ping.sync_paused) { - logger.warn('GEWISDB API paused, aborting.'); - return null; - } - - if (!ping.healthy) { - logger.warn('GEWISDB API unhealthy, aborting.'); - return null; - } - - logger.info(`Syncing ${gewisUsers.length} users with GEWIS DB`); - const updates: UserResponse[] = []; - const promises = gewisUsers.map(user => GewisDBService.updateUser(user, commit).then((u: UserResponse) => { - if (u) updates.push(u); - })); - - await Promise.allSettled(promises); - return updates; - } - - /** - * Updates a user in the local database based on the GEWIS DB data. - * @param commit - Whether to commit the changes to the database. - * @param {GewisUser} gewisUser - The user to be updated. - */ - private static async updateUser(gewisUser: GewisUser, commit: boolean = true) { - logger.trace(`Syncing GEWIS User ${gewisUser.gewisId}`); - let dbMember; - try { - dbMember = await GewisDBService.api.membersLidnrGet(gewisUser.gewisId).then(member => member.data.data); - } catch (error) { - logger.error(`Failed to fetch: ${error}`); - return; - } - - if (!dbMember) { - logger.trace(`Could not find GEWIS User ${gewisUser.gewisId} in DB.`); - return; - } - - const expirationDate = new Date(dbMember.expiration); - const expired = new Date() > expirationDate; - - if (expired) { - try { - logger.log(`User ${gewisUser.gewisId} has expired, closing account.`); - if (!commit) return null; - - const currentBalance = await new BalanceService().getBalance(gewisUser.user.id); - const isZero = currentBalance.amount.amount === 0; - const user = await UserService.closeUser(gewisUser.user.id, isZero); - Mailer.getInstance().send(gewisUser.user, new MembershipExpiryNotification({ - balance: DineroTransformer.Instance.from(currentBalance.amount.amount), - }), Language.ENGLISH, { bcc: process.env.FINANCIAL_RESPONSIBLE }).catch((e) => getLogger('User').error(e)); - return user; - } catch (e) { - logger.error(e); - return null; - } - } - - const update = webResponseToUpdate(dbMember); - if (GewisDBService.isUpdateNeeded(gewisUser, update)) { - logger.log(`Updated user m${gewisUser.gewisId} (id ${gewisUser.userId}) with `, update); - if (!commit) return null; - - return UserService.updateUser(gewisUser.user.id, update); - } - } - - /** - * Checks if the user needs an update. - * @param {GewisUser} gewisUser - The local user data. - * @param {any} update - The new data to potentially update. - * @returns {boolean} True if an update is needed, otherwise false. - */ - private static isUpdateNeeded(gewisUser: GewisUser, update: any): boolean { - return gewisUser.user.firstName !== update.firstName || - gewisUser.user.lastName !== update.lastName || - gewisUser.user.ofAge !== update.ofAge || - gewisUser.user.email !== update.email; - } -} diff --git a/src/gewis/service/gewisdb-sync-service.ts b/src/gewis/service/gewisdb-sync-service.ts new file mode 100644 index 000000000..dcdfed424 --- /dev/null +++ b/src/gewis/service/gewisdb-sync-service.ts @@ -0,0 +1,145 @@ +/** + * SudoSOS back-end API service. + * Copyright (C) 2024 Study association GEWIS + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * @license + */ + +/** + * This is the module page of the gewis-db-sync-service. + * + * @module GEWIS/gewis-db-sync-service + */ + +import { UserSyncService } from '../../service/sync/user/user-sync-service'; +import User, { UserType } from '../../entity/user/user'; +import log4js, { getLogger, Logger } from 'log4js'; +import GewisUser from '../entity/gewis-user'; +import { webResponseToUpdate } from '../helpers/gewis-helper'; +import BalanceService from '../../service/balance-service'; +import UserService from '../../service/user-service'; +import MembershipExpiryNotification from '../../mailer/messages/membership-expiry-notification'; +import DineroTransformer from '../../entity/transformer/dinero-transformer'; +import Mailer from '../../mailer'; +import { Language } from '../../mailer/mail-message'; +import { BasicApi, Configuration, MembersApi } from 'gewisdb-ts-client'; +import ServerSettingsStore from '../../server-settings/server-settings-store'; +import { ISettings } from '../../entity/server-setting'; +import { EntityManager } from 'typeorm'; + + +export default class GewisDBSyncService extends UserSyncService { + + targets = [UserType.MEMBER]; + + private api: MembersApi; + + private pinger: BasicApi; + + private logger: Logger = log4js.getLogger('GewisDBSyncService'); + + constructor(gewisdbApiKey?: string, gewisdbApiUrl?: string, manager?: EntityManager) { + super(manager); + const basePath = gewisdbApiUrl ?? process.env.GEWISDB_API_URL; + const token = gewisdbApiKey ?? process.env.GEWISDB_API_KEY; + this.api = new MembersApi(new Configuration({ basePath, accessToken: () => token })); + this.pinger = new BasicApi(new Configuration({ basePath, accessToken: () => token })); + this.logger.level = process.env.LOG_LEVEL; + } + + async guard(entity: User): Promise { + if (!await super.guard(entity)) return false; + + const gewisUser = await this.manager.findOne(GewisUser, { where: { user: { id: entity.id } }, relations: ['user'] }); + return !!gewisUser; + } + + async pre(): Promise { + const ping = await this.pinger.healthGet().then(health => health.data); + const ready = ping.sync_paused === false && ping.healthy === true; + if (!ready) { + throw new Error('GEWISDB is not ready for syncing'); + } + } + + async sync(entity: User): Promise { + const gewisUser = await this.manager.findOne(GewisUser, { where: { user: { id: entity.id } }, relations: ['user'] }); + if (!gewisUser) { + throw new Error('GEWIS User not found.'); + } + + const dbMember = await this.api.membersLidnrGet(gewisUser.gewisId).then(member => member.data.data); + if (!dbMember) return false; + + const expirationDate = new Date(dbMember.expiration); + const expired = new Date() > expirationDate; + + if (expired) { + this.logger.log(`User ${gewisUser.gewisId} has expired.`); + return false; + } + + const update = webResponseToUpdate(dbMember); + if (GewisDBSyncService.isUpdateNeeded(gewisUser, update)) { + this.logger.log(`Updating user m${gewisUser.gewisId} (id ${gewisUser.userId}) with `, update); + const user = gewisUser.user; + user.firstName = update.firstName; + user.lastName = update.lastName; + user.email = update.email; + user.ofAge = update.ofAge; + await this.manager.save(user); + return true; + } + return true; + } + + private static async getAllowDelete(): Promise { + return ServerSettingsStore.getInstance().getSetting('allowGewisSyncDelete') as ISettings['allowGewisSyncDelete']; + } + + async down(entity: User): Promise { + const gewisUser = await this.manager.findOne(GewisUser, { where: { user: { id: entity.id } } }); + if (!gewisUser) return; + + // We do not delete the GewisUser, for if we ever want to undo deletions and keep the link to the m-number. + // await this.manager.delete(GewisUser, gewisUser); + + // Sync deleting a user is quite impactful, so we only do it if the setting is explicitly set. + if (!await GewisDBSyncService.getAllowDelete()) return; + + const currentBalance = await new BalanceService().getBalance(entity.id); + await UserService.closeUser(entity.id, true).then(() => { + this.logger.trace(`User ${entity.id} closed`); + Mailer.getInstance().send(entity, new MembershipExpiryNotification({ + balance: DineroTransformer.Instance.from(currentBalance.amount.amount), + }), Language.ENGLISH, { bcc: process.env.FINANCIAL_RESPONSIBLE }).catch((e) => getLogger('User').error(e)); + }).catch((e) => { + this.logger.error('Syncing error for', entity, e); + }); + } + + fetch(): Promise { + // We do not fetch anything. + return Promise.resolve(undefined); + } + + static isUpdateNeeded(gewisUser: GewisUser, update: any): boolean { + return gewisUser.user.firstName !== update.firstName || + gewisUser.user.lastName !== update.lastName || + gewisUser.user.ofAge !== update.ofAge || + gewisUser.user.email !== update.email; + } +} \ No newline at end of file diff --git a/src/helpers/ad.ts b/src/helpers/ad.ts index c6a5bdf79..2b764ebe1 100644 --- a/src/helpers/ad.ts +++ b/src/helpers/ad.ts @@ -62,6 +62,7 @@ export interface LDAPUser { sn: string, objectGUID: Buffer, mail: string, + displayName: string, mNumber: number | undefined; } @@ -112,6 +113,7 @@ export interface LDAPResult { sn: string, objectGUID: Buffer, mail: string, + displayName: string, employeeNumber: number | undefined; } @@ -121,7 +123,7 @@ export interface LDAPResult { */ export function userFromLDAP(ldapResult: LDAPResult): LDAPUser { const { - dn, memberOfFlattened, givenName, sn, + dn, memberOfFlattened, givenName, sn, displayName, objectGUID, mail, employeeNumber, whenChanged, } = ldapResult; return { @@ -132,6 +134,7 @@ export function userFromLDAP(ldapResult: LDAPResult): LDAPUser { sn, objectGUID, mail, + displayName, mNumber: employeeNumber, }; } diff --git a/src/helpers/bindings.ts b/src/helpers/bindings.ts index 51a43ac86..375af8aad 100644 --- a/src/helpers/bindings.ts +++ b/src/helpers/bindings.ts @@ -32,16 +32,15 @@ import { parseRawUserToResponse, parseUserToResponse, RawUser } from './revision import { UserResponse } from '../controller/response/user-response'; import { AppDataSource } from '../database/database'; + /** * Class used for setting default functions or bindings. * For example, this allows the behaviour of user creation to be changed easily. * In this case it is used to inject GEWIS related code without editing the files themselves. */ export default class Bindings { - /** - * Function called when an unbound User is found and created. - */ - public static ldapUserCreation: () => (ADUser: LDAPUser) => Promise = () => { + + public static onNewUserCreate: () => (ADUser: LDAPUser) => Promise = () => { const service = new AuthenticationService(); return service.createUserAndBind.bind(service); }; diff --git a/src/helpers/logging.ts b/src/helpers/logging.ts new file mode 100644 index 000000000..9af1cdd2b --- /dev/null +++ b/src/helpers/logging.ts @@ -0,0 +1,35 @@ +/** + * SudoSOS back-end API service. + * Copyright (C) 2024 Study association GEWIS + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * @license + */ + +import log4js from 'log4js'; + +export default function getAppLogger(category: string = 'Application'): log4js.Logger { + log4js.configure({ + pm2: true, + appenders: { + out: { type: 'stdout' }, + }, + disableClustering: true, + categories: { default: { appenders: ['out'], level: 'all' } }, + }); + const logger = log4js.getLogger(category); + logger.level = process.env.LOG_LEVEL; + return logger; +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index a193f1043..6e0fb891e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -78,6 +78,7 @@ import SellerPayoutController from './controller/seller-payout-controller'; import { ISettings } from './entity/server-setting'; import ServerSettingsController from './controller/server-settings-controller'; import TransactionSummaryController from './controller/transaction-summary-controller'; +import getAppLogger from './helpers/logging'; export class Application { app: express.Express; @@ -162,16 +163,7 @@ async function setupAuthentication(tokenHandler: TokenHandler, application: Appl export default async function createApp(): Promise { const application = new Application(); - log4js.configure({ - pm2: true, - appenders: { - out: { type: 'stdout' }, - }, - disableClustering: true, - categories: { default: { appenders: ['out'], level: 'all' } }, - }); - application.logger = log4js.getLogger('Application'); - application.logger.level = process.env.LOG_LEVEL; + application.logger = getAppLogger(); application.logger.info('Starting application instance...'); // Validate environment variables diff --git a/src/server-settings/setting-defaults.ts b/src/server-settings/setting-defaults.ts index d00e5a311..0476b7962 100644 --- a/src/server-settings/setting-defaults.ts +++ b/src/server-settings/setting-defaults.ts @@ -31,6 +31,7 @@ const SettingsDefaults: ISettings = { jwtExpiryDefault: 3600, jwtExpiryPointOfSale: 60 * 60 * 24 * 14, maintenanceMode: false, + allowGewisSyncDelete: false, }; export default SettingsDefaults; diff --git a/src/service/ad-service.ts b/src/service/ad-service.ts index ffd876eb5..486b5829f 100644 --- a/src/service/ad-service.ts +++ b/src/service/ad-service.ts @@ -24,33 +24,50 @@ * @module internal/ldap */ -import { Client, SearchResult } from 'ldapts'; +import { Client, EqualityFilter, SearchResult } from 'ldapts'; import { In } from 'typeorm'; import LDAPAuthenticator from '../entity/authenticator/ldap-authenticator'; import User, { TermsOfServiceStatus, UserType } from '../entity/user/user'; -import { bindUser, getLDAPConnection, LDAPGroup, LDAPResponse, LDAPResult, LDAPUser, userFromLDAP } from '../helpers/ad'; +import { bindUser, LDAPGroup, LDAPResponse, LDAPResult, LDAPUser, userFromLDAP } from '../helpers/ad'; import AuthenticationService from './authentication-service'; -import Bindings from '../helpers/bindings'; import RoleManager from '../rbac/role-manager'; -import RBACService from './rbac-service'; import WithManager from '../database/with-manager'; +import Bindings from '../helpers/bindings'; export default class ADService extends WithManager { + /** - * Creates and binds an Shared (Organ) group to an actual User + * Creates and binds a Shared (Organ) group to an actual User * @param sharedUser - The group that needs an account. */ - private async toSharedUser(sharedUser: LDAPGroup) { - const account = Object.assign(new User(), { + async toSharedUser(sharedUser: LDAPGroup): Promise { + const account = await this.manager.save(User, { firstName: sharedUser.displayName, lastName: '', type: UserType.ORGAN, active: true, acceptedToS: TermsOfServiceStatus.NOT_REQUIRED, - }) as User; + }); + await bindUser(this.manager, sharedUser, account); + return account; + } - const acc = await this.manager.save(account); - await bindUser(this.manager, sharedUser, acc); + /** + * Create a new user account for the given service account. + * @param serviceAccount + */ + async toServiceAccount(serviceAccount: LDAPUser): Promise { + const account = await this.manager.save(User, { + firstName: serviceAccount.displayName, + lastName: '', + type: UserType.INTEGRATION, + active: true, + acceptedToS: TermsOfServiceStatus.NOT_REQUIRED, + canGoIntoDebt: false, + }); + + await bindUser(this.manager, serviceAccount, account); + return account; } /** @@ -58,13 +75,10 @@ export default class ADService extends WithManager { * @param ldapUsers */ public async createAccountIfNew(ldapUsers: LDAPUser[]) { - const filtered = await this.filterUnboundGUID(ldapUsers); - const createUser = async (ADUsers: LDAPUser[]): Promise => { - const promises: Promise[] = []; - ADUsers.forEach((u) => promises.push(Bindings.ldapUserCreation()(u))); - await Promise.all(promises); - }; - await createUser(filtered as LDAPUser[]); + const filtered = (await this.filterUnboundGUID(ldapUsers)) as LDAPUser[]; + for (const u of filtered) { + await Bindings.onNewUserCreate()(u); + } } /** @@ -76,146 +90,85 @@ export default class ADService extends WithManager { public async getUsers(ldapUsers: LDAPUser[], createIfNew = false): Promise { if (createIfNew) await this.createAccountIfNew(ldapUsers); + const uuids = ldapUsers.map((u) => (u.objectGUID)); const authenticators = await this.manager.find(LDAPAuthenticator, { where: { UUID: In(uuids) }, relations: ['user'] }); - return authenticators.map((u) => u.user); - } - /** - * Gives access to a shared account for a list of LDAPUsers. - * @param user - The user to give access - * @param ldapUsers - The users to gain access - */ - private async setSharedUsers(user: User, ldapUsers: LDAPUser[]) { - const members = await this.getUsers(ldapUsers, true); - // Give accounts access to the shared user. - await new AuthenticationService(this.manager).setMemberAuthenticator(members, user); + return authenticators.map((u) => u.user); } /** * Returns all objects with a GUID that is not in the LDAPAuthenticator table. * @param ldapResponses - Array to filter. */ - private async filterUnboundGUID(ldapResponses: LDAPResponse[]) { + async filterUnboundGUID(ldapResponses: LDAPResponse[]) { const ids = ldapResponses.map((s) => s.objectGUID); const auths = await this.manager.find(LDAPAuthenticator, { where: { UUID: In(ids) }, relations: ['user'] }); const existing = auths.map((l: LDAPAuthenticator) => l.UUID); // Use Buffer.compare to filter out existing GUIDs - const filtered = ldapResponses.filter((response) => + return ldapResponses.filter((response) => !existing.some((uuid) => Buffer.compare(response.objectGUID, uuid) === 0), ); - - return filtered; } /** * Handles and updates a shared group * Gives authentications to the members of the shared group * @param client - The LDAP client - * @param sharedAccounts - Accounts to give access - */ - private async handleSharedGroups(client: Client, sharedAccounts: LDAPGroup[]) { - for (let i = 0; i < sharedAccounts.length; i += 1) { - // Extract members - const shared = sharedAccounts[i]; - const result = await this.getLDAPGroupMembers(client, shared.dn); - const members: LDAPUser[] = result.searchEntries.map((u) => userFromLDAP(u)); - const auth = await LDAPAuthenticator.findOne({ where: { UUID: shared.objectGUID }, relations: ['user'] }); - if (auth) await this.setSharedUsers(auth.user, members); - } - } - - /** - * Helper function to prevent transactions in transactions - * @param responses - * @private + * @param sharedAccount - Account to give access */ - private async createSharedFromArray(responses: LDAPGroup[]) { - const promises: Promise[] = []; - responses.forEach((r) => promises.push(this.toSharedUser(r))); - await Promise.all(promises); - } + async updateSharedAccountMembership(client: Client, sharedAccount: LDAPGroup): Promise { + const auth = await LDAPAuthenticator.findOne({ where: { UUID: sharedAccount.objectGUID }, relations: ['user'] }); + if (!auth) throw new Error('No authenticator found for shared account'); - /** - * Syncs all the shared account and access with AD. - */ - public async syncSharedAccounts() { - if (!process.env.ENABLE_LDAP) return; - const client = await getLDAPConnection(); + // Get all the members of the shared account from AD + const ldapMembers = (await this.getLDAPGroupMembers(client, sharedAccount.dn)) + .searchEntries.map((u) => userFromLDAP(u)); - const sharedAccounts = await this.getLDAPGroups( - client, process.env.LDAP_SHARED_ACCOUNT_FILTER, - ); + // Turn the ldapMembers into SudoSOS users + const members = await this.getUsers(ldapMembers, true); - const unexisting = (await this.filterUnboundGUID(sharedAccounts)) as LDAPGroup[]; - - // Makes new Shared Users for all new shared users. - await this.createSharedFromArray(unexisting); - - // Adds users to the shared groups. - await this.handleSharedGroups(client, sharedAccounts); + // Set the memberAuthenticator accordingly + await new AuthenticationService(this.manager).setMemberAuthenticator(members, auth.user); } /** * Gives Users the correct role. * Note that this creates Users if they do not exists in the LDAPAuth. table. + * @param client + * @param ldapRole - the AD entry linked to this role. * @param roleManager - Reference to the application role manager - * @param role - Name of the role - * @param users - LDAPUsers to give the role to */ - public async addUsersToRole(roleManager: RoleManager, - role: string, users: LDAPUser[]) { - const members = await this.getUsers(users, true); - await roleManager.setRoleUsers(members, role); - } + async updateRoleMembership(client: Client, ldapRole: LDAPGroup, roleManager: RoleManager): Promise { + const ldapMembers = (await this.getLDAPGroupMembers(client, ldapRole.dn)) + .searchEntries.map((u) => userFromLDAP(u)); - /** - * Function that handles the updating of the AD roles as returned by the AD Query - * @param roleManager - Reference to the application role manager - * @param client - LDAP Client connection - * @param ldapRoles - Roles returned from LDAP - */ - private async handleADRoles(roleManager: RoleManager, - client: Client, ldapRoles: LDAPGroup[]) { - const [dbRoles] = await RBACService.getRoles(); - for (let i = 0; i < ldapRoles.length; i += 1) { - const ldapRole = ldapRoles[i]; - - // The LDAP role should also exist in SudoSOS - if (dbRoles.some((r) => r.name === ldapRole.cn)) { - const result = await this.getLDAPGroupMembers(client, ldapRole.dn); - const members: LDAPUser[] = result.searchEntries.map((u) => userFromLDAP(u)); - await this.addUsersToRole(roleManager, ldapRole.cn, members); - } - } + // Turn the ldapMembers into SudoSOS users + const members = await this.getUsers(ldapMembers, true); + + await roleManager.setRoleUsers(members, ldapRole.dn); } /** - * Sync User Roles from AD - * @param roleManager - Reference to the application role manager + * Retrieves the LDAP entry matching the provided GUID, or undefined if there is none. + * + * @param client + * @param guid */ - public async syncUserRoles(roleManager: RoleManager) { - if (!process.env.ENABLE_LDAP) return; - const client = await getLDAPConnection(); - - const roles = await this.getLDAPGroups(client, process.env.LDAP_ROLE_FILTER); - if (!roles) return; + public async getLDAPResponseFromGUID(client: Client, guid: Buffer): Promise { + const results = await client.search(process.env.LDAP_BASE, { + filter: new EqualityFilter({ + attribute: 'objectGUID', + value: guid, + }), + explicitBufferAttributes: ['objectGUID'], + }); - await this.handleADRoles(roleManager, client, roles); - } + if (results.searchEntries.length === 0) + return undefined; - /** - * Sync all Users from AD and create account if needed. - */ - public async syncUsers() { - if (!process.env.ENABLE_LDAP) return; - const client = await getLDAPConnection(); - - const { searchEntries } = await this.getLDAPGroupMembers(client, - process.env.LDAP_USER_BASE); - const users = searchEntries.map((entry) => userFromLDAP(entry)); - await this.getUsers(users, true); + return userFromLDAP(results.searchEntries[0] as any as LDAPResult); } /** @@ -223,7 +176,8 @@ export default class ADService extends WithManager { * @param client - The LDAP Connection * @param dn - DN Of the group to get members of */ - public getLDAPGroupMembers(client: Client, dn: string) { + public getLDAPGroupMembers(client: Client, dn: string): + Promise & { searchEntries: LDAPResult[] }> { return client.search(process.env.LDAP_BASE, { filter: `(&(objectClass=user)(objectCategory=person)(memberOf:1.2.840.113556.1.4.1941:=${dn}))`, explicitBufferAttributes: ['objectGUID'], @@ -244,6 +198,7 @@ export default class ADService extends WithManager { }); return searchEntries.map((e) => (e as any) as T); } catch (error) { + console.error(error); return undefined; } } diff --git a/src/service/product-service.ts b/src/service/product-service.ts index cdaaf479b..50fde437e 100644 --- a/src/service/product-service.ts +++ b/src/service/product-service.ts @@ -349,7 +349,12 @@ export default class ProductService { const { containers } = productRevision; containers.forEach((c => c.products = c.products.filter((p) => p.productId !== productId))); - await this.executePropagation(containers); + // Make sure to filter out deleted and non-current containers + const current = containers + .filter((c) => c.container.deletedAt == null && c.revision === c.container.currentRevision) + .filter((c, index, self) => ( + index === self.findIndex((c2) => c.container.id === c2.container.id))); + await this.executePropagation(current); await Product.softRemove(productRevision.product); } diff --git a/src/service/sync/sync-manager.ts b/src/service/sync/sync-manager.ts new file mode 100644 index 000000000..bedae6dcd --- /dev/null +++ b/src/service/sync/sync-manager.ts @@ -0,0 +1,119 @@ +/** + * SudoSOS back-end API service. + * Copyright (C) 2024 Study association GEWIS + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * @license + */ + +import WithManager from '../../database/with-manager'; +import { SyncResult, SyncService } from './sync-service'; +import log4js, { Logger } from 'log4js'; + +export default abstract class SyncManager> extends WithManager { + + protected readonly services: S[]; + + protected logger: Logger = log4js.getLogger('SyncManager'); + + constructor(services: S[]) { + super(); + this.logger.level = process.env.LOG_LEVEL; + this.services = services; + } + + abstract getTargets(): Promise; + + async run() { + this.logger.trace('Start sync job'); + const entities = await this.getTargets(); + + try { + await this.pre(); + } catch (error) { + this.logger.error('Aborting sync due to error', error); + return; + } + for (const entity of entities) { + try { + const result = await this.sync(entity); + + if (result.skipped) { + this.logger.trace('Syncing skipped for', entity); + continue; + } + + if (result.result === false) { + this.logger.warn('Sync result: false for', entity); + await this.down(entity); + } else { + this.logger.trace('Sync result: true for', entity); + } + + } catch (error) { + this.logger.error('Syncing error for', entity, error); + } + } + await this.post(); + } + + async sync(entity: T): Promise { + const syncResult: SyncResult = { skipped: true, result: false }; + + // Aggregate results from all services + for (const service of this.services) { + const result = await service.up(entity); + + if (!result.skipped) syncResult.skipped = false; + if (result.result) syncResult.result = true; + } + + return syncResult; + } + + async down(entity: T): Promise { + for (const service of this.services) { + try { + await service.down(entity); + } catch (error) { + this.logger.error('Could not down', entity, error); + } + } + } + + async fetch(): Promise { + for (const service of this.services) { + try { + await service.pre(); + await service.fetch(); + } catch (error) { + this.logger.error('Syncing fetch error for', service, error); + } + await service.post(); + } + } + + async pre(): Promise { + for (const service of this.services) { + await service.pre(); + } + } + + async post(): Promise { + for (const service of this.services) { + await service.post(); + } + } +} diff --git a/src/service/sync/sync-service.ts b/src/service/sync/sync-service.ts new file mode 100644 index 000000000..4f671c17b --- /dev/null +++ b/src/service/sync/sync-service.ts @@ -0,0 +1,106 @@ +/** + * SudoSOS back-end API service. + * Copyright (C) 2024 Study association GEWIS + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * @license + */ + +/** + * This is the module page of the abstract sync-service. + * + * @module internal/sync-service + */ + +import WithManager from '../../database/with-manager'; + +export interface SyncResult { + skipped: boolean; + result: boolean; +} + +/** + * SyncService interface. + * + * SyncService is the abstract class which is used to sync entity data. + * This can be used to integrate external data sources into the SudoSOS back-end. + */ +export abstract class SyncService extends WithManager { + + /** + * Guard determines whether the entity should be synced using this sync service. + * + * Not passing the guard will result in the user being skipped. + * A skipped sync does not count as a failure. + * + * @param entity The entity to check. + * @returns {Promise} True if the entity should be synced, false otherwise. + */ + abstract guard(entity: T): Promise; + + /** + * Up is a wrapper around `sync` that handles the guard. + * + * @param entity + * + * @returns {Promise} The result of the sync. + */ + async up(entity: T): Promise { + const guardResult = await this.guard(entity); + if (!guardResult) return { skipped: true, result: false }; + + const result = await this.sync(entity); + return { skipped: false, result }; + } + + /** + * Synchronizes the user data with the external data source. + * + * @param entity The user to synchronize. + * @returns {Promise} True if the user was synchronized, false otherwise. + */ + protected abstract sync(entity: T): Promise; + + /** + * Fetches the user data from the external data source. + * `sync` can be seen as a `push` and `fetch` as a `pull`. + * + */ + abstract fetch(): Promise; + + /** + * Down is called when the SyncService decides that the entity is no longer connected to this sync service be removed. + * This can be used to remove the entity from the database or clean up entities. + * + * This should be revertible and idempotent! + * + * @param entity + */ + abstract down(entity: T): Promise; + + /** + * Called before a sync batch is started. + */ + pre(): Promise { + return Promise.resolve(); + } + + /** + * Called after a sync batch is finished. + */ + post(): Promise { + return Promise.resolve(); + } +} diff --git a/src/service/sync/user/ldap-sync-service.ts b/src/service/sync/user/ldap-sync-service.ts new file mode 100644 index 000000000..f304c9d6b --- /dev/null +++ b/src/service/sync/user/ldap-sync-service.ts @@ -0,0 +1,225 @@ +/** + * SudoSOS back-end API service. + * Copyright (C) 2024 Study association GEWIS + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * @license + */ + +/** + * This is the module page of the ldap-sync-service. + * + * @module internal/ldap-sync-service + */ + +import User, { TermsOfServiceStatus, UserType } from '../../../entity/user/user'; +import { Client } from 'ldapts'; +import ADService from '../../ad-service'; +import LDAPAuthenticator from '../../../entity/authenticator/ldap-authenticator'; +import RoleManager from '../../../rbac/role-manager'; +import { EntityManager } from 'typeorm'; +import { getLDAPConnection, LDAPGroup, LDAPUser } from '../../../helpers/ad'; +import RBACService from '../../rbac-service'; +import log4js, { Logger } from 'log4js'; +import { UserSyncService } from './user-sync-service'; + +export default class LdapSyncService extends UserSyncService { + + // We only sync organs, members and integrations. + targets = [UserType.ORGAN, UserType.MEMBER, UserType.INTEGRATION]; + + // Is set in the `pre` function. + private ldapClient: Client; + + private readonly adService: ADService; + + private readonly roleManager: RoleManager; + + private logger: Logger = log4js.getLogger('AdSyncService'); + + constructor(roleManager: RoleManager, adService?: ADService, manager?: EntityManager) { + // Sanity check, since we already have a ldapClient + if (!process.env.ENABLE_LDAP) throw new Error('LDAP is not enabled'); + + super(manager); + this.logger.level = process.env.LOG_LEVEL; + this.roleManager = roleManager; + this.adService = adService ?? new ADService(this.manager); + } + + async guard(user: User): Promise { + if (!await super.guard(user)) return false; + + // For members, we only sync if we have an LDAPAuthenticator + if (user.type === UserType.MEMBER) { + const ldapAuth = await this.manager.findOne(LDAPAuthenticator, { where: { user: { id: user.id } } }); + return !!ldapAuth; + } + + return true; + } + + /** + * Sync user based on LDAPAuthenticator. + * Only organs are actually updated. + * @param user + */ + async sync(user: User): Promise { + const ldapAuth = await this.manager.findOne(LDAPAuthenticator, { where: { user: { id: user.id } } }); + if (!ldapAuth) return false; + + const ldapUser = await this.adService.getLDAPResponseFromGUID(this.ldapClient, ldapAuth.UUID); + if (!ldapUser) return false; + + // For members, we fetch user info from the GEWISDB + // Therefore we do not need to update the user + // But we do return true to indicate that the user is "bound" to the LDAP + if (user.type === UserType.MEMBER) return true; + + this.logger.trace(`Updating user ${user} from LDAP.`); + user.firstName = ldapUser.displayName; + user.lastName = ''; + user.canGoIntoDebt = false; + user.acceptedToS = TermsOfServiceStatus.NOT_REQUIRED; + user.active = true; + await this.manager.save(user); + + return true; + } + + /** + * Removes the LDAPAuthenticator for the given user. + * @param user + */ + async down(user: User): Promise { + this.logger.trace('Running down for user', user); + const ldapAuth = await this.manager.findOne(LDAPAuthenticator, { where: { user: { id: user.id } } }); + if (ldapAuth) await this.manager.delete(LDAPAuthenticator, { userId: user.id }); + + // For members, we only remove the authenticator. + if (user.type === UserType.MEMBER) return; + + // For organs and integrations, we set the user to deleted and inactive. + // TODO: closing organ active with non-zero balance? + user.deleted = true; + user.active = false; + await this.manager.save(user); + } + + + /** + * Fetches all shared accounts from AD and creates them in SudoSOS. + * Also updates the membership of the shared accounts. + * @private + */ + private async fetchSharedAccounts(): Promise { + this.logger.debug('Fetching shared accounts from LDAP'); + const sharedAccounts = await this.adService.getLDAPGroups( + this.ldapClient, process.env.LDAP_SHARED_ACCOUNT_FILTER); + + // If there are new shared accounts, we create them. + const newSharedAccounts = (await this.adService.filterUnboundGUID(sharedAccounts)) as LDAPGroup[]; + this.logger.trace(`Found ${newSharedAccounts.length} new shared accounts`); + for (const sharedAccount of newSharedAccounts) { + await this.adService.toSharedUser(sharedAccount); + } + + for (const sharedAccount of sharedAccounts) { + await this.adService.updateSharedAccountMembership(this.ldapClient, sharedAccount); + } + } + + /** + * Adds local users to roles based on AD membership. + * Roles are matched using the CN of the AD group. + * + * If an AD user has a role but no account yet, the account is created. + * + * @private + */ + private async fetchUserRoles(): Promise { + this.logger.debug('Fetching user roles from LDAP'); + const roles = await this.adService.getLDAPGroups( + this.ldapClient, process.env.LDAP_ROLE_FILTER); + if (!roles) return; + + const [dbRoles] = await RBACService.getRoles(); + const dbRoleNames = new Set(dbRoles.map((r) => r.name)); + + const nonLocalRoles = roles.filter(ldapRole => !dbRoleNames.has(ldapRole.cn)); + nonLocalRoles.forEach(ldapRole => { + this.logger.warn(`LDAP role ${ldapRole.cn} does not exist locally.`); + }); + + const localRoles = roles.filter(ldapRole => dbRoleNames.has(ldapRole.cn)); + this.logger.trace(`Found ${localRoles.length} local roles`); + for (const ldapRole of localRoles) { + await this.adService.updateRoleMembership(this.ldapClient, ldapRole, this.roleManager); + } + } + + /** + * Fetches all service accounts from LDAP and creates them locally. + * + * @private + */ + private async fetchServiceAccounts(): Promise { + this.logger.debug('Fetching service accounts from LDAP'); + const serviceAccounts = (await this.adService.getLDAPGroupMembers( + this.ldapClient, process.env.LDAP_SERVICE_ACCOUNT_FILTER)).searchEntries; + + const newServiceAccounts = await this.adService.filterUnboundGUID(serviceAccounts); + this.logger.trace(`Found ${newServiceAccounts.length} new service accounts`); + for (const serviceAccount of newServiceAccounts) { + await this.adService.toServiceAccount(serviceAccount as LDAPUser); + } + } + + /** + * LDAP fetch retrieves organs, service accounts, and user roles from AD. + */ + async fetch(): Promise { + this.logger.trace('Fetching LDAP data'); + + if (!process.env.LDAP_SHARED_ACCOUNT_FILTER) { + this.logger.warn('LDAP_SHARED_ACCOUNT_FILTER is not set, skipping shared accounts'); + } else { + await this.fetchSharedAccounts(); + } + + if (!process.env.LDAP_ROLE_FILTER) { + this.logger.warn('LDAP_ROLE_FILTER is not set, skipping user roles'); + } else { + await this.fetchUserRoles(); + } + + if (!process.env.LDAP_SERVICE_ACCOUNT_FILTER) { + this.logger.warn('LDAP_SERVICE_ACCOUNT_FILTER is not set, skipping service accounts'); + } else { + await this.fetchServiceAccounts(); + } + } + + // TODO: dependency injection of Client instead? + // i.e. add a Client to the constructor + // this would require us to make a wrapper constructor to be able to bind the client on call + async pre(): Promise { + this.ldapClient = await getLDAPConnection(); + } + + async post(): Promise { + await this.ldapClient.unbind(); + } +} diff --git a/src/service/sync/user/user-sync-manager.ts b/src/service/sync/user/user-sync-manager.ts new file mode 100644 index 000000000..7e0675160 --- /dev/null +++ b/src/service/sync/user/user-sync-manager.ts @@ -0,0 +1,36 @@ +/** + * SudoSOS back-end API service. + * Copyright (C) 2024 Study association GEWIS + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * @license + */ + +import User from '../../../entity/user/user'; +import { In } from 'typeorm'; +import log4js, { Logger } from 'log4js'; +import SyncManager from '../sync-manager'; +import { UserSyncService } from './user-sync-service'; + +export default class UserSyncManager extends SyncManager { + + protected logger: Logger = log4js.getLogger('UserSyncManager'); + + async getTargets(): Promise { + const userTypes = this.services.flatMap((s) => s.targets); + this.logger.trace('Syncing users of types', userTypes); + return this.manager.find(User, { where: { type: In(userTypes), deleted: false } }); + } +} diff --git a/src/service/sync/user/user-sync-service.ts b/src/service/sync/user/user-sync-service.ts new file mode 100644 index 000000000..2cccc6986 --- /dev/null +++ b/src/service/sync/user/user-sync-service.ts @@ -0,0 +1,42 @@ +/** + * SudoSOS back-end API service. + * Copyright (C) 2024 Study association GEWIS + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * @license + */ + +/** + * This is the module page of the abstract sync-service. + * + * @module internal/user-user-service + */ + +import User, { UserType } from '../../../entity/user/user'; +import { SyncService } from '../sync-service'; + +/** + * UserSyncService interface. + * + * Specific sync service for users. + */ +export abstract class UserSyncService extends SyncService { + + targets: UserType[]; + + guard(user: User): Promise { + return Promise.resolve(this.targets.includes(user.type)); + } +} diff --git a/test/helpers/test-helpers.ts b/test/helpers/test-helpers.ts index 4d8e9aa83..3b6f8b779 100644 --- a/test/helpers/test-helpers.ts +++ b/test/helpers/test-helpers.ts @@ -132,3 +132,15 @@ export function restoreLDAPEnv(ldapEnv:{ [key: string]: any; }) { process.env.ENABLE_LDAP = ldapEnv.ENABLE_LDAP; process.env.LDAP_USER_BASE = ldapEnv.LDAP_USER_BASE; } + +export function setDefaultLDAPEnv() { + process.env.LDAP_SERVER_URL = 'ldaps://gewisdc03.gewis.nl:636'; + process.env.LDAP_BASE = 'DC=gewiswg,DC=gewis,DC=nl'; + process.env.LDAP_USER_FILTER = '(&(objectClass=user)(objectCategory=person)(memberOf:1.2.840.113556.1.4.1941:=CN=PRIV - SudoSOS Users,OU=Privileges,OU=Groups,DC=gewiswg,DC=gewis,DC=nl)(mail=*)(sAMAccountName=%u))'; + process.env.LDAP_BIND_USER = 'CN=Service account SudoSOS,OU=Service Accounts,OU=Special accounts,DC=gewiswg,DC=gewis,DC=nl'; + process.env.LDAP_BIND_PW = 'BIND PW'; + process.env.LDAP_SHARED_ACCOUNT_FILTER = 'OU=SudoSOS Shared Accounts,OU=Groups,DC=GEWISWG,DC=GEWIS,DC=NL'; + process.env.LDAP_ROLE_FILTER = 'OU=SudoSOS Roles,OU=Groups,DC=GEWISWG,DC=GEWIS,DC=NL'; + process.env.ENABLE_LDAP = 'true'; + process.env.LDAP_USER_BASE = 'CN=PRIV - SudoSOS Users,OU=SudoSOS Roles,OU=Groups,DC=gewiswg,DC=gewis,DC=nl'; +} diff --git a/test/helpers/user-factory.ts b/test/helpers/user-factory.ts index 1197272db..68c73afb9 100644 --- a/test/helpers/user-factory.ts +++ b/test/helpers/user-factory.ts @@ -27,15 +27,14 @@ export class Builder { public async default() { const count = await User.count(); - this.user = Object.assign(new User(), { + this.user = await User.save( { firstName: `User #${count + 1}`, lastName: `Doe #${count + 1}`, type: UserType.MEMBER, active: true, acceptedToS: TermsOfServiceStatus.ACCEPTED, canGoIntoDebt: true, - } as User); - await User.save(this.user); + }); return this; } @@ -58,23 +57,23 @@ export class Builder { } public async clone(amount: number): Promise { - const users: any[] = []; + const users: User[] = []; const count = await User.count(); const user = this.user ?? (await this.default()).user; + const promises: Promise[] = []; for (let i = 1; i <= amount; i += 1) { - const clone = { + promises.push(User.save(Object.assign(new User(), { ...user, firstName: `User #${count + i}`, lastName: `Doe #${count + i}`, email: `${count + i}@sudosos.nl`, type: user.type ?? UserType.MEMBER, id: count + i, - } as User; - users.push(clone); + })).then((u) => { users.push(u); })); } - await User.save(users); + await Promise.all(promises); return users; } } @@ -112,6 +111,17 @@ export const INVOICE_USER = async () => { } as User); }; +export const INTEGRATION_USER = async () => { + const count = await User.count(); + return Object.assign(new User(), { + firstName: `Integration #${count + 1}`, + lastName: `Doe #${count + 1}`, + type: UserType.INTEGRATION, + active: true, + acceptedToS: TermsOfServiceStatus.NOT_REQUIRED, + }); +}; + async function setInactive(users: User[]) { const promises: Promise[] = []; users.forEach((u) => { diff --git a/test/unit/gewis/gewisdb-sync-service.ts b/test/unit/gewis/gewisdb-sync-service.ts new file mode 100644 index 000000000..eed308220 --- /dev/null +++ b/test/unit/gewis/gewisdb-sync-service.ts @@ -0,0 +1,291 @@ +/** + * SudoSOS back-end API service. + * Copyright (C) 2024 Study association GEWIS + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * @license + */ + +import sinon, { SinonSandbox, SinonSpy } from 'sinon'; +import generateBalance, { defaultBefore, DefaultContext, finishTestDB } from '../../helpers/test-helpers'; +import Mailer from '../../../src/mailer'; +import nodemailer, { Transporter } from 'nodemailer'; +import { BasicApi, MemberAllAttributes, MembersApi } from 'gewisdb-ts-client'; +import GewisDBSyncService from '../../../src/gewis/service/gewisdb-sync-service'; +import { expect } from 'chai'; +import { rootStubs } from '../../root-hooks'; +import GewisUser from '../../../src/gewis/entity/gewis-user'; +import User from '../../../src/entity/user/user'; +import { inUserContext, UserFactory } from '../../helpers/user-factory'; +import ServerSettingsStore from '../../../src/server-settings/server-settings-store'; + +async function createGewisUser(user: User, gewisId: number): Promise { + expect(await GewisUser.findOne({ where: { user: { id: user.id } } })).to.be.null; + expect(await GewisUser.findOne({ where: { gewisId } })).to.be.null; + const gewisUser = Object.assign(new GewisUser(), { + user, + gewisId, + }); + await gewisUser.save(); + return gewisUser; +} + +function toWebResponse(gewisUser: GewisUser): MemberAllAttributes { + // Expiration is one year in the future. + const d = new Date(gewisUser.user.updatedAt); + const year = d.getFullYear(); + const month = d.getMonth(); + const day = d.getDate(); + const expiration = new Date(year + 1, month, day); + + return { + deleted: gewisUser.user.deleted, + email: gewisUser.user.email, + expiration: expiration.toISOString(), + given_name: gewisUser.user.firstName, + family_name: gewisUser.user.lastName, + is_18_plus: gewisUser.user.ofAge, + lidnr: gewisUser.gewisId, + }; +} + +async function checkUpdateAgainstDB(update: MemberAllAttributes, userId: number) { + const dbUser = await GewisUser.findOne({ where: { userId }, relations: ['user'] }); + expect(dbUser).to.not.be.undefined; + expect(dbUser.user.deleted).to.eq(update.deleted); + expect(dbUser.user.email).to.eq(update.email); + expect(dbUser.user.firstName).to.eq(update.given_name); + expect(dbUser.user.lastName).to.eq(update.family_name); + expect(dbUser.user.ofAge).to.eq(update.is_18_plus); + expect(dbUser.gewisId).to.eq(update.lidnr); +} + +describe('GewisDBSyncService', () => { + let ctx: DefaultContext; + let membersApiStub: sinon.SinonStubbedInstance; + let basicApiStub: sinon.SinonStubbedInstance; + let sandbox: SinonSandbox; + let sendMailFake: SinonSpy; + let serverSettingsStore: ServerSettingsStore; + + before(async () => { + ctx = { + ...(await defaultBefore()), + } as any; + ServerSettingsStore.deleteInstance(); + serverSettingsStore = await ServerSettingsStore.getInstance().initialize(); + }); + + beforeEach(async () => { + // Restore the default stub + rootStubs?.mail.restore(); + await serverSettingsStore.setSetting('allowGewisSyncDelete', false); + Mailer.reset(); + sandbox = sinon.createSandbox(); + sendMailFake = sandbox.spy(); + sandbox.stub(nodemailer, 'createTransport').returns({ + sendMail: sendMailFake, + } as any as Transporter); + }); + + after(async () => { + await finishTestDB(ctx.connection); + }); + + afterEach(() => { + sandbox.restore(); + sinon.restore(); + }); + + describe('sync', () => { + let syncService: GewisDBSyncService; + + beforeEach(() => { + syncService = new GewisDBSyncService(); + membersApiStub = sinon.createStubInstance(MembersApi); + basicApiStub = sinon.createStubInstance(BasicApi); + // @ts-ignore + syncService.api = membersApiStub as any; + // @ts-ignore + syncService.pinger = basicApiStub as any; + + basicApiStub.healthGet.resolves({ data: { healthy: true, sync_paused: false } } as any); + }); + + describe('guard', () => { + it('should return true if user is a GEWIS user', async () => { + await inUserContext( + await (await UserFactory()).clone(1), + async (user: User) => { + const gewisUser = await createGewisUser(user, user.id); + const result = await syncService.guard(gewisUser.user); + expect(result).to.be.true; + }, + ); + }); + + it('should return false if user is not a GEWIS user', async () => { + await inUserContext( + await (await UserFactory()).clone(1), + async (user: User) => { + const result = await syncService.guard(user); + expect(result).to.be.false; + }, + ); + }); + }); + + + it('should abort synchronization if GEWISDB API is unhealthy', async () => { + basicApiStub.healthGet.resolves({ data: { healthy: false, sync_paused: false } } as any); + await expect(syncService.pre()).to.be.rejectedWith('GEWISDB is not ready for syncing'); + sinon.assert.calledOnce(basicApiStub.healthGet); + }); + + it('should allow syncing if GEWISDB API is healthy', async () => { + basicApiStub.healthGet.resolves({ data: { healthy: true, sync_paused: false } } as any); + const result = await syncService.pre(); + expect(result).to.be.undefined; + sinon.assert.calledOnce(basicApiStub.healthGet); + }); + + it('should thrown an error if GEWIS User is not found', async () => { + await inUserContext( + await (await UserFactory()).clone(1), + async (user: User) => { + await expect(syncService.sync(user)).to.be.rejectedWith('GEWIS User not found.'); + }, + ); + }); + + it('should update user details if sync is needed', async () => { + await inUserContext( + await (await UserFactory()).clone(1), + async (user: User) => { + const gewisUser = await createGewisUser(user, user.id); + const updatedResponse: MemberAllAttributes = toWebResponse(gewisUser); + updatedResponse.given_name = 'UpdatedName'; + updatedResponse.family_name = 'UpdatedFamily'; + updatedResponse.email = 'updated@example.com'; + updatedResponse.is_18_plus = true; + + membersApiStub.membersLidnrGet.resolves({ data: { data: updatedResponse } } as any); + + const result = await syncService.sync(gewisUser.user); + expect(result).to.be.true; + await checkUpdateAgainstDB(updatedResponse, gewisUser.user.id); + }, + ); + }); + + it('should return false if user has no GEWISDB entry', async () => { + await inUserContext( + await (await UserFactory()).clone(1), + async (user: User) => { + const gewisUser = await createGewisUser(user, user.id); + membersApiStub.membersLidnrGet.resolves({ data: { data: null } } as any); + + const result = await syncService.sync(gewisUser.user); + expect(result).to.be.false; + }, + ); + }); + + it('should return false if user is expired', async () => { + await inUserContext( + await (await UserFactory()).clone(1), + async (user: User) => { + const gewisUser = await createGewisUser(user, user.id); + const updatedResponse: MemberAllAttributes = toWebResponse(gewisUser); + updatedResponse.expiration = new Date(Date.now() - 100000).toISOString(); + membersApiStub.membersLidnrGet.resolves({ data: { data: updatedResponse } } as any); + + const result = await syncService.sync(gewisUser.user); + expect(result).to.be.false; + }, + ); + }); + + it('should return true if no update is needed', async () => { + await inUserContext( + await (await UserFactory()).clone(1), + async (user: User) => { + const gewisUser = await createGewisUser(user, user.id); + const updatedResponse: MemberAllAttributes = toWebResponse(gewisUser); + membersApiStub.membersLidnrGet.resolves({ data: { data: updatedResponse } } as any); + const result = await syncService.sync(gewisUser.user); + expect(result).to.be.true; + }); + }); + + describe('down', () => { + it('should correctly delete the user', async () => { + await serverSettingsStore.setSetting('allowGewisSyncDelete', true); + await inUserContext( + await (await UserFactory()).clone(1), + async (user: User) => { + const gewisUser = await createGewisUser(user, user.id); + await syncService.down(gewisUser.user); + const dbUser = await User.findOne({ where: { id: gewisUser.user.id } }); + expect(dbUser.active).to.be.false; + expect(dbUser.deleted).to.be.true; + expect(dbUser.canGoIntoDebt).to.be.false; + }, + ); + }); + it('should not delete the user if allowGewisSyncDelete is false', async () => { + await serverSettingsStore.setSetting('allowGewisSyncDelete', false); + await inUserContext( + await (await UserFactory()).clone(1), + async (user: User) => { + const gewisUser = await createGewisUser(user, user.id); + await syncService.down(gewisUser.user); + const dbUser = await User.findOne({ where: { id: gewisUser.user.id } }); + expect(dbUser.active).to.be.true; + expect(dbUser.deleted).to.be.false; + expect(dbUser.canGoIntoDebt).to.be.true; + expect(sendMailFake).to.be.callCount(0); + }, + ); + }); + it('should not delete the user if balance is non-zero', async () => { + await serverSettingsStore.setSetting('allowGewisSyncDelete', true); + await inUserContext( + await (await UserFactory()).clone(1), + async (user: User) => { + const gewisUser = await createGewisUser(user, user.id); + await generateBalance(100, gewisUser.user.id); + await syncService.down(gewisUser.user); + const dbUser = await User.findOne({ where: { id: gewisUser.user.id } }); + expect(dbUser.active).to.be.true; + expect(dbUser.deleted).to.be.false; + expect(dbUser.canGoIntoDebt).to.be.true; + expect(sendMailFake).to.be.callCount(0); + }, + ); + }); + it('should send an email to the user', async () => { + await serverSettingsStore.setSetting('allowGewisSyncDelete', true); + await inUserContext( + await (await UserFactory()).clone(1), + async (user: User) => { + const gewisUser = await createGewisUser(user, user.id); + await syncService.down(gewisUser.user); + expect(sendMailFake).to.be.callCount(1); + }); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/unit/gewis/gewisdb.ts b/test/unit/gewis/gewisdb.ts deleted file mode 100644 index 39a1608db..000000000 --- a/test/unit/gewis/gewisdb.ts +++ /dev/null @@ -1,344 +0,0 @@ -/** - * SudoSOS back-end API service. - * Copyright (C) 2024 Study association GEWIS - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * @license - */ - -import { expect } from 'chai'; -import sinon, { SinonSandbox, SinonSpy } from 'sinon'; -import { defaultBefore, DefaultContext, finishTestDB } from '../../helpers/test-helpers'; -import User from '../../../src/entity/user/user'; -import GewisUser from '../../../src/gewis/entity/gewis-user'; -import seedGEWISUsers from '../../../src/gewis/database/seed'; -import GewisDBService from '../../../src/gewis/service/gewisdb-service'; -import { BasicApi, MemberAllAttributes, MembersApi } from 'gewisdb-ts-client'; -import nodemailer, { Transporter } from 'nodemailer'; -import Mailer from '../../../src/mailer'; -import { In } from 'typeorm'; -import { UserSeeder } from '../../seed'; -import { rootStubs } from '../../root-hooks'; - -describe('GEWISDB Service', () => { - - let ctx: DefaultContext & { - users: User[], - gewisUsers: GewisUser[], - }; - - let membersApiStub: sinon.SinonStubbedInstance; - let basicApiStub: sinon.SinonStubbedInstance; - - let sandbox: SinonSandbox; - let sendMailFake: SinonSpy; - - before(async () => { - ctx = { - ...(await defaultBefore()), - } as any; - ctx.users = await new UserSeeder().seed(); - ctx.gewisUsers = await seedGEWISUsers(ctx.users); - }); - - beforeEach(() => { - // Restore the default stub - rootStubs?.mail.restore(); - - // Reset the mailer, because it was created with an old, expired stub - Mailer.reset(); - - sandbox = sinon.createSandbox(); - sendMailFake = sandbox.spy(); - sandbox.stub(nodemailer, 'createTransport').returns({ - sendMail: sendMailFake, - } as any as Transporter); - }); - - after(async () => { - await finishTestDB(ctx.connection); - }); - - afterEach(() => { - sandbox.restore(); - sinon.restore(); - }); - - describe('sync', () => { - - function toWebResponse(gewisUser: GewisUser): MemberAllAttributes { - // Expiration is one year in the future. - const d = new Date(gewisUser.user.updatedAt); - const year = d.getFullYear(); - const month = d.getMonth(); - const day = d.getDate(); - const expiration = new Date(year + 1, month, day); - - return { - deleted: gewisUser.user.deleted, - email: gewisUser.user.email, - expiration: expiration.toISOString(), - given_name: gewisUser.user.firstName, - family_name: gewisUser.user.lastName, - is_18_plus: gewisUser.user.ofAge, - lidnr: gewisUser.gewisId, - }; - } - - async function checkUpdateAgainstDB(update: MemberAllAttributes, userId: number) { - const dbUser = await GewisUser.findOne({ where: { userId }, relations: ['user'] }); - expect(dbUser).to.not.be.undefined; - expect(dbUser.user.deleted).to.eq(update.deleted); - expect(dbUser.user.email).to.eq(update.email); - expect(dbUser.user.firstName).to.eq(update.given_name); - expect(dbUser.user.lastName).to.eq(update.family_name); - expect(dbUser.user.ofAge).to.eq(update.is_18_plus); - expect(dbUser.gewisId).to.eq(update.lidnr); - } - - beforeEach(() => { - membersApiStub = sinon.createStubInstance(MembersApi); - basicApiStub = sinon.createStubInstance(BasicApi); - - GewisDBService.api = membersApiStub as any; - GewisDBService.pinger = basicApiStub as any; - - basicApiStub.healthGet.returns(Promise.resolve({ data: { healthy: true, sync_paused: false } }) as any); - }); - - afterEach(() => { - sinon.restore(); - }); - - it('should abort synchronization if the GEWISDB API is unhealthy', async () => { - basicApiStub.healthGet.returns(Promise.resolve({ data: { healthy: false, sync_paused: false } }) as any); - - const result = await GewisDBService.syncAll(); - - expect(result).to.be.null; - sinon.assert.calledOnce(basicApiStub.healthGet); - sinon.assert.notCalled(membersApiStub.membersLidnrGet); - }); - - it('should abort synchronization if the GEWISDB API is paused', async () => { - basicApiStub.healthGet.returns(Promise.resolve({ data: { healthy: true, sync_paused: true } }) as any); - - const result = await GewisDBService.syncAll(); - - expect(result).to.be.null; - sinon.assert.calledOnce(basicApiStub.healthGet); - sinon.assert.notCalled(membersApiStub.membersLidnrGet); - }); - - it('should start synchronization if the GEWISDB API is healthy', async () => { - basicApiStub.healthGet.returns(Promise.resolve({ data: { healthy: true, sync_paused: false } }) as any); - membersApiStub.membersLidnrGet.returns(Promise.resolve({ data: {} }) as any); - const result = await GewisDBService.syncAll(); - - expect(result).to.be.empty; - sinon.assert.calledOnce(basicApiStub.healthGet); - }); - - - it('should sync a single non-deleted GEWIS user with the database', async () => { - const user = await GewisUser.findOne({ where: { user: { deleted: false } }, relations: ['user'] }); - const update = toWebResponse(user); - update.given_name = `updated ${user.user.firstName}`; - // @ts-ignore - membersApiStub.membersLidnrGet.returns(Promise.resolve({ - data: { - data: update, - }, - })); - - await GewisDBService.sync([user]); - await checkUpdateAgainstDB(update, user.userId); - }); - - it('should sync multiple non-deleted GEWIS user with the database', async () => { - const users = await GewisUser.find({ where: { user: { deleted: false } }, relations: ['user'], take: 5 }); - - const updates: { [key: number]: MemberAllAttributes; } = {}; - membersApiStub.membersLidnrGet.callsFake((async (gewisId: number) => { - const user = users.find(u => u.gewisId === gewisId); - if (!user) return Promise.resolve({ data: null }); - - const update = toWebResponse(user); - updates[user.userId] = update; - - update.given_name = `updated ${user.user.firstName}`; - update.family_name = `updated ${user.user.lastName}`; - update.email = `updated ${user.user.email}`; - update.is_18_plus = !user.user.ofAge; - - return Promise.resolve({ - data: { data: update }, - }); - }) as any); - - - await GewisDBService.sync(users); - for (const u of users) { await checkUpdateAgainstDB(updates[u.userId], u.userId); } - }); - - it('should handle cases where a user cannot be found in the GEWIS database', async () => { - const user = await GewisUser.findOne({ where: { user: { deleted: false } }, relations: ['user'] }); - membersApiStub.membersLidnrGet.returns(Promise.resolve({ - data: {}, - } as any)); - - await GewisDBService.sync([user]); - // Check if user remained the same - await checkUpdateAgainstDB(toWebResponse(user), user.userId); - }); - - it('should return an empty array if there were no updates', async () => { - const users = await GewisUser.find({ where: { user: { deleted: false } }, relations: ['user'], take: 5 }); - - const updates: { [key: number]: MemberAllAttributes; } = {}; - membersApiStub.membersLidnrGet.callsFake((async (gewisId: number) => { - const user = users.find(u => u.gewisId === gewisId); - if (!user) return Promise.resolve({ data: null }); - - const update = toWebResponse(user); - updates[user.userId] = update; - - return Promise.resolve({ - data: { data: update }, - }); - }) as any); - - - const res = await GewisDBService.sync(users); - expect(res).to.be.empty; - for (const u of users) { await checkUpdateAgainstDB(updates[u.userId], u.userId); } - }); - - it('should return an array with all updated users', async () => { - const users = await GewisUser.find({ where: { user: { deleted: false } }, relations: ['user'], take: 5 }); - const toUpdate = (users.filter((u) => (u.userId % 2) === 0)).map((u) => u.userId); - expect(toUpdate).to.not.be.empty; - - const updates: { [key: number]: MemberAllAttributes; } = {}; - membersApiStub.membersLidnrGet.callsFake((async (gewisId: number) => { - const user = users.find(u => u.gewisId === gewisId); - if (!user) return Promise.resolve({ data: null }); - - const update = toWebResponse(user); - updates[user.userId] = update; - - if (toUpdate.indexOf(user.userId) !== -1) { - update.given_name = `updated ${user.user.firstName}`; - update.family_name = `updated ${user.user.lastName}`; - update.email = `updated ${user.user.email}`; - update.is_18_plus = !user.user.ofAge; - } - - return Promise.resolve({ - data: { data: update }, - }); - }) as any); - - - const res = await GewisDBService.sync(users); - expect(res).to.not.be.empty; - expect(res.map((u => u.id))).to.deep.equalInAnyOrder(toUpdate); - for (const u of toUpdate) { await checkUpdateAgainstDB(updates[u], u); } - }); - - it('should properly handle expired users', async function () { - const users = await GewisUser.find({ where: { user: { deleted: false } }, relations: ['user'], take: 5 }); - - const updates: { [key: number]: MemberAllAttributes; } = {}; - membersApiStub.membersLidnrGet.callsFake((async (gewisId: number) => { - const user = users.find(u => u.gewisId === gewisId); - if (!user) return Promise.resolve({ data: null }); - - const update = toWebResponse(user); - update.expiration = new Date(2020, 1, 1).toISOString(); - updates[user.userId] = update; - - return Promise.resolve({ - data: { data: update }, - }); - }) as any); - - - const res = await GewisDBService.sync(users); - res.forEach((u) => { - expect(u.active).to.be.false; - expect(u.deleted).to.be.true; - expect(u.canGoIntoDebt).to.be.false; - }); - }); - - it('should send email to expired users', async function () { - const users = await GewisUser.find({ where: { user: { deleted: false } }, relations: ['user'], take: 5 }); - - const updates: { [key: number]: MemberAllAttributes; } = {}; - membersApiStub.membersLidnrGet.callsFake((async (gewisId: number) => { - const user = users.find(u => u.gewisId === gewisId); - if (!user) return Promise.resolve({ data: null }); - - const update = toWebResponse(user); - update.expiration = new Date(2020, 1, 1).toISOString(); - updates[user.userId] = update; - - return Promise.resolve({ - data: { data: update }, - }); - }) as any); - - - const res = await GewisDBService.sync(users); - res.forEach((u) => { - expect(u.active).to.be.false; - expect(u.deleted).to.be.true; - expect(u.canGoIntoDebt).to.be.false; - }); - - expect(sendMailFake).to.be.callCount(res.length); - }); - it('should not commit changes to the database if commit is false', async function () { - const users = await GewisUser.find({ where: { user: { deleted: false, active: true } }, relations: ['user'], take: 5 }); - - const updates: { [key: number]: MemberAllAttributes; } = {}; - membersApiStub.membersLidnrGet.callsFake((async (gewisId: number) => { - const user = users.find(u => u.gewisId === gewisId); - if (!user) return Promise.resolve({ data: null }); - - const update = toWebResponse(user); - update.expiration = new Date(2020, 1, 1).toISOString(); - updates[user.userId] = update; - - return Promise.resolve({ - data: { data: update }, - }); - }) as any); - - - await GewisDBService.sync(users, false); - const res = await User.find({ where: { id: In(users.map(u => u.userId)) } }); - expect(res).to.not.be.empty; - expect(res.length).to.eq(users.length); - res.forEach((u) => { - expect(u.active).to.be.true; - expect(u.deleted).to.be.false; - }); - - expect(sendMailFake).to.be.callCount(0); - }); - }); -}); diff --git a/test/unit/service/ad-service.ts b/test/unit/service/ad-service.ts index edd15755f..329504058 100644 --- a/test/unit/service/ad-service.ts +++ b/test/unit/service/ad-service.ts @@ -18,27 +18,25 @@ * @license */ -import { DataSource, EntityManager } from 'typeorm'; +import { DataSource } from 'typeorm'; import express, { Application } from 'express'; import { SwaggerSpecification } from 'swagger-model-validator'; import sinon from 'sinon'; -import { Client } from 'ldapts'; import chai, { expect } from 'chai'; import deepEqualInAnyOrder from 'deep-equal-in-any-order'; -import User, { UserType } from '../../../src/entity/user/user'; +import User, { TermsOfServiceStatus, UserType } from '../../../src/entity/user/user'; import Database from '../../../src/database/database'; import Swagger from '../../../src/start/swagger'; import ADService from '../../../src/service/ad-service'; import LDAPAuthenticator from '../../../src/entity/authenticator/ldap-authenticator'; -import AuthenticationService from '../../../src/service/authentication-service'; -import MemberAuthenticator from '../../../src/entity/authenticator/member-authenticator'; import { LDAPGroup, LDAPUser } from '../../../src/helpers/ad'; import userIsAsExpected from './authentication-service'; -import RoleManager from '../../../src/rbac/role-manager'; -import AssignedRole from '../../../src/entity/rbac/assigned-role'; -import { finishTestDB, restoreLDAPEnv, storeLDAPEnv } from '../../helpers/test-helpers'; +import { finishTestDB, restoreLDAPEnv, setDefaultLDAPEnv, storeLDAPEnv } from '../../helpers/test-helpers'; import { truncateAllTables } from '../../setup'; -import { RbacSeeder, UserSeeder } from '../../seed'; +import { UserSeeder } from '../../seed'; +import { Client } from 'ldapts'; +import RoleManager from '../../../src/rbac/role-manager'; +import AuthenticationService from '../../../src/service/authentication-service'; chai.use(deepEqualInAnyOrder); @@ -48,7 +46,45 @@ describe('AD Service', (): void => { app: Application, users: User[], spec: SwaggerSpecification, - validADUser: (mNumber: number) => (LDAPUser), + }; + + const validADUser = (mNumber: number): LDAPUser => ({ + dn: `CN=SudoSOS (m${mNumber}),OU=Member accounts,DC=gewiswg,DC=gewis,DC=nl`, + memberOfFlattened: [ + 'CN=Domain Users,CN=Users,DC=gewiswg,DC=gewis,DC=nl', + ], + givenName: `Sudo (${mNumber})`, + sn: 'SOS', + objectGUID: Buffer.from((mNumber.toString().length % 2 ? '0' : '') + mNumber.toString(), 'hex'), + mNumber: mNumber, + mail: `m${mNumber}@gewis.nl`, + whenChanged: '202204151213.0Z', + displayName: `Sudo (${mNumber})`, + }); + + const validLDAPGroup = (mNumber: number): LDAPGroup => ({ + displayName: `Group ${mNumber}`, + dn: 'OU=SudoSOS Shared Accounts,OU=Groups,DC=GEWISWG,DC=GEWIS,DC=NL', + cn: `Group ${mNumber}`, + objectGUID: Buffer.from((mNumber.toString().length % 2 ? '0' : '') + mNumber.toString(), 'hex'), + whenChanged: Date.now().toString(), + }); + + const organIsAsExpected = (user: User, organ: LDAPGroup) => { + expect(user.type).to.equal(UserType.ORGAN); + expect(user.firstName).to.equal(organ.displayName); + expect(user.lastName).to.equal(''); + expect(user.active).to.equal(true); + expect(user.acceptedToS).to.equal(TermsOfServiceStatus.NOT_REQUIRED); + }; + + const serviceAccountIsAsExpected = (user: User, organ: LDAPUser) => { + expect(user.type).to.equal(UserType.INTEGRATION); + expect(user.firstName).to.equal(organ.displayName); + expect(user.lastName).to.equal(''); + expect(user.active).to.equal(true); + expect(user.acceptedToS).to.equal(TermsOfServiceStatus.NOT_REQUIRED); + expect(user.canGoIntoDebt).to.equal(false); }; const stubs: sinon.SinonStub[] = []; @@ -59,16 +95,7 @@ describe('AD Service', (): void => { this.timeout(50000); ldapEnvVariables = storeLDAPEnv(); - - process.env.LDAP_SERVER_URL = 'ldaps://gewisdc03.gewis.nl:636'; - process.env.LDAP_BASE = 'DC=gewiswg,DC=gewis,DC=nl'; - process.env.LDAP_USER_FILTER = '(&(objectClass=user)(objectCategory=person)(memberOf:1.2.840.113556.1.4.1941:=CN=PRIV - SudoSOS Users,OU=Privileges,OU=Groups,DC=gewiswg,DC=gewis,DC=nl)(mail=*)(sAMAccountName=%u))'; - process.env.LDAP_BIND_USER = 'CN=Service account SudoSOS,OU=Service Accounts,OU=Special accounts,DC=gewiswg,DC=gewis,DC=nl'; - process.env.LDAP_BIND_PW = 'BIND PW'; - process.env.LDAP_SHARED_ACCOUNT_FILTER = 'OU=SudoSOS Shared Accounts,OU=Groups,DC=GEWISWG,DC=GEWIS,DC=NL'; - process.env.LDAP_ROLE_FILTER = 'OU=SudoSOS Roles,OU=Groups,DC=GEWISWG,DC=GEWIS,DC=NL'; - process.env.ENABLE_LDAP = 'true'; - process.env.LDAP_USER_BASE = 'CN=PRIV - SudoSOS Users,OU=SudoSOS Roles,OU=Groups,DC=gewiswg,DC=gewis,DC=nl'; + setDefaultLDAPEnv(); const connection = await Database.initialize(); await truncateAllTables(connection); @@ -81,24 +108,10 @@ describe('AD Service', (): void => { }, ); - const validADUser = (mNumber: number): LDAPUser => ({ - dn: `CN=Sudo SOS (m${mNumber}),OU=Member accounts,DC=gewiswg,DC=gewis,DC=nl`, - memberOfFlattened: [ - 'CN=Domain Users,CN=Users,DC=gewiswg,DC=gewis,DC=nl', - ], - givenName: `Sudo (${mNumber})`, - sn: 'SOS', - objectGUID: Buffer.from((mNumber.toString().length % 2 ? '0' : '') + mNumber.toString(), 'hex'), - mNumber: mNumber, - mail: `m${mNumber}@gewis.nl`, - whenChanged: '202204151213.0Z', - }); - ctx = { connection, app, users, - validADUser, spec: await Swagger.importSpecification(), }; }); @@ -113,217 +126,9 @@ describe('AD Service', (): void => { stubs.splice(0, stubs.length); }); - describe('syncSharedAccounts functions', () => { - async function createAccountsFromLDAP(accounts: any[]) { - async function createAccounts(manager: EntityManager, acc: any[]): Promise { - const users: User[] = []; - const promises: Promise[] = []; - acc.forEach((m) => { - promises.push(new AuthenticationService(manager).createUserAndBind(m) - .then((u) => users.push(u))); - }); - await Promise.all(promises); - return users; - } - - let result: any[]; - await ctx.connection.transaction(async (manager) => { - result = await createAccounts(manager, accounts); - }); - return result; - } - it('should create an account for new shared accounts', async () => { - const newADSharedAccount = { - objectGUID: Buffer.from('111111', 'hex'), - displayName: 'Shared Organ #1', - dn: 'CN=SudoSOSAccount - Shared Organ #1,OU=SudoSOS Shared Accounts,OU=Groups,DC=sudososwg,DC=sudosos,DC=nl', - }; - - const auth = await LDAPAuthenticator.findOne( - { where: { UUID: newADSharedAccount.objectGUID } }, - ); - expect(auth).to.be.null; - - const clientBindStub = sinon.stub(Client.prototype, 'bind').resolves(null); - const clientSearchStub = sinon.stub(Client.prototype, 'search').resolves({ searchReferences: [], searchEntries: [] }); - - clientSearchStub.withArgs(process.env.LDAP_SHARED_ACCOUNT_FILTER, { - filter: '(CN=*)', - explicitBufferAttributes: ['objectGUID'], - }).resolves({ searchReferences: [], searchEntries: [newADSharedAccount] }); - - stubs.push(clientBindStub); - stubs.push(clientSearchStub); - - const organCount = await User.count({ where: { type: UserType.ORGAN } }); - await new ADService().syncSharedAccounts(); - - expect(await User.count({ where: { type: UserType.ORGAN } })).to.be.equal(organCount + 1); - const newOrgan = (await LDAPAuthenticator.findOne({ where: { UUID: newADSharedAccount.objectGUID }, relations: ['user'] })).user; - - expect(newOrgan.firstName).to.be.equal(newADSharedAccount.displayName); - expect(newOrgan.lastName).to.be.equal(''); - expect(newOrgan.type).to.be.equal(UserType.ORGAN); - }); - it('should give member access to shared account', async () => { - const newADSharedAccount = { - objectGUID: Buffer.from('22', 'hex'), - displayName: 'Shared Organ #2', - dn: 'CN=SudoSOSAccount - Shared Organ #2,OU=SudoSOS Shared Accounts,OU=Groups,DC=sudososwg,DC=sudosos,DC=nl', - }; - - const auth = await LDAPAuthenticator.findOne( - { where: { UUID: newADSharedAccount.objectGUID } }, - ); - expect(auth).to.be.null; - - const clientBindStub = sinon.stub(Client.prototype, 'bind').resolves(null); - const clientSearchStub = sinon.stub(Client.prototype, 'search'); - - const sharedAccountMember = { - dn: 'CN=Sudo SOS (m4141),OU=Member accounts,DC=gewiswg,DC=gewis,DC=nl', - memberOfFlattened: [ - 'CN=Domain Users,CN=Users,DC=gewiswg,DC=gewis,DC=nl', - ], - givenName: 'Sudo Organ #2', - sn: 'SOS', - objectGUID: Buffer.from('4141', 'hex'), - sAMAccountName: 'm4141', - mail: 'm4141@gewis.nl', - // TODO: Fix this type inconsistency between ADUser and ldapts.Client - mNumber: '4141' as string & number, - whenChanged: new Date().toISOString(), - }; - - let user: User; - await ctx.connection.transaction(async (manager) => { - user = await new AuthenticationService(manager).createUserAndBind(sharedAccountMember); - }); - - clientSearchStub.withArgs(process.env.LDAP_SHARED_ACCOUNT_FILTER, { - filter: '(CN=*)', - explicitBufferAttributes: ['objectGUID'], - }).resolves({ searchReferences: [], searchEntries: [newADSharedAccount] }); - - clientSearchStub.withArgs(process.env.LDAP_BASE, { - filter: `(&(objectClass=user)(objectCategory=person)(memberOf:1.2.840.113556.1.4.1941:=${newADSharedAccount.dn}))`, - explicitBufferAttributes: ['objectGUID'], - }) - .resolves({ searchReferences: [], searchEntries: [sharedAccountMember] }); - - stubs.push(clientBindStub); - stubs.push(clientSearchStub); - - await new ADService().syncSharedAccounts(); - - const newOrgan = (await LDAPAuthenticator.findOne({ where: { UUID: newADSharedAccount.objectGUID }, relations: ['user'] })).user; - - const canAuthenticateAs = await MemberAuthenticator.find( - { where: { authenticateAs: { id: newOrgan.id } }, relations: ['user'] }, - ); - - expect(canAuthenticateAs.length).to.be.equal(1); - expect(canAuthenticateAs[0].user.id).to.be.equal(user.id); - }); - it('should update the members of an existing shared account', async () => { - const newADSharedAccount = { - objectGUID: Buffer.from('39', 'hex'), - displayName: 'Shared Organ #3', - dn: 'CN=SudoSOSAccount - Shared Organ #3,OU=SudoSOS Shared Accounts,OU=Groups,DC=sudososwg,DC=sudosos,DC=nl', - }; - - const auth = await LDAPAuthenticator.findOne( - { where: { UUID: newADSharedAccount.objectGUID } }, - ); - expect(auth).to.be.null; - - const clientBindStub = sinon.stub(Client.prototype, 'bind').resolves(null); - const clientSearchStub = sinon.stub(Client.prototype, 'search'); - - const sharedAccountMemberConstruction = (number: number) => ({ - dn: `CN=Sudo SOS (m${number}),OU=Member accounts,DC=gewiswg,DC=gewis,DC=nl`, - memberOfFlattened: [ - 'CN=Domain Users,CN=Users,DC=gewiswg,DC=gewis,DC=nl', - ], - givenName: `Sudo Organ #3 ${number}`, - sn: 'SOS', - mNumber: `${number}`, - objectGUID: Buffer.from((number.toString().length % 2 ? '0' : '') + number.toString(), 'hex'), - sAMAccountName: `m${number}`, - mail: `m${number}@gewis.nl`, - }); - - let sharedAccountMembers = [sharedAccountMemberConstruction(10), - sharedAccountMemberConstruction(21)]; - - const firstMembers = await createAccountsFromLDAP(sharedAccountMembers); - - clientSearchStub.withArgs(process.env.LDAP_SHARED_ACCOUNT_FILTER, { - filter: '(CN=*)', - explicitBufferAttributes: ['objectGUID'], - }).resolves({ searchReferences: [], searchEntries: [newADSharedAccount] }); - - clientSearchStub.withArgs(process.env.LDAP_BASE, { - filter: `(&(objectClass=user)(objectCategory=person)(memberOf:1.2.840.113556.1.4.1941:=${newADSharedAccount.dn}))`, - explicitBufferAttributes: ['objectGUID'], - }) - .resolves({ searchReferences: [], searchEntries: sharedAccountMembers }); - - stubs.push(clientBindStub); - stubs.push(clientSearchStub); - - await new ADService().syncSharedAccounts(); - - // Should contain the first users - const newOrgan = (await LDAPAuthenticator.findOne({ where: { UUID: newADSharedAccount.objectGUID }, relations: ['user'] })).user; - expect(newOrgan).to.not.be.undefined; - - const canAuthenticateAs = await MemberAuthenticator.find( - { where: { authenticateAs: { id: newOrgan.id } }, relations: ['user'] }, - ); - let canAuthenticateAsIDs = canAuthenticateAs.map((mAuth) => mAuth.user.id); - - expect(canAuthenticateAsIDs).to.deep.equalInAnyOrder(firstMembers.map((u: any) => u.id)); - - stubs.forEach((stub) => stub.restore()); - stubs.splice(0, stubs.length); - // stubs = []; - - const clientBindStub2 = sinon.stub(Client.prototype, 'bind').resolves(null); - const clientSearchStub2 = sinon.stub(Client.prototype, 'search'); - - sharedAccountMembers = [sharedAccountMemberConstruction(11), - sharedAccountMemberConstruction(3)]; - - const secondMembers = await createAccountsFromLDAP(sharedAccountMembers); - - clientSearchStub2.withArgs(process.env.LDAP_SHARED_ACCOUNT_FILTER, { - filter: '(CN=*)', - explicitBufferAttributes: ['objectGUID'], - }).resolves({ searchReferences: [], searchEntries: [newADSharedAccount] }); - - clientSearchStub2.withArgs(process.env.LDAP_BASE, { - filter: `(&(objectClass=user)(objectCategory=person)(memberOf:1.2.840.113556.1.4.1941:=${newADSharedAccount.dn}))`, - explicitBufferAttributes: ['objectGUID'], - }) - .resolves({ searchReferences: [], searchEntries: sharedAccountMembers }); - - stubs.push(clientBindStub2); - stubs.push(clientSearchStub2); - - await new ADService().syncSharedAccounts(); - - canAuthenticateAsIDs = (await MemberAuthenticator.find( - { where: { authenticateAs: { id: newOrgan.id } }, relations: ['user'] }, - )).map((mAuth) => mAuth.user.id); - - const currentMemberIDs = secondMembers.map((u: any) => u.id); - expect(canAuthenticateAsIDs).to.deep.equalInAnyOrder(currentMemberIDs); - }); - }); describe('createAccountIfNew function', () => { it('should create an account if GUID is unknown to DB', async () => { - const adUser = { ...(ctx.validADUser(await User.count() + 200)) }; + const adUser = { ...(validADUser(await User.count() + 200)) }; // precondition. expect(await LDAPAuthenticator.findOne( { where: { UUID: adUser.objectGUID } }, @@ -340,94 +145,314 @@ describe('AD Service', (): void => { const { user } = auth; userIsAsExpected(user, adUser); }); - }); - describe('syncUserRoles function', () => { - it('should assign roles to members of the group in AD', async () => { - process.env.ENABLE_LDAP = 'true'; + it('should not create an account if GUID is known to DB', async () => { + const adUser = { ...(validADUser(await User.count() + 200)) }; + // precondition. + await new ADService().createAccountIfNew([adUser]); - const newUser = { ...(ctx.validADUser(await User.count() + 2)) }; - const existingUser = { ...(ctx.validADUser(await User.count() + 3)) }; + expect(await LDAPAuthenticator.findOne( + { where: { UUID: adUser.objectGUID } }, + )).to.not.be.null; - await new ADService().createAccountIfNew([existingUser]); + const userCount = await User.count(); + await new ADService().createAccountIfNew([adUser]); + expect(await User.count()).to.be.equal(userCount); + }); + }); + describe('toSharedUser function', () => { + it('should create an ORGAN user', async () => { + const userCount = await User.count(); + const organUser = validLDAPGroup(userCount + 200); // precondition. expect(await LDAPAuthenticator.findOne( - { where: { UUID: newUser.objectGUID } }, + { where: { UUID: organUser.objectGUID } }, )).to.be.null; + await new ADService().toSharedUser(validLDAPGroup(await User.count() + 200)); + expect(await User.count()).to.be.equal(userCount + 1); + const auth = (await LDAPAuthenticator.findOne( + { where: { UUID: organUser.objectGUID }, relations: ['user'] }, + )); + expect(auth).to.exist; + organIsAsExpected(auth.user, organUser); + }); + }); + + describe('toServiceAccount function', () => { + it('should create an INTEGRATION user', async () => { + const userCount = await User.count(); + const adUser = validADUser(await User.count() + 200); + // precondition. expect(await LDAPAuthenticator.findOne( - { where: { UUID: existingUser.objectGUID } }, - )).to.exist; - - const roleGroup: LDAPGroup = { - cn: 'SudoSOS - Test', - displayName: 'Test group', - dn: 'CN=PRIV - SudoSOS Test,OU=SudoSOS Roles,OU=Groups,DC=gewiswg,DC=gewis,DC=nl', - objectGUID: Buffer.from('1234', 'hex'), - whenChanged: '', + { where: { UUID: adUser.objectGUID } }, + )).to.be.null; + await new ADService().toServiceAccount(adUser); + expect(await User.count()).to.be.equal(userCount + 1); + const auth = (await LDAPAuthenticator.findOne( + { where: { UUID: adUser.objectGUID }, relations: ['user'] }, + )); + expect(auth).to.exist; + serviceAccountIsAsExpected(auth.user, adUser); + }); + }); + + describe('getUsers function', () => { + it('should return only bound users if createIfNew is false', async () => { + const rawLdapUsers = [validADUser(await User.count() + 200), validADUser(await User.count() + 201)]; + await new ADService().createAccountIfNew(rawLdapUsers); + const newLdapUsers = [validADUser(await User.count() + 200), validADUser(await User.count() + 201)]; + + const ldapUsers: User[] = []; + for (const ldapUser of rawLdapUsers) { + const auth = await LDAPAuthenticator.findOne( + { where: { UUID: ldapUser.objectGUID }, relations: ['user'] }, + ); + expect(auth).to.exist; + ldapUsers.push(auth.user); + } + + const userCount = await User.count(); + const users = await new ADService().getUsers(rawLdapUsers.concat(newLdapUsers), false); + expect(await User.count()).to.be.equal(userCount); + expect(users).to.have.length(2); + expect(users).to.deep.equalInAnyOrder(ldapUsers); + }); + it('should return return all and create new users if createIfNew is true', async () => { + const rawLdapUsers = [validADUser(await User.count() + 200), validADUser(await User.count() + 201)]; + await new ADService().createAccountIfNew(rawLdapUsers); + const newLdapUsers = [validADUser(await User.count() + 200), validADUser(await User.count() + 201)]; + + const ldapUsers: User[] = []; + for (const ldapUser of rawLdapUsers) { + const auth = await LDAPAuthenticator.findOne( + { where: { UUID: ldapUser.objectGUID }, relations: ['user'] }, + ); + expect(auth).to.exist; + ldapUsers.push(auth.user); + } + + for (const ldapUser of newLdapUsers) { + const auth = await LDAPAuthenticator.findOne( + { where: { UUID: ldapUser.objectGUID }, relations: ['user'] }, + ); + expect(auth).to.not.exist; + } + + const userCount = await User.count(); + const users = await new ADService().getUsers(rawLdapUsers.concat(newLdapUsers), true); + expect(await User.count()).to.be.equal(userCount + newLdapUsers.length); + expect(users).to.have.length(rawLdapUsers.length + newLdapUsers.length); + }); + }); + describe('AD Client functions', () => { + beforeEach(() => { + stubs.push(sinon.stub(Client.prototype, 'bind').resolves(null)); + }); + describe('getLDAPGroupMembers function', () => { + let ldapClient: Client; + const dn = 'CN=GroupName,OU=Groups,DC=example,DC=com'; + const mockResponse = { + searchEntries: [ + { + objectGUID: Buffer.from('12345678', 'hex'), + dn: 'CN=Test User,OU=Users,DC=example,DC=com', + givenName: 'Test', + sn: 'User', + }, + ], + searchReferences: [] as string[], }; - const clientBindStub = sinon.stub(Client.prototype, 'bind').resolves(null); - const clientSearchStub = sinon.stub(Client.prototype, 'search'); + beforeEach(() => { + ldapClient = new Client({ url: 'ldap://example.com' }); + stubs.push(sinon.stub(Client.prototype, 'search').resolves(mockResponse)); + }); + + afterEach(() => { + stubs.forEach((stub) => stub.restore()); + stubs.splice(0, stubs.length); + }); + + it('should return searchEntries for a valid group DN', async () => { + const adService = new ADService(); + const result = await adService.getLDAPGroupMembers(ldapClient, dn); - clientSearchStub.withArgs(process.env.LDAP_ROLE_FILTER, { - filter: '(CN=*)', - explicitBufferAttributes: ['objectGUID'], - }).resolves({ searchReferences: [], searchEntries: [roleGroup as any] }); + expect(result).to.have.property('searchEntries').that.is.an('array'); + expect(result.searchEntries).to.deep.equal(mockResponse.searchEntries); + expect(result).to.have.property('searchReferences').that.is.an('array'); + expect(result.searchReferences).to.deep.equal(mockResponse.searchReferences); - clientSearchStub.withArgs(process.env.LDAP_BASE, { - filter: `(&(objectClass=user)(objectCategory=person)(memberOf:1.2.840.113556.1.4.1941:=${roleGroup.dn}))`, - explicitBufferAttributes: ['objectGUID'], - }) - .resolves({ searchReferences: [], searchEntries: [newUser as any, existingUser as any] }); + sinon.assert.calledOnceWithExactly(Client.prototype.search as sinon.SinonStub, process.env.LDAP_BASE, { + filter: `(&(objectClass=user)(objectCategory=person)(memberOf:1.2.840.113556.1.4.1941:=${dn}))`, + explicitBufferAttributes: ['objectGUID'], + }); + }); - stubs.push(clientBindStub); - stubs.push(clientSearchStub); + it('should throw an error if the search fails', async () => { + const searchStub = Client.prototype.search as sinon.SinonStub; + searchStub.rejects(new Error('LDAP search failed')); - await new RbacSeeder().seed([{ - name: 'SudoSOS - Test', - permissions: { - }, - assignmentCheck: async (user: User) => await AssignedRole.findOne({ where: { role: { name: 'SudoSOS - Test' }, user: { id: user.id } } }) !== undefined, - }]); + const adService = new ADService(); + await expect(adService.getLDAPGroupMembers(ldapClient, dn)).to.be.rejectedWith('LDAP search failed'); - const roleManager = await new RoleManager().initialize(); + sinon.assert.calledOnce(searchStub); + }); + }); + describe('getLDAPGroups function', () => { + let ldapClient: Client; + const mockResponse = { + searchEntries: [ + { + objectGUID: Buffer.from('12345678', 'hex'), + dn: 'CN=Test Group,OU=Groups,DC=example,DC=com', + cn: 'Test Group', + displayName: 'Test Group', + }, + ], + searchReferences: [] as string[], + }; - await new ADService().syncUserRoles(roleManager); - const auth = (await LDAPAuthenticator.findOne( - { where: { UUID: newUser.objectGUID }, relations: ['user'] }, - )); - expect(auth).to.exist; - const { user } = auth; - userIsAsExpected(user, newUser); + beforeEach(() => { + ldapClient = new Client({ url: 'ldap://example.com' }); + stubs.push(sinon.stub(Client.prototype, 'search').resolves(mockResponse)); + }); + + afterEach(() => { + stubs.forEach((stub) => stub.restore()); + stubs.splice(0, stubs.length); + }); + + it('should return searchEntries for a valid group DN', async () => { + const adService = new ADService(); + const result = (await adService.getLDAPGroups(ldapClient, process.env.LDAP_USER_BASE))[0]; + + expect(result).to.have.property('displayName').that.is.an('string'); + expect(result.displayName).to.equal(mockResponse.searchEntries[0].displayName); + expect(result).to.have.property('dn').that.is.an('string'); + expect(result.dn).to.equal(mockResponse.searchEntries[0].dn); + expect(result).to.have.property('cn').that.is.an('string'); + expect(result.cn).to.equal(mockResponse.searchEntries[0].cn); + expect(result.objectGUID).to.deep.equal(mockResponse.searchEntries[0].objectGUID); + + sinon.assert.calledOnceWithExactly(Client.prototype.search as sinon.SinonStub, process.env.LDAP_USER_BASE, { + filter: '(CN=*)', + explicitBufferAttributes: ['objectGUID'], + }); + }); + it('should not throw an error if the search fails', async () => { + const searchStub = Client.prototype.search as sinon.SinonStub; + searchStub.rejects(new Error('LDAP search failed')); + + const adService = new ADService(); + await expect(adService.getLDAPGroups(ldapClient, process.env.LDAP_USER_BASE)).to.eventually.be.fulfilled; - const users = await new ADService().getUsers([newUser as LDAPUser, existingUser as LDAPUser]); - expect(await AssignedRole.findOne({ where: { role: { name: 'SudoSOS - Test' }, user: { id: users[0].id } } })).to.exist; - expect(await AssignedRole.findOne({ where: { role: { name: 'SudoSOS - Test' }, user: { id: users[1].id } } })).to.exist; + sinon.assert.calledOnce(searchStub); + }); }); - }); - describe('syncUsers function', () => { - it('should create new users if needed', async () => { - process.env.ENABLE_LDAP = 'true'; + describe('update functions', () => { + let ldapClient: Client; + let adService: ADService; + let roleManager: RoleManager; + const sharedAccountMock = { + objectGUID: Buffer.from('abcdef', 'hex'), + dn: 'CN=SharedGroup,OU=Groups,DC=example,DC=com', + displayName: 'SharedGroup', + }; + const roleGroupMock = { + objectGUID: Buffer.from('123456', 'hex'), + dn: 'CN=RoleGroup,OU=Groups,DC=example,DC=com', + displayName: 'RoleGroup', + }; + const mockLDAPUser = { + dn: 'CN=Test User,OU=Users,DC=example,DC=com', + objectGUID: Buffer.from('654321', 'hex'), + givenName: 'Test', + sn: 'User', + sAMAccountName: 'test.user', + mail: 'test.user@example.com', + }; - const newUser = { ...(ctx.validADUser(await User.count() + 23)) }; - const clientBindStub = sinon.stub(Client.prototype, 'bind').resolves(null); - const clientSearchStub = sinon.stub(Client.prototype, 'search'); + beforeEach(() => { + ldapClient = new Client({ url: 'ldap://example.com' }); + adService = new ADService(); + roleManager = new RoleManager(); + sinon.stub(Client.prototype, 'search').resolves({ + searchEntries: [mockLDAPUser], + searchReferences: [], + }); + sinon.stub(LDAPAuthenticator, 'findOne') + // @ts-ignore + .withArgs({ where: { UUID: sharedAccountMock.objectGUID }, relations: ['user'] }) + // @ts-ignore + .resolves({ user: { id: 1, displayName: sharedAccountMock.displayName } }); + // @ts-ignore + sinon.stub(adService, 'getUsers').resolves([{ id: 1, email: 'test.user@example.com' }]); + sinon.stub(roleManager, 'setRoleUsers').resolves(); + sinon.stub(AuthenticationService.prototype, 'setMemberAuthenticator').resolves(); + }); - clientSearchStub.withArgs(process.env.LDAP_BASE, { - filter: `(&(objectClass=user)(objectCategory=person)(memberOf:1.2.840.113556.1.4.1941:=${process.env.LDAP_USER_BASE}))`, - explicitBufferAttributes: ['objectGUID'], - }) - .resolves({ searchReferences: [], searchEntries: [newUser as any] }); + afterEach(() => { + sinon.restore(); + }); - stubs.push(clientBindStub); - stubs.push(clientSearchStub); + describe('updateSharedAccountMembership function', () => { + it('should update the members of a shared account', async () => { + const ldapGroup = sharedAccountMock as LDAPGroup; + await adService.updateSharedAccountMembership(ldapClient, ldapGroup); + + // Assertions + sinon.assert.calledOnceWithExactly(Client.prototype.search as sinon.SinonStub, process.env.LDAP_BASE, { + filter: `(&(objectClass=user)(objectCategory=person)(memberOf:1.2.840.113556.1.4.1941:=${ldapGroup.dn}))`, + explicitBufferAttributes: ['objectGUID'], + }); + sinon.assert.calledOnceWithExactly(LDAPAuthenticator.findOne as sinon.SinonStub, { + where: { UUID: ldapGroup.objectGUID }, + relations: ['user'], + }); + sinon.assert.calledOnce(adService.getUsers as sinon.SinonStub); + sinon.assert.calledOnce(AuthenticationService.prototype.setMemberAuthenticator as sinon.SinonStub); + }); - await new ADService().syncUsers(); - const auth = (await LDAPAuthenticator.findOne( - { where: { UUID: newUser.objectGUID }, relations: ['user'] }, - )); - expect(auth).to.exist; - const { user } = auth; - userIsAsExpected(user, newUser); + it('should throw an error if no authenticator is found for the shared account', async () => { + sinon.restore(); + sinon.stub(LDAPAuthenticator, 'findOne').resolves(null); + + const ldapGroup = sharedAccountMock as LDAPGroup; + await expect(adService.updateSharedAccountMembership(ldapClient, ldapGroup)) + .to.be.rejectedWith('No authenticator found for shared account'); + }); + }); + + describe('updateRoleMembership function', () => { + it('should update the members of a role', async () => { + const ldapGroup = roleGroupMock as LDAPGroup; + await adService.updateRoleMembership(ldapClient, ldapGroup, roleManager); + + // Assertions + sinon.assert.calledOnceWithExactly(Client.prototype.search as sinon.SinonStub, process.env.LDAP_BASE, { + filter: `(&(objectClass=user)(objectCategory=person)(memberOf:1.2.840.113556.1.4.1941:=${ldapGroup.dn}))`, + explicitBufferAttributes: ['objectGUID'], + }); + sinon.assert.calledOnce(adService.getUsers as sinon.SinonStub); + sinon.assert.calledOnce(roleManager.setRoleUsers as sinon.SinonStub); + }); + + it('should not throw an error even if no users are found in the group', async () => { + sinon.restore(); + sinon.stub(Client.prototype, 'search').resolves({ + searchEntries: [], + searchReferences: [], + }); + + const ldapGroup = roleGroupMock as LDAPGroup; + await expect(adService.updateRoleMembership(ldapClient, ldapGroup, roleManager)) + .to.eventually.be.fulfilled; + + sinon.assert.calledOnceWithExactly(Client.prototype.search as sinon.SinonStub, process.env.LDAP_BASE, { + filter: `(&(objectClass=user)(objectCategory=person)(memberOf:1.2.840.113556.1.4.1941:=${ldapGroup.dn}))`, + explicitBufferAttributes: ['objectGUID'], + }); + }); + }); }); }); }); diff --git a/test/unit/service/sync/ldap-sync-service.ts b/test/unit/service/sync/ldap-sync-service.ts new file mode 100644 index 000000000..8c11d9263 --- /dev/null +++ b/test/unit/service/sync/ldap-sync-service.ts @@ -0,0 +1,435 @@ +/** + * SudoSOS back-end API service. + * Copyright (C) 2024 Study association GEWIS + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * @license + */ + +import User, { TermsOfServiceStatus, UserType } from '../../../../src/entity/user/user'; +import { AppDataSource } from '../../../../src/database/database'; +import { + defaultAfter, + defaultBefore, + DefaultContext, restoreLDAPEnv, + setDefaultLDAPEnv, + storeLDAPEnv, +} from '../../../helpers/test-helpers'; +import LdapSyncService from '../../../../src/service/sync/user/ldap-sync-service'; +import { expect } from 'chai'; +import LDAPAuthenticator from '../../../../src/entity/authenticator/ldap-authenticator'; +import sinon from 'sinon'; +import { Client, EqualityFilter } from 'ldapts'; +import { LDAPResponse, LDAPResult, LDAPUser } from '../../../../src/helpers/ad'; +import { INTEGRATION_USER, inUserContext, ORGAN_USER, UserFactory } from '../../../helpers/user-factory'; +import ADService from '../../../../src/service/ad-service'; +import RBACService from '../../../../src/service/rbac-service'; +import Role from '../../../../src/entity/rbac/role'; + +export function stubGroupSearch(stub: sinon.SinonStub, baseDN: string, returns: any, stubs: sinon.SinonStub[]) { + stub.withArgs(baseDN, { + filter: '(CN=*)', + explicitBufferAttributes: ['objectGUID'], + }).resolves(returns); + stubs.push(stub); +} + +export function stubMemberSearch(stub: sinon.SinonStub, dn: string, returns: any, stubs: sinon.SinonStub[]) { + stub.withArgs(process.env.LDAP_BASE, { + filter: `(&(objectClass=user)(objectCategory=person)(memberOf:1.2.840.113556.1.4.1941:=${dn}))`, + explicitBufferAttributes: ['objectGUID'], + }).resolves(returns); + stubs.push(stub); +} + +export function stubGUIDSearch(stub: sinon.SinonStub, value: Buffer, returns: any, stubs: sinon.SinonStub[]) { + stub.withArgs(process.env.LDAP_BASE, { + filter: new EqualityFilter({ + attribute: 'objectGUID', + value, + }), + explicitBufferAttributes: ['objectGUID'], + }).resolves(returns); + stubs.push(stub); +} + +export async function addLDAPAuthenticator(UUID: Buffer, user: User) { + expect(await AppDataSource.manager.findOne(LDAPAuthenticator, { + where: { UUID }, + })).to.be.null; + + return AppDataSource.manager.save(LDAPAuthenticator, { + UUID, + user, + }); +} + +describe('LdapSyncService', () => { + let ctx: DefaultContext; + + let ldapEnvVariables: { [key: string]: any; } = {}; + + before(async () => { + ctx = { + ...(await defaultBefore()), + }; + + ldapEnvVariables = storeLDAPEnv(); + setDefaultLDAPEnv(); + }); + + after(async () => { + restoreLDAPEnv(ldapEnvVariables); + await defaultAfter(ctx); + }); + + let stubs: sinon.SinonStub[] = []; + + afterEach(() => { + stubs.forEach((stub) => stub.restore()); + stubs.splice(0, stubs.length); + }); + + describe('guard function', () => { + it('should return true if the user is of type ORGAN or INTEGRATION', async () => { + await inUserContext( + await (await UserFactory()).clone(3), + async (organ: User, integration: User, local: User) => { + organ.type = UserType.ORGAN; + integration.type = UserType.INTEGRATION; + local.type = UserType.LOCAL_USER; + await User.save(organ); + await User.save(integration); + await User.save(local); + + const ldapSyncService = new LdapSyncService(ctx.roleManager); + expect(await ldapSyncService.guard(organ)).to.be.true; + expect(await ldapSyncService.guard(integration)).to.be.true; + expect(await ldapSyncService.guard(local)).to.be.false; + }, + ); + }); + it('should only return true for MEMBERS if they have an LDAPAuthenticator', async () => { + await inUserContext( + await (await UserFactory()).clone(1), + async (member: User) => { + const ldapSyncService = new LdapSyncService(ctx.roleManager); + expect(await ldapSyncService.guard(member)).to.be.false; + + await AppDataSource.manager.save(LDAPAuthenticator, { + UUID: Buffer.from('1234', 'hex'), + user: member, + }); + + expect(await ldapSyncService.guard(member)).to.be.true; + }, + ); + }); + }); + + describe('ldap functions', () => { + let ldapSyncService: LdapSyncService; + + before(async () => { + ldapSyncService = new LdapSyncService(ctx.roleManager); + }); + + after(async () => { + await ldapSyncService.post(); + }); + + beforeEach(() => { + stubs.push(sinon.stub(Client.prototype, 'bind').resolves(null)); + }); + + + + function userIsAsExpected(user: User, ldapUser: LDAPUser) { + expect(user.firstName).to.be.equal(ldapUser.displayName); + expect(user.lastName).to.be.equal(''); + expect(user.canGoIntoDebt).to.be.false; + expect(user.acceptedToS).to.be.equal(TermsOfServiceStatus.NOT_REQUIRED); + expect(user.active).to.be.true; + } + + describe('sync function', () => { + it('should return false if user no AD entry matching the LDAPAuthenticator UUID', async () => { + await inUserContext( + await (await UserFactory()).clone(1), + async (member: User) => { + const UUID = Buffer.from('4321', 'hex'); + expect(await ldapSyncService.guard(member)).to.be.false; + const stub = sinon.stub(Client.prototype, 'search'); + stubGUIDSearch(stub, UUID, { searchReferences: [], searchEntries: [] }, stubs); + await ldapSyncService.pre(); + + await addLDAPAuthenticator(UUID, member); + expect(await ldapSyncService.sync(member)).to.be.false; + }, + ); + }); + it('should return true if user has AD entry matching the LDAPAuthenticator UUID', async () => { + await inUserContext( + await (await UserFactory()).clone(1), + async (member: User) => { + const UUID = Buffer.from('4444', 'hex'); + await addLDAPAuthenticator(UUID, member); + + const stub = sinon.stub(Client.prototype, 'search'); + stubGUIDSearch(stub, UUID, { searchReferences: [], searchEntries: [{ member }] }, stubs); + await ldapSyncService.pre(); + + expect(await ldapSyncService.sync(member)).to.be.true; + }, + ); + }); + it('should return true and update user if user is of type ORGAN with known LDAPAuthenticator', async () => { + await inUserContext( + await (await UserFactory(await ORGAN_USER())).clone(1), + async (organ: User) => { + const UUID = Buffer.from('8989', 'hex'); + await addLDAPAuthenticator(UUID, organ); + + // Intentionally "mess up" the user + await AppDataSource.manager.update(User, organ.id, { + firstName: 'Wrong', + lastName: 'Wrong', + canGoIntoDebt: true, + acceptedToS: TermsOfServiceStatus.ACCEPTED, + active: false, + }); + + const displayName = `${organ.firstName} Updated`; + const stub = sinon.stub(Client.prototype, 'search'); + stubGUIDSearch(stub, UUID, { + searchReferences: [], searchEntries: [{ + displayName, + }], + }, stubs); + + await ldapSyncService.pre(); + expect(await ldapSyncService.sync(organ)).to.be.true; + const dbUser = await AppDataSource.manager.findOne(User, { where: { id: organ.id } }); + userIsAsExpected(dbUser, { displayName } as LDAPUser); + }, + ); + }); + it('should return true and update user if user is of type INTEGRATION with known LDAPAuthenticator', async () => { + await inUserContext( + await (await UserFactory(await INTEGRATION_USER())).clone(1), + async (organ: User) => { + const UUID = Buffer.from('4141', 'hex'); + await addLDAPAuthenticator(UUID, organ); + + // Intentionally "mess up" the user + await AppDataSource.manager.update(User, organ.id, { + firstName: 'Wrong', + lastName: 'Wrong', + canGoIntoDebt: true, + acceptedToS: TermsOfServiceStatus.ACCEPTED, + active: false, + }); + + const displayName = `${organ.firstName} Updated`; + const stub = sinon.stub(Client.prototype, 'search'); + stubGUIDSearch(stub, UUID, { + searchReferences: [], searchEntries: [{ + displayName, + }], + }, stubs); + + await ldapSyncService.pre(); + expect(await ldapSyncService.sync(organ)).to.be.true; + const dbUser = await AppDataSource.manager.findOne(User, { where: { id: organ.id } }); + userIsAsExpected(dbUser, { displayName } as LDAPUser); + }, + ); + }); + it('should return false if user has no LDAPAuthenticator', async () => { + await inUserContext( + await (await UserFactory()).clone(1), + async (member: User) => { + expect(await ldapSyncService.sync(member)).to.be.false; + }, + ); + }); + }); + + describe('down function', () => { + it('should remove the LDAPAuthenticator for the given user', async () => { + await inUserContext( + await (await UserFactory()).clone(1), + async (member: User) => { + const UUID = Buffer.from((member.id.toString().length % 2 ? '0' : '') + member.id.toString(), 'hex'); + expect(await ldapSyncService.guard(member)).to.be.false; + const stub = sinon.stub(Client.prototype, 'search'); + stubGUIDSearch(stub, UUID, { searchReferences: [], searchEntries: [] }, stubs); + await ldapSyncService.pre(); + + await addLDAPAuthenticator(UUID, member); + expect(await ldapSyncService.sync(member)).to.be.false; + + await ldapSyncService.down(member); + const auth = await LDAPAuthenticator.findOne({ where: { UUID } }); + expect(auth).to.be.null; + }, + ); + }); + it('should set INTEGRATION and ORGAN users to deleted and inactive', async () => { + await inUserContext( + await (await UserFactory(await ORGAN_USER())).clone(1), + async (organ: User) => { + const UUID = Buffer.from((organ.id.toString().length % 2 ? '0' : '') + organ.id.toString(), 'hex'); + expect(await ldapSyncService.guard(organ)).to.be.true; + const stub = sinon.stub(Client.prototype, 'search'); + stubGUIDSearch(stub, UUID, { searchReferences: [], searchEntries: [] }, stubs); + await ldapSyncService.pre(); + + await addLDAPAuthenticator(UUID, organ); + expect(await ldapSyncService.sync(organ)).to.be.false; + + await ldapSyncService.down(organ); + const auth = await LDAPAuthenticator.findOne({ where: { UUID } }); + expect(auth).to.be.null; + + const dbUser = await AppDataSource.manager.findOne(User, { where: { id: organ.id } }); + expect(dbUser.deleted).to.be.true; + expect(dbUser.active).to.be.false; + }, + ); + }); + }); + + describe('fetch functions', () => { + let mockAdService: sinon.SinonStubbedInstance; + beforeEach(() => { + mockAdService = sinon.createStubInstance(ADService); + // @ts-ignore + ldapSyncService.adService = mockAdService; + }); + describe('fetchSharedAccounts function', () => { + it('should create accounts for new shared accounts', async () => { + const sharedAccountsMock = [ + { cn: 'shared1', objectGUID: 'guid1' }, + { cn: 'shared2', objectGUID: 'guid2' }, + ]; + + const unboundMock = [ + { cn: 'shared1', objectGUID: 'guid1' }, + ]; + + mockAdService.getLDAPGroups.resolves(sharedAccountsMock); + mockAdService.filterUnboundGUID.resolves(unboundMock as unknown as LDAPResponse[]); + mockAdService.toSharedUser.resolves(); + + // eslint-disable-next-line @typescript-eslint/dot-notation + await ldapSyncService['fetchSharedAccounts'](); + + expect(mockAdService.toSharedUser.calledOnceWith(unboundMock[0] as unknown as LDAPResponse)).to.be.true; + }); + it('should update membership of all shared accounts', async () => { + const sharedAccountsMock = [ + { cn: 'shared1', objectGUID: 'guid1' }, + { cn: 'shared2', objectGUID: 'guid2' }, + ]; + + mockAdService.getLDAPGroups.resolves(sharedAccountsMock); + mockAdService.filterUnboundGUID.resolves([] as unknown as LDAPResponse[]); + mockAdService.updateSharedAccountMembership.resolves(); + + // eslint-disable-next-line @typescript-eslint/dot-notation + await ldapSyncService['fetchSharedAccounts'](); + + expect(mockAdService.updateSharedAccountMembership.calledWith(sinon.match.any, sharedAccountsMock[0] as unknown as LDAPResponse)).to.be.true; + expect(mockAdService.updateSharedAccountMembership.calledWith(sinon.match.any, sharedAccountsMock[1] as unknown as LDAPResponse)).to.be.true; + }); + }); + describe('fetchUserRoles function', () => { + it('should update membership of all existing roles', async () => { + const localRolesMock = [ + { cn: 'role1', objectGUID: 'guid1' }, + { cn: 'role2', objectGUID: 'guid2' }, + ]; + const rolesMock = [ + ...localRolesMock, + { cn: 'role3', objectGUID: 'guid3' }, + ]; + + const roles: Role[] = localRolesMock.map((r) => { + return { + name: r.cn, + } as unknown as Role; + }); + + const stub = sinon.stub(RBACService, 'getRoles'); + stub.resolves([roles, 2]); + stubs.push(stub); + + mockAdService.getLDAPGroups.resolves(rolesMock); + // @ts-ignore + await ldapSyncService.fetchUserRoles(); + + expect(mockAdService.getLDAPGroups.calledOnceWith(sinon.match.any, process.env.LDAP_ROLE_FILTER)).to.be.true; + expect(mockAdService.updateRoleMembership.calledWith(sinon.match.any, rolesMock[0] as unknown as LDAPResponse, sinon.match.any)).to.be.true; + expect(mockAdService.updateRoleMembership.calledWith(sinon.match.any, rolesMock[1] as unknown as LDAPResponse, sinon.match.any)).to.be.true; + expect(mockAdService.updateRoleMembership.calledWith(sinon.match.any, rolesMock[2] as unknown as LDAPResponse, sinon.match.any)).to.be.false; + }); + }); + describe('fetchServiceAccounts function', () => { + it('should create accounts for new service accounts', async () => { + const newResult = { + cn: 'service3', + objectGUID: 'guid1', + } as unknown as LDAPResult; + + const ldapResults: LDAPResult[] = [ + { cn: 'service1', objectGUID: 'guid1' }, + { cn: 'service2', objectGUID: 'guid2' }, + newResult, + ] as unknown as LDAPResult[]; + + const serviceAccountsMock = { + searchEntries: ldapResults, + searchReferences: [] as string[], + }; + + mockAdService.getLDAPGroupMembers.resolves(serviceAccountsMock); + mockAdService.filterUnboundGUID.resolves([newResult]); + // @ts-ignore + await ldapSyncService.fetchServiceAccounts(); + + expect(mockAdService.getLDAPGroupMembers.calledOnceWith(sinon.match.any, process.env.LDAP_SERVICE_ACCOUNT_FILTER)).to.be.true; + expect(mockAdService.toServiceAccount.calledWith(ldapResults[0] as unknown as LDAPUser)).to.be.false; + expect(mockAdService.toServiceAccount.calledWith(ldapResults[1] as unknown as LDAPUser)).to.be.false; + expect(mockAdService.toServiceAccount.calledWith(ldapResults[2] as unknown as LDAPUser)).to.be.true; + }); + }); + describe('fetch function', () => { + it('should run all sync functions', async () => { + setDefaultLDAPEnv(); + // @ts-ignore + ldapSyncService.fetchSharedAccounts = sinon.stub().resolves(); + // @ts-ignore + ldapSyncService.fetchUserRoles = sinon.stub().resolves(); + // @ts-ignore + ldapSyncService.fetchServiceAccounts = sinon.stub().resolves(); + + const result = await ldapSyncService.fetch(); + expect(result).to.be.undefined; + }); + }); + }); + }); +});