From c1633e504c23ae13d352929ab16b53c38e6b3eb8 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Wed, 22 Jul 2026 15:14:34 -0400 Subject: [PATCH 01/12] build: use distroless runtime image --- api-docs/openapi.json | 2 +- docker/Dockerfile | 73 ++++++++++++++++++++++++++----------------- package-lock.json | 50 +++++++++++++++++++++++++++-- package.json | 6 ++-- src/scripts/start.js | 28 +++++++++++++++++ src/swagger.js | 2 +- 6 files changed, 125 insertions(+), 36 deletions(-) create mode 100644 src/scripts/start.js diff --git a/api-docs/openapi.json b/api-docs/openapi.json index f9fbbb5bd..9d8d9e074 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": { diff --git a/docker/Dockerfile b/docker/Dockerfile index f2f477f74..0a9ac5cd3 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,41 +1,58 @@ -FROM node:24-alpine3.22 +FROM node:24-trixie 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/* -# Install build essentials (also required for argon2) -RUN apk add --update --no-cache build-base +WORKDIR /app -# 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 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/package-lock.json b/package-lock.json index 0fe7e1a0b..133a9a732 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,12 +31,9 @@ "mongoose": "^8.9.5", "mongoose-aggregate-paginate-v2": "1.0.6", "morgan": "^1.11.0", - "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", @@ -61,9 +58,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" } }, @@ -1115,6 +1115,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" @@ -2229,6 +2230,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": "*" @@ -2238,6 +2240,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": { @@ -2291,6 +2294,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" @@ -2493,6 +2497,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" @@ -2515,6 +2520,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": { @@ -2726,6 +2732,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" @@ -3502,6 +3509,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" @@ -3846,6 +3854,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.*" @@ -3889,6 +3898,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" @@ -4106,6 +4116,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": { @@ -4527,6 +4538,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" @@ -4577,6 +4589,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" @@ -4618,6 +4631,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" @@ -4933,6 +4947,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" @@ -4952,6 +4967,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": { @@ -5610,6 +5626,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" @@ -6008,6 +6025,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", @@ -6070,6 +6088,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", @@ -6084,6 +6103,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" @@ -6754,6 +6774,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": { @@ -7539,6 +7560,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", @@ -7556,6 +7578,7 @@ "version": "1.1.13", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -7566,6 +7589,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", @@ -7581,6 +7605,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", @@ -7601,6 +7626,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" @@ -7613,6 +7639,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", @@ -7631,6 +7658,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" @@ -7655,6 +7683,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" @@ -7687,6 +7716,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", @@ -7919,6 +7949,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" @@ -8087,6 +8118,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": { @@ -8926,6 +8958,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", @@ -9027,6 +9060,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" @@ -9110,6 +9144,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" @@ -9122,6 +9157,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", @@ -9134,6 +9170,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" @@ -9146,6 +9183,7 @@ "version": "1.1.13", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -9157,6 +9195,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", @@ -9177,6 +9216,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" @@ -9699,6 +9739,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" @@ -9883,6 +9924,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", @@ -9951,6 +9993,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" @@ -9960,6 +10003,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 b67f9e094..13c660dcf 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": { @@ -49,12 +52,9 @@ "mongoose": "^8.9.5", "mongoose-aggregate-paginate-v2": "1.0.6", "morgan": "^1.11.0", - "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/src/scripts/start.js b/src/scripts/start.js new file mode 100644 index 000000000..1fc578582 --- /dev/null +++ b/src/scripts/start.js @@ -0,0 +1,28 @@ +const environmentAliases = { + 'prod-staging': 'production', + 'adp-test': 'production' +} + +const supportedEnvironments = new Set([ + 'development', + 'staging', + 'integration', + 'prod-staging', + 'adp-test', + 'production' +]) + +const requestedEnvironment = process.env.NODE_ENV + +if (!supportedEnvironments.has(requestedEnvironment)) { + console.error(`NODE_ENV '${requestedEnvironment}' does not correspond with an application environment.`) + process.exit(1) +} + +process.env.NODE_ENV = environmentAliases[requestedEnvironment] || requestedEnvironment + +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') diff --git a/src/swagger.js b/src/swagger.js index 6f491125f..2aa674301 100644 --- a/src/swagger.js +++ b/src/swagger.js @@ -48,7 +48,7 @@ const doc = { }, servers: [ { - url: 'urlplaceholder' + url: '/api' } ], basePath: '/api', From b80b92025291748a8daa3dc04c5865fe8b9f7474 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Wed, 22 Jul 2026 15:28:04 -0400 Subject: [PATCH 02/12] fix: avoid starting app during coverage discovery --- src/scripts/start.js | 32 ++++++++++++++++++++-------- test/unit-tests/scripts/startTest.js | 22 +++++++++++++++++++ 2 files changed, 45 insertions(+), 9 deletions(-) create mode 100644 test/unit-tests/scripts/startTest.js diff --git a/src/scripts/start.js b/src/scripts/start.js index 1fc578582..f5eae5789 100644 --- a/src/scripts/start.js +++ b/src/scripts/start.js @@ -12,17 +12,31 @@ const supportedEnvironments = new Set([ 'production' ]) -const requestedEnvironment = process.env.NODE_ENV +function resolveEnvironment (requestedEnvironment) { + if (!supportedEnvironments.has(requestedEnvironment)) { + throw new Error(`NODE_ENV '${requestedEnvironment}' does not correspond with an application environment.`) + } -if (!supportedEnvironments.has(requestedEnvironment)) { - console.error(`NODE_ENV '${requestedEnvironment}' does not correspond with an application environment.`) - process.exit(1) + return environmentAliases[requestedEnvironment] || requestedEnvironment } -process.env.NODE_ENV = 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}`) + 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') + require('../index') +} + +if (require.main === module) { + start() +} + +module.exports = { resolveEnvironment } 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.") + }) +}) From fdec3f4012c4e73a5281308e20dd81772874c0e6 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Thu, 23 Jul 2026 14:36:49 -0400 Subject: [PATCH 03/12] build: pin Node and distroless images --- docker/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 0a9ac5cd3..d8dec8317 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,4 +1,4 @@ -FROM node:24-trixie AS build +FROM node:24.18.0-trixie-slim@sha256:ae91dcc111a68c9d2d81ff2a17bda61be126426176fde6fe7d08ab13b7f50573 AS build LABEL \ mitre.name=cveawg \ @@ -34,7 +34,7 @@ RUN node src/swagger.js \ && npm prune --omit=dev \ && npm cache clean --force -FROM gcr.io/distroless/nodejs24-debian13:nonroot AS runtime +FROM gcr.io/distroless/nodejs24-debian13:nonroot@sha256:af85d11ce7ef10172855a6e3649e3e8125b1b9e3ca41849ec2918036f05cb212 AS runtime LABEL \ mitre.name=cveawg \ From 87b60c23e83422753d83b1ea191baf6cb731ee0a Mon Sep 17 00:00:00 2001 From: Leo Williamson Date: Wed, 12 Aug 2026 14:02:56 -0400 Subject: [PATCH 04/12] Validate registry organization program status --- schemas/registry-org/BaseOrg.json | 3 +- .../create-registry-org-request.json | 3 +- .../get-registry-org-response.json | 3 +- .../list-registry-orgs-response.json | 3 +- .../update-registry-org-request.json | 3 +- src/model/baseorg.js | 2 +- test/unit-tests/org/baseOrgRepositoryTest.js | 39 +++++++++++++++++++ 7 files changed, 50 insertions(+), 6 deletions(-) diff --git a/schemas/registry-org/BaseOrg.json b/schemas/registry-org/BaseOrg.json index 01956a1ef..0bc4aa126 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..78b678e50 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 ae40b965c..4efbaafe4 100644 --- a/schemas/registry-org/get-registry-org-response.json +++ b/schemas/registry-org/get-registry-org-response.json @@ -159,7 +159,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 84f3f4b80..91df59b00 100644 --- a/schemas/registry-org/list-registry-orgs-response.json +++ b/schemas/registry-org/list-registry-orgs-response.json @@ -181,7 +181,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..a4c6b93d3 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/model/baseorg.js b/src/model/baseorg.js index 0a720f8f3..669d3cfce 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/test/unit-tests/org/baseOrgRepositoryTest.js b/test/unit-tests/org/baseOrgRepositoryTest.js index f14244ac0..af7b9a632 100644 --- a/test/unit-tests/org/baseOrgRepositoryTest.js +++ b/test/unit-tests/org/baseOrgRepositoryTest.js @@ -69,4 +69,43 @@ 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('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') + }) }) From a26f7dc2230a15d22f111e7cedcf0054444d8a57 Mon Sep 17 00:00:00 2001 From: James Dalphond Date: Wed, 9 Sep 2026 11:00:37 -0400 Subject: [PATCH 05/12] Use title-case registry organization statuses --- schemas/registry-org/BaseOrg.json | 2 +- .../create-registry-org-request.json | 2 +- .../get-registry-org-response.json | 2 +- .../list-registry-orgs-response.json | 2 +- .../update-registry-org-request.json | 2 +- src/model/baseorg.js | 2 +- src/repositories/baseOrgRepository.js | 22 +++++++++++++++---- .../registry-org/registryOrgCRUDTest.js | 22 ++++++++++++------- test/unit-tests/org/baseOrgRepositoryTest.js | 21 ++++++++++++++++-- 9 files changed, 57 insertions(+), 20 deletions(-) diff --git a/schemas/registry-org/BaseOrg.json b/schemas/registry-org/BaseOrg.json index 0bc4aa126..778ec22c5 100644 --- a/schemas/registry-org/BaseOrg.json +++ b/schemas/registry-org/BaseOrg.json @@ -186,7 +186,7 @@ }, "status": { "type": "string", - "enum": ["active", "inactive", "pending"] + "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 78b678e50..11a5137b6 100644 --- a/schemas/registry-org/create-registry-org-request.json +++ b/schemas/registry-org/create-registry-org-request.json @@ -195,7 +195,7 @@ }, "status": { "type": "string", - "enum": ["active", "inactive", "pending"] + "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 4efbaafe4..7db85b0ea 100644 --- a/schemas/registry-org/get-registry-org-response.json +++ b/schemas/registry-org/get-registry-org-response.json @@ -160,7 +160,7 @@ }, "status": { "type": "string", - "enum": ["active", "inactive", "pending"] + "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 91df59b00..b0de5e240 100644 --- a/schemas/registry-org/list-registry-orgs-response.json +++ b/schemas/registry-org/list-registry-orgs-response.json @@ -182,7 +182,7 @@ }, "status": { "type": "string", - "enum": ["active", "inactive", "pending"] + "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 a4c6b93d3..6d88c9071 100644 --- a/schemas/registry-org/update-registry-org-request.json +++ b/schemas/registry-org/update-registry-org-request.json @@ -210,7 +210,7 @@ }, "status": { "type": "string", - "enum": ["active", "inactive", "pending"] + "enum": ["Active", "Inactive", "Pending"] } }, "description": "Additional partner metadata (restricted)" diff --git a/src/model/baseorg.js b/src/model/baseorg.js index 669d3cfce..18a752cb4 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: { type: String, enum: ['active', 'inactive', 'pending'] } + status: { type: String, enum: ['Active', 'Inactive', 'Pending'] } }, advisory_locations: [String], advisory_location_require_credentials: Boolean, diff --git a/src/repositories/baseOrgRepository.js b/src/repositories/baseOrgRepository.js index 076bc38dc..50e1cd06e 100644 --- a/src/repositories/baseOrgRepository.js +++ b/src/repositories/baseOrgRepository.js @@ -69,6 +69,19 @@ function decorateCnaRelationships (activeOrgs, hierarchyOrgs) { }) } +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) } @@ -851,16 +864,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 @@ -1322,6 +1335,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/test/integration-tests/registry-org/registryOrgCRUDTest.js b/test/integration-tests/registry-org/registryOrgCRUDTest.js index 3dfb2e3f9..08e15bf59 100644 --- a/test/integration-tests/registry-org/registryOrgCRUDTest.js +++ b/test/integration-tests/registry-org/registryOrgCRUDTest.js @@ -90,7 +90,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') @@ -123,7 +123,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') @@ -734,7 +734,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) @@ -754,7 +759,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') @@ -780,7 +786,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) }) }) @@ -818,7 +824,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) }) }) @@ -835,7 +841,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 @@ -856,7 +862,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/unit-tests/org/baseOrgRepositoryTest.js b/test/unit-tests/org/baseOrgRepositoryTest.js index af7b9a632..31cbd747c 100644 --- a/test/unit-tests/org/baseOrgRepositoryTest.js +++ b/test/unit-tests/org/baseOrgRepositoryTest.js @@ -73,7 +73,7 @@ describe('Testing BaseOrgRepository', () => { it('accepts every supported program data status', () => { const repository = new BaseOrgRepository() - for (const status of ['active', 'inactive', 'pending']) { + for (const status of ['Active', 'Inactive', 'Pending']) { const org = { short_name: 'example-org', id_quota: 1, @@ -86,6 +86,23 @@ describe('Testing BaseOrgRepository', () => { } }) + 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 = { @@ -101,7 +118,7 @@ describe('Testing BaseOrgRepository', () => { instancePath: '/program_data/status', schemaPath: '/BaseOrg#/properties/program_data/properties/status/enum', keyword: 'enum', - params: { allowedValues: ['active', 'inactive', 'pending'] }, + params: { allowedValues: ['Active', 'Inactive', 'Pending'] }, message: 'must be equal to one of the allowed values' }) From 90c36c52cfa3ed714951a044616250012dd4ea34 Mon Sep 17 00:00:00 2001 From: Leo Williamson Date: Wed, 19 Aug 2026 10:29:30 -0400 Subject: [PATCH 06/12] Update registry org status assertion casing --- test/integration-tests/org/registryOrgAsOrgAdmin.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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', () => { From 8a4d47f10ff4db7f9ec7c6e2e09cef5fe0feb6fa Mon Sep 17 00:00:00 2001 From: James Dalphond Date: Wed, 9 Sep 2026 11:14:57 -0400 Subject: [PATCH 07/12] Rebasing on dev after 2.8.6 merge --- ...1-normalize-registry-org-program-status.js | 33 +++++++++++++++++++ src/scripts/migrate.js | 2 +- src/scripts/populate.js | 2 +- test/integration-tests/helpers.js | 3 ++ .../registry-org/activeCnaListTest.js | 2 +- .../registryOrgDisabledMigrationTest.js | 9 +++++ 6 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 migrations/20260909-01-normalize-registry-org-program-status.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/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 b39034332..ce11de10a 100644 --- a/src/scripts/populate.js +++ b/src/scripts/populate.js @@ -157,7 +157,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/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/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/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') From cf1e2aa1b0d09d3dd15a378b0f8674643b92febf Mon Sep 17 00:00:00 2001 From: David T Rocca Date: Mon, 21 Sep 2026 16:09:59 -0400 Subject: [PATCH 08/12] First pass at notifications --- api-docs/openapi.json | 106 +++++++++ .../list-notifications-response.json | 6 + schemas/notification/notification.json | 12 + .../notification.controller/index.js | 59 +++++ .../notification.controller.js | 45 ++++ src/model/notification.js | 21 ++ src/repositories/baseOrgRepository.js | 9 + src/repositories/baseUserRepository.js | 5 + src/repositories/conversationRepository.js | 27 +++ src/repositories/notificationRepository.js | 61 +++++ src/repositories/repositoryFactory.js | 5 + src/routes.config.js | 2 + src/swagger.js | 3 +- .../notification/notificationTest.js | 213 ++++++++++++++++++ 14 files changed, 573 insertions(+), 1 deletion(-) create mode 100644 schemas/notification/list-notifications-response.json create mode 100644 schemas/notification/notification.json create mode 100644 src/controller/notification.controller/index.js create mode 100644 src/controller/notification.controller/notification.controller.js create mode 100644 src/model/notification.js create mode 100644 src/repositories/notificationRepository.js create mode 100644 test/integration-tests/notification/notificationTest.js diff --git a/api-docs/openapi.json b/api-docs/openapi.json index e077a9476..029499686 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -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/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/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/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..ebbcb7760 100644 --- a/src/repositories/baseOrgRepository.js +++ b/src/repositories/baseOrgRepository.js @@ -498,6 +498,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. 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..24a238d15 100644 --- a/src/repositories/conversationRepository.js +++ b/src/repositories/conversationRepository.js @@ -112,6 +112,33 @@ 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) + const authorOrgUUID = await orgRepo.getOrgUUIDByUserUUID(user.UUID, options) + const authorOrg = authorOrgUUID + ? await orgRepo.findOneByUUID(authorOrgUUID, options, false, { short_name: 1 }) + : null + let recipientUUIDs = result.visibility === 'private' || !isSecretariat + ? await orgRepo.findSecretariatUserUUIDs(options) + : targetOrg?.users || (targetUser ? [targetUser.UUID] : []) + if (result.visibility === 'private') { + recipientUUIDs = recipientUUIDs.filter(recipientUUID => recipientUUID !== user.UUID) + } + const activeRecipients = await userRepo.findActiveUsersByUUIDs(recipientUUIDs, options) + await notificationRepo.addNotifications(activeRecipients.map(recipient => recipient.UUID), { + body: result.visibility === 'private' + ? `${user.username} has left a note on the ${targetOrg?.short_name || 'target'} organization.` + : `${result.author_name} has sent a message to ${targetOrg?.short_name || targetUser?.username || 'this organization'}.`, + links: { org_shortname: authorOrg?.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/swagger.js b/src/swagger.js index fd775f5cd..34ac3cba5 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') diff --git a/test/integration-tests/notification/notificationTest.js b/test/integration-tests/notification/notificationTest.js new file mode 100644 index 000000000..dccef5ae6 --- /dev/null +++ b/test/integration-tests/notification/notificationTest.js @@ -0,0 +1,213 @@ +/* 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('creates a notification for each active organization user when a public conversation is created', async () => { + const conversationResponse = await chai.request(app) + .post(`/api/conversation/target/${orgUUID}`) + .set(constants.headers) + .send({ body: 'notification integration test', 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 === 'Secretariat has sent a message to win_5.') + expect(notification).to.exist + expect(notification).to.include({ type: 'PUBLIC_MESSAGE' }) + expect(notification.links).to.deep.equal({ org_shortname: 'mitre', user_shortname: 'test_secretariat_0@mitre.org' }) + automaticNotificationUUID = notification.UUID + }) + + 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) + expect(notificationsResponse.body.notifications.some(notification => + notification.links.user_shortname === 'test_secretariat_0@mitre.org' && notification.type === 'PUBLIC_MESSAGE' + )).to.equal(true) + }) + + 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) + }) + + 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 = 'test_secretariat_0@mitre.org has left a note on the win_5 organization.' + expect(secretariatResponse.body.notifications.some(item => item.body === privateNotificationBody)).to.equal(false) + expect(otherSecretariatAfter.body.notifications).to.have.lengthOf(otherSecretariatBefore.body.notifications.length + 1) + expect(otherSecretariatAfter.body.notifications.some(item => item.body === privateNotificationBody && item.type === 'PRIVATE_MESSAGE')).to.equal(true) + }) + + it('creates notifications for 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 + delete org.disabled + + const beforeResponse = await chai.request(app).get('/api/notification').set(otherSecretariatHeaders) + expect(beforeResponse).to.have.status(200) + + const updateResponse = await chai.request(app) + .put('/api/registry/org/win_5') + .set(constants.headers) + .send({ ...org, conversation: { body: 'registry update notification integration test' } }) + 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) + }) + + 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) + }) +}) From 0b8ac2e90e1cef2d3d4f3484d803591f0e36a850 Mon Sep 17 00:00:00 2001 From: David T Rocca Date: Tue, 22 Sep 2026 10:15:51 -0400 Subject: [PATCH 09/12] Fixing missing stubs for unit tests --- .../conversation/conversationRepositoryTest.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/unit-tests/conversation/conversationRepositoryTest.js b/test/unit-tests/conversation/conversationRepositoryTest.js index d8cab9b19..f8056f06b 100644 --- a/test/unit-tests/conversation/conversationRepositoryTest.js +++ b/test/unit-tests/conversation/conversationRepositoryTest.js @@ -5,8 +5,22 @@ 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 author-name tests do not require a database connection. + sinon.stub(BaseOrgRepository.prototype, 'findOneByUUID').resolves(null) + sinon.stub(BaseOrgRepository.prototype, 'getOrgUUIDByUserUUID').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() }) From bfff0cc07435175e60d1ae55fd1b73afdf9ca877 Mon Sep 17 00:00:00 2001 From: David T Rocca Date: Wed, 23 Sep 2026 11:25:31 -0400 Subject: [PATCH 10/12] Added the body stuff --- src/repositories/conversationRepository.js | 4 +--- .../notification/notificationTest.js | 22 ++++++++++++++----- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/repositories/conversationRepository.js b/src/repositories/conversationRepository.js index 24a238d15..7115f214b 100644 --- a/src/repositories/conversationRepository.js +++ b/src/repositories/conversationRepository.js @@ -132,9 +132,7 @@ class ConversationRepository extends BaseRepository { } const activeRecipients = await userRepo.findActiveUsersByUUIDs(recipientUUIDs, options) await notificationRepo.addNotifications(activeRecipients.map(recipient => recipient.UUID), { - body: result.visibility === 'private' - ? `${user.username} has left a note on the ${targetOrg?.short_name || 'target'} organization.` - : `${result.author_name} has sent a message to ${targetOrg?.short_name || targetUser?.username || 'this organization'}.`, + body: result.body, links: { org_shortname: authorOrg?.short_name || '', user_shortname: user.username || '' }, type: result.visibility === 'private' ? 'PRIVATE_MESSAGE' : 'PUBLIC_MESSAGE' }, options) diff --git a/test/integration-tests/notification/notificationTest.js b/test/integration-tests/notification/notificationTest.js index dccef5ae6..9417f05f2 100644 --- a/test/integration-tests/notification/notificationTest.js +++ b/test/integration-tests/notification/notificationTest.js @@ -36,10 +36,11 @@ describe('Testing Notification endpoints', () => { }) it('creates a notification for each active organization user when a public conversation is created', 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: 'notification integration test', visibility: 'public' }) + .send({ body: messageBody, visibility: 'public' }) expect(conversationResponse).to.have.status(200) conversationUUID = conversationResponse.body.UUID @@ -49,7 +50,7 @@ describe('Testing Notification endpoints', () => { 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 === 'Secretariat has sent a message to win_5.') + 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: 'mitre', user_shortname: 'test_secretariat_0@mitre.org' }) @@ -79,9 +80,10 @@ describe('Testing Notification endpoints', () => { .get('/api/notification') .set(constants.nonSecretariatUserHeaders2) expect(notificationsResponse).to.have.status(200) - expect(notificationsResponse.body.notifications.some(notification => - notification.links.user_shortname === 'test_secretariat_0@mitre.org' && notification.type === 'PUBLIC_MESSAGE' - )).to.equal(true) + 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: 'mitre', user_shortname: 'test_secretariat_0@mitre.org' }) }) it('notifies Secretariat users, rather than CNA members, about CNA-authored conversations', async () => { @@ -102,6 +104,10 @@ describe('Testing Notification endpoints', () => { 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('does not create a notification when an existing conversation is edited', async () => { @@ -139,8 +145,9 @@ describe('Testing Notification endpoints', () => { 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 = 'test_secretariat_0@mitre.org has left a note on the win_5 organization.' + 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) expect(otherSecretariatAfter.body.notifications.some(item => item.body === privateNotificationBody && item.type === 'PRIVATE_MESSAGE')).to.equal(true) }) @@ -172,6 +179,9 @@ describe('Testing Notification endpoints', () => { 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) + expect(afterResponse.body.notifications.some(item => + item.body === 'registry update notification integration test' && item.type === 'PRIVATE_MESSAGE' + )).to.equal(true) }) it('allows Secretariat to create a generic notification for an existing user', async () => { From f637978f45ad5db68a06f10fca8d39b7dce3508c Mon Sep 17 00:00:00 2001 From: David T Rocca Date: Wed, 23 Sep 2026 16:18:22 -0400 Subject: [PATCH 11/12] Iterating on feedback --- src/repositories/conversationRepository.js | 16 +- .../notification/notificationTest.js | 151 ++++++++++++++---- .../conversationRepositoryTest.js | 46 +++++- 3 files changed, 167 insertions(+), 46 deletions(-) diff --git a/src/repositories/conversationRepository.js b/src/repositories/conversationRepository.js index 7115f214b..4e245477d 100644 --- a/src/repositories/conversationRepository.js +++ b/src/repositories/conversationRepository.js @@ -120,20 +120,18 @@ class ConversationRepository extends BaseRepository { 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) - const authorOrgUUID = await orgRepo.getOrgUUIDByUserUUID(user.UUID, options) - const authorOrg = authorOrgUUID - ? await orgRepo.findOneByUUID(authorOrgUUID, options, false, { short_name: 1 }) - : null - let recipientUUIDs = result.visibility === 'private' || !isSecretariat - ? await orgRepo.findSecretariatUserUUIDs(options) - : targetOrg?.users || (targetUser ? [targetUser.UUID] : []) - if (result.visibility === 'private') { + 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: authorOrg?.short_name || '', user_shortname: user.username || '' }, + links: { org_shortname: targetOrg?.short_name || '', user_shortname: user.username || '' }, type: result.visibility === 'private' ? 'PRIVATE_MESSAGE' : 'PUBLIC_MESSAGE' }, options) diff --git a/test/integration-tests/notification/notificationTest.js b/test/integration-tests/notification/notificationTest.js index 9417f05f2..a3f1bffa3 100644 --- a/test/integration-tests/notification/notificationTest.js +++ b/test/integration-tests/notification/notificationTest.js @@ -35,7 +35,7 @@ describe('Testing Notification endpoints', () => { await NotificationModel.deleteMany({ user_uuid: recipientUUID }) }) - it('creates a notification for each active organization user when a public conversation is created', async () => { + 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}`) @@ -53,8 +53,18 @@ describe('Testing Notification endpoints', () => { 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: 'mitre', user_shortname: 'test_secretariat_0@mitre.org' }) + 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 () => { @@ -83,7 +93,14 @@ describe('Testing Notification endpoints', () => { 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: 'mitre', user_shortname: 'test_secretariat_0@mitre.org' }) + 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 () => { @@ -110,6 +127,54 @@ describe('Testing Notification endpoints', () => { 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) @@ -149,40 +214,56 @@ describe('Testing Notification endpoints', () => { 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) - expect(otherSecretariatAfter.body.notifications.some(item => item.body === privateNotificationBody && item.type === 'PRIVATE_MESSAGE')).to.equal(true) + 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' }) }) - it('creates notifications for 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 - delete org.disabled - - const beforeResponse = await chai.request(app).get('/api/notification').set(otherSecretariatHeaders) - expect(beforeResponse).to.have.status(200) - - const updateResponse = await chai.request(app) - .put('/api/registry/org/win_5') - .set(constants.headers) - .send({ ...org, conversation: { body: 'registry update notification integration test' } }) - 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) - expect(afterResponse.body.notifications.some(item => - item.body === 'registry update notification integration test' && item.type === 'PRIVATE_MESSAGE' - )).to.equal(true) - }) + 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 + delete org.disabled + + 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) diff --git a/test/unit-tests/conversation/conversationRepositoryTest.js b/test/unit-tests/conversation/conversationRepositoryTest.js index f8056f06b..915305451 100644 --- a/test/unit-tests/conversation/conversationRepositoryTest.js +++ b/test/unit-tests/conversation/conversationRepositoryTest.js @@ -12,9 +12,8 @@ const NotificationRepository = require('../../../src/repositories/notificationRe describe('Testing Conversation Repository', () => { beforeEach(() => { // Conversation creation also resolves notification recipients and writes notifications. - // Stub those dependencies so author-name tests do not require a database connection. + // Stub those dependencies so repository tests do not require a database connection. sinon.stub(BaseOrgRepository.prototype, 'findOneByUUID').resolves(null) - sinon.stub(BaseOrgRepository.prototype, 'getOrgUUIDByUserUUID').resolves(null) sinon.stub(BaseOrgRepository.prototype, 'findSecretariatUserUUIDs').resolves([]) sinon.stub(BaseUserRepository.prototype, 'findUserByUUID').resolves(null) sinon.stub(BaseUserRepository.prototype, 'findActiveUsersByUUIDs').resolves([]) @@ -73,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([ From 78414a27994e246f382b8cb7b9997d7956c4a043 Mon Sep 17 00:00:00 2001 From: David T Rocca Date: Mon, 28 Sep 2026 10:10:29 -0400 Subject: [PATCH 12/12] Notification unit test changes --- test/integration-tests/notification/notificationTest.js | 1 - 1 file changed, 1 deletion(-) diff --git a/test/integration-tests/notification/notificationTest.js b/test/integration-tests/notification/notificationTest.js index a3f1bffa3..80158c8fb 100644 --- a/test/integration-tests/notification/notificationTest.js +++ b/test/integration-tests/notification/notificationTest.js @@ -234,7 +234,6 @@ describe('Testing Notification endpoints', () => { delete org.reports_to delete org._hierarchy delete org.program_data - delete org.disabled const beforeResponse = await chai.request(app).get('/api/notification').set(otherSecretariatHeaders) expect(beforeResponse).to.have.status(200)