diff --git a/api-docs/openapi.json b/api-docs/openapi.json index a2523b515..2826ea13c 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -11,7 +11,7 @@ }, "servers": [ { - "url": "https://cveawg-dev.mitre.org/api" + "url": "/api" } ], "paths": { @@ -6179,6 +6179,112 @@ } } } + }, + "/notification": { + "get": { + "tags": [ + "Notification" + ], + "summary": "Retrieves notifications for the authenticated user", + "description": "", + "operationId": "getNotifications", + "responses": { + "200": { + "description": "Notifications ordered newest first", + "content": { + "application/json": { + "schema": { + "$ref": "../schemas/notification/list-notifications-response.json" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + } + } + } + }, + "/notification/{uuid}": { + "delete": { + "tags": [ + "Notification" + ], + "summary": "Dismisses one notification for the authenticated user", + "description": "", + "operationId": "dismissNotification", + "parameters": [ + { + "name": "uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Notification dismissed" + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Notification was not found for the authenticated user" + } + } + } + }, + "/notification/target/{user_uuid}": { + "post": { + "tags": [ + "Notification" + ], + "summary": "Creates a notification for an existing user (Secretariat only)", + "description": "", + "operationId": "createNotificationForUser", + "parameters": [ + { + "name": "user_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "Notification created", + "content": { + "application/json": { + "schema": { + "$ref": "../schemas/notification/notification.json" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + } + } + } } }, "components": { diff --git a/docker/Dockerfile b/docker/Dockerfile index 88d4e1850..495f37d2a 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,21 +1,20 @@ -FROM node:24-alpine3.22 +FROM node:24.18.0-trixie-slim@sha256:ae91dcc111a68c9d2d81ff2a17bda61be126426176fde6fe7d08ab13b7f50573 AS build LABEL \ mitre.name=cveawg \ - mitre.project=cveawg + mitre.project=cveawg -ENV PIP_BREAK_SYSTEM_PACKAGES=1 -# Run an optional pre-flight script for host-dependent reqs such as CA certs -# Use --build-arg: -# docker compose --build-arg CVE_PREFLIGHT="wget -q -O - --no-check-certificate http://pki.local/install_certs.sh | sh" +# Run an optional pre-flight script for host-dependent requirements such as +# corporate CA certificates before package installation performs network I/O. ARG CVE_PREFLIGHT -RUN sh -c "${CVE_PREFLIGHT:-exit 0}" +RUN /bin/sh -c "${CVE_PREFLIGHT:-exit 0}" -# Install python/pip (required for argon2 build from source) -ENV PYTHONUNBUFFERED=1 -RUN apk add --update --no-cache python3 py3-pip -RUN pip3 install --no-cache --upgrade pip setuptools +# Python and build essentials are required when argon2 builds from source. +RUN apt-get update \ + && apt-get install --yes --no-install-recommends python3 make g++ ca-certificates \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /app # Amazon DocumentDB presents certificates chained to AWS's RDS global CA. RUN mkdir -p /app/certs \ && wget -q -O /app/certs/global-bundle.pem https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem @@ -23,23 +22,43 @@ RUN mkdir -p /app/certs \ # Install build essentials (also required for argon2) and git-based dependencies RUN apk add --update --no-cache build-base git -# Set up directory to run as node user rather than root -ADD . /home/node/app -RUN rm -Rf /home/node/app/.git # we don't need this -RUN chown -R node:node /home/node +COPY package.json package-lock.json ./ +RUN npm ci -WORKDIR /home/node/app +COPY src ./src +COPY schemas ./schemas +COPY api-docs ./api-docs +COPY config ./config +COPY docker/default.json-docker ./config/default.json +RUN for environment in development staging integration production test; do \ + printf '{}\n' > "./config/${environment}.json"; \ + done -RUN npm install --production -COPY --chown=node:node docker/entrypoint.sh /home/node/app/entrypoint.sh -RUN echo '{}' > /home/node/app/config/dev.json -RUN echo '{}' > /home/node/app/config/test.json -RUN echo '{}' > /home/node/app/config/staging.json +# Generate the OpenAPI artifact while build-only dependencies are available, +# then remove them before copying node_modules into the runtime image. +RUN node src/swagger.js \ + && npm prune --omit=dev \ + && npm cache clean --force -# Change db hostname from localhost to docdb for use inside docker -COPY docker/default.json-docker /home/node/app/config/default.json +FROM gcr.io/distroless/nodejs24-debian13:nonroot@sha256:af85d11ce7ef10172855a6e3649e3e8125b1b9e3ca41849ec2918036f05cb212 AS runtime + +LABEL \ + mitre.name=cveawg \ + mitre.project=cveawg + +ENV NODE_ENV=production + +WORKDIR /app + +# Preserve any custom CA certificates installed by CVE_PREFLIGHT in the build +# stage. The application otherwise runs with a read-only application tree. +COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +COPY --from=build --chown=65532:65532 /app/node_modules ./node_modules +COPY --from=build --chown=65532:65532 /app/package.json ./package.json +COPY --from=build --chown=65532:65532 /app/src ./src +COPY --from=build --chown=65532:65532 /app/schemas ./schemas +COPY --from=build --chown=65532:65532 /app/api-docs ./api-docs +COPY --from=build --chown=65532:65532 /app/config ./config -# Run as the node user rather than root -USER node EXPOSE 3000 -ENTRYPOINT '/home/node/app/entrypoint.sh' +CMD ["src/scripts/start.js"] diff --git a/migrations/20260909-01-normalize-registry-org-program-status.js b/migrations/20260909-01-normalize-registry-org-program-status.js new file mode 100644 index 000000000..ab1eb971d --- /dev/null +++ b/migrations/20260909-01-normalize-registry-org-program-status.js @@ -0,0 +1,33 @@ +const TITLE_CASE_STATUS_BY_LOWER_CASE = { + active: 'Active', + inactive: 'Inactive', + pending: 'Pending' +} + +module.exports = { + async up (db) { + const baseOrgCollection = db.collection('BaseOrg') + + await Promise.all( + Object.entries(TITLE_CASE_STATUS_BY_LOWER_CASE).map(([lowerCaseStatus, titleCaseStatus]) => + baseOrgCollection.updateMany( + { 'program_data.status': lowerCaseStatus }, + { $set: { 'program_data.status': titleCaseStatus } } + ) + ) + ) + }, + + async down (db) { + const baseOrgCollection = db.collection('BaseOrg') + + await Promise.all( + Object.entries(TITLE_CASE_STATUS_BY_LOWER_CASE).map(([lowerCaseStatus, titleCaseStatus]) => + baseOrgCollection.updateMany( + { 'program_data.status': titleCaseStatus }, + { $set: { 'program_data.status': lowerCaseStatus } } + ) + ) + ) + } +} diff --git a/package-lock.json b/package-lock.json index f5737ad74..f5e8bba5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,9 +36,7 @@ "node-dev": "^7.4.3", "packageurl-js": "^2.0.1", "prompt-sync": "^4.2.0", - "replace-in-file": "6.3.5", "replace-json-property": "^1.8.0", - "swagger-autogen": "^2.19.0", "swagger-ui-express": "^4.3.0", "uuid": "^14.0.0", "validator": ">=13.7.0", @@ -63,9 +61,12 @@ "eslint-plugin-promise": "^4.2.1", "eslint-plugin-standard": "^4.0.1", "mocha": "^10.8.2", + "node-dev": "^7.4.3", "nyc": "^15.1.0", + "replace-in-file": "6.3.5", "sinon": "^15.0.4", "standard": "^16.0.3", + "swagger-autogen": "^2.19.0", "xlsx": "^0.18.5" } }, @@ -1192,6 +1193,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2327,6 +2329,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-3.0.3.tgz", "integrity": "sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==", + "dev": true, "license": "MIT", "engines": { "node": "*" @@ -2336,6 +2339,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", + "dev": true, "license": "MIT" }, "node_modules/debug": { @@ -2389,6 +2393,7 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -2581,6 +2586,7 @@ "version": "0.3.0", "resolved": "https://registry.npmjs.org/dynamic-dedupe/-/dynamic-dedupe-0.3.0.tgz", "integrity": "sha512-ssuANeD+z97meYOqd50e04Ze5qp4bPqo8cCkI4TRjZkzAUgIDTrXV1R8QCdINpiI+hw14+rYazvTRdQrz0/rFQ==", + "dev": true, "license": "MIT", "dependencies": { "xtend": "^4.0.0" @@ -2603,6 +2609,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, "license": "MIT" }, "node_modules/enabled": { @@ -2814,6 +2821,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3597,6 +3605,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/filewatcher/-/filewatcher-3.0.1.tgz", "integrity": "sha512-Fro8py2B8EJupSP37Kyd4kjKZLr+5ksFq7Vbw8A392Z15Unq8016SPUDvO/AsDj5V6bbPk98PTAinpc5YhPbJw==", + "dev": true, "license": "MIT", "dependencies": { "debounce": "^1.0.0" @@ -3929,6 +3938,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -3972,6 +3982,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.0.0" @@ -4189,6 +4200,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", "integrity": "sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw==", + "dev": true, "license": "MIT" }, "node_modules/has": { @@ -4614,6 +4626,7 @@ "version": "2.16.2", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.3" @@ -4664,6 +4677,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, "license": "MIT", "bin": { "is-docker": "cli.js" @@ -4705,6 +4719,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5026,6 +5041,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, "license": "MIT", "dependencies": { "is-docker": "^2.0.0" @@ -5045,6 +5061,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -5729,6 +5746,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -6147,6 +6165,7 @@ "version": "7.4.3", "resolved": "https://registry.npmjs.org/node-dev/-/node-dev-7.4.3.tgz", "integrity": "sha512-o8aYipN28xY+WEunMHHiNc3hpPSkGG8ulHyYBapNbkg4dQxohmhx6jiRbiFhTF6zy+5IwljUGv1EcuxsaWI4Bw==", + "dev": true, "license": "MIT", "dependencies": { "dateformat": "^3.0.3", @@ -6209,6 +6228,7 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.2.tgz", "integrity": "sha512-oJP/9NAdd9+x2Q+rfphB2RJCHjod70RcRLjosiPMMu5gjIfwVnOUGq2nbTjTUbmy0DJ/tFIVT30+Qe3nzl4TJg==", + "dev": true, "license": "MIT", "dependencies": { "growly": "^1.3.0", @@ -6223,6 +6243,7 @@ "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, "license": "MIT", "bin": { "uuid": "dist/bin/uuid" @@ -6893,6 +6914,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, "license": "MIT" }, "node_modules/path-to-regexp": { @@ -7687,6 +7709,7 @@ "version": "6.3.5", "resolved": "https://registry.npmjs.org/replace-in-file/-/replace-in-file-6.3.5.tgz", "integrity": "sha512-arB9d3ENdKva2fxRnSjwBEXfK1npgyci7ZZuwysgAp7ORjHSyxz6oqIjTEv8R0Ydl4Ll7uOAZXL4vbkhGIizCg==", + "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.1.2", @@ -7714,6 +7737,7 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -7729,6 +7753,7 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -7749,6 +7774,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -7761,6 +7787,7 @@ "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -7779,6 +7806,7 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -7803,6 +7831,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7835,6 +7864,7 @@ "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, "license": "MIT", "dependencies": { "is-core-module": "^2.16.1", @@ -8083,6 +8113,7 @@ "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -8267,6 +8298,7 @@ "version": "0.1.1", "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", + "dev": true, "license": "MIT" }, "node_modules/side-channel": { @@ -9106,6 +9138,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -9207,6 +9240,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -9290,6 +9324,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9302,6 +9337,7 @@ "version": "2.23.7", "resolved": "https://registry.npmjs.org/swagger-autogen/-/swagger-autogen-2.23.7.tgz", "integrity": "sha512-vr7uRmuV0DCxWc0wokLJAwX3GwQFJ0jwN+AWk0hKxre2EZwusnkGSGdVFd82u7fQLgwSTnbWkxUL7HXuz5LTZQ==", + "dev": true, "license": "MIT", "dependencies": { "acorn": "^7.4.1", @@ -9314,6 +9350,7 @@ "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -9337,6 +9374,7 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -9357,6 +9395,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -9913,6 +9952,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -10097,6 +10137,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -10165,6 +10206,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.4" @@ -10174,6 +10216,7 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, "license": "ISC", "engines": { "node": ">=10" diff --git a/package.json b/package.json index d60cecaee..fc055c260 100644 --- a/package.json +++ b/package.json @@ -21,9 +21,12 @@ "eslint-plugin-promise": "^4.2.1", "eslint-plugin-standard": "^4.0.1", "mocha": "^10.8.2", + "node-dev": "^7.4.3", "nyc": "^15.1.0", + "replace-in-file": "6.3.5", "sinon": "^15.0.4", "standard": "^16.0.3", + "swagger-autogen": "^2.19.0", "xlsx": "^0.18.5" }, "dependencies": { @@ -54,9 +57,7 @@ "node-dev": "^7.4.3", "packageurl-js": "^2.0.1", "prompt-sync": "^4.2.0", - "replace-in-file": "6.3.5", "replace-json-property": "^1.8.0", - "swagger-autogen": "^2.19.0", "swagger-ui-express": "^4.3.0", "uuid": "^14.0.0", "validator": ">=13.7.0", diff --git a/schemas/notification/list-notifications-response.json b/schemas/notification/list-notifications-response.json new file mode 100644 index 000000000..05118bc43 --- /dev/null +++ b/schemas/notification/list-notifications-response.json @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { "notifications": { "type": "array", "items": { "$ref": "notification.json" } } }, + "required": ["notifications"] +} diff --git a/schemas/notification/notification.json b/schemas/notification/notification.json new file mode 100644 index 000000000..6e038f3c6 --- /dev/null +++ b/schemas/notification/notification.json @@ -0,0 +1,12 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "UUID": { "type": "string" }, + "body": { "type": "string" }, + "links": { "type": "object", "properties": { "org_shortname": { "type": "string" }, "user_shortname": { "type": "string" } } }, + "type": { "enum": ["PRIVATE_MESSAGE", "PUBLIC_MESSAGE"] }, + "created_at": { "type": "string", "format": "date-time" } + }, + "required": ["UUID", "body", "links", "type", "created_at"] +} diff --git a/schemas/registry-org/BaseOrg.json b/schemas/registry-org/BaseOrg.json index 01956a1ef..778ec22c5 100644 --- a/schemas/registry-org/BaseOrg.json +++ b/schemas/registry-org/BaseOrg.json @@ -185,7 +185,8 @@ "format": "date" }, "status": { - "type": "string" + "type": "string", + "enum": ["Active", "Inactive", "Pending"] } }, "additionalProperties": false diff --git a/schemas/registry-org/create-registry-org-request.json b/schemas/registry-org/create-registry-org-request.json index 9278be8fa..11a5137b6 100644 --- a/schemas/registry-org/create-registry-org-request.json +++ b/schemas/registry-org/create-registry-org-request.json @@ -194,7 +194,8 @@ "format": "date" }, "status": { - "type": "string" + "type": "string", + "enum": ["Active", "Inactive", "Pending"] } }, "description": "Additional partner metadata (restricted)" diff --git a/schemas/registry-org/get-registry-org-response.json b/schemas/registry-org/get-registry-org-response.json index 31dd07ad2..2ddb94e62 100644 --- a/schemas/registry-org/get-registry-org-response.json +++ b/schemas/registry-org/get-registry-org-response.json @@ -167,7 +167,8 @@ "format": "date" }, "status": { - "type": "string" + "type": "string", + "enum": ["Active", "Inactive", "Pending"] } }, "description": "Additional partner metadata (restricted)" diff --git a/schemas/registry-org/list-registry-orgs-response.json b/schemas/registry-org/list-registry-orgs-response.json index d72d8780d..7bcbf9505 100644 --- a/schemas/registry-org/list-registry-orgs-response.json +++ b/schemas/registry-org/list-registry-orgs-response.json @@ -177,7 +177,8 @@ "format": "date" }, "status": { - "type": "string" + "type": "string", + "enum": ["Active", "Inactive", "Pending"] } }, "description": "Additional partner metadata (restricted)" diff --git a/schemas/registry-org/update-registry-org-request.json b/schemas/registry-org/update-registry-org-request.json index 80d4eed99..6d88c9071 100644 --- a/schemas/registry-org/update-registry-org-request.json +++ b/schemas/registry-org/update-registry-org-request.json @@ -209,7 +209,8 @@ "format": "date" }, "status": { - "type": "string" + "type": "string", + "enum": ["Active", "Inactive", "Pending"] } }, "description": "Additional partner metadata (restricted)" diff --git a/src/controller/notification.controller/index.js b/src/controller/notification.controller/index.js new file mode 100644 index 000000000..31c910ef5 --- /dev/null +++ b/src/controller/notification.controller/index.js @@ -0,0 +1,59 @@ +const router = require('express').Router() +const { body, param } = require('express-validator') +const mw = require('../../middleware/middleware') +const controller = require('./notification.controller') + +function parseError (req, res, next) { + const { validationResult } = require('express-validator') + const errors = validationResult(req) + if (!errors.isEmpty()) return res.status(400).json({ error: 'BAD_INPUT', message: 'Parameters were invalid', details: errors.array() }) + next() +} + +router.get('/notification', + /* + #swagger.tags = ['Notification'] + #swagger.operationId = 'getNotifications' + #swagger.summary = 'Retrieves notifications for the authenticated user' + #swagger.responses[200] = { description: 'Notifications ordered newest first', content: { 'application/json': { schema: { $ref: '../schemas/notification/list-notifications-response.json' } } } } + */ + mw.validateUser, + controller.getNotifications +) + +router.delete('/notification/:uuid', + /* + #swagger.tags = ['Notification'] + #swagger.operationId = 'dismissNotification' + #swagger.summary = 'Dismisses one notification for the authenticated user' + #swagger.responses[204] = { description: 'Notification dismissed' } + #swagger.responses[404] = { description: 'Notification was not found for the authenticated user' } + */ + mw.validateUser, + param('uuid').isUUID(4), + parseError, + (req, res, next) => { req.ctx.params = { uuid: req.params.uuid }; next() }, + controller.dismissNotification +) + +router.post('/notification/target/:user_uuid', + /* + #swagger.tags = ['Notification'] + #swagger.operationId = 'createNotificationForUser' + #swagger.summary = 'Creates a notification for an existing user (Secretariat only)' + #swagger.responses[201] = { description: 'Notification created', content: { 'application/json': { schema: { $ref: '../schemas/notification/notification.json' } } } } + */ + mw.validateUser, + mw.onlySecretariat, + param('user_uuid').isUUID(4), + body('body').isString().trim().notEmpty(), + body('type').isIn(['PRIVATE_MESSAGE', 'PUBLIC_MESSAGE']), + body('links').optional().isObject(), + body('links.org_shortname').optional().isString(), + body('links.user_shortname').optional().isString(), + parseError, + (req, res, next) => { req.ctx.params = { user_uuid: req.params.user_uuid }; req.ctx.body = req.body; next() }, + controller.createNotificationForUser +) + +module.exports = router diff --git a/src/controller/notification.controller/notification.controller.js b/src/controller/notification.controller/notification.controller.js new file mode 100644 index 000000000..57f4469b7 --- /dev/null +++ b/src/controller/notification.controller/notification.controller.js @@ -0,0 +1,45 @@ +const authContext = require('../../utils/authContext') + +async function getNotifications (req, res, next) { + try { + const notificationRepo = req.ctx.repositories.getNotificationRepository() + const userRepo = req.ctx.repositories.getBaseUserRepository() + const orgRepo = req.ctx.repositories.getBaseOrgRepository() + const userUUID = await authContext.getRequesterUserUUID(req, userRepo, orgRepo) + return res.status(200).json({ notifications: await notificationRepo.getNotifications(userUUID) }) + } catch (err) { + next(err) + } +} + +async function dismissNotification (req, res, next) { + try { + const notificationRepo = req.ctx.repositories.getNotificationRepository() + const userRepo = req.ctx.repositories.getBaseUserRepository() + const orgRepo = req.ctx.repositories.getBaseOrgRepository() + const userUUID = await authContext.getRequesterUserUUID(req, userRepo, orgRepo) + const dismissed = await notificationRepo.dismissNotification(userUUID, req.ctx.params.uuid) + if (!dismissed) return res.status(404).json({ error: 'NOTIFICATION_DNE', message: 'Notification does not exist.' }) + return res.status(204).send() + } catch (err) { + next(err) + } +} + +async function createNotificationForUser (req, res, next) { + try { + const notificationRepo = req.ctx.repositories.getNotificationRepository() + const userRepo = req.ctx.repositories.getBaseUserRepository() + const recipient = await userRepo.findUserByUUID(req.ctx.params.user_uuid) + if (!recipient) return res.status(404).json({ error: 'USER_DNE', message: 'User does not exist.' }) + if (!notificationRepo.validateNotification(req.ctx.body)) { + return res.status(400).json({ error: 'BAD_INPUT', message: 'Notification body, links, or type is invalid.' }) + } + const notification = await notificationRepo.addNotification(recipient.UUID, req.ctx.body) + return res.status(201).json(notification) + } catch (err) { + next(err) + } +} + +module.exports = { getNotifications, dismissNotification, createNotificationForUser } diff --git a/src/model/baseorg.js b/src/model/baseorg.js index 24c404bef..86aa46a72 100644 --- a/src/model/baseorg.js +++ b/src/model/baseorg.js @@ -41,7 +41,7 @@ const schema = { cve_website_update_needed: Boolean, partner_active_date: String, partner_inactive_date: String, - status: String + status: { type: String, enum: ['Active', 'Inactive', 'Pending'] } }, advisory_locations: [String], advisory_location_require_credentials: Boolean, diff --git a/src/model/notification.js b/src/model/notification.js new file mode 100644 index 000000000..a9ed6af5e --- /dev/null +++ b/src/model/notification.js @@ -0,0 +1,21 @@ +const mongoose = require('mongoose') + +const NotificationSchema = new mongoose.Schema({ + user_uuid: { type: String, required: true, unique: true, index: true }, + notifications: [{ + _id: false, + UUID: { type: String, required: true }, + body: { type: String, required: true }, + links: { + _id: false, + org_shortname: { type: String, default: '' }, + user_shortname: { type: String, default: '' } + }, + type: { type: String, enum: ['PRIVATE_MESSAGE', 'PUBLIC_MESSAGE'], required: true }, + created_at: { type: Date, required: true } + }] +}, { collection: 'Notification' }) + +NotificationSchema.index({ user_uuid: 1, 'notifications.created_at': -1 }) + +module.exports = mongoose.model('Notification', NotificationSchema) diff --git a/src/repositories/baseOrgRepository.js b/src/repositories/baseOrgRepository.js index 9bcc5ecf1..8a19e576c 100644 --- a/src/repositories/baseOrgRepository.js +++ b/src/repositories/baseOrgRepository.js @@ -149,6 +149,19 @@ function buildOrgHierarchy (orgs, orgUUID) { return hierarchy } +const PROGRAM_DATA_STATUS_BY_LOWERCASE = new Map([ + ['active', 'Active'], + ['inactive', 'Inactive'], + ['pending', 'Pending'] +]) + +function normalizeProgramDataStatus (org) { + const status = org?.program_data?.status + if (typeof status !== 'string') return + + const normalizedStatus = PROGRAM_DATA_STATUS_BY_LOWERCASE.get(status.toLowerCase()) + if (normalizedStatus) org.program_data.status = normalizedStatus +} function isResponseExtensionField (key) { return key.startsWith('_') && !INTERNAL_UNDERSCORE_FIELDS.includes(key) } @@ -498,6 +511,15 @@ class BaseOrgRepository extends BaseRepository { ).lean() } + async findSecretariatUserUUIDs (options = {}) { + const orgs = await BaseOrgModel.find( + { authority: 'SECRETARIAT' }, + { _id: 0, users: 1 }, + options + ).lean() + return [...new Set(orgs.flatMap(org => Array.isArray(org.users) ? org.users : []))] + } + /** * @function hasRole * @description Checks if an organization object has the requested role. @@ -1022,16 +1044,16 @@ class BaseOrgRepository extends BaseRepository { registryObjectRaw.program_data = {} } - // Default to 'inactive' if not provided + // Default to 'Inactive' if not provided if (!registryObjectRaw.program_data.status) { - registryObjectRaw.program_data.status = 'inactive' + registryObjectRaw.program_data.status = 'Inactive' } - if (registryObjectRaw.program_data.status === 'active') { + if (registryObjectRaw.program_data.status === 'Active') { registryObjectRaw.program_data.partner_active_date = new Date().toISOString().split('T')[0] // ensure inactive is not set delete registryObjectRaw.program_data.partner_inactive_date - } else if (registryObjectRaw.program_data.status === 'inactive') { + } else if (registryObjectRaw.program_data.status === 'Inactive') { registryObjectRaw.program_data.partner_inactive_date = new Date().toISOString().split('T')[0] // ensure active is not set delete registryObjectRaw.program_data.partner_active_date @@ -1493,6 +1515,7 @@ class BaseOrgRepository extends BaseRepository { */ validateOrg (org) { normalizeOrgCveWebsiteUpdateDate(org) + normalizeProgramDataStatus(org) if (!org.authority || (Array.isArray(org.authority) && org.authority.length === 0)) { return { isValid: false, errors: [{ instancePath: '/authority', message: 'authority is required' }] } diff --git a/src/repositories/baseUserRepository.js b/src/repositories/baseUserRepository.js index 4160a8bbc..8060a1abb 100644 --- a/src/repositories/baseUserRepository.js +++ b/src/repositories/baseUserRepository.js @@ -216,6 +216,11 @@ class BaseUserRepository extends BaseRepository { ).lean() } + async findActiveUsersByUUIDs (uuids, options = {}) { + if (!Array.isArray(uuids) || uuids.length === 0) return [] + return await BaseUser.find({ UUID: { $in: uuids }, status: 'active' }, { _id: 0, UUID: 1 }, options).lean() + } + /** * @async * @function isUserAdminOfOrgUUID diff --git a/src/repositories/conversationRepository.js b/src/repositories/conversationRepository.js index 82c902733..4e245477d 100644 --- a/src/repositories/conversationRepository.js +++ b/src/repositories/conversationRepository.js @@ -112,6 +112,29 @@ class ConversationRepository extends BaseRepository { const newConversation = new ConversationModel(conversationObj) const result = await newConversation.save(options) + const BaseOrgRepository = require('./baseOrgRepository') + const BaseUserRepository = require('./baseUserRepository') + const NotificationRepository = require('./notificationRepository') + const orgRepo = new BaseOrgRepository() + const userRepo = new BaseUserRepository() + const notificationRepo = new NotificationRepository() + const targetOrg = await orgRepo.findOneByUUID(targetUUID, options, false, { UUID: 1, short_name: 1, users: 1 }) + const targetUser = targetOrg ? null : await userRepo.findUserByUUID(targetUUID, options) + let recipientUUIDs = await orgRepo.findSecretariatUserUUIDs(options) + if (result.visibility === 'public' && isSecretariat) { + const targetRecipientUUIDs = targetOrg?.users || (targetUser ? [targetUser.UUID] : []) + recipientUUIDs = [...recipientUUIDs, ...targetRecipientUUIDs] + } + if (isSecretariat) { + recipientUUIDs = recipientUUIDs.filter(recipientUUID => recipientUUID !== user.UUID) + } + const activeRecipients = await userRepo.findActiveUsersByUUIDs(recipientUUIDs, options) + await notificationRepo.addNotifications(activeRecipients.map(recipient => recipient.UUID), { + body: result.body, + links: { org_shortname: targetOrg?.short_name || '', user_shortname: user.username || '' }, + type: result.visibility === 'private' ? 'PRIVATE_MESSAGE' : 'PUBLIC_MESSAGE' + }, options) + const rawObject = result.toObject() delete rawObject._id diff --git a/src/repositories/notificationRepository.js b/src/repositories/notificationRepository.js new file mode 100644 index 000000000..159ad8bf5 --- /dev/null +++ b/src/repositories/notificationRepository.js @@ -0,0 +1,61 @@ +const uuid = require('uuid') +const NotificationModel = require('../model/notification') +const BaseRepository = require('./baseRepository') + +class NotificationRepository extends BaseRepository { + constructor () { + super(NotificationModel) + } + + validateNotification (notification) { + return notification && + typeof notification.body === 'string' && notification.body.trim().length > 0 && + ['PRIVATE_MESSAGE', 'PUBLIC_MESSAGE'].includes(notification.type) && + (!notification.links || (typeof notification.links === 'object' && !Array.isArray(notification.links) && + (!notification.links.org_shortname || typeof notification.links.org_shortname === 'string') && + (!notification.links.user_shortname || typeof notification.links.user_shortname === 'string'))) + } + + createNotification (notification) { + return { + UUID: uuid.v4(), + body: notification.body, + links: { + org_shortname: notification.links?.org_shortname || '', + user_shortname: notification.links?.user_shortname || '' + }, + type: notification.type, + created_at: new Date() + } + } + + async addNotification (userUUID, notification, options = {}) { + const createdNotification = this.createNotification(notification) + await NotificationModel.updateOne( + { user_uuid: userUUID }, + { $push: { notifications: createdNotification } }, + { upsert: true, ...options } + ) + return createdNotification + } + + async addNotifications (userUUIDs, notification, options = {}) { + return Promise.all([...new Set(userUUIDs)].map(userUUID => this.addNotification(userUUID, notification, options))) + } + + async getNotifications (userUUID, options = {}) { + const result = await NotificationModel.findOne({ user_uuid: userUUID }, null, options).lean() + return (result?.notifications || []).sort((a, b) => new Date(b.created_at) - new Date(a.created_at)) + } + + async dismissNotification (userUUID, notificationUUID, options = {}) { + const result = await NotificationModel.updateOne( + { user_uuid: userUUID, 'notifications.UUID': notificationUUID }, + { $pull: { notifications: { UUID: notificationUUID } } }, + options + ) + return result.modifiedCount === 1 + } +} + +module.exports = NotificationRepository diff --git a/src/repositories/repositoryFactory.js b/src/repositories/repositoryFactory.js index 7f97e1177..99ad8e3c0 100644 --- a/src/repositories/repositoryFactory.js +++ b/src/repositories/repositoryFactory.js @@ -8,6 +8,7 @@ const BaseUserRepository = require('./baseUserRepository') const ConversationRepository = require('./conversationRepository') const ReviewObjectRepository = require('./reviewObjectRepository') const GlossaryRepository = require('./glossaryRepository') +const NotificationRepository = require('./notificationRepository') class RepositoryFactory { getOrgRepository () { @@ -60,6 +61,10 @@ class RepositoryFactory { return repo } + getNotificationRepository () { + return new NotificationRepository() + } + getAuditRepository () { const AuditRepository = require('./auditRepository') const repo = new AuditRepository() diff --git a/src/routes.config.js b/src/routes.config.js index b914e243c..344a8b798 100644 --- a/src/routes.config.js +++ b/src/routes.config.js @@ -12,6 +12,7 @@ const AuditController = require('./controller/audit.controller') const ConversationController = require('./controller/conversation.controller') const ReviewObjectController = require('./controller/review-object.controller') const GlossaryController = require('./controller/glossary.controller') +const NotificationController = require('./controller/notification.controller') var options = { swaggerOptions: { @@ -39,6 +40,7 @@ module.exports = async function configureRoutes (app) { app.use('/api/', ConversationController) app.use('/api/', ReviewObjectController) app.use('/api/', GlossaryController) + app.use('/api/', NotificationController) app.get('/api-docs/openapi.json', (req, res) => res.json(openApiSpecification)) app.use('/api-docs', swaggerUi.serveFiles(null, options), swaggerUi.setup(null, setupOptions)) app.use('/schemas/', SchemasController) diff --git a/src/scripts/migrate.js b/src/scripts/migrate.js index c643ac7b9..983edd9db 100644 --- a/src/scripts/migrate.js +++ b/src/scripts/migrate.js @@ -214,7 +214,7 @@ async function orgHelper (db) { websites: site ? [site] : [] }, program_data: { - status: 'active' + status: 'Active' }, inUse: doc.inUse, created: doc.time.created, diff --git a/src/scripts/populate.js b/src/scripts/populate.js index e1a126a5e..87805b910 100644 --- a/src/scripts/populate.js +++ b/src/scripts/populate.js @@ -162,7 +162,7 @@ function buildBaseOrgDocument (org, allUsers) { id_quota: org.policies?.id_quota, admins: admins, program_data: { - status: 'active' + status: 'Active' }, private_contacts: [], contact_info: { diff --git a/src/scripts/start.js b/src/scripts/start.js new file mode 100644 index 000000000..f5eae5789 --- /dev/null +++ b/src/scripts/start.js @@ -0,0 +1,42 @@ +const environmentAliases = { + 'prod-staging': 'production', + 'adp-test': 'production' +} + +const supportedEnvironments = new Set([ + 'development', + 'staging', + 'integration', + 'prod-staging', + 'adp-test', + 'production' +]) + +function resolveEnvironment (requestedEnvironment) { + if (!supportedEnvironments.has(requestedEnvironment)) { + throw new Error(`NODE_ENV '${requestedEnvironment}' does not correspond with an application environment.`) + } + + return environmentAliases[requestedEnvironment] || requestedEnvironment +} + +function start () { + try { + process.env.NODE_ENV = resolveEnvironment(process.env.NODE_ENV) + } catch (err) { + console.error(err.message) + process.exit(1) + } + + console.log(`> NODE_ENV=${process.env.NODE_ENV}`) + console.log(`> MONGO_HOST=${process.env.MONGO_HOST}`) + console.log(`> MONGO_PORT=${process.env.MONGO_PORT}`) + + require('../index') +} + +if (require.main === module) { + start() +} + +module.exports = { resolveEnvironment } diff --git a/src/swagger.js b/src/swagger.js index 2375b8c08..2b426fdfd 100644 --- a/src/swagger.js +++ b/src/swagger.js @@ -8,7 +8,8 @@ const endpointsFiles = [ 'src/controller/system.controller/index.js', 'src/controller/registry.controller/index.js', 'src/controller/conversation.controller/index.js', - 'src/controller/review-object.controller/index.js' + 'src/controller/review-object.controller/index.js', + 'src/controller/notification.controller/index.js' ] const publishedCVERecord = require('../schemas/cve/published-cve-example.json') const rejectedCVERecord = require('../schemas/cve/rejected-cve-example.json') @@ -47,7 +48,7 @@ const doc = { }, servers: [ { - url: 'urlplaceholder' + url: '/api' } ], basePath: '/api', diff --git a/test/integration-tests/helpers.js b/test/integration-tests/helpers.js index de16ccfd8..2286b37d7 100644 --- a/test/integration-tests/helpers.js +++ b/test/integration-tests/helpers.js @@ -13,6 +13,9 @@ async function cveIdReserveHelper (requestLength, year, shortName, batchType) { .post(`/api/cve-id?amount=${requestLength}&cve_year=${year}&short_name=${shortName}&batch_type=${batchType}`) .set(constants.nonSecretariatUserHeaders) .then((res, err) => { + if (res.status !== 200) { + throw new Error(`CVE-ID reservation failed with status ${res.status}: ${JSON.stringify(res.body)}`) + } return res.body.cve_ids[0].cve_id }) } diff --git a/test/integration-tests/notification/notificationTest.js b/test/integration-tests/notification/notificationTest.js new file mode 100644 index 000000000..80158c8fb --- /dev/null +++ b/test/integration-tests/notification/notificationTest.js @@ -0,0 +1,303 @@ +/* eslint-disable no-unused-expressions */ + +const chai = require('chai') +const expect = chai.expect +chai.use(require('chai-http')) + +const constants = require('../constants.js') +const app = require('../../../src/index.js') +const NotificationModel = require('../../../src/model/notification') + +const otherSecretariatHeaders = { + ...constants.headers, + 'CVE-API-USER': 'cps@mitre.org' +} + +describe('Testing Notification endpoints', () => { + let orgUUID + let recipientUUID + let automaticNotificationUUID + let conversationUUID + + before(async () => { + const orgResponse = await chai.request(app).get('/api/registry/org/win_5').set(constants.headers) + expect(orgResponse).to.have.status(200) + orgUUID = orgResponse.body.UUID + + const userResponse = await chai.request(app) + .get('/api/registry/org/win_5/user/win_5_admin@win_5.com') + .set(constants.headers) + expect(userResponse).to.have.status(200) + recipientUUID = userResponse.body.UUID + }) + + after(async () => { + await NotificationModel.deleteMany({ user_uuid: recipientUUID }) + }) + + it('notifies target organization users and other Secretariat users about a public Secretariat message', async () => { + const messageBody = 'notification integration test\nPlease review the "contact" details & confirm they are current.' + const conversationResponse = await chai.request(app) + .post(`/api/conversation/target/${orgUUID}`) + .set(constants.headers) + .send({ body: messageBody, visibility: 'public' }) + expect(conversationResponse).to.have.status(200) + conversationUUID = conversationResponse.body.UUID + + const notificationResponse = await chai.request(app) + .get('/api/notification') + .set(constants.nonSecretariatUserHeaders2) + expect(notificationResponse).to.have.status(200) + expect(notificationResponse.body.notifications).to.be.an('array').that.is.not.empty + + const notification = notificationResponse.body.notifications.find(item => item.body === messageBody) + expect(notification).to.exist + expect(notification).to.include({ type: 'PUBLIC_MESSAGE' }) + expect(notification.links).to.deep.equal({ org_shortname: 'win_5', user_shortname: 'test_secretariat_0@mitre.org' }) + automaticNotificationUUID = notification.UUID + + const otherSecretariatResponse = await chai.request(app).get('/api/notification').set(otherSecretariatHeaders) + const senderResponse = await chai.request(app).get('/api/notification').set(constants.headers) + expect(otherSecretariatResponse).to.have.status(200) + expect(senderResponse).to.have.status(200) + const otherNotifications = otherSecretariatResponse.body.notifications.filter(item => item.body === messageBody) + expect(otherNotifications).to.have.lengthOf(1) + expect(otherNotifications[0].type).to.equal('PUBLIC_MESSAGE') + expect(otherNotifications[0].links).to.deep.equal(notification.links) + expect(senderResponse.body.notifications.some(item => item.body === messageBody)).to.equal(false) + }) + + it('allows a user to dismiss only their own notification', async () => { + const dismissResponse = await chai.request(app) + .delete(`/api/notification/${automaticNotificationUUID}`) + .set(constants.nonSecretariatUserHeaders2) + expect(dismissResponse).to.have.status(204) + + const missingResponse = await chai.request(app) + .delete(`/api/notification/${automaticNotificationUUID}`) + .set(constants.nonSecretariatUserHeaders) + expect(missingResponse).to.have.status(404) + }) + + it('notifies an individual target user when a conversation targets their UUID', async () => { + const response = await chai.request(app) + .post(`/api/conversation/target/${recipientUUID}`) + .set(constants.headers) + .send({ body: 'individual notification integration test', visibility: 'public' }) + expect(response).to.have.status(200) + + const notificationsResponse = await chai.request(app) + .get('/api/notification') + .set(constants.nonSecretariatUserHeaders2) + expect(notificationsResponse).to.have.status(200) + const notification = notificationsResponse.body.notifications.find(item => item.body === 'individual notification integration test') + expect(notification).to.exist + expect(notification.type).to.equal('PUBLIC_MESSAGE') + expect(notification.links).to.deep.equal({ org_shortname: '', user_shortname: 'test_secretariat_0@mitre.org' }) + + const otherSecretariatResponse = await chai.request(app).get('/api/notification').set(otherSecretariatHeaders) + const senderResponse = await chai.request(app).get('/api/notification').set(constants.headers) + expect(otherSecretariatResponse).to.have.status(200) + expect(senderResponse).to.have.status(200) + expect(otherSecretariatResponse.body.notifications.filter(item => item.body === notification.body)).to.have.lengthOf(1) + expect(senderResponse.body.notifications.some(item => item.body === notification.body)).to.equal(false) + }) + + it('notifies Secretariat users, rather than CNA members, about CNA-authored conversations', async () => { + const recipientBefore = await chai.request(app).get('/api/notification').set(constants.nonSecretariatUserHeaders2) + const secretariatBefore = await chai.request(app).get('/api/notification').set(constants.headers) + expect(recipientBefore).to.have.status(200) + expect(secretariatBefore).to.have.status(200) + + const response = await chai.request(app) + .post(`/api/conversation/target/${orgUUID}`) + .set(constants.nonSecretariatUserHeaders2) + .send({ body: 'CNA notification integration test' }) + expect(response).to.have.status(200) + + const recipientAfter = await chai.request(app).get('/api/notification').set(constants.nonSecretariatUserHeaders2) + const secretariatAfter = await chai.request(app).get('/api/notification').set(constants.headers) + expect(recipientAfter).to.have.status(200) + expect(secretariatAfter).to.have.status(200) + expect(recipientAfter.body.notifications).to.have.lengthOf(recipientBefore.body.notifications.length) + expect(secretariatAfter.body.notifications).to.have.lengthOf(secretariatBefore.body.notifications.length + 1) + const notification = secretariatAfter.body.notifications.find(item => item.body === 'CNA notification integration test') + expect(notification).to.exist + expect(notification.type).to.equal('PUBLIC_MESSAGE') + expect(notification.links).to.deep.equal({ org_shortname: 'win_5', user_shortname: 'win_5_admin@win_5.com' }) + }) + + it('notifies the CNA and other Secretariat users when Secretariat replies to a public CNA message', async () => { + const messageResponse = await chai.request(app) + .post(`/api/conversation/target/${orgUUID}`) + .set(constants.nonSecretariatUserHeaders2) + .send({ body: 'CNA message awaiting a Secretariat reply' }) + expect(messageResponse).to.have.status(200) + + const replyBody = 'Public Secretariat reply notification integration test' + const replyResponse = await chai.request(app) + .post(`/api/conversation/target/${orgUUID}`) + .set(constants.headers) + .send({ body: replyBody, visibility: 'public' }) + expect(replyResponse).to.have.status(200) + + for (const headers of [constants.nonSecretariatUserHeaders2, otherSecretariatHeaders]) { + const response = await chai.request(app).get('/api/notification').set(headers) + expect(response).to.have.status(200) + const notifications = response.body.notifications.filter(item => item.body === replyBody) + expect(notifications).to.have.lengthOf(1) + expect(notifications[0].type).to.equal('PUBLIC_MESSAGE') + expect(notifications[0].links).to.deep.equal({ org_shortname: 'win_5', user_shortname: 'test_secretariat_0@mitre.org' }) + } + const senderResponse = await chai.request(app).get('/api/notification').set(constants.headers) + expect(senderResponse).to.have.status(200) + expect(senderResponse.body.notifications.some(item => item.body === replyBody)).to.equal(false) + }) + + it('excludes the sender and avoids duplicates when a public message targets a Secretariat organization', async () => { + const orgResponse = await chai.request(app).get('/api/registry/org/mitre').set(constants.headers) + expect(orgResponse).to.have.status(200) + const messageBody = 'Public Secretariat organization notification integration test' + const messageResponse = await chai.request(app) + .post(`/api/conversation/target/${orgResponse.body.UUID}`) + .set(constants.headers) + .send({ body: messageBody, visibility: 'public' }) + expect(messageResponse).to.have.status(200) + + const otherSecretariatResponse = await chai.request(app).get('/api/notification').set(otherSecretariatHeaders) + const senderResponse = await chai.request(app).get('/api/notification').set(constants.headers) + expect(otherSecretariatResponse).to.have.status(200) + expect(senderResponse).to.have.status(200) + const notifications = otherSecretariatResponse.body.notifications.filter(item => item.body === messageBody) + expect(notifications).to.have.lengthOf(1) + expect(notifications[0].type).to.equal('PUBLIC_MESSAGE') + expect(notifications[0].links).to.deep.equal({ org_shortname: 'mitre', user_shortname: 'test_secretariat_0@mitre.org' }) + expect(senderResponse.body.notifications.some(item => item.body === messageBody)).to.equal(false) + }) + + it('does not create a notification when an existing conversation is edited', async () => { + const beforeResponse = await chai.request(app).get('/api/notification').set(constants.nonSecretariatUserHeaders2) + expect(beforeResponse).to.have.status(200) + + const editResponse = await chai.request(app) + .put(`/api/conversation/${conversationUUID}`) + .set(constants.headers) + .send({ body: 'notification integration test edited' }) + expect(editResponse).to.have.status(200) + + const afterResponse = await chai.request(app).get('/api/notification').set(constants.nonSecretariatUserHeaders2) + expect(afterResponse).to.have.status(200) + expect(afterResponse.body.notifications).to.have.lengthOf(beforeResponse.body.notifications.length) + }) + + it('notifies Secretariat users, but not target organization users, about private conversations', async () => { + const recipientBefore = await chai.request(app).get('/api/notification').set(constants.nonSecretariatUserHeaders2) + const otherSecretariatBefore = await chai.request(app).get('/api/notification').set(otherSecretariatHeaders) + expect(recipientBefore).to.have.status(200) + expect(otherSecretariatBefore).to.have.status(200) + + const privateConversationResponse = await chai.request(app) + .post(`/api/conversation/target/${orgUUID}`) + .set(constants.headers) + .send({ body: 'private notification integration test', visibility: 'private' }) + expect(privateConversationResponse).to.have.status(200) + + const recipientAfter = await chai.request(app).get('/api/notification').set(constants.nonSecretariatUserHeaders2) + expect(recipientAfter).to.have.status(200) + expect(recipientAfter.body.notifications).to.have.lengthOf(recipientBefore.body.notifications.length) + + const secretariatResponse = await chai.request(app).get('/api/notification').set(constants.headers) + const otherSecretariatAfter = await chai.request(app).get('/api/notification').set(otherSecretariatHeaders) + expect(secretariatResponse).to.have.status(200) + expect(otherSecretariatAfter).to.have.status(200) + const privateNotificationBody = 'private notification integration test' + expect(secretariatResponse.body.notifications.some(item => item.body === privateNotificationBody)).to.equal(false) + expect(recipientAfter.body.notifications.some(item => item.body === privateNotificationBody)).to.equal(false) + expect(otherSecretariatAfter.body.notifications).to.have.lengthOf(otherSecretariatBefore.body.notifications.length + 1) + const notification = otherSecretariatAfter.body.notifications.find(item => item.body === privateNotificationBody) + expect(notification).to.exist + expect(notification.type).to.equal('PRIVATE_MESSAGE') + expect(notification.links).to.deep.equal({ org_shortname: 'win_5', user_shortname: 'test_secretariat_0@mitre.org' }) + }) + + for (const visibility of ['private', 'public']) { + it(`creates notifications for ${visibility} conversations submitted through registry organization updates`, async () => { + const orgResponse = await chai.request(app).get('/api/registry/org/win_5').set(constants.headers) + expect(orgResponse).to.have.status(200) + const org = orgResponse.body + delete org.created + delete org.last_updated + delete org.admins + delete org.users + delete org.top_level_root + delete org.oversees + delete org.reports_to + delete org._hierarchy + delete org.program_data + + const beforeResponse = await chai.request(app).get('/api/notification').set(otherSecretariatHeaders) + expect(beforeResponse).to.have.status(200) + + const messageBody = `${visibility} registry update notification integration test` + const conversation = { body: messageBody } + if (visibility === 'public') conversation.visibility = 'public' + const updateResponse = await chai.request(app) + .put('/api/registry/org/win_5') + .set(constants.headers) + .send({ ...org, conversation }) + expect(updateResponse).to.have.status(200) + + const afterResponse = await chai.request(app).get('/api/notification').set(otherSecretariatHeaders) + expect(afterResponse).to.have.status(200) + expect(afterResponse.body.notifications).to.have.lengthOf(beforeResponse.body.notifications.length + 1) + const notification = afterResponse.body.notifications.find(item => item.body === messageBody) + expect(notification).to.exist + expect(notification.type).to.equal(visibility === 'public' ? 'PUBLIC_MESSAGE' : 'PRIVATE_MESSAGE') + expect(notification.links).to.deep.equal({ org_shortname: 'win_5', user_shortname: 'test_secretariat_0@mitre.org' }) + + const senderResponse = await chai.request(app).get('/api/notification').set(constants.headers) + const targetResponse = await chai.request(app).get('/api/notification').set(constants.nonSecretariatUserHeaders2) + expect(senderResponse).to.have.status(200) + expect(targetResponse).to.have.status(200) + expect(senderResponse.body.notifications.some(item => item.body === messageBody)).to.equal(false) + expect(targetResponse.body.notifications.filter(item => item.body === messageBody)).to.have.lengthOf(visibility === 'public' ? 1 : 0) + }) + } + + it('allows Secretariat to create a generic notification for an existing user', async () => { + const response = await chai.request(app) + .post(`/api/notification/target/${recipientUUID}`) + .set(constants.headers) + .send({ + body: 'generic notification integration test', + links: { user_shortname: 'win_5_admin@win_5.com' }, + type: 'PUBLIC_MESSAGE' + }) + expect(response).to.have.status(201) + expect(response.body).to.include({ body: 'generic notification integration test', type: 'PUBLIC_MESSAGE' }) + expect(response.body).to.have.property('UUID') + expect(response.body).to.have.property('created_at') + }) + + it('denies generic notification creation to non-Secretariat users', async () => { + const response = await chai.request(app) + .post(`/api/notification/target/${recipientUUID}`) + .set(constants.nonSecretariatUserHeaders2) + .send({ body: 'denied', type: 'PUBLIC_MESSAGE' }) + expect(response).to.have.status(403) + }) + + it('rejects invalid generic notification payloads and nonexistent recipients', async () => { + const invalidResponse = await chai.request(app) + .post(`/api/notification/target/${recipientUUID}`) + .set(constants.headers) + .send({ body: '', type: 'OTHER' }) + expect(invalidResponse).to.have.status(400) + + const missingUserResponse = await chai.request(app) + .post('/api/notification/target/00000000-0000-4000-8000-000000000000') + .set(constants.headers) + .send({ body: 'missing user', type: 'PUBLIC_MESSAGE' }) + expect(missingUserResponse).to.have.status(404) + }) +}) diff --git a/test/integration-tests/org/registryOrgAsOrgAdmin.js b/test/integration-tests/org/registryOrgAsOrgAdmin.js index 3ed5f0c25..bf8c01b26 100644 --- a/test/integration-tests/org/registryOrgAsOrgAdmin.js +++ b/test/integration-tests/org/registryOrgAsOrgAdmin.js @@ -290,7 +290,7 @@ describe('Testing Registry Org as org admin', () => { .set(secretariatHeaders) expect(res.body).to.have.property('program_data') - expect(res.body.program_data).to.have.property('status', 'active') + expect(res.body.program_data).to.have.property('status', 'Active') }) }) context('Negative Tests', () => { diff --git a/test/integration-tests/registry-org/activeCnaListTest.js b/test/integration-tests/registry-org/activeCnaListTest.js index cdc671729..d6883258c 100644 --- a/test/integration-tests/registry-org/activeCnaListTest.js +++ b/test/integration-tests/registry-org/activeCnaListTest.js @@ -75,7 +75,7 @@ describe('Secretariat active CNA list', () => { const migratedCna = await BaseOrg.findOne({ short_name: 'window_1' }).lean() expect(migratedCna).to.not.equal(null) - expect(migratedCna.program_data.status).to.equal('active') + expect(migratedCna.program_data.status).to.equal('Active') const res = await chai.request(app) .get('/api/registry/org/cnas') diff --git a/test/integration-tests/registry-org/registryOrgCRUDTest.js b/test/integration-tests/registry-org/registryOrgCRUDTest.js index 54dabfe1f..207fe27be 100644 --- a/test/integration-tests/registry-org/registryOrgCRUDTest.js +++ b/test/integration-tests/registry-org/registryOrgCRUDTest.js @@ -107,7 +107,7 @@ describe('Testing /registry/org endpoints', () => { expect(res.body.created.is_last_resort).to.equal(true) expect(res.body.created).to.haveOwnProperty('program_data') - expect(res.body.created.program_data.status).to.equal('inactive') + expect(res.body.created.program_data.status).to.equal('Inactive') expect(res.body.created.program_data).to.haveOwnProperty('partner_inactive_date') expect(res.body.created.program_data).to.not.haveOwnProperty('partner_active_date') @@ -140,7 +140,7 @@ describe('Testing /registry/org endpoints', () => { expect(res.body.message).to.equal(orgWithProgramData.short_name + ' organization was successfully created.') expect(res.body.created).to.haveOwnProperty('program_data') - expect(res.body.created.program_data.status).to.equal('active') + expect(res.body.created.program_data.status).to.equal('Active') expect(res.body.created.program_data).to.haveOwnProperty('partner_active_date') expect(res.body.created.program_data).to.not.haveOwnProperty('advisory_location_require_credentials') expect(res.body.created.program_data).to.not.haveOwnProperty('vulnerability_advisory_location_for_web_scraping') @@ -773,7 +773,12 @@ describe('Testing /registry/org endpoints', () => { expect(res.body.disabled).to.equal(false) }) }) - it('Allows Secretariat to update program_data', async () => { + it('Allows Secretariat to update program_data and updates its timestamp', async () => { + const beforeUpdate = await chai.request(app) + .get('/api/registry/org/registry_org_test') + .set(secretariatHeaders) + expect(beforeUpdate).to.have.status(200) + const beforeUpdateTimestamp = Date.parse(beforeUpdate.body.last_updated) const partnerActiveDate = '2024-01-15' const cveWebsiteUpdateDate = '2024-04-10T18:30:00.000Z' await chai.request(app) @@ -793,7 +798,8 @@ describe('Testing /registry/org endpoints', () => { expect(err).to.be.undefined expect(res).to.have.status(200) expect(res.body.updated).to.haveOwnProperty('program_data') - expect(res.body.updated.program_data.status).to.equal('active') + expect(res.body.updated.program_data.status).to.equal('Active') + expect(Date.parse(res.body.updated.last_updated)).to.be.greaterThan(beforeUpdateTimestamp) expect(res.body.updated.program_data).to.haveOwnProperty('partner_active_date') expect(res.body.updated.program_data.partner_active_date).to.equal(partnerActiveDate) expect(res.body.updated.program_data.cve_website_update_date).to.equal('2024-04-10') @@ -819,7 +825,7 @@ describe('Testing /registry/org endpoints', () => { expect(err).to.be.undefined expect(res).to.have.status(200) expect(res.body.updated).to.haveOwnProperty('program_data') - expect(res.body.updated.program_data.status).to.equal('active') + expect(res.body.updated.program_data.status).to.equal('Active') expect(res.body.updated.program_data.partner_active_date).to.equal(partnerActiveDate) }) }) @@ -857,7 +863,7 @@ describe('Testing /registry/org endpoints', () => { expect(err).to.be.undefined expect(res).to.have.status(200) expect(res.body.updated).to.haveOwnProperty('program_data') - expect(res.body.updated.program_data.status).to.equal('inactive') + expect(res.body.updated.program_data.status).to.equal('Inactive') expect(res.body.updated.program_data.partner_inactive_date).to.equal(partnerInactiveDate) }) }) @@ -874,7 +880,7 @@ describe('Testing /registry/org endpoints', () => { .send(statusOnlyOrg) expect(createRes).to.have.status(200) - expect(createRes.body.created.program_data.status).to.equal('inactive') + expect(createRes.body.created.program_data.status).to.equal('Inactive') expect(createRes.body.created.program_data).to.not.haveOwnProperty('partner_active_date') const statusOnlyUpdateOrg = { ...createRes.body.created } delete statusOnlyUpdateOrg.created @@ -895,7 +901,7 @@ describe('Testing /registry/org endpoints', () => { expect(err).to.be.undefined expect(res).to.have.status(200) expect(res.body.updated).to.haveOwnProperty('program_data') - expect(res.body.updated.program_data.status).to.equal('active') + expect(res.body.updated.program_data.status).to.equal('Active') expect(res.body.updated.program_data).to.not.haveOwnProperty('partner_active_date') }) }) diff --git a/test/integration-tests/registry-org/registryOrgDisabledMigrationTest.js b/test/integration-tests/registry-org/registryOrgDisabledMigrationTest.js index 75d9c5c4d..263327be0 100644 --- a/test/integration-tests/registry-org/registryOrgDisabledMigrationTest.js +++ b/test/integration-tests/registry-org/registryOrgDisabledMigrationTest.js @@ -9,6 +9,15 @@ const app = require('../../../src/index.js') const secretariatHeaders = { ...constants.headers, 'content-type': 'application/json' } describe('Registry organization disabled migration', () => { + it('normalizes a seeded registry organization program status', async () => { + const res = await chai.request(app) + .get('/api/registry/org/interesting_19') + .set(secretariatHeaders) + + expect(res).to.have.status(200) + expect(res.body.program_data.status).to.equal('Active') + }) + it('enables a seeded registry organization whose legacy organization has an active authority', async () => { const res = await chai.request(app) .get('/api/registry/org/interesting_19') diff --git a/test/unit-tests/conversation/conversationRepositoryTest.js b/test/unit-tests/conversation/conversationRepositoryTest.js index d8cab9b19..915305451 100644 --- a/test/unit-tests/conversation/conversationRepositoryTest.js +++ b/test/unit-tests/conversation/conversationRepositoryTest.js @@ -5,8 +5,21 @@ const expect = chai.expect const ConversationModel = require('../../../src/model/conversation') const ConversationRepository = require('../../../src/repositories/conversationRepository') +const BaseOrgRepository = require('../../../src/repositories/baseOrgRepository') +const BaseUserRepository = require('../../../src/repositories/baseUserRepository') +const NotificationRepository = require('../../../src/repositories/notificationRepository') describe('Testing Conversation Repository', () => { + beforeEach(() => { + // Conversation creation also resolves notification recipients and writes notifications. + // Stub those dependencies so repository tests do not require a database connection. + sinon.stub(BaseOrgRepository.prototype, 'findOneByUUID').resolves(null) + sinon.stub(BaseOrgRepository.prototype, 'findSecretariatUserUUIDs').resolves([]) + sinon.stub(BaseUserRepository.prototype, 'findUserByUUID').resolves(null) + sinon.stub(BaseUserRepository.prototype, 'findActiveUsersByUUIDs').resolves([]) + sinon.stub(NotificationRepository.prototype, 'addNotifications').resolves([]) + }) + afterEach(() => { sinon.restore() }) @@ -59,6 +72,49 @@ describe('Testing Conversation Repository', () => { expect(result.author_role).to.equal('Partner') }) + for (const isReply of [false, true]) { + it(`notifies the target and other active Secretariat users for a public ${isReply ? 'reply' : 'first message'}`, async () => { + const latestConversation = isReply ? { UUID: 'previous-message', save: sinon.stub().resolves() } : null + sinon.stub(ConversationModel, 'findOne').resolves(latestConversation) + sinon.stub(ConversationModel.prototype, 'save').callsFake(async function () { + return this + }) + BaseOrgRepository.prototype.findOneByUUID.resolves({ + UUID: 'target-uuid', + short_name: 'target-org', + users: ['target-user'] + }) + BaseOrgRepository.prototype.findSecretariatUserUUIDs.resolves([ + 'sender', 'other-secretariat', 'another-secretariat', 'inactive-secretariat' + ]) + const activeRecipientUUIDs = ['other-secretariat', 'another-secretariat', 'target-user'] + BaseUserRepository.prototype.findActiveUsersByUUIDs.resolves(activeRecipientUUIDs.map(UUID => ({ UUID }))) + const options = { session: {} } + + const repo = new ConversationRepository() + const result = await repo.createConversation( + 'target-uuid', + { body: 'Public Secretariat message', visibility: 'public' }, + { UUID: 'sender', username: 'sender@example.org' }, + true, + options + ) + + sinon.assert.calledOnceWithExactly(BaseOrgRepository.prototype.findSecretariatUserUUIDs, options) + sinon.assert.calledOnceWithExactly(BaseUserRepository.prototype.findActiveUsersByUUIDs, + ['other-secretariat', 'another-secretariat', 'inactive-secretariat', 'target-user'], options) + sinon.assert.calledOnceWithExactly(NotificationRepository.prototype.addNotifications, activeRecipientUUIDs, { + body: 'Public Secretariat message', + links: { org_shortname: 'target-org', user_shortname: 'sender@example.org' }, + type: 'PUBLIC_MESSAGE' + }, options) + if (isReply) { + expect(latestConversation.next_conversation_uuid).to.equal(result.UUID) + sinon.assert.calledOnceWithExactly(latestConversation.save, options) + } + }) + } + it('normalizes stored Secretariat author names when conversations are returned to Secretariat', async () => { sinon.stub(ConversationModel, 'find').returns({ lean: sinon.stub().resolves([ diff --git a/test/unit-tests/org/baseOrgRepositoryTest.js b/test/unit-tests/org/baseOrgRepositoryTest.js index f14244ac0..31cbd747c 100644 --- a/test/unit-tests/org/baseOrgRepositoryTest.js +++ b/test/unit-tests/org/baseOrgRepositoryTest.js @@ -69,4 +69,60 @@ describe('Testing BaseOrgRepository', () => { select ]) }) + + it('accepts every supported program data status', () => { + const repository = new BaseOrgRepository() + + for (const status of ['Active', 'Inactive', 'Pending']) { + const org = { + short_name: 'example-org', + id_quota: 1, + authority: ['CNA'], + program_data: { status } + } + + expect(repository.validateOrg(org).isValid, status).to.equal(true) + expect(new BaseOrgModel({ program_data: { status } }).validateSync(), status).to.equal(undefined) + } + }) + + it('normalizes lower-case program data statuses to supported title-case values', () => { + const repository = new BaseOrgRepository() + const expectedStatuses = { active: 'Active', inactive: 'Inactive', pending: 'Pending' } + + for (const [status, expectedStatus] of Object.entries(expectedStatuses)) { + const org = { + short_name: 'example-org', + id_quota: 1, + authority: ['CNA'], + program_data: { status } + } + + expect(repository.validateOrg(org).isValid, status).to.equal(true) + expect(org.program_data.status, status).to.equal(expectedStatus) + expect(new BaseOrgModel(org).validateSync(), status).to.equal(undefined) + } + }) + it('rejects an unsupported program data status', () => { + const repository = new BaseOrgRepository() + const org = { + short_name: 'example-org', + id_quota: 1, + authority: ['CNA'], + program_data: { status: 'archived' } + } + + const schemaResult = repository.validateOrg(org) + expect(schemaResult.isValid).to.equal(false) + expect(schemaResult.errors).to.deep.include({ + instancePath: '/program_data/status', + schemaPath: '/BaseOrg#/properties/program_data/properties/status/enum', + keyword: 'enum', + params: { allowedValues: ['Active', 'Inactive', 'Pending'] }, + message: 'must be equal to one of the allowed values' + }) + + const validationError = new BaseOrgModel({ program_data: { status: 'archived' } }).validateSync() + expect(validationError.errors['program_data.status'].kind).to.equal('enum') + }) }) diff --git a/test/unit-tests/scripts/startTest.js b/test/unit-tests/scripts/startTest.js new file mode 100644 index 000000000..aee41965e --- /dev/null +++ b/test/unit-tests/scripts/startTest.js @@ -0,0 +1,22 @@ +const chai = require('chai') +const expect = chai.expect + +const { resolveEnvironment } = require('../../../src/scripts/start') + +describe('Distroless startup environment resolution', () => { + it('preserves supported application environments', () => { + for (const environment of ['development', 'staging', 'integration', 'production']) { + expect(resolveEnvironment(environment)).to.equal(environment) + } + }) + + it('maps deployment aliases to production', () => { + expect(resolveEnvironment('prod-staging')).to.equal('production') + expect(resolveEnvironment('adp-test')).to.equal('production') + }) + + it('rejects unsupported environments', () => { + expect(() => resolveEnvironment('test')) + .to.throw("NODE_ENV 'test' does not correspond with an application environment.") + }) +})