diff --git a/.husky/pre-commit b/.husky/pre-commit index 22e8b022..08404245 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,2 @@ -#!/usr/bin/env sh -. "$(dirname -- "$0")/_/husky.sh" - -npx lint-staged --shell +#!/bin/sh +npx --no-install lint-staged \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index bd3ec80e..e8064497 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:18-alpine AS build-stage +FROM node:22-alpine AS build-stage USER node @@ -14,7 +14,7 @@ COPY --chown=node:node . . RUN npm run build -FROM node:18-alpine +FROM node:22-alpine USER node diff --git a/Dockerfile.connector.dev b/Dockerfile.connector.dev new file mode 100644 index 00000000..0abffcf5 --- /dev/null +++ b/Dockerfile.connector.dev @@ -0,0 +1,11 @@ +FROM node:22-alpine + +USER node + +RUN mkdir -p /home/node/app + +WORKDIR /home/node/app + +COPY --chown=node:node package*.json ./ + +RUN npm ci --loglevel error --no-fund diff --git a/README.md b/README.md index cf549ace..3c673db8 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ The connector connects other services through the message queue. # Requirements -This service requires node =>18.0.0 +This service requires node >=22.0.0 ## Getting started diff --git a/package-lock.json b/package-lock.json index 0ac0c2d7..f8c57a56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,14 +11,14 @@ "dependencies": { "@user-office-software/duo-logger": "^2.2.1", "@user-office-software/duo-message-broker": "^1.7.0", - "axios": "^1.7.7", + "axios": "^1.12.0", "dotenv": "^16.4.5", "envalid": "^8.0.0", - "express": "^4.21.1", + "express": "^4.21.2", "kafkajs": "^2.2.3", "knex": "^3.1.0", "matrix-js-sdk": "24.1.0", - "pg": "^8.13.1", + "pg": "^8.14.1", "prom-client": "^15.1.3", "reflect-metadata": "^0.2.2", "ts-node-dev": "^2.0.0", @@ -26,28 +26,28 @@ }, "devDependencies": { "@types/express": "^4.17.21", - "@types/jest": "^29.5.13", + "@types/jest": "^29.5.14", "@types/knex": "^0.16.1", - "@types/node": "^22.9.0", - "@types/pg": "^8.11.10", + "@types/node": "^22.13.9", + "@types/pg": "^8.11.11", "@typescript-eslint/eslint-plugin": "^7.16.1", "@typescript-eslint/parser": "^7.18.0", "eslint": "^8.57.1", "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "^2.31.0", - "eslint-plugin-jest": "^28.8.3", - "eslint-plugin-prettier": "^5.2.1", + "eslint-plugin-jest": "^28.11.0", + "eslint-plugin-prettier": "^5.2.3", "eslint-plugin-unused-imports": "^3.2.0", "husky": "^9.1.6", "jest": "^29.7.0", - "lint-staged": "^15.2.10", + "lint-staged": "^15.4.3", "prettier": "3.3.3", - "ts-jest": "^29.2.5", - "typescript": "^5.6.3" + "ts-jest": "^29.3.0", + "typescript": "^5.7.2" }, "engines": { - "node": ">=18.0.0", - "npm": ">=9.0.0" + "node": ">=22.0.0", + "npm": ">=10.0.0" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -680,11 +680,12 @@ } }, "node_modules/@babel/runtime": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.21.0.tgz", - "integrity": "sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw==", + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz", + "integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==", + "license": "MIT", "dependencies": { - "regenerator-runtime": "^0.13.11" + "regenerator-runtime": "^0.14.0" }, "engines": { "node": ">=6.9.0" @@ -1556,9 +1557,9 @@ } }, "node_modules/@types/jest": { - "version": "29.5.13", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.13.tgz", - "integrity": "sha512-wd+MVEZCHt23V0/L642O5APvspWply/rGY5BcW4SUETo2UzPU3Z26qr8jC2qxpimI2jjx9h7+2cj2FwIr01bXg==", + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", "dev": true, "dependencies": { "expect": "^29.0.0", @@ -1588,18 +1589,20 @@ "dev": true }, "node_modules/@types/node": { - "version": "22.9.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz", - "integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==", + "version": "22.13.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.9.tgz", + "integrity": "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw==", + "license": "MIT", "dependencies": { - "undici-types": "~6.19.8" + "undici-types": "~6.20.0" } }, "node_modules/@types/pg": { - "version": "8.11.10", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.10.tgz", - "integrity": "sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg==", + "version": "8.11.11", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.11.tgz", + "integrity": "sha512-kGT1qKM8wJQ5qlawUrEkXgvMSXoV213KfMGXcwfDwUIfUHXqXYXOfS1nE1LINRJVVVx5wCm70XnFlMHaIcQAfw==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -2291,7 +2294,8 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" }, "node_modules/available-typed-arrays": { "version": "1.0.7", @@ -2309,12 +2313,13 @@ } }, "node_modules/axios": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", - "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz", + "integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==", + "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", + "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, @@ -2415,9 +2420,10 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "node_modules/base-x": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.0.tgz", - "integrity": "sha512-FuwxlW4H5kh37X/oW59pwTzzTKRzfrrQwhmyspRM7swOEZcHtDZSCt45U6oKgtuFE+WYPblePMVIPR4RZrh/hw==" + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.1.tgz", + "integrity": "sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==", + "license": "MIT" }, "node_modules/binary-extensions": { "version": "2.2.0", @@ -2585,6 +2591,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2711,6 +2730,7 @@ "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", "dev": true, + "license": "MIT", "dependencies": { "restore-cursor": "^5.0.0" }, @@ -2726,6 +2746,7 @@ "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", "dev": true, + "license": "MIT", "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" @@ -2738,10 +2759,11 @@ } }, "node_modules/cli-truncate/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -2750,16 +2772,18 @@ } }, "node_modules/cli-truncate/node_modules/emoji-regex": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.3.0.tgz", - "integrity": "sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==", - "dev": true + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true, + "license": "MIT" }, "node_modules/cli-truncate/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, + "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", @@ -2777,6 +2801,7 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", "dev": true, + "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" }, @@ -2839,12 +2864,14 @@ "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" }, @@ -2853,10 +2880,11 @@ } }, "node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" } @@ -3101,6 +3129,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", "engines": { "node": ">=0.4.0" } @@ -3183,6 +3212,20 @@ "url": "https://dotenvx.com" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/dynamic-dedupe": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/dynamic-dedupe/-/dynamic-dedupe-0.3.0.tgz", @@ -3259,6 +3302,7 @@ "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -3336,12 +3380,10 @@ } }, "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", - "dependencies": { - "get-intrinsic": "^1.2.4" - }, + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", "engines": { "node": ">= 0.4" } @@ -3355,10 +3397,10 @@ } }, "node_modules/es-object-atoms": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", - "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", - "dev": true, + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", "dependencies": { "es-errors": "^1.3.0" }, @@ -3367,14 +3409,15 @@ } }, "node_modules/es-set-tostringtag": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", - "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", - "dev": true, + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.4", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", - "hasown": "^2.0.1" + "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" @@ -3608,10 +3651,11 @@ } }, "node_modules/eslint-plugin-jest": { - "version": "28.8.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-28.8.3.tgz", - "integrity": "sha512-HIQ3t9hASLKm2IhIOqnu+ifw7uLZkIlR7RYNv7fMcEi/p0CIiJmfriStQS2LDkgtY4nyLbIZAD+JL347Yc2ETQ==", + "version": "28.11.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-28.11.0.tgz", + "integrity": "sha512-QAfipLcNCWLVocVbZW8GimKn5p5iiMcgGbRzz8z/P5q7xw+cNEpYqyzFMtIF/ZgF2HLOyy+dYBut+DoYolvqig==", "dev": true, + "license": "MIT", "dependencies": { "@typescript-eslint/utils": "^6.0.0 || ^7.0.0 || ^8.0.0" }, @@ -3633,10 +3677,11 @@ } }, "node_modules/eslint-plugin-prettier": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.1.tgz", - "integrity": "sha512-gH3iR3g4JfF+yYPaJYkN7jEl9QbweL/YfkoRlNnuIEHEz1vHVlCmWOS+eGGiRuzHQXdJFCOTxRgvju9b8VUmrw==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.3.tgz", + "integrity": "sha512-qJ+y0FfCp/mQYQ/vWQ3s7eUlFEL4PyKfAJxsnYTJ4YT73nsJBWqmEpFryxV9OeUiqmsTsYJ5Y+KDNaeP31wrRw==", "dev": true, + "license": "MIT", "dependencies": { "prettier-linter-helpers": "^1.0.0", "synckit": "^0.9.1" @@ -3812,7 +3857,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/events": { "version": "3.3.0", @@ -3871,9 +3917,9 @@ } }, "node_modules/express": { - "version": "4.21.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", - "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -3894,7 +3940,7 @@ "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.10", + "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", @@ -3909,6 +3955,10 @@ }, "engines": { "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/express/node_modules/debug": { @@ -4165,12 +4215,15 @@ } }, "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { @@ -4273,10 +4326,11 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.2.0.tgz", - "integrity": "sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", + "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -4285,15 +4339,21 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -4310,6 +4370,19 @@ "node": ">=8.0.0" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -4426,11 +4499,12 @@ } }, "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dependencies": { - "get-intrinsic": "^1.1.3" + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4481,6 +4555,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "dev": true, "engines": { "node": ">= 0.4" }, @@ -4489,9 +4564,10 @@ } }, "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -4503,7 +4579,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "dependencies": { "has-symbols": "^1.0.3" }, @@ -5806,10 +5881,11 @@ } }, "node_modules/lilconfig": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.2.tgz", - "integrity": "sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", "dev": true, + "license": "MIT", "engines": { "node": ">=14" }, @@ -5824,21 +5900,22 @@ "dev": true }, "node_modules/lint-staged": { - "version": "15.2.10", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.2.10.tgz", - "integrity": "sha512-5dY5t743e1byO19P9I4b3x8HJwalIznL5E1FWYnU6OWw33KxNBSLAc6Cy7F2PsFEO8FKnLwjwm5hx7aMF0jzZg==", - "dev": true, - "dependencies": { - "chalk": "~5.3.0", - "commander": "~12.1.0", - "debug": "~4.3.6", - "execa": "~8.0.1", - "lilconfig": "~3.1.2", - "listr2": "~8.2.4", - "micromatch": "~4.0.8", - "pidtree": "~0.6.0", - "string-argv": "~0.3.2", - "yaml": "~2.5.0" + "version": "15.4.3", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.4.3.tgz", + "integrity": "sha512-FoH1vOeouNh1pw+90S+cnuoFwRfUD9ijY2GKy5h7HS3OR7JVir2N2xrsa0+Twc1B7cW72L+88geG5cW4wIhn7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.4.1", + "commander": "^13.1.0", + "debug": "^4.4.0", + "execa": "^8.0.1", + "lilconfig": "^3.1.3", + "listr2": "^8.2.5", + "micromatch": "^4.0.8", + "pidtree": "^0.6.0", + "string-argv": "^0.3.2", + "yaml": "^2.7.0" }, "bin": { "lint-staged": "bin/lint-staged.js" @@ -5851,10 +5928,11 @@ } }, "node_modules/lint-staged/node_modules/chalk": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", - "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", "dev": true, + "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" }, @@ -5863,12 +5941,13 @@ } }, "node_modules/lint-staged/node_modules/debug": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", - "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", "dev": true, + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -5947,6 +6026,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lint-staged/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/lint-staged/node_modules/npm-run-path": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.1.0.tgz", @@ -6014,10 +6100,11 @@ } }, "node_modules/listr2": { - "version": "8.2.4", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.2.4.tgz", - "integrity": "sha512-opevsywziHd3zHCVQGAj8zu+Z3yHNkkoYhWIGnq54RrCVwLz0MozotJEDnKsIBLvkfLGN6BLOyAeRrYI0pKA4g==", + "version": "8.2.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.2.5.tgz", + "integrity": "sha512-iyAZCeyD+c1gPyE9qpFu8af0Y+MRtmKOncdGoA2S5EY8iFq99dmmvkNnHiWo+pj0s7yH7l3KPIgee77tKpXPWQ==", "dev": true, + "license": "MIT", "dependencies": { "cli-truncate": "^4.0.0", "colorette": "^2.0.20", @@ -6031,10 +6118,11 @@ } }, "node_modules/listr2/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -6047,6 +6135,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -6055,16 +6144,18 @@ } }, "node_modules/listr2/node_modules/emoji-regex": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.3.0.tgz", - "integrity": "sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==", - "dev": true + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true, + "license": "MIT" }, "node_modules/listr2/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, + "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", @@ -6082,6 +6173,7 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", "dev": true, + "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" }, @@ -6097,6 +6189,7 @@ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", @@ -6146,6 +6239,7 @@ "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", "dev": true, + "license": "MIT", "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", @@ -6165,6 +6259,7 @@ "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", "dev": true, + "license": "MIT", "dependencies": { "environment": "^1.0.0" }, @@ -6176,10 +6271,11 @@ } }, "node_modules/log-update/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -6192,6 +6288,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -6200,16 +6297,18 @@ } }, "node_modules/log-update/node_modules/emoji-regex": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.3.0.tgz", - "integrity": "sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==", - "dev": true + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true, + "license": "MIT" }, "node_modules/log-update/node_modules/is-fullwidth-code-point": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", "integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==", "dev": true, + "license": "MIT", "dependencies": { "get-east-asian-width": "^1.0.0" }, @@ -6225,6 +6324,7 @@ "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" @@ -6241,6 +6341,7 @@ "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, + "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", @@ -6258,6 +6359,7 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", "dev": true, + "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" }, @@ -6273,6 +6375,7 @@ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", @@ -6335,6 +6438,15 @@ "tmpl": "1.0.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/matrix-events-sdk": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz", @@ -6467,6 +6579,7 @@ "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -6817,9 +6930,9 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "node_modules/path-to-regexp": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", - "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" }, "node_modules/path-type": { "version": "4.0.0", @@ -6831,13 +6944,14 @@ } }, "node_modules/pg": { - "version": "8.13.1", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.1.tgz", - "integrity": "sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ==", + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.14.1.tgz", + "integrity": "sha512-0TdbqfjwIun9Fm/r89oB7RFQ0bLgduAhiIqIXOsyKoiC/L54DbuAAzIEN/9Op0f1Po9X7iCPXGoa/Ah+2aI8Xw==", + "license": "MIT", "dependencies": { "pg-connection-string": "^2.7.0", - "pg-pool": "^3.7.0", - "pg-protocol": "^1.7.0", + "pg-pool": "^3.8.0", + "pg-protocol": "^1.8.0", "pg-types": "^2.1.0", "pgpass": "1.x" }, @@ -6885,17 +6999,19 @@ } }, "node_modules/pg-pool": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.7.0.tgz", - "integrity": "sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.8.0.tgz", + "integrity": "sha512-VBw3jiVm6ZOdLBTIcXLNdSotb6Iy3uOCwDGFAksZCXmi10nyRvnP2v3jl4d+IsLYRyXf6o9hIm/ZtUzlByNUdw==", + "license": "MIT", "peerDependencies": { "pg": ">=8.0" } }, "node_modules/pg-protocol": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.7.0.tgz", - "integrity": "sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==" + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.8.0.tgz", + "integrity": "sha512-jvuYlEkL03NRvOoyoRktBK7+qU5kOvlAwvmrH8sr3wbLrOdVWsRxQfz8mMy9sZFsqJ1hEWNfdWKI4SAmoL+j7g==", + "license": "MIT" }, "node_modules/pg-types": { "version": "4.0.2", @@ -7369,9 +7485,10 @@ "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" }, "node_modules/regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "license": "MIT" }, "node_modules/regexp.prototype.flags": { "version": "1.5.3", @@ -7465,6 +7582,7 @@ "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", "dev": true, + "license": "MIT", "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" @@ -7481,6 +7599,7 @@ "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", "dev": true, + "license": "MIT", "dependencies": { "mimic-function": "^5.0.0" }, @@ -7496,6 +7615,7 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, + "license": "ISC", "engines": { "node": ">=14" }, @@ -7525,7 +7645,8 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/rimraf": { "version": "3.0.2", @@ -7625,10 +7746,11 @@ } }, "node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -7799,6 +7921,7 @@ "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^6.0.0", "is-fullwidth-code-point": "^4.0.0" @@ -7815,6 +7938,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -7827,6 +7951,7 @@ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -8164,10 +8289,11 @@ } }, "node_modules/ts-jest": { - "version": "29.2.5", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.2.5.tgz", - "integrity": "sha512-KD8zB2aAZrcKIdGk4OwpJggeLcH1FgrICqDSROWqlnJXGCXK4Mn6FcdK2B6670Xr73lHMG1kHw8R87A0ecZ+vA==", + "version": "29.3.0", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.3.0.tgz", + "integrity": "sha512-4bfGBX7Gd1Aqz3SyeDS9O276wEU/BInZxskPrbhZLyv+c1wskDCqDFMJQJLWrIr/fKoAH4GE5dKUlrdyvo+39A==", "dev": true, + "license": "MIT", "dependencies": { "bs-logger": "^0.2.6", "ejs": "^3.1.10", @@ -8176,7 +8302,8 @@ "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.6.3", + "semver": "^7.7.1", + "type-fest": "^4.37.0", "yargs-parser": "^21.1.1" }, "bin": { @@ -8211,6 +8338,19 @@ } } }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.38.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.38.0.tgz", + "integrity": "sha512-2dBz5D5ycHIoliLYLi0Q2V7KRaDlH0uWIvmk7TYlAg5slqwiPv1ezJdZm1QEM0xgk29oYWMCbIG7E6gHpvChlg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ts-node": { "version": "10.9.1", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", @@ -8497,9 +8637,9 @@ } }, "node_modules/typescript": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", - "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8524,9 +8664,10 @@ } }, "node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "license": "MIT" }, "node_modules/unhomoglyph": { "version": "1.0.6", @@ -8756,10 +8897,11 @@ "dev": true }, "node_modules/yaml": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.0.tgz", - "integrity": "sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", + "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", "dev": true, + "license": "ISC", "bin": { "yaml": "bin.mjs" }, @@ -9288,11 +9430,11 @@ } }, "@babel/runtime": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.21.0.tgz", - "integrity": "sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw==", + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz", + "integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==", "requires": { - "regenerator-runtime": "^0.13.11" + "regenerator-runtime": "^0.14.0" } }, "@babel/template": { @@ -10006,9 +10148,9 @@ } }, "@types/jest": { - "version": "29.5.13", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.13.tgz", - "integrity": "sha512-wd+MVEZCHt23V0/L642O5APvspWply/rGY5BcW4SUETo2UzPU3Z26qr8jC2qxpimI2jjx9h7+2cj2FwIr01bXg==", + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", "dev": true, "requires": { "expect": "^29.0.0", @@ -10037,17 +10179,17 @@ "dev": true }, "@types/node": { - "version": "22.9.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz", - "integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==", + "version": "22.13.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.9.tgz", + "integrity": "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw==", "requires": { - "undici-types": "~6.19.8" + "undici-types": "~6.20.0" } }, "@types/pg": { - "version": "8.11.10", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.10.tgz", - "integrity": "sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg==", + "version": "8.11.11", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.11.tgz", + "integrity": "sha512-kGT1qKM8wJQ5qlawUrEkXgvMSXoV213KfMGXcwfDwUIfUHXqXYXOfS1nE1LINRJVVVx5wCm70XnFlMHaIcQAfw==", "dev": true, "requires": { "@types/node": "*", @@ -10532,12 +10674,12 @@ } }, "axios": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", - "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz", + "integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==", "requires": { "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", + "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, @@ -10617,9 +10759,9 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "base-x": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.0.tgz", - "integrity": "sha512-FuwxlW4H5kh37X/oW59pwTzzTKRzfrrQwhmyspRM7swOEZcHtDZSCt45U6oKgtuFE+WYPblePMVIPR4RZrh/hw==" + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.1.tgz", + "integrity": "sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==" }, "binary-extensions": { "version": "2.2.0", @@ -10747,6 +10889,15 @@ "set-function-length": "^1.2.1" } }, + "call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "requires": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + } + }, "callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -10838,15 +10989,15 @@ }, "dependencies": { "ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", "dev": true }, "emoji-regex": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.3.0.tgz", - "integrity": "sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", "dev": true }, "string-width": { @@ -10924,9 +11075,9 @@ } }, "commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", "dev": true }, "concat-map": { @@ -11142,6 +11293,16 @@ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==" }, + "dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "requires": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + } + }, "dynamic-dedupe": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/dynamic-dedupe/-/dynamic-dedupe-0.3.0.tgz", @@ -11265,12 +11426,9 @@ } }, "es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", - "requires": { - "get-intrinsic": "^1.2.4" - } + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" }, "es-errors": { "version": "1.3.0", @@ -11278,23 +11436,22 @@ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" }, "es-object-atoms": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", - "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", - "dev": true, + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "requires": { "es-errors": "^1.3.0" } }, "es-set-tostringtag": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", - "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", - "dev": true, + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "requires": { - "get-intrinsic": "^1.2.4", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", - "hasown": "^2.0.1" + "hasown": "^2.0.2" } }, "es-shim-unscopables": { @@ -11494,18 +11651,18 @@ } }, "eslint-plugin-jest": { - "version": "28.8.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-28.8.3.tgz", - "integrity": "sha512-HIQ3t9hASLKm2IhIOqnu+ifw7uLZkIlR7RYNv7fMcEi/p0CIiJmfriStQS2LDkgtY4nyLbIZAD+JL347Yc2ETQ==", + "version": "28.11.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-28.11.0.tgz", + "integrity": "sha512-QAfipLcNCWLVocVbZW8GimKn5p5iiMcgGbRzz8z/P5q7xw+cNEpYqyzFMtIF/ZgF2HLOyy+dYBut+DoYolvqig==", "dev": true, "requires": { "@typescript-eslint/utils": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "eslint-plugin-prettier": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.1.tgz", - "integrity": "sha512-gH3iR3g4JfF+yYPaJYkN7jEl9QbweL/YfkoRlNnuIEHEz1vHVlCmWOS+eGGiRuzHQXdJFCOTxRgvju9b8VUmrw==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.3.tgz", + "integrity": "sha512-qJ+y0FfCp/mQYQ/vWQ3s7eUlFEL4PyKfAJxsnYTJ4YT73nsJBWqmEpFryxV9OeUiqmsTsYJ5Y+KDNaeP31wrRw==", "dev": true, "requires": { "prettier-linter-helpers": "^1.0.0", @@ -11638,9 +11795,9 @@ } }, "express": { - "version": "4.21.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", - "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "requires": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -11661,7 +11818,7 @@ "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.10", + "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", @@ -11882,12 +12039,14 @@ } }, "form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "requires": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, @@ -11956,21 +12115,26 @@ "dev": true }, "get-east-asian-width": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.2.0.tgz", - "integrity": "sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", + "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", "dev": true }, "get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "requires": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" } }, "get-package-type": { @@ -11978,6 +12142,15 @@ "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==" }, + "get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "requires": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + } + }, "get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -12055,12 +12228,9 @@ } }, "gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "requires": { - "get-intrinsic": "^1.1.3" - } + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" }, "graceful-fs": { "version": "4.2.11", @@ -12097,18 +12267,18 @@ "has-proto": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", - "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==" + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "dev": true }, "has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" }, "has-tostringtag": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "requires": { "has-symbols": "^1.0.3" } @@ -13032,9 +13202,9 @@ } }, "lilconfig": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.2.tgz", - "integrity": "sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", "dev": true }, "lines-and-columns": { @@ -13044,36 +13214,36 @@ "dev": true }, "lint-staged": { - "version": "15.2.10", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.2.10.tgz", - "integrity": "sha512-5dY5t743e1byO19P9I4b3x8HJwalIznL5E1FWYnU6OWw33KxNBSLAc6Cy7F2PsFEO8FKnLwjwm5hx7aMF0jzZg==", + "version": "15.4.3", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.4.3.tgz", + "integrity": "sha512-FoH1vOeouNh1pw+90S+cnuoFwRfUD9ijY2GKy5h7HS3OR7JVir2N2xrsa0+Twc1B7cW72L+88geG5cW4wIhn7g==", "dev": true, "requires": { - "chalk": "~5.3.0", - "commander": "~12.1.0", - "debug": "~4.3.6", - "execa": "~8.0.1", - "lilconfig": "~3.1.2", - "listr2": "~8.2.4", - "micromatch": "~4.0.8", - "pidtree": "~0.6.0", - "string-argv": "~0.3.2", - "yaml": "~2.5.0" + "chalk": "^5.4.1", + "commander": "^13.1.0", + "debug": "^4.4.0", + "execa": "^8.0.1", + "lilconfig": "^3.1.3", + "listr2": "^8.2.5", + "micromatch": "^4.0.8", + "pidtree": "^0.6.0", + "string-argv": "^0.3.2", + "yaml": "^2.7.0" }, "dependencies": { "chalk": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", - "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", "dev": true }, "debug": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", - "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", "dev": true, "requires": { - "ms": "2.1.2" + "ms": "^2.1.3" } }, "execa": { @@ -13117,6 +13287,12 @@ "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", "dev": true }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, "npm-run-path": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.1.0.tgz", @@ -13156,9 +13332,9 @@ } }, "listr2": { - "version": "8.2.4", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.2.4.tgz", - "integrity": "sha512-opevsywziHd3zHCVQGAj8zu+Z3yHNkkoYhWIGnq54RrCVwLz0MozotJEDnKsIBLvkfLGN6BLOyAeRrYI0pKA4g==", + "version": "8.2.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.2.5.tgz", + "integrity": "sha512-iyAZCeyD+c1gPyE9qpFu8af0Y+MRtmKOncdGoA2S5EY8iFq99dmmvkNnHiWo+pj0s7yH7l3KPIgee77tKpXPWQ==", "dev": true, "requires": { "cli-truncate": "^4.0.0", @@ -13170,9 +13346,9 @@ }, "dependencies": { "ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", "dev": true }, "ansi-styles": { @@ -13182,9 +13358,9 @@ "dev": true }, "emoji-regex": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.3.0.tgz", - "integrity": "sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", "dev": true }, "string-width": { @@ -13269,9 +13445,9 @@ } }, "ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", "dev": true }, "ansi-styles": { @@ -13281,9 +13457,9 @@ "dev": true }, "emoji-regex": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.3.0.tgz", - "integrity": "sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", "dev": true }, "is-fullwidth-code-point": { @@ -13375,6 +13551,11 @@ "tmpl": "1.0.5" } }, + "math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" + }, "matrix-events-sdk": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz", @@ -13719,9 +13900,9 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "path-to-regexp": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", - "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" }, "path-type": { "version": "4.0.0", @@ -13730,14 +13911,14 @@ "dev": true }, "pg": { - "version": "8.13.1", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.1.tgz", - "integrity": "sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ==", + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.14.1.tgz", + "integrity": "sha512-0TdbqfjwIun9Fm/r89oB7RFQ0bLgduAhiIqIXOsyKoiC/L54DbuAAzIEN/9Op0f1Po9X7iCPXGoa/Ah+2aI8Xw==", "requires": { "pg-cloudflare": "^1.1.1", "pg-connection-string": "^2.7.0", - "pg-pool": "^3.7.0", - "pg-protocol": "^1.7.0", + "pg-pool": "^3.8.0", + "pg-protocol": "^1.8.0", "pg-types": "^2.1.0", "pgpass": "1.x" }, @@ -13807,15 +13988,15 @@ "dev": true }, "pg-pool": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.7.0.tgz", - "integrity": "sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.8.0.tgz", + "integrity": "sha512-VBw3jiVm6ZOdLBTIcXLNdSotb6Iy3uOCwDGFAksZCXmi10nyRvnP2v3jl4d+IsLYRyXf6o9hIm/ZtUzlByNUdw==", "requires": {} }, "pg-protocol": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.7.0.tgz", - "integrity": "sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==" + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.8.0.tgz", + "integrity": "sha512-jvuYlEkL03NRvOoyoRktBK7+qU5kOvlAwvmrH8sr3wbLrOdVWsRxQfz8mMy9sZFsqJ1hEWNfdWKI4SAmoL+j7g==" }, "pg-types": { "version": "4.0.2", @@ -14109,9 +14290,9 @@ "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" }, "regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, "regexp.prototype.flags": { "version": "1.5.3", @@ -14284,9 +14465,9 @@ "integrity": "sha512-RjZyX3nVwJyCuTo5tGPx+PZWkDMCg7oOLpSlhjDdZfwUoNqG1mM8nyj31IGHyaPWXhjbP7cdK3qZ2bmkJ1GzRw==" }, "semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", "dev": true }, "send": { @@ -14682,9 +14863,9 @@ "requires": {} }, "ts-jest": { - "version": "29.2.5", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.2.5.tgz", - "integrity": "sha512-KD8zB2aAZrcKIdGk4OwpJggeLcH1FgrICqDSROWqlnJXGCXK4Mn6FcdK2B6670Xr73lHMG1kHw8R87A0ecZ+vA==", + "version": "29.3.0", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.3.0.tgz", + "integrity": "sha512-4bfGBX7Gd1Aqz3SyeDS9O276wEU/BInZxskPrbhZLyv+c1wskDCqDFMJQJLWrIr/fKoAH4GE5dKUlrdyvo+39A==", "dev": true, "requires": { "bs-logger": "^0.2.6", @@ -14694,8 +14875,17 @@ "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.6.3", + "semver": "^7.7.1", + "type-fest": "^4.37.0", "yargs-parser": "^21.1.1" + }, + "dependencies": { + "type-fest": { + "version": "4.38.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.38.0.tgz", + "integrity": "sha512-2dBz5D5ycHIoliLYLi0Q2V7KRaDlH0uWIvmk7TYlAg5slqwiPv1ezJdZm1QEM0xgk29oYWMCbIG7E6gHpvChlg==", + "dev": true + } } }, "ts-node": { @@ -14900,9 +15090,9 @@ } }, "typescript": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", - "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==" + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==" }, "unbox-primitive": { "version": "1.0.2", @@ -14917,9 +15107,9 @@ } }, "undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==" }, "unhomoglyph": { "version": "1.0.6", @@ -15086,9 +15276,9 @@ "dev": true }, "yaml": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.0.tgz", - "integrity": "sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", + "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", "dev": true }, "yargs": { diff --git a/package.json b/package.json index f75a0a24..f1291f4b 100644 --- a/package.json +++ b/package.json @@ -18,43 +18,43 @@ "service" ], "engines": { - "npm": ">=9.0.0", - "node": ">=18.0.0" + "npm": ">=10.0.0", + "node": ">=22.0.0" }, "author": "SIMS", "license": "MIT", "devDependencies": { "@types/express": "^4.17.21", - "@types/jest": "^29.5.13", + "@types/jest": "^29.5.14", "@types/knex": "^0.16.1", - "@types/node": "^22.9.0", - "@types/pg": "^8.11.10", + "@types/node": "^22.13.9", + "@types/pg": "^8.11.11", "@typescript-eslint/eslint-plugin": "^7.16.1", "@typescript-eslint/parser": "^7.18.0", "eslint": "^8.57.1", "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "^2.31.0", - "eslint-plugin-jest": "^28.8.3", - "eslint-plugin-prettier": "^5.2.1", + "eslint-plugin-jest": "^28.11.0", + "eslint-plugin-prettier": "^5.2.3", "eslint-plugin-unused-imports": "^3.2.0", "husky": "^9.1.6", "jest": "^29.7.0", - "lint-staged": "^15.2.10", + "lint-staged": "^15.4.3", "prettier": "3.3.3", - "ts-jest": "^29.2.5", - "typescript": "^5.6.3" + "ts-jest": "^29.3.0", + "typescript": "^5.7.2" }, "dependencies": { "@user-office-software/duo-logger": "^2.2.1", "@user-office-software/duo-message-broker": "^1.7.0", - "axios": "^1.7.7", + "axios": "^1.12.0", "dotenv": "^16.4.5", "envalid": "^8.0.0", - "express": "^4.21.1", + "express": "^4.21.2", "kafkajs": "^2.2.3", "knex": "^3.1.0", "matrix-js-sdk": "24.1.0", - "pg": "^8.13.1", + "pg": "^8.14.1", "prom-client": "^15.1.3", "reflect-metadata": "^0.2.2", "ts-node-dev": "^2.0.0", diff --git a/src/index.ts b/src/index.ts index f3f5635d..23409f92 100644 --- a/src/index.ts +++ b/src/index.ts @@ -49,7 +49,6 @@ async function bootstrap() { const enableOneIdentityIntegration = str2Bool( process.env.ENABLE_ONE_IDENTITY_INTEGRATION as string ); - const enableSyncVisaProposals = str2Bool( process.env.ENABLE_SYNC_VISA_PROPOSALS as string ); diff --git a/src/models/Event.ts b/src/models/Event.ts index 5661f87d..2ed1cff6 100644 --- a/src/models/Event.ts +++ b/src/models/Event.ts @@ -1,7 +1,7 @@ export enum Event { - PROPOSAL_STATUS_CHANGED_BY_WORKFLOW = 'PROPOSAL_STATUS_CHANGED_BY_WORKFLOW', - PROPOSAL_STATUS_CHANGED_BY_USER = 'PROPOSAL_STATUS_CHANGED_BY_USER', PROPOSAL_STATUS_ACTION_EXECUTED = 'PROPOSAL_STATUS_ACTION_EXECUTED', PROPOSAL_ACCEPTED = 'PROPOSAL_ACCEPTED', PROPOSAL_UPDATED = 'PROPOSAL_UPDATED', + VISIT_CREATED = 'VISIT_CREATED', + VISIT_DELETED = 'VISIT_DELETED', } diff --git a/src/models/ProposalMessage.ts b/src/models/ProposalMessage.ts index b61dfb02..a075c42a 100644 --- a/src/models/ProposalMessage.ts +++ b/src/models/ProposalMessage.ts @@ -29,6 +29,8 @@ export type ProposalMessageData = { newStatus?: ProposalStatusDefaultShortCodes; submitted: boolean; members: ProposalUser[]; + dataAccessUsers: ProposalUser[]; + visitors: ProposalUser[]; proposer?: ProposalUser; instruments?: Instrument[]; }; diff --git a/src/queue/consumers/nicos/NicosTopicConsumer.spec.ts b/src/queue/consumers/nicos/NicosTopicConsumer.spec.ts new file mode 100644 index 00000000..f67ad5f4 --- /dev/null +++ b/src/queue/consumers/nicos/NicosTopicConsumer.spec.ts @@ -0,0 +1,127 @@ +jest.mock('tsyringe', () => ({ + container: { + resolve: jest.fn(), + }, +})); +jest.mock('./utils/validateNicosMessage'); +jest.mock('@user-office-software/duo-logger', () => ({ + logger: { + logError: jest.fn(), + }, +})); + +import { logger } from '@user-office-software/duo-logger'; +import { container } from 'tsyringe'; + +import { TopicSciChatConsumer } from './NicosTopicConsumer'; +import { validateNicosMessage } from './utils/validateNicosMessage'; +import { Tokens } from '../../../config/Tokens'; + +const mockLogin = jest.fn(); +const mockSendMessage = jest.fn(); + +const mockSynapseService = { + login: mockLogin, + sendMessage: mockSendMessage, +}; + +const mockConsume = jest.fn(); + +const mockConsumerService = { + consume: mockConsume, +}; + +describe('TopicSciChatConsumer', () => { + const topic = 'test-topic'; + const kafkaClientId = 'test-client-id'; + const validMessageData = { + proposal: 'p1', + instrument: 'testInstrument', + source: 'test-source', + message: 'hello', + }; + + beforeEach(() => { + jest.clearAllMocks(); + (container.resolve as jest.Mock).mockReturnValue(mockSynapseService); + (validateNicosMessage as jest.Mock).mockReturnValue(validMessageData); + process.env.KAFKA_CLIENTID = kafkaClientId; + }); + + it('should login and consume messages', async () => { + const consumer = new TopicSciChatConsumer(mockConsumerService as any); + + await consumer.start(topic); + + expect(container.resolve).toHaveBeenCalledWith(Tokens.SynapseService); + expect(mockLogin).toHaveBeenCalledWith('TopicSciChatConsumer'); + expect(mockConsume).toHaveBeenCalledWith( + kafkaClientId, + { topics: [topic] }, + expect.objectContaining({ + eachMessage: expect.any(Function), + }) + ); + }); + + it('should process a valid message and call sendMessage', async () => { + const consumer = new TopicSciChatConsumer(mockConsumerService as any); + await consumer.start(topic); + + // Simulate eachMessage handler + const { eachMessage } = mockConsume.mock.calls[0][2]; + const fakeKafkaMessage = { + value: Buffer.from(JSON.stringify(validMessageData)), + offset: '123', + }; + + await eachMessage({ message: fakeKafkaMessage }); + + expect(validateNicosMessage).toHaveBeenCalledWith(validMessageData); + expect(mockSendMessage).toHaveBeenCalledWith('p1', 'hello'); + expect(logger.logError).not.toHaveBeenCalled(); + }); + + it('should log error and not throw if message processing fails', async () => { + const consumer = new TopicSciChatConsumer(mockConsumerService as any); + await consumer.start(topic); + + // Simulate eachMessage handler with invalid JSON + const { eachMessage } = mockConsume.mock.calls[0][2]; + const fakeKafkaMessage = { + value: Buffer.from('not-json'), + offset: '456', + }; + + await eachMessage({ message: fakeKafkaMessage }); + + expect(logger.logError).toHaveBeenCalledWith('Failed processing message', { + messageOffset: '456', + reason: expect.any(String), + }); + expect(mockSendMessage).not.toHaveBeenCalled(); + }); + + it('should log error if validateNicosMessage throws', async () => { + (validateNicosMessage as jest.Mock).mockImplementation(() => { + throw new Error('validation failed'); + }); + + const consumer = new TopicSciChatConsumer(mockConsumerService as any); + await consumer.start(topic); + + const { eachMessage } = mockConsume.mock.calls[0][2]; + const fakeKafkaMessage = { + value: Buffer.from(JSON.stringify(validMessageData)), + offset: '789', + }; + + await eachMessage({ message: fakeKafkaMessage }); + + expect(logger.logError).toHaveBeenCalledWith('Failed processing message', { + messageOffset: '789', + reason: 'validation failed', + }); + expect(mockSendMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/src/queue/consumers/nicos/NicosTopicConsumer.ts b/src/queue/consumers/nicos/NicosTopicConsumer.ts index 41b284f9..d06bffc5 100644 --- a/src/queue/consumers/nicos/NicosTopicConsumer.ts +++ b/src/queue/consumers/nicos/NicosTopicConsumer.ts @@ -1,12 +1,19 @@ import { logger } from '@user-office-software/duo-logger'; +import { container } from 'tsyringe'; import ConsumerService from '../KafkaConsumer'; -import { postNicosMessage } from './consumerCallbacks/postNicosMessage'; import { validateNicosMessage } from './utils/validateNicosMessage'; +import { Tokens } from '../../../config/Tokens'; +import { SynapseService } from '../../../services/synapse/SynapseService'; export class TopicSciChatConsumer { constructor(private _consumer: ConsumerService) {} async start(topic: string) { + const synapseService: SynapseService = container.resolve( + Tokens.SynapseService + ); + await synapseService.login('TopicSciChatConsumer'); + this._consumer.consume( `${process.env.KAFKA_CLIENTID}`, { topics: [topic] }, @@ -16,10 +23,10 @@ export class TopicSciChatConsumer { const messageData = JSON.parse(message.value?.toString() as string); const validMessageData = validateNicosMessage(messageData); - await postNicosMessage({ - roomName: validMessageData.proposal, - message: validMessageData.message, - }); + await synapseService.sendMessage( + validMessageData.proposal, + validMessageData.message + ); } catch (error) { logger.logError('Failed processing message', { // Note: offset is similar to the index of the message diff --git a/src/queue/consumers/nicos/consumerCallbacks/postNicosMessage.ts b/src/queue/consumers/nicos/consumerCallbacks/postNicosMessage.ts deleted file mode 100644 index ef9c5a07..00000000 --- a/src/queue/consumers/nicos/consumerCallbacks/postNicosMessage.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { container } from 'tsyringe'; - -import { Tokens } from '../../../../config/Tokens'; -import { SynapseService } from '../../../../services/synapse/SynapseService'; - -const postNicosMessage = async ({ - roomName, - message, -}: { - roomName: string; - message: string; -}) => { - const synapseService: SynapseService = container.resolve( - Tokens.SynapseService - ); - await synapseService.sendMessage(roomName, message); -}; - -export { postNicosMessage }; diff --git a/src/queue/consumers/nicos/utils/validateNicosMessage.spec.ts b/src/queue/consumers/nicos/utils/validateNicosMessage.spec.ts new file mode 100644 index 00000000..ef273ea1 --- /dev/null +++ b/src/queue/consumers/nicos/utils/validateNicosMessage.spec.ts @@ -0,0 +1,63 @@ +import { validateNicosMessage } from './validateNicosMessage'; +import { NicosMessageData } from '../../../../models/KafkaTypes'; +// Mock NicosMessageData type since it's imported from another file + +describe('validateNicosMessage', () => { + const validMessage: NicosMessageData = { + proposal: 'proposal1', + instrument: 'instrument1', + source: 'source1', + message: 'message1', + }; + + it('should return the message as ValidNicosMessage when all fields are valid', () => { + const result = validateNicosMessage(validMessage); + expect(result).toEqual(validMessage); + }); + + it('should throw error if proposal is missing', () => { + const msg = { ...validMessage, proposal: undefined }; + expect(() => validateNicosMessage(msg as any)).toThrow( + 'Proposal format is wrong' + ); + }); + + it('should throw error if proposal is not a string', () => { + const msg = { ...validMessage, proposal: 123 as any }; + expect(() => validateNicosMessage(msg)).toThrow('Proposal format is wrong'); + }); + + it('should throw error if instrument is missing', () => { + const msg = { ...validMessage, instrument: undefined as any }; + expect(() => validateNicosMessage(msg)).toThrow( + 'Instrument format is wrong' + ); + }); + + it('should throw error if instrument is not a string', () => { + const msg = { ...validMessage, instrument: 123 as any }; + expect(() => validateNicosMessage(msg)).toThrow( + 'Instrument format is wrong' + ); + }); + + it('should throw error if source is missing', () => { + const msg = { ...validMessage, source: undefined as any }; + expect(() => validateNicosMessage(msg)).toThrow('Source format is wrong'); + }); + + it('should throw error if source is not a string', () => { + const msg = { ...validMessage, source: 123 as any }; + expect(() => validateNicosMessage(msg)).toThrow('Source format is wrong'); + }); + + it('should throw error if message is missing', () => { + const msg = { ...validMessage, message: undefined as any }; + expect(() => validateNicosMessage(msg)).toThrow('message format is wrong'); + }); + + it('should throw error if message is not a string', () => { + const msg = { ...validMessage, message: 123 as any }; + expect(() => validateNicosMessage(msg)).toThrow('message format is wrong'); + }); +}); diff --git a/src/queue/consumers/oneidentity/OneIdentityIntegrationQueueConsumer.spec.ts b/src/queue/consumers/oneidentity/OneIdentityIntegrationQueueConsumer.spec.ts index 7ef8c4ef..3bdb9416 100644 --- a/src/queue/consumers/oneidentity/OneIdentityIntegrationQueueConsumer.spec.ts +++ b/src/queue/consumers/oneidentity/OneIdentityIntegrationQueueConsumer.spec.ts @@ -4,14 +4,23 @@ jest.mock('../QueueConsumer', () => ({ start: jest.fn(), })), })); -jest.mock('./consumerCallbacks/oneIdentityIntegrationHandler'); +jest.mock('./consumerCallbacks/syncProposalAndMembersToOneIdentityHandler'); +jest.mock('./consumerCallbacks/syncVisitToOneIdentityHandler'); +jest.mock('./utils/isVisitMessage'); +jest.mock('axios', () => ({ + isAxiosError: jest.fn(), +})); import { logger } from '@user-office-software/duo-logger'; import { MessageBroker } from '@user-office-software/duo-message-broker'; import { MessageProperties } from 'amqplib'; +import { isAxiosError } from 'axios'; -import { oneIdentityIntegrationHandler } from './consumerCallbacks/oneIdentityIntegrationHandler'; +import { syncProposalAndMembersToOneIdentityHandler } from './consumerCallbacks/syncProposalAndMembersToOneIdentityHandler'; +import { syncVisitToOneIdentityHandler } from './consumerCallbacks/syncVisitToOneIdentityHandler'; import { OneIdentityIntegrationQueueConsumer } from './OneIdentityIntegrationQueueConsumer'; +import { VisitMessage } from './utils/interfaces/VisitMessage'; +import { isVisitMessage } from './utils/isVisitMessage'; import { Event } from '../../../models/Event'; import { ProposalMessageData } from '../../../models/ProposalMessage'; @@ -47,54 +56,215 @@ describe('OneIdentityIntegrationQueueConsumer', () => { ).rejects.toThrow('Invalid proposal message'); }); - it('should call oneIdentityIntegrationHandler and log message handled', async () => { - const message = createProposalMessage({ - shortCode: 'shortCode', - proposerEmail: 'proposer-email', - memberEmails: [], - }); - const type = Event.PROPOSAL_ACCEPTED; + describe('syncProposalAndMembersToOneIdentityHandler', () => { + it('should call syncProposalAndMembersToOneIdentityHandler and log message handled', async () => { + const message = createProposalMessage({ + shortCode: 'shortCode', + proposerEmail: 'proposer-email', + memberEmails: [], + }); + const type = Event.PROPOSAL_ACCEPTED; - await consumer.onMessage(type, message, {} as MessageProperties); + await consumer.onMessage(type, message, {} as MessageProperties); - expect(logger.logInfo).toHaveBeenNthCalledWith( - 1, - 'OneIdentityIntegrationQueueConsumer', - { - type, + expect(syncProposalAndMembersToOneIdentityHandler).toHaveBeenCalledWith( message, - } - ); - expect(logger.logInfo).toHaveBeenNthCalledWith(2, 'Message handled', { - type, - message, + type + ); + expect(logger.logInfo).toHaveBeenNthCalledWith( + 1, + 'OneIdentityIntegrationQueueConsumer', + { + type, + message, + } + ); + expect(logger.logInfo).toHaveBeenNthCalledWith( + 2, + 'Message handled by OneIdentityIntegrationQueueConsumer', + { + type, + message, + } + ); + expect(logger.logException).not.toHaveBeenCalled(); }); - expect(logger.logException).not.toHaveBeenCalled(); - }); - it('should log exception and re-throw error if oneIdentityIntegrationHandler throws', async () => { - const message = createProposalMessage({ - shortCode: 'shortCode', - proposerEmail: 'proposer-email', - memberEmails: [], + it('should log exception and re-throw error if syncProposalAndMembersToOneIdentityHandler throws', async () => { + const message = createProposalMessage({ + shortCode: 'shortCode', + proposerEmail: 'proposer-email', + memberEmails: [], + }); + const type = Event.PROPOSAL_ACCEPTED; + const error = new Error('Error'); + + ( + syncProposalAndMembersToOneIdentityHandler as jest.Mock + ).mockRejectedValueOnce(error); + + await expect( + consumer.onMessage(type, message, {} as MessageProperties) + ).rejects.toThrow(error); + + expect(logger.logException).toHaveBeenCalledWith( + 'Error while handling message in OneIdentityIntegrationQueueConsumer', + error, + { + type, + message, + } + ); }); - const type = Event.PROPOSAL_ACCEPTED; - const error = new Error('Error'); - (oneIdentityIntegrationHandler as jest.Mock).mockRejectedValueOnce(error); + it('should include Axios error response data in logs when available', async () => { + const message = createProposalMessage({ + shortCode: 'shortCode', + proposerEmail: 'proposer-email', + memberEmails: [], + }); + const type = Event.PROPOSAL_ACCEPTED; - await expect( - consumer.onMessage(type, message, {} as MessageProperties) - ).rejects.toThrow(error); + const axiosError = new Error('Axios Error'); + const mockResponse = { + status: 400, + headers: { 'content-type': 'application/json' }, + data: { message: 'Bad Request' }, + }; + + Object.assign(axiosError, { + isAxiosError: true, + response: mockResponse, + }); + + (isAxiosError as unknown as jest.Mock).mockReturnValueOnce(true); + ( + syncProposalAndMembersToOneIdentityHandler as jest.Mock + ).mockRejectedValueOnce(axiosError); + + await expect( + consumer.onMessage(type, message, {} as MessageProperties) + ).rejects.toThrow(axiosError); + + expect(logger.logException).toHaveBeenCalledWith( + 'Error while handling message in OneIdentityIntegrationQueueConsumer', + axiosError, + { + type, + message, + response: { + status: mockResponse.status, + headers: mockResponse.headers, + data: mockResponse.data, + }, + } + ); + }); + }); + + describe('syncVisitToOneIdentityHandler', () => { + it('should call syncVisitToOneIdentityHandler and log message handled', async () => { + const message = { + visitorId: 'visitor-id', + startAt: '2021-01-01T00:00:00Z', + endAt: '2021-01-02T00:00:00Z', + proposal: { + shortCode: 'proposal-short-code', + }, + } as VisitMessage; + const type = Event.VISIT_CREATED; + + (isVisitMessage as unknown as jest.Mock).mockReturnValue(true); - expect(logger.logException).toHaveBeenCalledWith( - 'Error while handling proposal', - error, - { - type, + await consumer.onMessage(type, message as any, {} as MessageProperties); + + expect(syncVisitToOneIdentityHandler).toHaveBeenCalledWith( message, - } - ); + type + ); + expect(logger.logInfo).toHaveBeenNthCalledWith( + 1, + 'OneIdentityIntegrationQueueConsumer', + { + type, + message, + } + ); + expect(logger.logInfo).toHaveBeenNthCalledWith( + 2, + 'Message handled by OneIdentityIntegrationQueueConsumer', + { + type, + message, + } + ); + expect(logger.logException).not.toHaveBeenCalled(); + }); + + it('should log exception with Axios error details when syncVisitToOneIdentityHandler throws an Axios error', async () => { + const message = { + visitorId: 'visitor-id', + startAt: '2021-01-01T00:00:00Z', + endAt: '2021-01-02T00:00:00Z', + proposal: { + shortCode: 'proposal-short-code', + }, + } as VisitMessage; + const type = Event.VISIT_CREATED; + + const axiosError = new Error('Axios Error'); + const mockResponse = { + status: 500, + headers: { 'content-type': 'application/json' }, + data: { message: 'Internal Server Error' }, + }; + + Object.assign(axiosError, { + isAxiosError: true, + response: mockResponse, + }); + + (isVisitMessage as unknown as jest.Mock).mockReturnValue(true); + + (isAxiosError as unknown as jest.Mock).mockReturnValueOnce(true); + (syncVisitToOneIdentityHandler as jest.Mock).mockRejectedValueOnce( + axiosError + ); + + await expect( + consumer.onMessage(type, message as any, {} as MessageProperties) + ).rejects.toThrow(axiosError); + + expect(logger.logException).toHaveBeenCalledWith( + 'Error while handling message in OneIdentityIntegrationQueueConsumer', + axiosError, + { + type, + message, + response: { + status: mockResponse.status, + headers: mockResponse.headers, + data: mockResponse.data, + }, + } + ); + }); + + it('should throw an error if the visit message is invalid', async () => { + const message = { some: 'invalid message' }; + const type = Event.VISIT_CREATED; + + (isVisitMessage as unknown as jest.Mock).mockReturnValue(false); + + await expect( + consumer.onMessage(type, message as any, {} as MessageProperties) + ).rejects.toThrow( + `Invalid Visit message received: ${JSON.stringify(message)}` + ); + + expect(syncVisitToOneIdentityHandler).not.toHaveBeenCalled(); + expect(logger.logException).toHaveBeenCalled(); + }); }); }); diff --git a/src/queue/consumers/oneidentity/OneIdentityIntegrationQueueConsumer.ts b/src/queue/consumers/oneidentity/OneIdentityIntegrationQueueConsumer.ts index 680e31c7..7f03eb86 100644 --- a/src/queue/consumers/oneidentity/OneIdentityIntegrationQueueConsumer.ts +++ b/src/queue/consumers/oneidentity/OneIdentityIntegrationQueueConsumer.ts @@ -2,17 +2,30 @@ import { logger } from '@user-office-software/duo-logger'; import { ConsumerCallback } from '@user-office-software/duo-message-broker'; import { isAxiosError } from 'axios'; -import { oneIdentityIntegrationHandler } from './consumerCallbacks/oneIdentityIntegrationHandler'; -import { validateRequiredProposalMessageFields } from './utils/validateRequiredProposalMessageFields'; +import { syncProposalAndMembersToOneIdentityHandler } from './consumerCallbacks/syncProposalAndMembersToOneIdentityHandler'; +import { syncVisitToOneIdentityHandler } from './consumerCallbacks/syncVisitToOneIdentityHandler'; +import { validateProposalMessage } from './utils/validateProposalMessage'; import { Event } from '../../../models/Event'; import { QueueConsumer } from '../QueueConsumer'; import { hasTriggeringType } from '../utils/hasTriggeringType'; +import { isVisitMessage } from './utils/isVisitMessage'; const ONE_IDENTITY_INTEGRATION_QUEUE_NAME = process.env.ONE_IDENTITY_INTEGRATION_QUEUE_NAME || ''; const USER_OFFICE_CORE_EXCHANGE_NAME = process.env.USER_OFFICE_CORE_EXCHANGE_NAME || ''; -const EVENTS_FOR_HANDLING = [Event.PROPOSAL_ACCEPTED, Event.PROPOSAL_UPDATED]; + +// Events that trigger the handling of syncing proposals and members to One Identity +const SYNC_PROPOSAL_AND_MEMBERS_EVENTS_FOR_HANDLING = [ + Event.PROPOSAL_ACCEPTED, + Event.PROPOSAL_UPDATED, +]; + +// Events that trigger the handling of syncing visits to One Identity +const SYNC_VISIT_EVENTS_FOR_HANDLING = [ + Event.VISIT_CREATED, + Event.VISIT_DELETED, +]; // Class for consuming messages from the ESS One Identity Integration Queue export class OneIdentityIntegrationQueueConsumer extends QueueConsumer { @@ -25,9 +38,17 @@ export class OneIdentityIntegrationQueueConsumer extends QueueConsumer { } onMessage: ConsumerCallback = async (type, message) => { - if (!hasTriggeringType(type, EVENTS_FOR_HANDLING)) { - return; - } + const eventType = type as Event; + const isProposalEvent = hasTriggeringType( + eventType, + SYNC_PROPOSAL_AND_MEMBERS_EVENTS_FOR_HANDLING + ); + const isVisitEvent = hasTriggeringType( + eventType, + SYNC_VISIT_EVENTS_FOR_HANDLING + ); + + if (!isProposalEvent && !isVisitEvent) return; logger.logInfo('OneIdentityIntegrationQueueConsumer', { type, @@ -35,22 +56,37 @@ export class OneIdentityIntegrationQueueConsumer extends QueueConsumer { }); try { - const proposalMessage = validateRequiredProposalMessageFields(message); + if (isProposalEvent) { + const proposalMessage = validateProposalMessage(message); + await syncProposalAndMembersToOneIdentityHandler( + proposalMessage, + eventType + ); + } else if (isVisitEvent) { + if (isVisitMessage(message)) + await syncVisitToOneIdentityHandler(message, eventType); + else + throw new Error( + `Invalid Visit message received: ${JSON.stringify(message)}` + ); + } - await oneIdentityIntegrationHandler(proposalMessage, type as Event); - - logger.logInfo('Message handled', { + logger.logInfo('Message handled by OneIdentityIntegrationQueueConsumer', { type, message, }); } catch (error) { const response = extractAxiosErrorResponse(error); - logger.logException('Error while handling proposal', error, { - type, - message, - response, - }); + logger.logException( + 'Error while handling message in OneIdentityIntegrationQueueConsumer', + error, + { + type, + message, + response, + } + ); // Re-throw the error to make sure the message is not acknowledged throw error; diff --git a/src/queue/consumers/oneidentity/README.md b/src/queue/consumers/oneidentity/README.md new file mode 100644 index 00000000..3345b5fa --- /dev/null +++ b/src/queue/consumers/oneidentity/README.md @@ -0,0 +1,229 @@ +# One Identity Visit Access Management + +## Functional Specification + +### Purpose +The handler manages site and system access in One Identity based on visit creation and deletion events for science users. + +### Process Overview +- When a visit is created, both site access and system access are provisioned in One Identity +- When a visit is deleted, both site access and system access are cancelled in One Identity +- Only science users (users with `CCC_EmployeeSubType === ESSSCIENCEUSER`) are processed + +### Access Types +- **Site Access**: Physical access to facility for the exact visit duration +- **System Access**: Digital access to systems, extends beyond the visit end date by a configurable number of days (default: 30) + +### Key Relationships +- System access is linked to site access via `CustomProperty04` which stores the site access UID +- Both access types are identified by specific roles in `PersonWantsOrgRole` enum +- Proposal's short code is stored in `CustomProperty04` of the system access record + +## Process Flow Chart + +``` +┌─────────────────────────┐ +│ Visit Event Received │ +└──────────┬──────────────┘ + ↓ +┌─────────────────────────┐ +│ Login to One Identity │ +└──────────┬──────────────┘ + ↓ +┌─────────────────────────┐ +│ Get Person from ID │ +└──────────┬──────────────┘ + ↓ +┌─────────────────────────┐ +< Is Science User? >──No──┐ +└──────────┬──────────────┘ │ + Yes │ + ↓ │ +┌─────────────────────────┐ │ +< Event Type? > │ +└──────────┬──────────────┘ │ + │ │ + ┌────────┴────────┐ │ + ↓ ↓ │ +┌─────────────┐ ┌───────────────┐│ +│VISIT_CREATED│ │VISIT_DELETED ││ +└──────┬──────┘ └───────┬───────┘│ + │ │ │ + ↓ ↓ │ +┌─────────────┐ ┌───────────────┐│ +│Create Site │ │Find Site ││ +│Access │ │Access ││ +└──────┬──────┘ └───────┬───────┘│ + │ │ │ + ↓ ↓ │ +┌─────────────┐ ┌───────────────┐│ +│Create System│ │Cancel Site ││ +│Access │ │Access ││ +└──────┬──────┘ └───────┬───────┘│ + │ │ │ + │ ↓ │ + │ ┌───────────────┐ │ + │ │Find System │ │ + │ │Access via │ │ + │ │CustomProperty4│ │ + │ └───────┬───────┘ │ + │ │ │ + │ ↓ │ + │ ┌───────────────┐ │ + │ │Cancel System │ │ + │ │Access │ │ + │ └───────┬───────┘ │ + │ │ │ + │ │ │ + └───────┐ │ │ + │ │ │ + │ │ │ + ↓ ↓ │ +┌─────────────────────────┐ │ +│ Logout from One Identity│◄─────┘ +└─────────────────────────┘ +``` + +## Key Implementation Details + +### System Access Duration +- Extends beyond visit by `ONE_IDENTITY_SYSTEM_ACCESS_LASTS_FOR_DAYS` (default: 30 days) + +### Access Creation +- Site access matches exact visit dates +- System access starts from the visit start date and extends beyond the visit end date by a configurable number of days (default: 30) +- System access links to site access via `CustomProperty04` + +### Access Cancellation +- System access cancellation depends on finding the parent site access first +- Both must be cancelled + +### Error Handling +- Proper error messages when person or access records are not found +- Always performs logout in finally block to ensure clean session management + +--- + +## One Identity Proposal and Member Sync + +### Purpose +The handler synchronizes proposal information and its members (proposer and co-proposers) with One Identity. This ensures that proposals and their associated personnel are accurately represented and connected in One Identity. + +### Process Overview +- Triggered by `PROPOSAL_ACCEPTED` and `PROPOSAL_UPDATED` events. +- **Login**: Establishes a session with One Identity. +- **Proposal Retrieval/Creation**: + - For both event types, it first attempts to retrieve the proposal (`ESet`) from One Identity using the proposal data. + - If `PROPOSAL_ACCEPTED` event: + - If the proposal does not exist, it creates the proposal in One Identity. + - If creation fails, an error is thrown. + - If `PROPOSAL_UPDATED` event: + - If the proposal does not exist, the process logs this information and concludes, as there's no existing record to update. +- **User Synchronization**: + - Collects all unique user OIDC sub identifiers from the proposal message (proposer and members). + - Retrieves the corresponding `UID_Person` for these users from One Identity. + - Logs an error if any users from the proposal message are not found in One Identity. +- **Connection Management**: + - Fetches all existing `PersonHasESET` connections for the identified proposal (`UID_ESet`). + - **Remove Old Connections**: + - Identifies connections in One Identity for persons who are no longer part of the current proposal members list. + - Before removing a connection, it checks if the person has "site access" to the proposal (e.g., as a visitor). + - If the person has site access, their connection to the proposal is *not* removed. + - Otherwise, the outdated connection is removed. + - **Add New Connections**: + - Identifies persons in the current proposal members list who are not yet connected to the proposal in One Identity. + - Creates new `PersonHasESET` connections for these persons. +- **Logout**: Ensures logout from One Identity in a `finally` block, regardless of success or failure. + +### Key Relationships +- **Proposals**: Mapped to `ESet` objects in One Identity. +- **Users**: (Proposers, members) are `Person` objects in One Identity, identified via their OIDC sub. +- **Connections**: The link between a `Person` and an `ESet` is represented by a `PersonHasESET` record. + +### Process Flow Chart + +``` +┌─────────────────────────┐ +│ Proposal Event Received │ +│ (ACCEPTED/UPDATED) │ +└──────────┬──────────────┘ + ↓ +┌─────────────────────────┐ +│ Login to One Identity │ +└──────────┬──────────────┘ + ↓ +┌─────────────────────────┐ +│ Get Proposal (ESet) │ +│ from One Identity │ +└──────────┬──────────────┘ + │ +┌──────────┴──────────┐ +│ Event Type? │ +└────┬───────────┬────┘ + ↓ ↓ +PROPOSAL_ACCEPTED PROPOSAL_UPDATED + │ │ +┌────┴────────┐ │ ┌──────────────────────────┐ +│ ESet Exists?│ No │ ESet Exists? ├─No─┐ +└──────┬───No─┘ │ └──────────┬───────────────┘ │ + Yes │ Yes │ + │ │ │ ┌──────────────────────────┐ + │ ┌───────┴─────────┐ │ │ Log "Proposal not found │ + │ │ Create ESet in │ │ │ for update", Logout & Exit│ + │ │ One Identity │ │ └──────────────────────────┘ + │ └───────┬─────────┘ │ + │ ↓ │ + │ ┌───────┴─────────┐ │ + │ │ ESet Created? ├─No─┼───►Error: Creation Failed, Logout + │ └───────┬─────────┘ │ + Yes Yes │ + └──────────┼─────────────┘ + ↓ +┌─────────────────────────────────┐ +│ Get UIDs for all proposal users │ +│ (proposer & members) via OIDC sub│ +└────────────────┬────────────────┘ + ↓ +┌─────────────────────────────────┐ +│ All users found in One Identity?├─No─►Log Error: Users Missing +└────────────────┬────────────────┘ + Yes + ↓ +┌─────────────────────────────────┐ +│ Get existing PersonHasESET │ +│ connections for the ESet │ +└────────────────┬────────────────┘ + │ +┌────────────────┴────────────────┐ +│ For each existing connection: │ +│ - Is person still in proposal? │ +│ No ─► Has person site access? │ +│ No ─► Remove Connection │ +└────────────────┬────────────────┘ + ↓ +┌─────────────────────────────────┐ +│ For each user in current proposal:│ +│ - Not already connected? │ +│ Yes ─► Add Connection │ +└────────────────┬────────────────┘ + ↓ +┌─────────────────────────────────┐ +│ Log "Connections updated" │ +└────────────────┬────────────────┘ + ↓ +┌─────────────────────────────────┐ +│ Logout from One Identity │ +└─────────────────────────────────┘ +``` + +### Key Implementation Details +- **User Identification**: Users are primarily identified by their `oidcSub` from the proposal message, which is used to look up their `UID_Person` in One Identity. +- **Proposal Identification**: The proposal itself is identified in One Identity using its `shortCode` and other details from the `ProposalMessageData`. +- **Conditional Connection Removal**: A crucial feature is that connections for users no longer in the proposal are only removed if those users do not also have separate site access to that proposal. This prevents inadvertently revoking access for individuals like visitors who might be associated with a proposal through a different mechanism (site access). +- **Idempotency for `PROPOSAL_ACCEPTED`**: If a `PROPOSAL_ACCEPTED` event is processed for a proposal that already exists in One Identity (e.g., due to a retry), the system does not attempt to re-create it but proceeds to synchronize the member connections. +- **Handling `PROPOSAL_UPDATED` for Non-existent Proposals**: If a `PROPOSAL_UPDATED` event is received for a proposal that isn't found in One Identity, the handler logs this and exits gracefully, as there's no record to update. + +### Error Handling +- **Proposal Creation Failure**: If creating a new proposal (`ESet`) in One Identity fails during a `PROPOSAL_ACCEPTED` event, an error is thrown, and the process is halted. +- **User Not Found**: If any users listed in the proposal message (proposer or members) cannot be found in One Identity, an error is logged. The process continues with the users that were found. +- **Logout Guarantee**: The One Identity session is always closed in a `finally` block, ensuring resources are released even if errors occur during the synchronization process. \ No newline at end of file diff --git a/src/queue/consumers/oneidentity/consumerCallbacks/oneIdentityIntegrationHandler.spec.ts b/src/queue/consumers/oneidentity/consumerCallbacks/oneIdentityIntegrationHandler.spec.ts deleted file mode 100644 index da3cd746..00000000 --- a/src/queue/consumers/oneidentity/consumerCallbacks/oneIdentityIntegrationHandler.spec.ts +++ /dev/null @@ -1,231 +0,0 @@ -jest.mock('@user-office-software/duo-logger'); -jest.mock('../utils/ESSOneIdentity', () => ({ - ESSOneIdentity: jest.fn().mockImplementation(() => mockOneIdentity), -})); - -import { logger } from '@user-office-software/duo-logger'; - -import { oneIdentityIntegrationHandler } from './oneIdentityIntegrationHandler'; -import { Event } from '../../../../models/Event'; -import { ProposalMessageData } from '../../../../models/ProposalMessage'; -import { - ESSOneIdentity, - PersonHasESETValues, - UID_ESet, - UserPersonConnection, -} from '../utils/ESSOneIdentity'; - -const mockOneIdentity: jest.Mocked> = { - login: jest.fn(), - logout: jest.fn(), - getProposal: jest.fn(), - getPerson: jest.fn(), - getPersons: jest.fn(), - createProposal: jest.fn(), - connectPersonToProposal: jest.fn(), - getProposalPersonConnections: jest.fn(), - removeConnectionBetweenPersonAndProposal: jest.fn(), -}; - -const setupMocks = (data: { - getProposal: UID_ESet | undefined; - getProposalPersonConnections?: PersonHasESETValues[]; - getPersons?: UserPersonConnection[]; -}) => { - mockOneIdentity.createProposal.mockResolvedValueOnce('proposal-UID_ESet'); - mockOneIdentity.getProposal.mockResolvedValueOnce(data.getProposal); - mockOneIdentity.getProposalPersonConnections.mockResolvedValueOnce( - data.getProposalPersonConnections ?? [] - ); - mockOneIdentity.getPersons.mockResolvedValueOnce( - data.getPersons ?? [ - { - email: 'proposer@email', - uidPerson: 'proposer-uid', - }, - { - email: 'member@email', - uidPerson: 'member-uid', - }, - ] - ); -}; - -const proposalMessage = { - shortCode: 'shortCode', - proposer: { email: 'proposer@email' }, - members: [{ email: 'member@email' }], -} as ProposalMessageData; - -describe('oneIdentityIntegrationHandler', () => { - describe('PROPOSAL_ACCEPTED', () => { - it('should handle accepted proposal', async () => { - setupMocks({ - getProposal: undefined, - getProposalPersonConnections: [], - }); - - await oneIdentityIntegrationHandler( - proposalMessage, - Event.PROPOSAL_ACCEPTED - ); - - expect(mockOneIdentity.createProposal).toHaveBeenCalledWith( - proposalMessage - ); - expect(mockOneIdentity.getProposalPersonConnections).toHaveBeenCalledWith( - 'proposal-UID_ESet' - ); - expect( - mockOneIdentity.removeConnectionBetweenPersonAndProposal - ).toHaveBeenCalledTimes(0); - expect(mockOneIdentity.connectPersonToProposal).toHaveBeenCalledTimes(2); - expect(mockOneIdentity.connectPersonToProposal).toHaveBeenNthCalledWith( - 1, - 'proposal-UID_ESet', - 'proposer-uid' - ); - expect(mockOneIdentity.connectPersonToProposal).toHaveBeenNthCalledWith( - 2, - 'proposal-UID_ESet', - 'member-uid' - ); - expect(logger.logError).not.toHaveBeenCalled(); - expect(logger.logInfo).toHaveBeenCalledWith('Connections updated', { - uidESet: 'proposal-UID_ESet', - uidPersons: ['proposer-uid', 'member-uid'], - }); - expect(mockOneIdentity.logout).toHaveBeenCalled(); - }); - - it('should log error if some of the users are not found in One Identity', async () => { - setupMocks({ - getProposal: undefined, - getProposalPersonConnections: [], - getPersons: [ - { - email: 'proposer@email', - uidPerson: 'proposer-uid', - }, - ], - }); - - await oneIdentityIntegrationHandler( - proposalMessage, - Event.PROPOSAL_ACCEPTED - ); - - expect(logger.logError).toHaveBeenCalledWith( - 'Not all users found in One Identity (investigate)', - { - users: [{ email: 'member@email' }, { email: 'proposer@email' }], - uidPersons: ['proposer-uid'], - } - ); - }); - - describe('when proposal already exists in One Identity (Retry logic)', () => { - it('should not create proposal but handle connections if proposal exists', async () => { - setupMocks({ - getProposal: 'proposal-UID_ESet', - getProposalPersonConnections: [ - { - UID_ESet: 'proposal-UID_ESet', - UID_Person: 'proposer-uid', - }, - ], - }); - - await oneIdentityIntegrationHandler( - proposalMessage, - Event.PROPOSAL_ACCEPTED - ); - - expect(mockOneIdentity.createProposal).not.toHaveBeenCalled(); - expect(mockOneIdentity.connectPersonToProposal).toHaveBeenCalledTimes( - 1 - ); - expect(mockOneIdentity.connectPersonToProposal).toHaveBeenCalledWith( - 'proposal-UID_ESet', - 'member-uid' - ); - expect( - mockOneIdentity.removeConnectionBetweenPersonAndProposal - ).not.toHaveBeenCalled(); - expect(logger.logInfo).toHaveBeenCalledWith('Connections updated', { - uidESet: 'proposal-UID_ESet', - uidPersons: ['proposer-uid', 'member-uid'], - }); - expect(mockOneIdentity.logout).toHaveBeenCalled(); - }); - }); - }); - - describe('PROPOSAL_UPDATED', () => { - it('should handle updated proposal', async () => { - setupMocks({ - getProposal: 'proposal-UID_ESet', - getProposalPersonConnections: [ - { - UID_ESet: 'proposal-UID_ESet', - UID_Person: 'proposer-uid', - }, - { - UID_ESet: 'proposal-UID_ESet', - UID_Person: 'old-member-uid', // this person should be removed, because it's not in the updated proposal - }, - ], - }); - - await oneIdentityIntegrationHandler( - proposalMessage, - Event.PROPOSAL_UPDATED - ); - - expect(mockOneIdentity.createProposal).not.toHaveBeenCalled(); - expect(mockOneIdentity.getProposalPersonConnections).toHaveBeenCalledWith( - 'proposal-UID_ESet' - ); - expect( - mockOneIdentity.removeConnectionBetweenPersonAndProposal - ).toHaveBeenCalledTimes(1); - expect( - mockOneIdentity.removeConnectionBetweenPersonAndProposal - ).toHaveBeenCalledWith('proposal-UID_ESet', 'old-member-uid'); - expect(mockOneIdentity.connectPersonToProposal).toHaveBeenCalledTimes(1); - expect(mockOneIdentity.connectPersonToProposal).toHaveBeenCalledWith( - 'proposal-UID_ESet', - 'member-uid' - ); - expect(logger.logInfo).toHaveBeenCalledWith('Connections updated', { - uidESet: 'proposal-UID_ESet', - uidPersons: ['proposer-uid', 'member-uid'], - }); - expect(mockOneIdentity.logout).toHaveBeenCalled(); - }); - - it('should not handle proposal if there is no created proposal in One Identity', async () => { - setupMocks({ - getProposal: undefined, - }); - - await oneIdentityIntegrationHandler( - proposalMessage, - Event.PROPOSAL_UPDATED - ); - - expect(mockOneIdentity.createProposal).not.toHaveBeenCalled(); - expect( - mockOneIdentity.getProposalPersonConnections - ).not.toHaveBeenCalled(); - expect( - mockOneIdentity.removeConnectionBetweenPersonAndProposal - ).not.toHaveBeenCalled(); - expect(mockOneIdentity.connectPersonToProposal).not.toHaveBeenCalled(); - expect(logger.logInfo).toHaveBeenCalledWith('Proposal in One Identity', { - uidESet: undefined, - }); - expect(mockOneIdentity.logout).toHaveBeenCalled(); - }); - }); -}); diff --git a/src/queue/consumers/oneidentity/consumerCallbacks/syncProposalAndMembersToOneIdentityHandler.spec.ts b/src/queue/consumers/oneidentity/consumerCallbacks/syncProposalAndMembersToOneIdentityHandler.spec.ts new file mode 100644 index 00000000..ce45e904 --- /dev/null +++ b/src/queue/consumers/oneidentity/consumerCallbacks/syncProposalAndMembersToOneIdentityHandler.spec.ts @@ -0,0 +1,357 @@ +jest.mock('@user-office-software/duo-logger'); +jest.mock('../utils/ESSOneIdentity', () => ({ + ESSOneIdentity: jest.fn().mockImplementation(() => mockOneIdentity), +})); + +import { logger } from '@user-office-software/duo-logger'; + +import { syncProposalAndMembersToOneIdentityHandler } from './syncProposalAndMembersToOneIdentityHandler'; +import { Event } from '../../../../models/Event'; +import { ProposalMessageData } from '../../../../models/ProposalMessage'; +import { ESSOneIdentity } from '../utils/ESSOneIdentity'; +import { UID_ESet } from '../utils/interfaces/Eset'; +import { PersonHasESET } from '../utils/interfaces/PersonHasESET'; + +const mockOneIdentity: jest.Mocked> = { + login: jest.fn(), + logout: jest.fn(), + getProposal: jest.fn(), + getPerson: jest.fn(), + getPersons: jest.fn(), + createProposal: jest.fn(), + connectPersonToProposal: jest.fn(), + getProposalPersonConnections: jest.fn(), + removeConnectionBetweenPersonAndProposal: jest.fn(), + getPersonWantsOrg: jest.fn(), + createPersonWantsOrg: jest.fn(), + cancelPersonWantsOrg: jest.fn(), + hasPersonSiteAccessToProposal: jest.fn(), +}; + +const setupMocks = (data: { + getProposal: UID_ESet | undefined; + getProposalPersonConnections?: PersonHasESET[]; + getPersons?: string[]; + hasPersonSiteAccessToProposalConfig?: { [key: string]: boolean }; +}) => { + mockOneIdentity.createProposal.mockResolvedValueOnce('proposal-UID_ESet'); + mockOneIdentity.getProposal.mockResolvedValueOnce(data.getProposal); + mockOneIdentity.getProposalPersonConnections.mockResolvedValueOnce( + data.getProposalPersonConnections ?? [] + ); + mockOneIdentity.getPersons.mockResolvedValueOnce( + data.getPersons ?? ['proposer-uid', 'member-uid'] + ); + if (data.hasPersonSiteAccessToProposalConfig) { + mockOneIdentity.hasPersonSiteAccessToProposal.mockImplementation( + async (uidPerson: string, _proposalUid: string) => { + return data.hasPersonSiteAccessToProposalConfig?.[uidPerson] ?? false; + } + ); + } else { + mockOneIdentity.hasPersonSiteAccessToProposal.mockResolvedValue(false); + } +}; + +const proposalMessage = { + shortCode: 'shortCode', + proposer: { oidcSub: 'proposer-oidc-sub' }, + members: [{ oidcSub: 'member-oidc-sub' }], +} as ProposalMessageData; + +describe('oneIdentityIntegrationHandler', () => { + describe('PROPOSAL_ACCEPTED', () => { + it('should handle accepted proposal', async () => { + setupMocks({ + getProposal: undefined, + getProposalPersonConnections: [], + }); + + await syncProposalAndMembersToOneIdentityHandler( + proposalMessage, + Event.PROPOSAL_ACCEPTED + ); + + expect(mockOneIdentity.createProposal).toHaveBeenCalledWith( + proposalMessage + ); + expect(mockOneIdentity.getProposalPersonConnections).toHaveBeenCalledWith( + 'proposal-UID_ESet' + ); + expect( + mockOneIdentity.removeConnectionBetweenPersonAndProposal + ).toHaveBeenCalledTimes(0); + expect(mockOneIdentity.connectPersonToProposal).toHaveBeenCalledTimes(2); + expect(mockOneIdentity.connectPersonToProposal).toHaveBeenNthCalledWith( + 1, + 'proposal-UID_ESet', + 'proposer-uid' + ); + expect(mockOneIdentity.connectPersonToProposal).toHaveBeenNthCalledWith( + 2, + 'proposal-UID_ESet', + 'member-uid' + ); + expect(logger.logError).not.toHaveBeenCalled(); + expect(logger.logInfo).toHaveBeenCalledWith('Connections updated', { + uidESet: 'proposal-UID_ESet', + uidPersons: ['proposer-uid', 'member-uid'], + }); + expect(mockOneIdentity.logout).toHaveBeenCalled(); + }); + + it('should log error if some of the users are not found in One Identity', async () => { + setupMocks({ + getProposal: undefined, + getProposalPersonConnections: [], + getPersons: ['proposer-uid'], + }); + + await syncProposalAndMembersToOneIdentityHandler( + proposalMessage, + Event.PROPOSAL_ACCEPTED + ); + + expect(logger.logError).toHaveBeenCalledWith( + 'Not all users found in One Identity (Investigate). Missing central accounts:', + { + centralAccounts: ['member-oidc-sub', 'proposer-oidc-sub'], + foundUsersInOneIdentity: ['proposer-uid'], + } + ); + }); + + it('should throw error if proposal creation fails', async () => { + // Set up mocks with getProposal returning undefined (proposal doesn't exist) + mockOneIdentity.getProposal.mockResolvedValueOnce(undefined); + + // Mock createProposal to return undefined (creation failed) + mockOneIdentity.createProposal.mockResolvedValueOnce(undefined); + + // Expect the handler to throw an error + await expect( + syncProposalAndMembersToOneIdentityHandler( + proposalMessage, + Event.PROPOSAL_ACCEPTED + ) + ).rejects.toThrow('Proposal creation failed in ESS One Identity'); + + // Verify that logout is still called (in the finally block) + expect(mockOneIdentity.logout).toHaveBeenCalled(); + }); + + describe('when proposal already exists in One Identity (Retry logic)', () => { + it('should not create proposal but handle connections if proposal exists', async () => { + setupMocks({ + getProposal: 'proposal-UID_ESet', + getProposalPersonConnections: [ + { + UID_ESet: 'proposal-UID_ESet', + UID_Person: 'proposer-uid', + }, + ], + }); + + await syncProposalAndMembersToOneIdentityHandler( + proposalMessage, + Event.PROPOSAL_ACCEPTED + ); + + expect(mockOneIdentity.createProposal).not.toHaveBeenCalled(); + expect(mockOneIdentity.connectPersonToProposal).toHaveBeenCalledTimes( + 1 + ); + expect(mockOneIdentity.connectPersonToProposal).toHaveBeenCalledWith( + 'proposal-UID_ESet', + 'member-uid' + ); + expect( + mockOneIdentity.removeConnectionBetweenPersonAndProposal + ).not.toHaveBeenCalled(); + expect(logger.logInfo).toHaveBeenCalledWith('Connections updated', { + uidESet: 'proposal-UID_ESet', + uidPersons: ['proposer-uid', 'member-uid'], + }); + expect(mockOneIdentity.logout).toHaveBeenCalled(); + }); + }); + }); + + describe('PROPOSAL_UPDATED', () => { + it('should handle updated proposal and remove old connections', async () => { + setupMocks({ + getProposal: 'proposal-UID_ESet', + getProposalPersonConnections: [ + { + UID_ESet: 'proposal-UID_ESet', + UID_Person: 'proposer-uid', + }, + { + UID_ESet: 'proposal-UID_ESet', + UID_Person: 'old-member-uid', // this person should be removed + }, + ], + hasPersonSiteAccessToProposalConfig: { 'old-member-uid': false }, + }); + + await syncProposalAndMembersToOneIdentityHandler( + proposalMessage, + Event.PROPOSAL_UPDATED + ); + + expect(mockOneIdentity.createProposal).not.toHaveBeenCalled(); + expect(mockOneIdentity.getProposalPersonConnections).toHaveBeenCalledWith( + 'proposal-UID_ESet' + ); + expect( + mockOneIdentity.removeConnectionBetweenPersonAndProposal + ).toHaveBeenCalledTimes(1); + expect( + mockOneIdentity.removeConnectionBetweenPersonAndProposal + ).toHaveBeenCalledWith('proposal-UID_ESet', 'old-member-uid'); + expect(mockOneIdentity.connectPersonToProposal).toHaveBeenCalledTimes(1); + expect(mockOneIdentity.connectPersonToProposal).toHaveBeenCalledWith( + 'proposal-UID_ESet', + 'member-uid' + ); + expect(logger.logInfo).toHaveBeenCalledWith('Connections updated', { + uidESet: 'proposal-UID_ESet', + uidPersons: ['proposer-uid', 'member-uid'], + }); + expect(mockOneIdentity.logout).toHaveBeenCalled(); + }); + + it('should not remove old connection if person has site access to proposal', async () => { + setupMocks({ + getProposal: 'proposal-UID_ESet', + getProposalPersonConnections: [ + { + UID_ESet: 'proposal-UID_ESet', + UID_Person: 'proposer-uid', + }, + { + UID_ESet: 'proposal-UID_ESet', + UID_Person: 'visitor-member-uid', // this person should NOT be removed due to site access + }, + ], + // 'visitor-member-uid' has site access + hasPersonSiteAccessToProposalConfig: { 'visitor-member-uid': true }, + }); + + await syncProposalAndMembersToOneIdentityHandler( + proposalMessage, + Event.PROPOSAL_UPDATED + ); + + expect(mockOneIdentity.createProposal).not.toHaveBeenCalled(); + expect(mockOneIdentity.getProposalPersonConnections).toHaveBeenCalledWith( + 'proposal-UID_ESet' + ); + // removeConnectionBetweenPersonAndProposal should NOT be called for 'visitor-member-uid' + expect( + mockOneIdentity.removeConnectionBetweenPersonAndProposal + ).not.toHaveBeenCalledWith('proposal-UID_ESet', 'visitor-member-uid'); + expect( + mockOneIdentity.removeConnectionBetweenPersonAndProposal + ).toHaveBeenCalledTimes(0); // No connections should be removed in this specific setup + + expect(mockOneIdentity.connectPersonToProposal).toHaveBeenCalledTimes(1); // 'member-uid' is new + expect(mockOneIdentity.connectPersonToProposal).toHaveBeenCalledWith( + 'proposal-UID_ESet', + 'member-uid' + ); + expect(logger.logInfo).toHaveBeenCalledWith('Connections updated', { + uidESet: 'proposal-UID_ESet', + uidPersons: ['proposer-uid', 'member-uid'], + }); + expect(mockOneIdentity.logout).toHaveBeenCalled(); + }); + + it('should remove one old connection and keep another due to site access', async () => { + setupMocks({ + getProposal: 'proposal-UID_ESet', + getProposalPersonConnections: [ + { + UID_ESet: 'proposal-UID_ESet', + UID_Person: 'proposer-uid', // Keep (in proposal) + }, + { + UID_ESet: 'proposal-UID_ESet', + UID_Person: 'old-member-to-remove-uid', // Remove (not in proposal, no site access) + }, + { + UID_ESet: 'proposal-UID_ESet', + UID_Person: 'visitor-member-to-keep-uid', // Keep (not in proposal, but has site access) + }, + ], + getPersons: ['proposer-uid', 'member-uid'], // Current members in the proposal message + hasPersonSiteAccessToProposalConfig: { + 'old-member-to-remove-uid': false, + 'visitor-member-to-keep-uid': true, + }, + }); + + await syncProposalAndMembersToOneIdentityHandler( + proposalMessage, // Contains proposer-uid and member-uid + Event.PROPOSAL_UPDATED + ); + + expect(mockOneIdentity.createProposal).not.toHaveBeenCalled(); + expect(mockOneIdentity.getProposalPersonConnections).toHaveBeenCalledWith( + 'proposal-UID_ESet' + ); + + // Check removals + expect( + mockOneIdentity.removeConnectionBetweenPersonAndProposal + ).toHaveBeenCalledTimes(1); + expect( + mockOneIdentity.removeConnectionBetweenPersonAndProposal + ).toHaveBeenCalledWith('proposal-UID_ESet', 'old-member-to-remove-uid'); + expect( + mockOneIdentity.removeConnectionBetweenPersonAndProposal + ).not.toHaveBeenCalledWith( + 'proposal-UID_ESet', + 'visitor-member-to-keep-uid' + ); + + // Check additions + // 'member-uid' is in proposalMessage.members and not in initial connections that are kept + expect(mockOneIdentity.connectPersonToProposal).toHaveBeenCalledTimes(1); + expect(mockOneIdentity.connectPersonToProposal).toHaveBeenCalledWith( + 'proposal-UID_ESet', + 'member-uid' + ); + + expect(logger.logInfo).toHaveBeenCalledWith('Connections updated', { + uidESet: 'proposal-UID_ESet', + uidPersons: ['proposer-uid', 'member-uid'], + }); + expect(mockOneIdentity.logout).toHaveBeenCalled(); + }); + + it('should not handle proposal if there is no created proposal in One Identity', async () => { + setupMocks({ + getProposal: undefined, + }); + + await syncProposalAndMembersToOneIdentityHandler( + proposalMessage, + Event.PROPOSAL_UPDATED + ); + + expect(mockOneIdentity.createProposal).not.toHaveBeenCalled(); + expect( + mockOneIdentity.getProposalPersonConnections + ).not.toHaveBeenCalled(); + expect( + mockOneIdentity.removeConnectionBetweenPersonAndProposal + ).not.toHaveBeenCalled(); + expect(mockOneIdentity.connectPersonToProposal).not.toHaveBeenCalled(); + expect(logger.logInfo).toHaveBeenCalledWith('Proposal in One Identity', { + uidESet: undefined, + }); + expect(mockOneIdentity.logout).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/queue/consumers/oneidentity/consumerCallbacks/oneIdentityIntegrationHandler.ts b/src/queue/consumers/oneidentity/consumerCallbacks/syncProposalAndMembersToOneIdentityHandler.ts similarity index 65% rename from src/queue/consumers/oneidentity/consumerCallbacks/oneIdentityIntegrationHandler.ts rename to src/queue/consumers/oneidentity/consumerCallbacks/syncProposalAndMembersToOneIdentityHandler.ts index 67f3aebb..21d84957 100644 --- a/src/queue/consumers/oneidentity/consumerCallbacks/oneIdentityIntegrationHandler.ts +++ b/src/queue/consumers/oneidentity/consumerCallbacks/syncProposalAndMembersToOneIdentityHandler.ts @@ -3,15 +3,12 @@ import { logger } from '@user-office-software/duo-logger'; import { Event } from '../../../../models/Event'; import { ProposalMessageData } from '../../../../models/ProposalMessage'; import { collectUsersFromProposalMessage } from '../../utils/collectUsersFromProposalMessage'; -import { - ESSOneIdentity, - PersonHasESETValues, - UID_ESet, - UID_Person, - UserPersonConnection, -} from '../utils/ESSOneIdentity'; - -export async function oneIdentityIntegrationHandler( +import { ESSOneIdentity } from '../utils/ESSOneIdentity'; +import { UID_ESet } from '../utils/interfaces/Eset'; +import { UID_Person } from '../utils/interfaces/Person'; +import { PersonHasESET } from '../utils/interfaces/PersonHasESET'; + +export async function syncProposalAndMembersToOneIdentityHandler( message: ProposalMessageData, type: Event ): Promise { @@ -27,10 +24,11 @@ export async function oneIdentityIntegrationHandler( logger.logInfo('UID_ESet from One Identity', { uidESet }); if (uidESet) { + const users = collectUsersFromProposalMessage(message); await handleConnectionsBetweenProposalAndPersons( oneIdentity, uidESet, - message + users.map((user) => user.oidcSub) ); } } finally { @@ -71,22 +69,24 @@ async function getUIDESetFromOneIdentity( async function handleConnectionsBetweenProposalAndPersons( oneIdentity: ESSOneIdentity, uidESet: UID_ESet, - message: ProposalMessageData + centralAccounts: string[] ) { - const users = collectUsersFromProposalMessage(message); - - logger.logInfo('Users from proposal', { users }); + logger.logInfo('Users to be connected to proposal', { + centralAccounts, + }); // Get all users from One Identity - const userPersonConnections = await oneIdentity.getPersons(users); - const uidPersons = getUidPersons(userPersonConnections); + const uidPersons = await oneIdentity.getPersons(centralAccounts); // Log an error if not all users are found in One Identity to be able to investigate - if (uidPersons.length !== users.length) { - logger.logError('Not all users found in One Identity (investigate)', { - users, - uidPersons, - }); + if (uidPersons.length !== centralAccounts.length) { + logger.logError( + 'Not all users found in One Identity (Investigate). Missing central accounts:', + { + centralAccounts, + foundUsersInOneIdentity: uidPersons, + } + ); } logger.logInfo('Found persons in One Identity', { uidPersons }); @@ -100,22 +100,10 @@ async function handleConnectionsBetweenProposalAndPersons( logger.logInfo('Connections updated', { uidESet, uidPersons }); } -// Method to get UID_Person from UserPersonConnection -function getUidPersons( - userPersonConnections: UserPersonConnection[] -): UID_Person[] { - return userPersonConnections - .filter( - (connection): connection is { email: string; uidPerson: UID_Person } => - connection.uidPerson !== undefined - ) - .map(({ uidPerson }) => uidPerson); -} - async function addNewConnections( oneIdentity: ESSOneIdentity, uidESet: UID_ESet, - connections: PersonHasESETValues[], + connections: PersonHasESET[], uidPersons: UID_Person[] ): Promise { const connectionsToAdd = uidPersons.filter( @@ -132,13 +120,33 @@ async function addNewConnections( async function removeOldConnections( oneIdentity: ESSOneIdentity, - connections: PersonHasESETValues[], + connections: PersonHasESET[], uidPersons: UID_Person[] ): Promise { - const connectionsToRemove = connections.filter( + // Collect connections that are not in the list of current persons (OIM) + const potentiallyRemoveableConnections = connections.filter( (connection) => !uidPersons.includes(connection.UID_Person) ); + const removalChecks = await Promise.all( + potentiallyRemoveableConnections.map(async (connectionToRemove) => { + const hasAccess = await oneIdentity.hasPersonSiteAccessToProposal( + connectionToRemove.UID_Person, + connectionToRemove.UID_ESet + ); + + return { + connection: connectionToRemove, + shouldRemove: !hasAccess, // Remove if the person does NOT have site access + }; + }) + ); + + // Filter out connections that should not be removed + const connectionsToRemove = removalChecks + .filter((check) => check.shouldRemove) + .map((check) => check.connection); + await Promise.all( connectionsToRemove.map((connection) => oneIdentity.removeConnectionBetweenPersonAndProposal( diff --git a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts new file mode 100644 index 00000000..bcd45b9c --- /dev/null +++ b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts @@ -0,0 +1,622 @@ +jest.mock('@user-office-software/duo-logger'); +jest.mock('../utils/ESSOneIdentity', () => ({ + ESSOneIdentity: jest.fn().mockImplementation(() => mockOneIdentity), +})); + +const ONE_IDENTITY_SYSTEM_ACCESS_LASTS_FOR_DAYS = '45'; + +jest.mock('process', () => ({ + env: { + ONE_IDENTITY_SYSTEM_ACCESS_LASTS_FOR_DAYS, + }, +})); + +import { logger } from '@user-office-software/duo-logger'; + +import { syncVisitToOneIdentityHandler } from './syncVisitToOneIdentityHandler'; +import { Event } from '../../../../models/Event'; +import { ProposalMessageData } from '../../../../models/ProposalMessage'; +import { ESSOneIdentity } from '../utils/ESSOneIdentity'; +import { UID_ESet } from '../utils/interfaces/Eset'; +import { IdentityType, Person } from '../utils/interfaces/Person'; +import { + OrderState, + PersonWantsOrg, + PersonWantsOrgRole, +} from '../utils/interfaces/PersonWantsOrg'; +import { VisitMessage } from '../utils/interfaces/VisitMessage'; + +const mockOneIdentity: jest.Mocked> = { + login: jest.fn(), + logout: jest.fn(), + getPerson: jest.fn(), + getPersons: jest.fn(), + getPersonWantsOrg: jest.fn(), + getProposal: jest.fn(), + createProposal: jest.fn(), + connectPersonToProposal: jest.fn(), + getProposalPersonConnections: jest.fn(), + removeConnectionBetweenPersonAndProposal: jest.fn(), + createPersonWantsOrg: jest.fn(), + cancelPersonWantsOrg: jest.fn(), + hasPersonSiteAccessToProposal: jest.fn(), +}; + +const mockUidESet: UID_ESet = 'eset-uid-123'; + +const visitMessage: VisitMessage = { + visitorId: 'visitor-oidc-sub', + startAt: '2023-01-01T00:00:00.000Z', + endAt: '2023-01-10T00:00:00.000Z', + proposal: { + shortCode: 'proposal-short-code', + members: [ + { oidcSub: 'member-oidc-sub' }, + { oidcSub: 'visitor-oidc-sub' }, // Visitor is also a member + ], + } as ProposalMessageData, +}; + +const visitMessageVisitorNotMember: VisitMessage = { + ...visitMessage, + proposal: { + ...visitMessage.proposal, + members: [{ oidcSub: 'member-oidc-sub' }], // Visitor is NOT a member + } as ProposalMessageData, +}; + +describe('syncVisitToOneIdentityHandler', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Science user verification', () => { + it('should skip processing if visitor is not a science user', async () => { + // Mock person that is not a science user + const mockPerson = { + UID_Person: 'visitor-uid', + CCC_EmployeeSubType: 'EMPLOYEEDK', + } as Person; + + mockOneIdentity.getPerson.mockResolvedValueOnce(mockPerson); + + await syncVisitToOneIdentityHandler(visitMessage, Event.VISIT_CREATED); + + expect(mockOneIdentity.login).toHaveBeenCalled(); + expect(mockOneIdentity.getPerson).toHaveBeenCalledWith( + 'visitor-oidc-sub' + ); + expect(logger.logInfo).toHaveBeenCalledWith( + 'Visitor is not a Science User, skipping', + {} + ); + expect(mockOneIdentity.createPersonWantsOrg).not.toHaveBeenCalled(); + expect(mockOneIdentity.logout).toHaveBeenCalled(); + }); + }); + + describe('VISIT_CREATED', () => { + it('should create site access and system access in One Identity for science users and connect to proposal', async () => { + // Mock the current time to a fixed value for testing + const mockNowDate = new Date('2022-12-15T00:00:00.000Z'); + const originalDateNow = Date.now; + Date.now = jest.fn(() => mockNowDate.getTime()); + + // Mock person that is a science user + const mockPerson = { + UID_Person: 'visitor-uid', + CCC_EmployeeSubType: IdentityType.ESSSCIENCEUSER, + } as Person; + + // Mock site access and system access creation responses + const mockSiteAccess = { + UID_PersonWantsOrg: 'site-access-uid', + } as PersonWantsOrg; + const mockSystemAccess = { + UID_PersonWantsOrg: 'system-access-uid', + } as PersonWantsOrg; + + mockOneIdentity.getPerson.mockResolvedValueOnce(mockPerson); + mockOneIdentity.getProposal.mockResolvedValueOnce(mockUidESet); + mockOneIdentity.getProposalPersonConnections.mockResolvedValueOnce([]); // No existing connection + + // Mock sequential calls to createPersonWantsOrg with different responses + mockOneIdentity.createPersonWantsOrg + .mockResolvedValueOnce([mockSiteAccess]) + .mockResolvedValueOnce([mockSystemAccess]); + + await syncVisitToOneIdentityHandler(visitMessage, Event.VISIT_CREATED); + + expect(mockOneIdentity.login).toHaveBeenCalled(); + expect(mockOneIdentity.getPerson).toHaveBeenCalledWith( + 'visitor-oidc-sub' + ); + expect(mockOneIdentity.getProposal).toHaveBeenCalledWith( + visitMessage.proposal + ); + expect(mockOneIdentity.getProposalPersonConnections).toHaveBeenCalledWith( + mockUidESet + ); + + // Verify site access creation + expect(mockOneIdentity.createPersonWantsOrg).toHaveBeenCalledTimes(2); + expect(mockOneIdentity.createPersonWantsOrg).toHaveBeenNthCalledWith( + 1, + PersonWantsOrgRole.SITE_ACCESS, + visitMessage.visitorId, + visitMessage.startAt, + visitMessage.endAt, + visitMessage.proposal.shortCode + ); + + // Calculate expected system access dates + // validFrom should be the current mock date + const expectedValidFrom = mockNowDate.toISOString(); + const expectedEndDate = new Date(visitMessage.endAt); + expectedEndDate.setDate( + expectedEndDate.getDate() + + parseInt(ONE_IDENTITY_SYSTEM_ACCESS_LASTS_FOR_DAYS) + ); + + // Verify system access creation + expect(mockOneIdentity.createPersonWantsOrg).toHaveBeenNthCalledWith( + 2, + PersonWantsOrgRole.SYSTEM_ACCESS, + visitMessage.visitorId, + expectedValidFrom, + expectedEndDate.toISOString(), + 'site-access-uid' + ); + + expect(logger.logInfo).toHaveBeenCalledWith( + 'Site access created in One Identity', + { + UID_PersonWantsOrg: 'site-access-uid', + } + ); + expect(logger.logInfo).toHaveBeenCalledWith( + 'System access created in One Identity', + { + UID_PersonWantsOrg: 'system-access-uid', + } + ); + + expect(mockOneIdentity.connectPersonToProposal).toHaveBeenCalledWith( + mockUidESet, + mockPerson.UID_Person + ); + expect(logger.logInfo).toHaveBeenCalledWith( + 'Connection created between proposal and visitor', + { + uidPerson: mockPerson.UID_Person, + uidESet: mockUidESet, + } + ); + + expect(mockOneIdentity.logout).toHaveBeenCalled(); + + // Restore original Date.now + Date.now = originalDateNow; + }); + + it('should skip creating proposal connection if it already exists', async () => { + const mockPerson = { + UID_Person: 'visitor-uid', + CCC_EmployeeSubType: IdentityType.ESSSCIENCEUSER, + } as Person; + const mockSiteAccess = { + UID_PersonWantsOrg: 'site-access-uid', + } as PersonWantsOrg; + const mockSystemAccess = { + UID_PersonWantsOrg: 'system-access-uid', + } as PersonWantsOrg; + + mockOneIdentity.getPerson.mockResolvedValueOnce(mockPerson); + mockOneIdentity.getProposal.mockResolvedValueOnce(mockUidESet); + mockOneIdentity.getProposalPersonConnections.mockResolvedValueOnce([ + { UID_Person: mockPerson.UID_Person, UID_ESet: mockUidESet }, + ]); // Connection exists + mockOneIdentity.createPersonWantsOrg + .mockResolvedValueOnce([mockSiteAccess]) + .mockResolvedValueOnce([mockSystemAccess]); + + await syncVisitToOneIdentityHandler(visitMessage, Event.VISIT_CREATED); + + expect(mockOneIdentity.connectPersonToProposal).not.toHaveBeenCalled(); + expect(logger.logInfo).toHaveBeenCalledWith( + 'Connection already exists, skipping', + { + uidPerson: mockPerson.UID_Person, + uidESet: mockUidESet, + } + ); + expect(mockOneIdentity.logout).toHaveBeenCalled(); + }); + + it('should throw an error if proposal is not found in One Identity', async () => { + const mockPerson = { + UID_Person: 'visitor-uid', + CCC_EmployeeSubType: IdentityType.ESSSCIENCEUSER, + } as Person; + + mockOneIdentity.getPerson.mockResolvedValueOnce(mockPerson); + mockOneIdentity.getProposal.mockResolvedValueOnce(undefined); // Proposal not found + + await expect( + syncVisitToOneIdentityHandler(visitMessage, Event.VISIT_CREATED) + ).rejects.toThrow( + 'Proposal not found in One Identity, cannot sync visit' + ); + + expect(mockOneIdentity.login).toHaveBeenCalled(); + expect(mockOneIdentity.getPerson).toHaveBeenCalledWith( + 'visitor-oidc-sub' + ); + expect(mockOneIdentity.getProposal).toHaveBeenCalledWith( + visitMessage.proposal + ); + expect(mockOneIdentity.createPersonWantsOrg).not.toHaveBeenCalled(); + expect(mockOneIdentity.logout).toHaveBeenCalled(); + }); + + it('should throw an error if site access creation fails', async () => { + // Mock person that is a science user + const mockPerson = { + UID_Person: 'visitor-uid', + CCC_EmployeeSubType: IdentityType.ESSSCIENCEUSER, + } as Person; + + mockOneIdentity.getPerson.mockResolvedValueOnce(mockPerson); + mockOneIdentity.getProposal.mockResolvedValueOnce(mockUidESet); + mockOneIdentity.createPersonWantsOrg.mockRejectedValueOnce( + new Error('Failed to create site access') + ); + + await expect( + syncVisitToOneIdentityHandler(visitMessage, Event.VISIT_CREATED) + ).rejects.toThrow('Failed to create site access'); + + expect(mockOneIdentity.login).toHaveBeenCalled(); + expect(mockOneIdentity.getPerson).toHaveBeenCalledWith( + 'visitor-oidc-sub' + ); + expect(mockOneIdentity.logout).toHaveBeenCalled(); + }); + + it('should throw an error when provided with an invalid date', async () => { + // Mock person that is a science user + const mockPerson = { + UID_Person: 'visitor-uid', + CCC_EmployeeSubType: IdentityType.ESSSCIENCEUSER, + } as Person; + + mockOneIdentity.getPerson.mockResolvedValueOnce(mockPerson); + mockOneIdentity.getProposal.mockResolvedValueOnce(mockUidESet); + + // Create a message with an invalid date + const invalidVisitMessage: VisitMessage = { + ...visitMessage, + startAt: 'invalid-date', + }; + + await expect( + syncVisitToOneIdentityHandler(invalidVisitMessage, Event.VISIT_CREATED) + ).rejects.toThrow('Invalid date provided to toIsoString: invalid-date'); + + expect(mockOneIdentity.login).toHaveBeenCalled(); + expect(mockOneIdentity.getPerson).toHaveBeenCalledWith( + 'visitor-oidc-sub' + ); + expect(mockOneIdentity.createPersonWantsOrg).not.toHaveBeenCalled(); + expect(mockOneIdentity.logout).toHaveBeenCalled(); + }); + }); + + describe('VISITOR_DELETED', () => { + it('should remove visitor access and proposal connection in One Identity for science users (if not a member)', async () => { + // Mock person that is a science user + const mockPerson = { + UID_Person: 'visitor-uid', + CCC_EmployeeSubType: IdentityType.ESSSCIENCEUSER, + } as Person; + + const mockPersonWantsOrgs = [ + { + UID_PersonWantsOrg: 'site-access-uid', + UID_PersonOrdered: 'visitor-uid', + DisplayOrg: PersonWantsOrgRole.SITE_ACCESS, + ValidFrom: '2023-01-01T00:00:00.000Z', + ValidUntil: '2023-01-10T00:00:00.000Z', + CustomProperty04: 'proposal-short-code', + OrderState: OrderState.GRANTED, + } as PersonWantsOrg, + { + UID_PersonWantsOrg: 'system-access-uid', + UID_PersonOrdered: 'visitor-uid', + DisplayOrg: PersonWantsOrgRole.SYSTEM_ACCESS, + ValidFrom: '2023-01-01T00:00:00.000Z', + ValidUntil: '2023-01-10T00:00:00.000Z', + CustomProperty04: 'site-access-uid', + OrderState: OrderState.GRANTED, + } as PersonWantsOrg, + ]; + + mockOneIdentity.getPerson.mockResolvedValueOnce(mockPerson); + mockOneIdentity.getProposal.mockResolvedValueOnce(mockUidESet); + mockOneIdentity.getPersonWantsOrg.mockResolvedValueOnce( + mockPersonWantsOrgs + ); + + await syncVisitToOneIdentityHandler( + visitMessageVisitorNotMember, // Visitor is NOT a member + Event.VISIT_DELETED + ); + + expect(mockOneIdentity.login).toHaveBeenCalled(); + + expect(mockOneIdentity.getPerson).toHaveBeenCalledWith( + visitMessageVisitorNotMember.visitorId + ); + expect(mockOneIdentity.getProposal).toHaveBeenCalledWith( + visitMessageVisitorNotMember.proposal + ); + expect(mockOneIdentity.getPersonWantsOrg).toHaveBeenCalledWith( + 'visitor-uid' + ); + + expect(logger.logInfo).toHaveBeenCalledWith( + 'One Identity successfully logged in', + {} + ); + + expect(mockOneIdentity.cancelPersonWantsOrg).toHaveBeenNthCalledWith( + 1, + 'site-access-uid' + ); + + expect(logger.logInfo).toHaveBeenCalledWith( + 'Site access cancelled in One Identity', + { + UID_PersonWantsOrg: 'site-access-uid', + } + ); + + expect(mockOneIdentity.cancelPersonWantsOrg).toHaveBeenNthCalledWith( + 2, + 'system-access-uid' + ); + + expect(logger.logInfo).toHaveBeenCalledWith( + 'System access cancelled in One Identity', + { + UID_PersonWantsOrg: 'system-access-uid', + } + ); + + expect(mockOneIdentity.cancelPersonWantsOrg).toHaveBeenCalledTimes(2); + + expect( + mockOneIdentity.removeConnectionBetweenPersonAndProposal + ).toHaveBeenCalledWith(mockUidESet, mockPerson.UID_Person); + expect(logger.logInfo).toHaveBeenCalledWith( + 'Connection removed between proposal and visitor', + { + uidPerson: mockPerson.UID_Person, + uidESet: mockUidESet, + } + ); + + expect(mockOneIdentity.logout).toHaveBeenCalled(); + expect(logger.logInfo).toHaveBeenCalledWith( + 'One Identity successfully logged out', + {} + ); + }); + + it('should skip removing proposal connection if visitor is a member of the proposal', async () => { + const mockPerson = { + UID_Person: 'visitor-uid', + CCC_EmployeeSubType: IdentityType.ESSSCIENCEUSER, + } as Person; + const mockPersonWantsOrgs = [ + { + UID_PersonWantsOrg: 'site-access-uid', + UID_PersonOrdered: 'visitor-uid', + DisplayOrg: PersonWantsOrgRole.SITE_ACCESS, + ValidFrom: '2023-01-01T00:00:00.000Z', + ValidUntil: '2023-01-10T00:00:00.000Z', + CustomProperty04: 'proposal-short-code', + OrderState: OrderState.GRANTED, + } as PersonWantsOrg, + { + UID_PersonWantsOrg: 'system-access-uid', + UID_PersonOrdered: 'visitor-uid', + DisplayOrg: PersonWantsOrgRole.SYSTEM_ACCESS, + ValidFrom: '2023-01-01T00:00:00.000Z', + ValidUntil: '2023-01-10T00:00:00.000Z', + CustomProperty04: 'site-access-uid', + OrderState: OrderState.GRANTED, + } as PersonWantsOrg, + ]; + + mockOneIdentity.getPerson.mockResolvedValueOnce(mockPerson); + mockOneIdentity.getProposal.mockResolvedValueOnce(mockUidESet); + mockOneIdentity.getPersonWantsOrg.mockResolvedValueOnce( + mockPersonWantsOrgs + ); + + // Using original visitMessage where visitor IS a member + await syncVisitToOneIdentityHandler(visitMessage, Event.VISIT_DELETED); + + expect( + mockOneIdentity.removeConnectionBetweenPersonAndProposal + ).not.toHaveBeenCalled(); + expect(logger.logInfo).toHaveBeenCalledWith( + 'Visitor is a proposal member, skipping removal', + { + uidPerson: mockPerson.UID_Person, + uidESet: mockUidESet, + } + ); + expect(mockOneIdentity.logout).toHaveBeenCalled(); + }); + + it('should throw an error if proposal is not found in One Identity on delete', async () => { + const mockPerson = { + UID_Person: 'visitor-uid', + CCC_EmployeeSubType: IdentityType.ESSSCIENCEUSER, + } as Person; + + mockOneIdentity.getPerson.mockResolvedValueOnce(mockPerson); + mockOneIdentity.getProposal.mockResolvedValueOnce(undefined); // Proposal not found + + await expect( + syncVisitToOneIdentityHandler(visitMessage, Event.VISIT_DELETED) + ).rejects.toThrow( + 'Proposal not found in One Identity, cannot sync visit' + ); + + expect(mockOneIdentity.login).toHaveBeenCalled(); + expect(mockOneIdentity.getPerson).toHaveBeenCalledWith( + 'visitor-oidc-sub' + ); + expect(mockOneIdentity.getProposal).toHaveBeenCalledWith( + visitMessage.proposal + ); + expect(mockOneIdentity.cancelPersonWantsOrg).not.toHaveBeenCalled(); + expect(mockOneIdentity.logout).toHaveBeenCalled(); + }); + + it('should skip processing if visitor is not a science user', async () => { + // Mock person that is not a science user + const mockPerson = { + UID_Person: 'visitor-uid', + CCC_EmployeeSubType: 'EMPLOYEEDK', + } as Person; + + mockOneIdentity.getPerson.mockResolvedValueOnce(mockPerson); + + await syncVisitToOneIdentityHandler(visitMessage, Event.VISIT_DELETED); + + expect(mockOneIdentity.login).toHaveBeenCalled(); + expect(mockOneIdentity.getPerson).toHaveBeenCalledWith( + 'visitor-oidc-sub' + ); + expect(logger.logInfo).toHaveBeenCalledWith( + 'Visitor is not a Science User, skipping', + {} + ); + expect(mockOneIdentity.getPersonWantsOrg).not.toHaveBeenCalled(); + expect(mockOneIdentity.cancelPersonWantsOrg).not.toHaveBeenCalled(); + expect(mockOneIdentity.logout).toHaveBeenCalled(); + }); + + it('should throw error if person not found', async () => { + mockOneIdentity.getPerson.mockResolvedValueOnce(undefined); + + await expect( + syncVisitToOneIdentityHandler(visitMessage, Event.VISIT_DELETED) + ).rejects.toThrow('Person not found in One Identity'); + + expect(mockOneIdentity.login).toHaveBeenCalled(); + expect(mockOneIdentity.getPerson).toHaveBeenCalledWith( + 'visitor-oidc-sub' + ); + expect(mockOneIdentity.getPersonWantsOrg).not.toHaveBeenCalled(); + expect(mockOneIdentity.cancelPersonWantsOrg).not.toHaveBeenCalled(); + expect(mockOneIdentity.logout).toHaveBeenCalled(); + }); + + it('should throw error if site access not found', async () => { + const mockPerson = { + UID_Person: 'visitor-uid', + CCC_EmployeeSubType: IdentityType.ESSSCIENCEUSER, + } as Person; + const mockPersonWantsOrgs = [ + // No site access matching the dates + { + UID_PersonWantsOrg: 'site-access-uid', + UID_PersonOrdered: 'visitor-uid', + DisplayOrg: PersonWantsOrgRole.SITE_ACCESS, + ValidFrom: '2023-01-02T00:00:00.000Z', // Different from message.startAt + ValidUntil: '2023-01-10T00:00:00.000Z', + OrderState: OrderState.GRANTED, + } as PersonWantsOrg, + ]; + + mockOneIdentity.getPerson.mockResolvedValueOnce(mockPerson); + mockOneIdentity.getProposal.mockResolvedValueOnce(mockUidESet); + mockOneIdentity.getPersonWantsOrg.mockResolvedValueOnce( + mockPersonWantsOrgs + ); + + await expect( + syncVisitToOneIdentityHandler(visitMessage, Event.VISIT_DELETED) + ).rejects.toThrow( + 'Site access not found in One Identity, cannot remove access' + ); + + expect(mockOneIdentity.login).toHaveBeenCalled(); + expect(mockOneIdentity.getPerson).toHaveBeenCalledWith( + 'visitor-oidc-sub' + ); + expect(mockOneIdentity.logout).toHaveBeenCalled(); + }); + + it('should throw error if system access not found', async () => { + const mockPerson = { + UID_Person: 'visitor-uid', + CCC_EmployeeSubType: IdentityType.ESSSCIENCEUSER, + } as Person; + + const mockPersonWantsOrgs = [ + { + UID_PersonWantsOrg: 'site-access-uid', + UID_PersonOrdered: 'visitor-uid', + DisplayOrg: PersonWantsOrgRole.SITE_ACCESS, + ValidFrom: visitMessage.startAt, + ValidUntil: visitMessage.endAt, + CustomProperty04: 'proposal-short-code', + OrderState: OrderState.GRANTED, + } as PersonWantsOrg, + // No system access with CustomProperty04 matching site-access-uid + { + UID_PersonWantsOrg: 'system-access-uid', + UID_PersonOrdered: 'visitor-uid', + DisplayOrg: PersonWantsOrgRole.SYSTEM_ACCESS, + ValidFrom: '2023-01-01T00:00:00.000Z', + ValidUntil: '2023-01-10T00:00:00.000Z', + CustomProperty04: 'different-site-access-uid', + OrderState: OrderState.GRANTED, + } as PersonWantsOrg, + ]; + + mockOneIdentity.getPerson.mockResolvedValueOnce(mockPerson); + mockOneIdentity.getProposal.mockResolvedValueOnce(mockUidESet); + mockOneIdentity.getPersonWantsOrg.mockResolvedValueOnce( + mockPersonWantsOrgs + ); + + await expect( + syncVisitToOneIdentityHandler(visitMessage, Event.VISIT_DELETED) + ).rejects.toThrow( + 'System access not found in One Identity, cannot remove access' + ); + + expect(mockOneIdentity.login).toHaveBeenCalled(); + expect(mockOneIdentity.getPerson).toHaveBeenCalledWith( + 'visitor-oidc-sub' + ); + expect(mockOneIdentity.cancelPersonWantsOrg).toHaveBeenCalledWith( + 'site-access-uid' + ); + expect(logger.logInfo).toHaveBeenCalledWith( + 'Site access cancelled in One Identity', + { + UID_PersonWantsOrg: 'site-access-uid', + } + ); + expect(mockOneIdentity.logout).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts new file mode 100644 index 00000000..5479bc95 --- /dev/null +++ b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts @@ -0,0 +1,245 @@ +import process from 'process'; + +import { logger } from '@user-office-software/duo-logger'; + +import { Event } from '../../../../models/Event'; +import { ProposalMessageData } from '../../../../models/ProposalMessage'; +import { collectUsersFromProposalMessage } from '../../utils/collectUsersFromProposalMessage'; +import { ESSOneIdentity } from '../utils/ESSOneIdentity'; +import { IdentityType, UID_Person } from '../utils/interfaces/Person'; +import { + OrderState, + PersonWantsOrgRole, +} from '../utils/interfaces/PersonWantsOrg'; +import { VisitMessage } from '../utils/interfaces/VisitMessage'; + +const ONE_IDENTITY_SYSTEM_ACCESS_LASTS_FOR_DAYS = parseInt( + process.env.ONE_IDENTITY_SYSTEM_ACCESS_LASTS_FOR_DAYS || '30' +); + +export async function syncVisitToOneIdentityHandler( + { startAt, endAt, visitorId: oidcSub, proposal }: VisitMessage, + type: Event +): Promise { + const oneIdentity = new ESSOneIdentity(); + await oneIdentity.login(); + + logger.logInfo('One Identity successfully logged in', {}); + + try { + // Only Science Users' access should be managed! + const uidPerson = await getScienceUser(oneIdentity, oidcSub); + if (!uidPerson) { + logger.logInfo('Visitor is not a Science User, skipping', {}); + + return; + } + + const uidESet = await oneIdentity.getProposal(proposal); + + if (!uidESet) { + throw new Error('Proposal not found in One Identity, cannot sync visit'); + } + + if (type === Event.VISIT_CREATED) { + await createAccessInOneIdentity( + oneIdentity, + startAt, + endAt, + oidcSub, + proposal + ); + + // Every visitor should have access to the proposal folders + await createProposalConnection(oneIdentity, uidESet, uidPerson); + } else if (type === Event.VISIT_DELETED) { + await removeAccessFromOneIdentity(oneIdentity, startAt, endAt, uidPerson); + + // Remove the connection between the proposal and the visitor + await removeProposalConnection( + oneIdentity, + uidESet, + uidPerson, + oidcSub, + proposal + ); + } + } finally { + await oneIdentity.logout(); + logger.logInfo('One Identity successfully logged out', {}); + } +} + +// Find person UID from oidcSub +// If the person is not a science user, return undefined +async function getScienceUser( + oneIdentity: ESSOneIdentity, + centralAccount: string +): Promise { + // Find person UID from oidcSub + const person = await oneIdentity.getPerson(centralAccount); + + if (!person) { + throw new Error('Person not found in One Identity'); + } + + if (person.CCC_EmployeeSubType === IdentityType.ESSSCIENCEUSER) + return person.UID_Person; + else return undefined; +} + +async function createAccessInOneIdentity( + oneIdentity: ESSOneIdentity, + startAt: string, + endAt: string, + centralAccount: string, + proposal: ProposalMessageData +) { + // Create site access + const [pwoSite] = await oneIdentity.createPersonWantsOrg( + PersonWantsOrgRole.SITE_ACCESS, + centralAccount, + toIsoString(startAt), + toIsoString(endAt), + proposal.shortCode // CustomProperty04 - We store the proposal short code for the site access to be able to find it later + ); + + logger.logInfo('Site access created in One Identity', { + UID_PersonWantsOrg: pwoSite.UID_PersonWantsOrg, + }); + + // validFrom in One Identity should be in the future so that the access is not immediately available + const validFrom = Date.now(); + const validUntil = new Date(endAt).setDate( + new Date(endAt).getDate() + ONE_IDENTITY_SYSTEM_ACCESS_LASTS_FOR_DAYS + ); + + // Create system access + const [pwoSystem] = await oneIdentity.createPersonWantsOrg( + PersonWantsOrgRole.SYSTEM_ACCESS, + centralAccount, + toIsoString(validFrom), + toIsoString(validUntil), + pwoSite.UID_PersonWantsOrg // CustomProperty04 - We store the site access UID for the system access to be able to find it later + ); + + logger.logInfo('System access created in One Identity', { + UID_PersonWantsOrg: pwoSystem.UID_PersonWantsOrg, + }); +} + +async function removeAccessFromOneIdentity( + oneIdentity: ESSOneIdentity, + startAt: string, + endAt: string, + uidPerson: UID_Person +) { + // Find person wants orgs for the visitor + const personWantsOrgs = await oneIdentity.getPersonWantsOrg(uidPerson); + + // Find site access for the visitor + const siteAccess = personWantsOrgs.find( + (pwo) => + pwo.DisplayOrg === PersonWantsOrgRole.SITE_ACCESS && + toIsoString(pwo.ValidFrom) === toIsoString(startAt) && + toIsoString(pwo.ValidUntil) === toIsoString(endAt) && + pwo.OrderState !== OrderState.ABORTED + ); + + if (!siteAccess) { + throw new Error( + 'Site access not found in One Identity, cannot remove access' + ); + } + + await oneIdentity.cancelPersonWantsOrg(siteAccess.UID_PersonWantsOrg); + + logger.logInfo('Site access cancelled in One Identity', { + UID_PersonWantsOrg: siteAccess.UID_PersonWantsOrg, + }); + + // Find system access for the site access (CustomProperty04 is the site access UID) + const systemAccess = personWantsOrgs.find( + (pwo) => + pwo.CustomProperty04 === siteAccess.UID_PersonWantsOrg && + pwo.DisplayOrg === PersonWantsOrgRole.SYSTEM_ACCESS && + pwo.OrderState !== OrderState.UNSUBSCRIBED + ); + + if (!systemAccess) { + throw new Error( + 'System access not found in One Identity, cannot remove access' + ); + } + + await oneIdentity.cancelPersonWantsOrg(systemAccess.UID_PersonWantsOrg); + + logger.logInfo('System access cancelled in One Identity', { + UID_PersonWantsOrg: systemAccess.UID_PersonWantsOrg, + }); +} + +async function createProposalConnection( + oneIdentity: ESSOneIdentity, + uidESet: string, + uidPerson: string +) { + // Check if the connection already exists + // If connection already exists, no need to create it again, reasons could be: + // - The visitor is a member of the proposal + // - The visitor has been added to the proposal in the past + const exists = (await oneIdentity.getProposalPersonConnections(uidESet)).some( + (c) => c.UID_Person === uidPerson + ); + + if (exists) { + logger.logInfo('Connection already exists, skipping', { + uidPerson, + uidESet, + }); + } else { + await oneIdentity.connectPersonToProposal(uidESet, uidPerson); + logger.logInfo('Connection created between proposal and visitor', { + uidPerson, + uidESet, + }); + } +} + +async function removeProposalConnection( + oneIdentity: ESSOneIdentity, + uidESet: string, + uidPerson: string, + oidcSub: string, + proposal: ProposalMessageData +) { + const isMember = collectUsersFromProposalMessage(proposal).some( + (m) => m.oidcSub === oidcSub + ); + + if (isMember) { + logger.logInfo('Visitor is a proposal member, skipping removal', { + uidPerson, + uidESet, + }); + } else { + await oneIdentity.removeConnectionBetweenPersonAndProposal( + uidESet, + uidPerson + ); + logger.logInfo('Connection removed between proposal and visitor', { + uidPerson, + uidESet, + }); + } +} + +function toIsoString(date: string | number) { + const parsedDate = new Date(date); + + if (isNaN(parsedDate.getTime())) { + throw new Error(`Invalid date provided to toIsoString: ${date}`); + } + + return parsedDate.toISOString(); +} diff --git a/src/queue/consumers/oneidentity/utils/ESSOneIdentity.spec.ts b/src/queue/consumers/oneidentity/utils/ESSOneIdentity.spec.ts index bf73d3f1..3a037a22 100644 --- a/src/queue/consumers/oneidentity/utils/ESSOneIdentity.spec.ts +++ b/src/queue/consumers/oneidentity/utils/ESSOneIdentity.spec.ts @@ -10,8 +10,12 @@ jest.mock('process', () => ({ })); import { ESSOneIdentity } from './ESSOneIdentity'; +import { + PersonWantsOrg, + PersonWantsOrgRole, + OrderState, +} from './interfaces/PersonWantsOrg'; import { ProposalMessageData } from '../../../../models/ProposalMessage'; -import { ProposalUser } from '../../scicat/scicatProposal/dto'; const mockOneIdentityApi = { login: jest.fn(), @@ -19,6 +23,7 @@ const mockOneIdentityApi = { createEntity: jest.fn(), getEntities: jest.fn(), deleteEntity: jest.fn(), + callScript: jest.fn(), }; describe('ESSOneIdentity', () => { @@ -76,6 +81,18 @@ describe('ESSOneIdentity', () => { }); expect(result).toBe('created-uid'); }); + + it('should throw an error when UID_ESetType is not found', async () => { + const proposalMessage = { + shortCode: 'some-short-code', + } as ProposalMessageData; + + mockOneIdentityApi.getEntities.mockResolvedValueOnce([]); + + await expect( + essOneIdentity.createProposal(proposalMessage) + ).rejects.toThrow('UID_ESetType not found: PROPOSAL_IDENT_ESET_TYPE'); + }); }); describe('getProposal', () => { @@ -100,6 +117,18 @@ describe('ESSOneIdentity', () => { ); expect(result).toBe('proposal-uid'); }); + + it('should return undefined if proposal is not found', async () => { + const proposalMessage = { + shortCode: 'some-short-code', + } as ProposalMessageData; + + mockOneIdentityApi.getEntities.mockResolvedValueOnce([]); + + const result = await essOneIdentity.getProposal(proposalMessage); + + expect(result).toBeUndefined(); + }); }); describe('getPerson', () => { @@ -108,42 +137,47 @@ describe('ESSOneIdentity', () => { { values: { UID_Person: 'person-uid', + CCC_EmployeeSubType: 'ESSSCIENCEUSER', }, }, ]); - const result = await essOneIdentity.getPerson({ - email: 'foo@email', - }); + const result = await essOneIdentity.getPerson('0000-0000-0000-0000'); expect(mockOneIdentityApi.getEntities).toHaveBeenCalledWith( 'Person', - "ContactEmail='foo@email' OR DefaultEmailAddress='foo@email'" + "CentralAccount='0000-0000-0000-0000'", + ['CCC_EmployeeSubType'] ); - expect(result).toEqual({ UID_Person: 'person-uid' }); + expect(result).toEqual({ + UID_Person: 'person-uid', + CCC_EmployeeSubType: 'ESSSCIENCEUSER', + }); }); - // Currently, the ContactEmail field is not unique in the 1IM.Person table. - // This means that it is possible to have multiple persons with the same email. + // The CentralAccount is unique, but the response is an array of entities it('should return the first person if multiple persons are found', async () => { mockOneIdentityApi.getEntities.mockResolvedValueOnce([ { values: { UID_Person: 'person-1-uid', + CCC_EmployeeSubType: 'ESSSCIENCEUSER', }, }, { values: { UID_Person: 'person-2-uid', + CCC_EmployeeSubType: 'ESSSCIENCEUSER', }, }, ]); - const result = await essOneIdentity.getPerson({ - email: 'foo@email', - }); + const result = await essOneIdentity.getPerson('0000-0000-0000-0000'); - expect(result).toEqual({ UID_Person: 'person-1-uid' }); + expect(result).toEqual({ + UID_Person: 'person-1-uid', + CCC_EmployeeSubType: 'ESSSCIENCEUSER', + }); }); }); @@ -152,8 +186,7 @@ describe('ESSOneIdentity', () => { mockOneIdentityApi.getEntities.mockImplementation((table, filter) => { if ( table === 'Person' && - filter === - "ContactEmail='unknown-email' OR DefaultEmailAddress='unknown-email'" + filter === "CentralAccount='unknown-oidc-sub'" ) return Promise.resolve([]); else @@ -167,24 +200,11 @@ describe('ESSOneIdentity', () => { }); const result = await essOneIdentity.getPersons([ - { - email: 'unknown-email', - } as ProposalUser, - { - email: 'known-email', - } as ProposalUser, + 'unknown-oidc-sub', + 'known-oidc-sub', ]); - expect(result).toEqual([ - { - email: 'unknown-email', - uidPerson: undefined, - }, - { - email: 'known-email', - uidPerson: 'known-person-uid', - }, - ]); + expect(result).toEqual(['known-person-uid']); }); }); @@ -260,4 +280,342 @@ describe('ESSOneIdentity', () => { ]); }); }); + + describe('createPersonWantsOrg', () => { + const role = PersonWantsOrgRole.SITE_ACCESS; + const centralAccount = 'user123'; + const startDate = '2023-01-01'; + const endDate = '2023-12-31'; + const mockPersonWantsOrgData: PersonWantsOrg[] = [ + { + UID_PersonWantsOrg: 'pwo-123', + ValidFrom: startDate, + ValidUntil: endDate, + } as PersonWantsOrg, + ]; + + it('should successfully create site access', async () => { + mockOneIdentityApi.callScript.mockResolvedValueOnce({ + IsSuccess: true, + Data: mockPersonWantsOrgData, + Message: 'Success', + }); + + const result = await essOneIdentity.createPersonWantsOrg( + role, + centralAccount, + startDate, + endDate + ); + + expect(mockOneIdentityApi.callScript).toHaveBeenCalledWith( + 'SCProposalSiteAccess', + [role, centralAccount, centralAccount, startDate, endDate, '', ''] + ); + expect(result).toEqual(mockPersonWantsOrgData); + }); + + it('should successfully create site access with custom data', async () => { + const customData = 'custom-data-123'; + mockOneIdentityApi.callScript.mockResolvedValueOnce({ + IsSuccess: true, + Data: mockPersonWantsOrgData, + Message: 'Success', + }); + + const result = await essOneIdentity.createPersonWantsOrg( + role, + centralAccount, + startDate, + endDate, + customData + ); + + expect(mockOneIdentityApi.callScript).toHaveBeenCalledWith( + 'SCProposalSiteAccess', + [ + role, + centralAccount, + centralAccount, + startDate, + endDate, + customData, + '', + ] + ); + expect(result).toEqual(mockPersonWantsOrgData); + }); + + it('should throw an error when site access creation fails', async () => { + const errorMessage = 'Access denied'; + mockOneIdentityApi.callScript.mockResolvedValueOnce({ + IsSuccess: false, + Data: null, + Message: errorMessage, + }); + + await expect( + essOneIdentity.createPersonWantsOrg( + role, + centralAccount, + startDate, + endDate + ) + ).rejects.toThrow(`Failed to create site access: ${errorMessage}`); + expect(mockOneIdentityApi.callScript).toHaveBeenCalledWith( + 'SCProposalSiteAccess', + [role, centralAccount, centralAccount, startDate, endDate, '', ''] + ); + }); + }); + + describe('cancelPersonWantsOrg', () => { + const uidPersonWantsOrg = 'pwo-123'; + + it('should successfully cancel site access', async () => { + mockOneIdentityApi.callScript.mockResolvedValueOnce({ + IsSuccess: true, + Message: 'Success', + }); + + await essOneIdentity.cancelPersonWantsOrg(uidPersonWantsOrg); + + expect(mockOneIdentityApi.callScript).toHaveBeenCalledWith( + 'SCProposalSiteAccessCancel', + [uidPersonWantsOrg] + ); + }); + + it('should throw an error when site access cancellation fails', async () => { + const errorMessage = 'Access not found'; + mockOneIdentityApi.callScript.mockResolvedValueOnce({ + IsSuccess: false, + Message: errorMessage, + }); + + await expect( + essOneIdentity.cancelPersonWantsOrg(uidPersonWantsOrg) + ).rejects.toThrow(`Failed to cancel site access:${errorMessage}`); + expect(mockOneIdentityApi.callScript).toHaveBeenCalledWith( + 'SCProposalSiteAccessCancel', + [uidPersonWantsOrg] + ); + }); + }); + + describe('getPersonWantsOrg', () => { + const mockPersonWantsOrgData = [ + { + values: { + UID_PersonWantsOrg: 'pwo-123', + DisplayOrg: PersonWantsOrgRole.SITE_ACCESS, + ValidFrom: '2023-01-01', + ValidUntil: '2023-12-31', + OrderState: 'Granted', + }, + }, + { + values: { + UID_PersonWantsOrg: 'pwo-456', + DisplayOrg: PersonWantsOrgRole.SYSTEM_ACCESS, + ValidFrom: '2023-01-01', + ValidUntil: '2023-12-31', + OrderState: 'Granted', + }, + }, + ]; + + it('should get person wants org records with default parameters', async () => { + mockOneIdentityApi.getEntities.mockResolvedValueOnce( + mockPersonWantsOrgData + ); + + const result = await essOneIdentity.getPersonWantsOrg('person-uid'); + + expect(mockOneIdentityApi.getEntities).toHaveBeenCalledWith( + 'PersonWantsOrg', + "UID_PersonOrdered='person-uid' AND (DisplayOrg='Experiment visit - site access' OR DisplayOrg='Experiment visit - system access')", + [ + 'ValidFrom', + 'ValidUntil', + 'OrderState', + 'DisplayOrg', + 'CustomProperty04', + ] + ); + expect(result).toEqual([ + mockPersonWantsOrgData[0].values, + mockPersonWantsOrgData[1].values, + ]); + }); + + it('should get person wants org records with custom parameters', async () => { + mockOneIdentityApi.getEntities.mockResolvedValueOnce( + mockPersonWantsOrgData + ); + + const result = await essOneIdentity.getPersonWantsOrg('person-uid', [ + PersonWantsOrgRole.SITE_ACCESS, + ]); + + expect(mockOneIdentityApi.getEntities).toHaveBeenCalledWith( + 'PersonWantsOrg', + "UID_PersonOrdered='person-uid' AND (DisplayOrg='Experiment visit - site access')", + [ + 'ValidFrom', + 'ValidUntil', + 'OrderState', + 'DisplayOrg', + 'CustomProperty04', + ] + ); + expect(result).toEqual([ + mockPersonWantsOrgData[0].values, + mockPersonWantsOrgData[1].values, + ]); + }); + + it('should return empty array when no records found', async () => { + mockOneIdentityApi.getEntities.mockResolvedValueOnce([]); // No records + + const result = await essOneIdentity.getPersonWantsOrg('person-uid'); + + expect(mockOneIdentityApi.getEntities).toHaveBeenCalledWith( + 'PersonWantsOrg', + "UID_PersonOrdered='person-uid' AND (DisplayOrg='Experiment visit - site access' OR DisplayOrg='Experiment visit - system access')", + [ + 'ValidFrom', + 'ValidUntil', + 'OrderState', + 'DisplayOrg', + 'CustomProperty04', + ] + ); + expect(result).toEqual([]); + }); + }); + + describe('hasPersonSiteAccessToProposal', () => { + const uidPerson = 'person-123'; + const proposalUid = 'proposal-abc'; + + it('should return true if person has site access to the proposal', async () => { + mockOneIdentityApi.getEntities.mockResolvedValueOnce([ + { + values: { + DisplayOrg: PersonWantsOrgRole.SITE_ACCESS, + CustomProperty04: proposalUid, + OrderState: OrderState.GRANTED, + } as PersonWantsOrg, + }, + ]); + + const result = await essOneIdentity.hasPersonSiteAccessToProposal( + uidPerson, + proposalUid + ); + + expect(mockOneIdentityApi.getEntities).toHaveBeenCalledWith( + 'PersonWantsOrg', + `UID_PersonOrdered='${uidPerson}' AND (DisplayOrg='${PersonWantsOrgRole.SITE_ACCESS}')`, + [ + 'ValidFrom', + 'ValidUntil', + 'OrderState', + 'DisplayOrg', + 'CustomProperty04', + ] + ); + expect(result).toBe(true); + }); + + it('should return false if person does not have site access to the proposal (different proposal)', async () => { + mockOneIdentityApi.getEntities.mockResolvedValueOnce([ + { + values: { + DisplayOrg: PersonWantsOrgRole.SITE_ACCESS, + CustomProperty04: 'other-proposal-uid', + OrderState: OrderState.GRANTED, + } as PersonWantsOrg, + }, + ]); + + const result = await essOneIdentity.hasPersonSiteAccessToProposal( + uidPerson, + proposalUid + ); + expect(result).toBe(false); + }); + + it('should return false if person does not have site access to the proposal (different role)', async () => { + mockOneIdentityApi.getEntities.mockResolvedValueOnce([ + { + values: { + DisplayOrg: PersonWantsOrgRole.SYSTEM_ACCESS, // Different role + CustomProperty04: proposalUid, + OrderState: OrderState.GRANTED, + } as PersonWantsOrg, + }, + ]); + + const result = await essOneIdentity.hasPersonSiteAccessToProposal( + uidPerson, + proposalUid + ); + expect(result).toBe(false); + }); + + it('should return false if site access is aborted', async () => { + mockOneIdentityApi.getEntities.mockResolvedValueOnce([ + { + values: { + DisplayOrg: PersonWantsOrgRole.SITE_ACCESS, + CustomProperty04: proposalUid, + OrderState: OrderState.ABORTED, // Aborted state + } as PersonWantsOrg, + }, + ]); + + const result = await essOneIdentity.hasPersonSiteAccessToProposal( + uidPerson, + proposalUid + ); + expect(result).toBe(false); + }); + + it('should return false if no PersonWantsOrg records are found', async () => { + mockOneIdentityApi.getEntities.mockResolvedValueOnce([]); // No records + + const result = await essOneIdentity.hasPersonSiteAccessToProposal( + uidPerson, + proposalUid + ); + expect(result).toBe(false); + }); + + it('should return true if person has multiple site access records and one matches', async () => { + mockOneIdentityApi.getEntities.mockResolvedValueOnce([ + { + values: { + DisplayOrg: PersonWantsOrgRole.SITE_ACCESS, + CustomProperty04: 'other-proposal-uid', + OrderState: OrderState.GRANTED, + } as PersonWantsOrg, + }, + { + values: { + DisplayOrg: PersonWantsOrgRole.SITE_ACCESS, + CustomProperty04: proposalUid, + OrderState: OrderState.GRANTED, + } as PersonWantsOrg, + }, + ]); + + const result = await essOneIdentity.hasPersonSiteAccessToProposal( + uidPerson, + proposalUid + ); + expect(result).toBe(true); + }); + }); }); diff --git a/src/queue/consumers/oneidentity/utils/ESSOneIdentity.ts b/src/queue/consumers/oneidentity/utils/ESSOneIdentity.ts index d9afda4d..ad79a121 100644 --- a/src/queue/consumers/oneidentity/utils/ESSOneIdentity.ts +++ b/src/queue/consumers/oneidentity/utils/ESSOneIdentity.ts @@ -1,35 +1,23 @@ import { env } from 'process'; +import { Eset, UID_ESet } from './interfaces/Eset'; +import { EsetType } from './interfaces/EsetType'; +import { Person, UID_Person } from './interfaces/Person'; +import { PersonHasESET } from './interfaces/PersonHasESET'; +import { + OrderState, + PersonWantsOrg, + PersonWantsOrgRole, +} from './interfaces/PersonWantsOrg'; +import { + SCProposalSiteAccessCancelResponse, + SCProposalSiteAccessResponse, +} from './interfaces/SCProposalSiteAccessResponse'; import { OneIdentityApi } from './OneIdentityApi'; import { ProposalMessageData } from '../../../../models/ProposalMessage'; -import { ProposalUser } from '../../scicat/scicatProposal/dto'; - -type UID_ESetType = string; -export type UID_Person = string; -export type UID_ESet = string; - -interface EsetValues { - UID_ESet: UID_ESet; - UID_ESetType: UID_ESetType; - Ident_ESet: string; - DisplayName: string; -} - -interface EsetTypeValues { - UID_ESetType: UID_ESetType; -} - -interface PersonValues { - UID_Person: UID_Person; -} - -export interface PersonHasESETValues { - UID_Person: UID_Person; - UID_ESet: UID_ESet; -} export interface UserPersonConnection { - email: string; + oidcSub: string; uidPerson: UID_Person | undefined; } @@ -60,7 +48,7 @@ export class ESSOneIdentity { proposalMessage: ProposalMessageData ): Promise { // get "Science Proposal" UID_ESetType from ESS One Identity - const entities = await this.oneIdentityApi.getEntities( + const entities = await this.oneIdentityApi.getEntities( 'EsetType', `Ident_ESetType='${PROPOSAL_IDENT_ESET_TYPE}'` ); @@ -73,7 +61,7 @@ export class ESSOneIdentity { // create proposal in ESS One Identity const esetResponse = await this.oneIdentityApi.createEntity< - Omit + Omit >('ESET', { UID_ESetType: uidESetType, Ident_ESet: proposalMessage.shortCode, @@ -86,7 +74,7 @@ export class ESSOneIdentity { public async getProposal( proposalMessage: ProposalMessageData ): Promise { - const entities = await this.oneIdentityApi.getEntities( + const entities = await this.oneIdentityApi.getEntities( 'ESET', `Ident_ESet='${proposalMessage.shortCode}'` ); @@ -94,38 +82,32 @@ export class ESSOneIdentity { return entities[0]?.values?.UID_ESet; } - public async getPerson( - user: Pick - ): Promise { - const entities = await this.oneIdentityApi.getEntities( + public async getPerson(centralAccount: string): Promise { + const entities = await this.oneIdentityApi.getEntities( 'Person', - `ContactEmail='${user.email}' OR DefaultEmailAddress='${user.email}'` // ContactEmail is for scienceusers, DefaultEmailAddress is for ESS employees + `CentralAccount='${centralAccount}'`, + ['CCC_EmployeeSubType'] ); - // In theory there should be only one person with the same email, but the 1IM.Person table has no unique constraint on ContactEmail. - // We can't control this, so we just take the first one. return entities[0]?.values; } - public async getPersons( - users: ProposalUser[] - ): Promise { - return await Promise.all( - users - .filter((user): user is ProposalUser => user !== undefined) - .map(async (user) => { - const uidPerson = (await this.getPerson(user))?.UID_Person; - - return { email: user.email, uidPerson }; - }) - ); + public async getPersons(centralAccounts: string[]): Promise { + return ( + await Promise.all( + centralAccounts.map( + async (centralAccount) => + (await this.getPerson(centralAccount))?.UID_Person + ) + ) + ).filter((uidPerson): uidPerson is string => uidPerson !== undefined); } public async connectPersonToProposal( uidEset: UID_ESet, uidPerson: UID_Person ): Promise { - const { uid } = await this.oneIdentityApi.createEntity( + const { uid } = await this.oneIdentityApi.createEntity( 'PersonHasESET', { UID_ESet: uidEset, @@ -148,12 +130,90 @@ export class ESSOneIdentity { public async getProposalPersonConnections( uidEset: UID_ESet - ): Promise { - const entities = await this.oneIdentityApi.getEntities( + ): Promise { + const entities = await this.oneIdentityApi.getEntities( 'PersonHasESET', `UID_ESet='${uidEset}'` ); return entities.map(({ values }) => values); } + + public async createPersonWantsOrg( + role: PersonWantsOrgRole, + centralAccount: string, + startDate: string, + endDate: string, + customData: string = '' + ): Promise { + const res = + await this.oneIdentityApi.callScript( + 'SCProposalSiteAccess', + [ + role, // access type + centralAccount, // requester + centralAccount, // recipient + startDate, + endDate, + customData, // PersonWantsOrg.CustomProperty04 + '', // UID_PersonWantsOrg (empty for new) + ] + ); + + if (!res.IsSuccess) + throw new Error('Failed to create site access: ' + res.Message); + + return res.Data; + } + + public async cancelPersonWantsOrg(uidPersonWantsOrg: string): Promise { + const res = + await this.oneIdentityApi.callScript( + 'SCProposalSiteAccessCancel', + [uidPersonWantsOrg] + ); + + if (!res.IsSuccess) + throw new Error('Failed to cancel site access:' + res.Message); + } + + public async getPersonWantsOrg( + uidPerson: UID_Person, + displayOrgs: PersonWantsOrgRole[] = [ + PersonWantsOrgRole.SITE_ACCESS, + PersonWantsOrgRole.SYSTEM_ACCESS, + ] + ): Promise { + const entities = await this.oneIdentityApi.getEntities( + 'PersonWantsOrg', + `UID_PersonOrdered='${uidPerson}' AND (${displayOrgs + .map((org) => `DisplayOrg='${org}'`) + .join(' OR ')})`, + [ + 'ValidFrom', + 'ValidUntil', + 'OrderState', + 'DisplayOrg', + 'CustomProperty04', + ] + ); + + return entities.map(({ values }) => values); + } + + public async hasPersonSiteAccessToProposal( + uidPerson: UID_Person, + proposal: UID_ESet + ): Promise { + const personWantsOrgs = await this.getPersonWantsOrg(uidPerson, [ + PersonWantsOrgRole.SITE_ACCESS, + ]); + + return personWantsOrgs.some( + (pwo) => + pwo.DisplayOrg === PersonWantsOrgRole.SITE_ACCESS && + pwo.CustomProperty04 === proposal && + pwo.OrderState !== OrderState.ABORTED + ); + } } diff --git a/src/queue/consumers/oneidentity/utils/OneIdentityApi.spec.ts b/src/queue/consumers/oneidentity/utils/OneIdentityApi.spec.ts index 6ede6de9..ac6e232a 100644 --- a/src/queue/consumers/oneidentity/utils/OneIdentityApi.spec.ts +++ b/src/queue/consumers/oneidentity/utils/OneIdentityApi.spec.ts @@ -47,6 +47,23 @@ describe('OneIdentityApi', () => { } ); }); + + it('should throw error when no cookie is set', async () => { + (axios.post as jest.Mock).mockResolvedValueOnce({ + headers: {}, + }); + + await expect(api.login('user', 'password')).rejects.toThrow( + 'No cookie set' + ); + }); + + it('should handle API errors during login', async () => { + const errorMessage = 'Authentication failed'; + (axios.post as jest.Mock).mockRejectedValueOnce(new Error(errorMessage)); + + await expect(api.login('user', 'password')).rejects.toThrow(errorMessage); + }); }); describe('logout', () => { @@ -87,6 +104,18 @@ describe('OneIdentityApi', () => { }); expect(result).toEqual(mockEntities); }); + + it('should get entities with display columns successfully', async () => { + const mockEntities = [{ id: 1, name: 'test' }]; + (axios.get as jest.Mock).mockResolvedValueOnce({ data: mockEntities }); + + const result = await api.getEntities('testTable', 'id=1', ['name']); + + expect(axios.get).toHaveBeenCalledWith('/entities/testTable', { + params: { where: 'id=1', displayColumns: 'name' }, + }); + expect(result).toEqual(mockEntities); + }); }); describe('deleteEntity', () => { @@ -98,4 +127,29 @@ describe('OneIdentityApi', () => { expect(axios.delete).toHaveBeenCalledWith('/entity/testTable/1'); }); }); + + describe('callScript', () => { + it('should call script successfully', async () => { + const mockResult = { success: true, data: 'script result' }; + (axios.put as jest.Mock).mockResolvedValueOnce({ data: mockResult }); + + const scriptParams = ['param1', 'param2']; + const result = await api.callScript('TestScript', scriptParams); + + expect(axios.put).toHaveBeenCalledWith('/script/TestScript', { + parameters: scriptParams, + returnRawResult: true, + }); + expect(result).toEqual(mockResult); + }); + + it('should handle errors during script calls', async () => { + const errorMessage = 'Script execution failed'; + (axios.put as jest.Mock).mockRejectedValueOnce(new Error(errorMessage)); + + await expect(api.callScript('TestScript', [])).rejects.toThrow( + errorMessage + ); + }); + }); }); diff --git a/src/queue/consumers/oneidentity/utils/OneIdentityApi.ts b/src/queue/consumers/oneidentity/utils/OneIdentityApi.ts index 99e9e015..20c2045b 100644 --- a/src/queue/consumers/oneidentity/utils/OneIdentityApi.ts +++ b/src/queue/consumers/oneidentity/utils/OneIdentityApi.ts @@ -89,13 +89,19 @@ export class OneIdentityApi { public async getEntities( table: string, - where: string + where: string, + displayColumns?: (keyof T)[] ): Promise[]> { const { data } = await this.axiosInstance.get< T, AxiosResponse[]> >(`/entities/${table}`, { - params: { where }, + params: { + where, + ...(displayColumns && displayColumns.length > 0 + ? { displayColumns: displayColumns.join(',') } + : {}), + }, }); return data; @@ -104,4 +110,16 @@ export class OneIdentityApi { public deleteEntity(table: string, uid: string): Promise { return this.axiosInstance.delete(`/entity/${table}/${uid}`); } + + public async callScript(name: string, parameters: string[]): Promise { + const { data } = await this.axiosInstance.put>( + `/script/${name}`, + { + parameters, + returnRawResult: true, // One Identity API returns the result as a string from the script. Axios will be able to JSON parse it. + } + ); + + return data; + } } diff --git a/src/queue/consumers/oneidentity/utils/interfaces/Eset.ts b/src/queue/consumers/oneidentity/utils/interfaces/Eset.ts new file mode 100644 index 00000000..81e2b3a6 --- /dev/null +++ b/src/queue/consumers/oneidentity/utils/interfaces/Eset.ts @@ -0,0 +1,10 @@ +import { UID_ESetType } from './EsetType'; + +export type UID_ESet = string; + +export interface Eset { + UID_ESet: UID_ESet; + UID_ESetType: UID_ESetType; + Ident_ESet: string; + DisplayName: string; +} diff --git a/src/queue/consumers/oneidentity/utils/interfaces/EsetType.ts b/src/queue/consumers/oneidentity/utils/interfaces/EsetType.ts new file mode 100644 index 00000000..83113e56 --- /dev/null +++ b/src/queue/consumers/oneidentity/utils/interfaces/EsetType.ts @@ -0,0 +1,6 @@ +export type UID_ESetType = string; + +export interface EsetType { + Ident_ESetType: string; + UID_ESetType: UID_ESetType; +} diff --git a/src/queue/consumers/oneidentity/utils/interfaces/Person.ts b/src/queue/consumers/oneidentity/utils/interfaces/Person.ts new file mode 100644 index 00000000..a5dfc121 --- /dev/null +++ b/src/queue/consumers/oneidentity/utils/interfaces/Person.ts @@ -0,0 +1,13 @@ +export enum IdentityType { + ESSSCIENCEUSER = 'ESSSCIENCEUSER', + EMPLOYEEDK = 'EMPLOYEEDK', +} + +export type UID_Person = string; + +export interface Person { + CCC_EmployeeSubType: IdentityType; + CentralAccount: string; + InternalName: string; + UID_Person: UID_Person; +} diff --git a/src/queue/consumers/oneidentity/utils/interfaces/PersonHasESET.ts b/src/queue/consumers/oneidentity/utils/interfaces/PersonHasESET.ts new file mode 100644 index 00000000..a307c90a --- /dev/null +++ b/src/queue/consumers/oneidentity/utils/interfaces/PersonHasESET.ts @@ -0,0 +1,7 @@ +import { UID_ESet } from './Eset'; +import { UID_Person } from './Person'; + +export interface PersonHasESET { + UID_Person: UID_Person; + UID_ESet: UID_ESet; +} diff --git a/src/queue/consumers/oneidentity/utils/interfaces/PersonWantsOrg.ts b/src/queue/consumers/oneidentity/utils/interfaces/PersonWantsOrg.ts new file mode 100644 index 00000000..47cd44df --- /dev/null +++ b/src/queue/consumers/oneidentity/utils/interfaces/PersonWantsOrg.ts @@ -0,0 +1,93 @@ +export enum OrderState { + GRANTED = 'Granted', + ABORTED = 'Aborted', + WAITING = 'Waiting', + ASSIGNED = 'Assigned', + UNSUBSCRIBED = 'Unsubscribed', + ORDERPRODUCT = 'OrderProduct', +} + +export enum PersonWantsOrgRole { + SYSTEM_ACCESS = 'Experiment visit - system access', + SITE_ACCESS = 'Experiment visit - site access', +} + +export interface PersonWantsOrg { + AdditionalData: string; + CCC_CustomDate01: string; + CCC_CustomPerson01: string; + CheckResult: number; + CheckResultDetail: string; + CustomProperty01: string; + CustomProperty02: string; + CustomProperty03: string; + CustomProperty04: string; + CustomProperty05: string; + CustomProperty06: string; + CustomProperty07: string; + CustomProperty08: string; + CustomProperty09: string; + CustomProperty10: string; + DateActivated: string; + DateDeactivated: string; + DateHead: string; + DecisionLevel: number; + DisplayObjectKeyAssignment: string; + DisplayOrg: PersonWantsOrgRole; + DisplayOrgParent: string; + DisplayOrgParentOfParent: string; + DisplayPersonHead: string; + DisplayPersonInserted: string; + DisplayPersonOrdered: string; + GenProcID: string; + IsCrossFunctional: boolean; + IsOptionalChild: boolean; + IsOrderForWorkDesk: boolean; + IsReserved: boolean; + ObjectKeyAssignment: string; + ObjectKeyElementUsedInAssign: string; + ObjectKeyFinal: string; + ObjectKeyOrdered: string; + ObjectKeyOrgUsedInAssign: string; + OrderDate: string; + OrderDetail1: string; + OrderDetail2: string; + OrderReason: string; + OrderState: OrderState; + PeerGroupFactor: number; + PWOPriority: number; + Quantity: number; + ReasonHead: string; + Recommendation: number; + RecommendationDetail: string; + UID_Department: string; + UID_ITShopOrgFinal: string; + UID_Org: string; + UID_OrgParent: string; + UID_OrgParentOfParent: string; + UID_PersonHead: string; + UID_PersonInserted: string; + UID_PersonOrdered: string; + UID_PersonWantsOrg: string; + UID_PersonWantsOrgParent: string; + UID_ProfitCenter: string; + UID_PWOState: string; + UID_QERJustification: string; + UID_QERJustificationOrder: string; + UID_QERResourceType: string; + UID_QERWorkingMethod: string; + UID_ShoppingCartOrder: string; + UID_WorkDeskOrdered: string; + UiOrderState: string; + ValidFrom: string; + ValidUntil: string; + ValidUntilProlongation: string; + ValidUntilUnsubscribe: string; + XDateInserted: string; + XDateUpdated: string; + XMarkedForDeletion: number; + XObjectKey: string; + XTouched: string; + XUserInserted: string; + XUserUpdated: string; +} diff --git a/src/queue/consumers/oneidentity/utils/interfaces/SCProposalSiteAccessResponse.ts b/src/queue/consumers/oneidentity/utils/interfaces/SCProposalSiteAccessResponse.ts new file mode 100644 index 00000000..1ae848f2 --- /dev/null +++ b/src/queue/consumers/oneidentity/utils/interfaces/SCProposalSiteAccessResponse.ts @@ -0,0 +1,14 @@ +import { PersonWantsOrg } from './PersonWantsOrg'; + +export interface SiteAccessResponse { + IsSuccess: boolean; + Message: string | null; +} + +export interface SCProposalSiteAccessResponse extends SiteAccessResponse { + Data: PersonWantsOrg[]; +} + +export interface SCProposalSiteAccessCancelResponse extends SiteAccessResponse { + Data: []; +} diff --git a/src/queue/consumers/oneidentity/utils/interfaces/VisitMessage.ts b/src/queue/consumers/oneidentity/utils/interfaces/VisitMessage.ts new file mode 100644 index 00000000..ce180475 --- /dev/null +++ b/src/queue/consumers/oneidentity/utils/interfaces/VisitMessage.ts @@ -0,0 +1,8 @@ +import { ProposalMessageData } from '../../../../../models/ProposalMessage'; + +export interface VisitMessage { + startAt: string; + endAt: string; + visitorId: string; + proposal: ProposalMessageData; +} diff --git a/src/queue/consumers/oneidentity/utils/isVisitMessage.spec.ts b/src/queue/consumers/oneidentity/utils/isVisitMessage.spec.ts new file mode 100644 index 00000000..5f7c0d78 --- /dev/null +++ b/src/queue/consumers/oneidentity/utils/isVisitMessage.spec.ts @@ -0,0 +1,58 @@ +import { isVisitMessage } from './isVisitMessage'; + +describe('isVisitMessage', () => { + it('should return false if message is not an object', () => { + const message = 'not an object'; + expect(isVisitMessage(message)).toBe(false); + }); + + it('should return false if message is null', () => { + const message = null; + expect(isVisitMessage(message)).toBe(false); + }); + + it('should return false if visitorId is undefined', () => { + const message = { + startAt: '2023-01-01T00:00:00Z', + endAt: '2023-01-02T00:00:00Z', + }; + expect(isVisitMessage(message)).toBe(false); + }); + + it('should return false if startAt is undefined', () => { + const message = { + visitorId: 'visitor123', + endAt: '2023-01-02T00:00:00Z', + }; + expect(isVisitMessage(message)).toBe(false); + }); + + it('should return false if endAt is undefined', () => { + const message = { + visitorId: 'visitor123', + startAt: '2023-01-01T00:00:00Z', + }; + expect(isVisitMessage(message)).toBe(false); + }); + + it('should return false if proposal is undefined', () => { + const message = { + visitorId: 'visitor123', + startAt: '2023-01-01T00:00:00Z', + endAt: '2023-01-02T00:00:00Z', + }; + expect(isVisitMessage(message)).toBe(false); + }); + + it('should return true if the message is valid', () => { + const message = { + visitorId: 'visitor123', + startAt: '2023-01-01T00:00:00Z', + endAt: '2023-01-02T00:00:00Z', + proposal: { + shortCode: 'proposal-short-code', + }, + }; + expect(isVisitMessage(message)).toBe(true); + }); +}); diff --git a/src/queue/consumers/oneidentity/utils/isVisitMessage.ts b/src/queue/consumers/oneidentity/utils/isVisitMessage.ts new file mode 100644 index 00000000..ed7cb282 --- /dev/null +++ b/src/queue/consumers/oneidentity/utils/isVisitMessage.ts @@ -0,0 +1,12 @@ +import { VisitMessage } from './interfaces/VisitMessage'; + +export function isVisitMessage(message: any): message is VisitMessage { + return ( + message != null && + typeof message === 'object' && + 'visitorId' in message && + 'startAt' in message && + 'endAt' in message && + 'proposal' in message + ); +} diff --git a/src/queue/consumers/oneidentity/utils/validateRequiredProposalMessageFields.spec.ts b/src/queue/consumers/oneidentity/utils/validateProposalMessage.spec.ts similarity index 63% rename from src/queue/consumers/oneidentity/utils/validateRequiredProposalMessageFields.spec.ts rename to src/queue/consumers/oneidentity/utils/validateProposalMessage.spec.ts index f16bd774..eb26d670 100644 --- a/src/queue/consumers/oneidentity/utils/validateRequiredProposalMessageFields.spec.ts +++ b/src/queue/consumers/oneidentity/utils/validateProposalMessage.spec.ts @@ -1,10 +1,10 @@ -import { validateRequiredProposalMessageFields } from './validateRequiredProposalMessageFields'; +import { validateProposalMessage } from './validateProposalMessage'; -describe('validateRequiredProposalMessageFields', () => { +describe('validateProposalMessage', () => { it('should throw an error if message is not an object', () => { const message = 'not an object'; - expect(() => validateRequiredProposalMessageFields(message)).toThrow( + expect(() => validateProposalMessage(message)).toThrow( 'Invalid proposal message' ); }); @@ -12,7 +12,7 @@ describe('validateRequiredProposalMessageFields', () => { it('should throw an error if message is null', () => { const message = null; - expect(() => validateRequiredProposalMessageFields(message)).toThrow( + expect(() => validateProposalMessage(message)).toThrow( 'Invalid proposal message' ); }); @@ -23,7 +23,7 @@ describe('validateRequiredProposalMessageFields', () => { members: [], }; - expect(() => validateRequiredProposalMessageFields(message)).toThrow( + expect(() => validateProposalMessage(message)).toThrow( 'Invalid proposal message' ); }); @@ -34,7 +34,7 @@ describe('validateRequiredProposalMessageFields', () => { members: [], }; - expect(() => validateRequiredProposalMessageFields(message)).toThrow( + expect(() => validateProposalMessage(message)).toThrow( 'Invalid proposal message' ); }); @@ -45,7 +45,7 @@ describe('validateRequiredProposalMessageFields', () => { proposer: {}, }; - expect(() => validateRequiredProposalMessageFields(message)).toThrow( + expect(() => validateProposalMessage(message)).toThrow( 'Invalid proposal message' ); }); @@ -57,6 +57,6 @@ describe('validateRequiredProposalMessageFields', () => { members: [], }; - expect(validateRequiredProposalMessageFields(message)).toEqual(message); + expect(validateProposalMessage(message)).toEqual(message); }); }); diff --git a/src/queue/consumers/oneidentity/utils/validateRequiredProposalMessageFields.ts b/src/queue/consumers/oneidentity/utils/validateProposalMessage.ts similarity index 69% rename from src/queue/consumers/oneidentity/utils/validateRequiredProposalMessageFields.ts rename to src/queue/consumers/oneidentity/utils/validateProposalMessage.ts index 072590e6..67227867 100644 --- a/src/queue/consumers/oneidentity/utils/validateRequiredProposalMessageFields.ts +++ b/src/queue/consumers/oneidentity/utils/validateProposalMessage.ts @@ -1,12 +1,10 @@ import { ProposalMessageData } from '../../../../models/ProposalMessage'; // For OneIdentity, only these fields are required -export function validateRequiredProposalMessageFields( +export function validateProposalMessage( message: any ): ProposalMessageData | never { if ( - typeof message !== 'object' || - message === null || message?.shortCode === undefined || message?.proposer === undefined || message?.members === undefined @@ -14,5 +12,5 @@ export function validateRequiredProposalMessageFields( throw new Error('Invalid proposal message'); } - return message as ProposalMessageData; + return message; } diff --git a/src/queue/consumers/scicat/scicatProposal/consumerCallbacks/createChatroom.ts b/src/queue/consumers/scicat/scicatProposal/consumerCallbacks/createChatroom.ts index db6e5824..20d0f21f 100644 --- a/src/queue/consumers/scicat/scicatProposal/consumerCallbacks/createChatroom.ts +++ b/src/queue/consumers/scicat/scicatProposal/consumerCallbacks/createChatroom.ts @@ -50,6 +50,7 @@ const createChatroom = async (message: ValidProposalMessageData) => { const synapseService: SynapseService = container.resolve( Tokens.SynapseService ); + await synapseService.login(); const allUsersOnProposal = [...message.members, message.proposer]; const { validUsers, invalidUsers } = validateUsersProfile(allUsersOnProposal); @@ -117,6 +118,8 @@ const createChatroom = async (message: ValidProposalMessageData) => { } } catch (err: unknown) { logger.logException('Error while creating chatroom: ', err, { message }); + } finally { + await synapseService.logout(); } }; diff --git a/src/queue/consumers/scicat/scicatProposal/consumerCallbacks/proposalFoldersCreation.spec.ts b/src/queue/consumers/scicat/scicatProposal/consumerCallbacks/proposalFoldersCreation.spec.ts index 24a1936f..28ade264 100644 --- a/src/queue/consumers/scicat/scicatProposal/consumerCallbacks/proposalFoldersCreation.spec.ts +++ b/src/queue/consumers/scicat/scicatProposal/consumerCallbacks/proposalFoldersCreation.spec.ts @@ -85,7 +85,7 @@ describe('proposalFoldersCreation', () => { expect(exec).toHaveBeenCalledTimes(1); expect(exec).toHaveBeenCalledWith( - 'command shortcode 2024 shortCode group_prefix_shortCode test.proposer@email.com test.member@email.com', + 'command shortcode 2025 shortCode group_prefix_shortCode test.proposer@email.com test.member@email.com', expect.any(Function) ); }); diff --git a/src/queue/consumers/scicat/scicatProposal/consumerCallbacks/upsertProposalInScicat.ts b/src/queue/consumers/scicat/scicatProposal/consumerCallbacks/upsertProposalInScicat.ts index d76c9de6..215b697c 100644 --- a/src/queue/consumers/scicat/scicatProposal/consumerCallbacks/upsertProposalInScicat.ts +++ b/src/queue/consumers/scicat/scicatProposal/consumerCallbacks/upsertProposalInScicat.ts @@ -1,5 +1,6 @@ import { logger } from '@user-office-software/duo-logger'; +import { Instrument } from '../../../../../models/ProposalMessage'; import { ValidProposalMessageData } from '../../../utils/validateProposalMessage'; import { CreateProposalDto, UpdateProposalDto } from '../dto'; @@ -64,6 +65,7 @@ const getCreateProposalDto = (proposalMessage: ValidProposalMessageData) => { startTime: new Date(), endTime: new Date(), MeasurementPeriodList: [], + metadata: createInstrumentsObject(proposalMessage.instruments), }; return createProposalDto; @@ -84,11 +86,34 @@ const getUpdateProposalDto = (proposalMessage: ValidProposalMessageData) => { startTime: new Date(), endTime: new Date(), MeasurementPeriodList: [], + metadata: createInstrumentsObject(proposalMessage.instruments), }; return updateProposalDto; }; +const createInstrumentsObject = (instruments: Instrument[]) => { + const instrumentsObject: Record< + string, + { value: string | number; unit: string } + > = {}; + + instruments.forEach((instrument, index) => { + if (instrument) { + instrumentsObject[`instrument_${index + 1}`] = { + value: instrument.shortCode, + unit: '', + }; + instrumentsObject[`instrument_time_${index + 1}`] = { + value: instrument.allocatedTime / 86400 || NaN, + unit: 'days', + }; + } + }); + + return instrumentsObject; +}; + const createProposal = async ( proposalMessage: ValidProposalMessageData, sciCatAccessToken: string @@ -151,9 +176,16 @@ const checkProposalExists = async ( Authorization: `Bearer ${sciCatAccessToken}`, }, }).catch((error) => { - const parsedError = JSON.parse(error.message || '{}'); - if (parsedError.statusCode === 404) { - return false; + try { + const parsedError = JSON.parse(error.message); + if (parsedError.statusCode === 404) { + return false; + } + } catch (reason) { + logger.logError('Error parsing error message', { + error, + reason, + }); } throw error; }); diff --git a/src/queue/consumers/scicat/scicatProposal/consumers/ChatroomCreationQueueConsumer.ts b/src/queue/consumers/scicat/scicatProposal/consumers/ChatroomCreationQueueConsumer.ts index 748d700b..cc431a52 100644 --- a/src/queue/consumers/scicat/scicatProposal/consumers/ChatroomCreationQueueConsumer.ts +++ b/src/queue/consumers/scicat/scicatProposal/consumers/ChatroomCreationQueueConsumer.ts @@ -8,8 +8,6 @@ import { validateProposalMessage } from '../../../utils/validateProposalMessage' import { createChatroom } from '../consumerCallbacks/createChatroom'; const EVENT_TYPES = [ - Event.PROPOSAL_STATUS_CHANGED_BY_WORKFLOW, - Event.PROPOSAL_STATUS_CHANGED_BY_USER, Event.PROPOSAL_STATUS_ACTION_EXECUTED, Event.PROPOSAL_UPDATED, ]; diff --git a/src/queue/consumers/scicat/scicatProposal/consumers/FolderCreationQueueConsumer.ts b/src/queue/consumers/scicat/scicatProposal/consumers/FolderCreationQueueConsumer.ts index e734aa10..fad6dc2e 100644 --- a/src/queue/consumers/scicat/scicatProposal/consumers/FolderCreationQueueConsumer.ts +++ b/src/queue/consumers/scicat/scicatProposal/consumers/FolderCreationQueueConsumer.ts @@ -7,11 +7,7 @@ import { hasTriggeringType } from '../../../utils/hasTriggeringType'; import { validateProposalMessage } from '../../../utils/validateProposalMessage'; import { proposalFoldersCreation } from '../consumerCallbacks/proposalFoldersCreation'; -const EVENT_TYPES = [ - Event.PROPOSAL_STATUS_CHANGED_BY_WORKFLOW, - Event.PROPOSAL_STATUS_CHANGED_BY_USER, - Event.PROPOSAL_STATUS_ACTION_EXECUTED, -]; +const EVENT_TYPES = [Event.PROPOSAL_STATUS_ACTION_EXECUTED]; const triggeringStatuses = process.env.PROPOSAL_FOLDERS_CREATION_TRIGGERING_STATUSES?.split(', '); diff --git a/src/queue/consumers/scicat/scicatProposal/consumers/ProposalCreationQueueConsumer.ts b/src/queue/consumers/scicat/scicatProposal/consumers/ProposalCreationQueueConsumer.ts index f3862bb2..e6f790c2 100644 --- a/src/queue/consumers/scicat/scicatProposal/consumers/ProposalCreationQueueConsumer.ts +++ b/src/queue/consumers/scicat/scicatProposal/consumers/ProposalCreationQueueConsumer.ts @@ -8,10 +8,10 @@ import { validateProposalMessage } from '../../../utils/validateProposalMessage' import { upsertProposalInScicat } from '../consumerCallbacks/upsertProposalInScicat'; const EVENT_TYPES = [ - Event.PROPOSAL_STATUS_CHANGED_BY_WORKFLOW, - Event.PROPOSAL_STATUS_CHANGED_BY_USER, Event.PROPOSAL_STATUS_ACTION_EXECUTED, + Event.PROPOSAL_UPDATED, ]; + const triggeringStatuses = process.env.SCICAT_PROPOSAL_TRIGGERING_STATUSES?.split(', '); diff --git a/src/queue/consumers/scicat/scicatProposal/dto.ts b/src/queue/consumers/scicat/scicatProposal/dto.ts index 04e90535..5380e2aa 100644 --- a/src/queue/consumers/scicat/scicatProposal/dto.ts +++ b/src/queue/consumers/scicat/scicatProposal/dto.ts @@ -13,6 +13,7 @@ export type CreateProposalDto = { startTime?: Date; endTime?: Date; MeasurementPeriodList: any[]; + metadata?: Record; }; export type UpdateProposalDto = { @@ -29,6 +30,7 @@ export type UpdateProposalDto = { startTime?: Date; endTime?: Date; MeasurementPeriodList?: any[]; + metadata?: Record; }; export interface Institution { diff --git a/src/queue/consumers/visa/consumerCallbacks/syncVisaProposal.ts b/src/queue/consumers/visa/consumerCallbacks/syncVisaProposal.ts index 824bb39a..0dd0b74d 100644 --- a/src/queue/consumers/visa/consumerCallbacks/syncVisaProposal.ts +++ b/src/queue/consumers/visa/consumerCallbacks/syncVisaProposal.ts @@ -104,13 +104,15 @@ export async function syncVisaProposal( }); } - const proposersAndCoproposers = [ + const experimenters = [ ...(proposalWithNewStatus.proposer ? [proposalWithNewStatus.proposer] : []), ...proposalWithNewStatus.members, + ...proposalWithNewStatus.dataAccessUsers, + ...proposalWithNewStatus.visitors, ]; - // Create new user for the Principal Investigator and Coproposers - for (const member of proposersAndCoproposers) { + // Create new user for the Principal Investigator, Coproposers, Data Access Users and Visitors + for (const member of experimenters) { await createUserAndAssignToExperiment( member, proposalWithNewStatus.proposalPk @@ -120,6 +122,6 @@ export async function syncVisaProposal( // Delete the users that are saved in the experiment users table, but not in the Proposal Payload await deleteMissingUsersFromExperiment( proposalWithNewStatus.proposalPk, - proposersAndCoproposers + experimenters ); } diff --git a/src/queue/consumers/visa/consumers/syncProposalQueueConsumer.ts b/src/queue/consumers/visa/consumers/syncProposalQueueConsumer.ts index e7f25cbf..1347bb4f 100644 --- a/src/queue/consumers/visa/consumers/syncProposalQueueConsumer.ts +++ b/src/queue/consumers/visa/consumers/syncProposalQueueConsumer.ts @@ -11,8 +11,6 @@ import { syncVisaProposal } from '../consumerCallbacks/syncVisaProposal'; import { sanitizeProposalMessage } from '../utils/sanitizeProposalMessage'; const EVENTS_FOR_HANDLING = [ - Event.PROPOSAL_STATUS_CHANGED_BY_WORKFLOW, - Event.PROPOSAL_STATUS_CHANGED_BY_USER, Event.PROPOSAL_STATUS_ACTION_EXECUTED, Event.PROPOSAL_UPDATED, ]; diff --git a/src/queue/consumers/visa/utils/sanitizeProposalMessage.ts b/src/queue/consumers/visa/utils/sanitizeProposalMessage.ts index 053354ce..648cc0dd 100644 --- a/src/queue/consumers/visa/utils/sanitizeProposalMessage.ts +++ b/src/queue/consumers/visa/utils/sanitizeProposalMessage.ts @@ -10,6 +10,16 @@ export function sanitizeProposalMessage(proposalMessage: ProposalMessageData) { oidcSub: member.oidcSub.toLowerCase(), email: member.email.toLowerCase(), })), + dataAccessUsers: proposalMessage.dataAccessUsers.map((user) => ({ + ...user, + oidcSub: user.oidcSub.toLowerCase(), + email: user.email.toLowerCase(), + })), + visitors: proposalMessage.visitors.map((visitor) => ({ + ...visitor, + oidcSub: visitor.oidcSub.toLowerCase(), + email: visitor.email.toLowerCase(), + })), proposer: proposalMessage.proposer ? { ...proposalMessage.proposer, diff --git a/src/services/synapse/SynapseService.ts b/src/services/synapse/SynapseService.ts index d0445b58..9bee5e8e 100644 --- a/src/services/synapse/SynapseService.ts +++ b/src/services/synapse/SynapseService.ts @@ -54,13 +54,36 @@ export class SynapseService { baseUrl: serverUrl, fetchFn: axiosFetch, }); - // TODO, If consumer service is started after downtime, and there are some pending messages in the queue // then it could be that queue handler will delegate handling of messages before connection to supabase is established - this.client.loginWithPassword( - serviceAccount.userId, - serviceAccount.password - ); + } + + async login(consumerName = 'ChatroomCreationQueueConsumer') { + if (!serviceAccount.userId) + throw new Error('SYNAPSE_SERVICE_USER is not set'); + if (!serviceAccount.password) + throw new Error('SYNAPSE_SERVICE_PASSWORD is not set'); + + try { + await this.client.loginWithPassword( + serviceAccount.userId, + serviceAccount.password + ); + } catch (error) { + logger.logError(`Failed to login to Synapse from ${consumerName}`, { + error, + }); + throw error; + } + } + + async logout() { + try { + await this.client.logout(); + } catch (error) { + logger.logError('Failed to logout from Synapse', { error }); + throw error; + } } async createRoom(name: string, topic: string, members: ProposalUser[]) { @@ -127,12 +150,6 @@ export class SynapseService { await this.client .sendEvent(roomId, EventType.RoomMessage, messageContent, '') - .then(() => { - logger.logInfo('Success sending message to chatroom ', { - roomId: roomId, - message: message, - }); - }) .catch((reason) => { logger.logError('Failed sending message to chatroom', { roomId: roomId,