diff --git a/.gitignore b/.gitignore index 5b3d11a63..511f05453 100644 --- a/.gitignore +++ b/.gitignore @@ -107,3 +107,7 @@ commit-message.txt # configuration) and provide an example instead docker/.docker-env +**/export.xlsx +**/poc_admin_report.csv +**/~$*.xlsx +src/scripts/importDb.sh diff --git a/api-docs/openapi.json b/api-docs/openapi.json index 4ece901f6..2eb805748 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -1,7 +1,7 @@ { "openapi": "3.0.2", "info": { - "version": "2.8.0", + "version": "2.8.1", "title": "CVE Services API", "description": "The CVE Services API supports automation tooling for the CVE Program. Credentials are required for most service endpoints. Representatives of CVE Numbering Authorities (CNAs) should use one of the methods below to obtain credentials:

CVE data is to be in the JSON 5.2 CVE Record format. Details of the JSON 5.2 schema are located here.

Contact the CVE Services team", "contact": { @@ -11,7 +11,7 @@ }, "servers": [ { - "url": "urlplaceholder" + "url": "https://cveawg-dev.mitre.org/api" } ], "paths": { @@ -2318,6 +2318,30 @@ }, "description": "The shortname or UUID of the registry organization" }, + { + "name": "expand", + "in": "query", + "description": "Optional expanded related data. Accepted value: users.", + "required": false, + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "users" + ], + "items": { + "type": "string" + } + } + } + } + }, { "$ref": "#/components/parameters/apiEntityHeader" }, diff --git a/package-lock.json b/package-lock.json index ecb64fd58..a835c73ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cve-services", - "version": "2.8.0", + "version": "2.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cve-services", - "version": "2.8.0", + "version": "2.8.1", "license": "(CC0)", "dependencies": { "ajv": "^8.6.2", @@ -17,7 +17,7 @@ "cors": "^2.8.5", "crypto-random-string": "^3.3.1", "dotenv": "^5.0.1", - "express": "^4.22.1", + "express": "^4.22.2", "express-jsonschema": "^1.1.6", "express-rate-limit": "^6.5.2", "express-validator": "^6.14.2", @@ -63,17 +63,18 @@ "mocha": "^10.8.2", "nyc": "^15.1.0", "sinon": "^15.0.4", - "standard": "^16.0.3" + "standard": "^16.0.3", + "xlsx": "^0.18.5" } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -82,9 +83,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -92,21 +93,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -140,14 +141,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -157,14 +158,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -184,9 +185,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -194,29 +195,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -226,9 +227,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -236,9 +237,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -246,9 +247,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -256,27 +257,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -286,33 +287,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -320,14 +321,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -442,10 +443,20 @@ } }, "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -1033,6 +1044,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -1412,9 +1433,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.10", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz", - "integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==", + "version": "2.10.37", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", + "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1456,9 +1477,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -1469,7 +1490,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.14.0", + "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -1494,21 +1515,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/body-parser/node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/brace-expansion": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", @@ -1540,9 +1546,9 @@ "license": "ISC" }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dev": true, "funding": [ { @@ -1560,11 +1566,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -1608,15 +1614,15 @@ } }, "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" }, "engines": { @@ -1688,9 +1694,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001781", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", - "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", "dev": true, "funding": [ { @@ -1708,6 +1714,20 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chai": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", @@ -1890,6 +1910,16 @@ "wrap-ansi": "^7.0.0" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", @@ -2101,6 +2131,19 @@ "node": ">=10" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2462,9 +2505,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.325", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.325.tgz", - "integrity": "sha512-PwfIw7WQSt3xX7yOf5OE/unLzsK9CaN2f/FvV3WjPR1Knoc1T9vePRVV4W1EM301JzzysK51K7FNKcusCr0zYA==", + "version": "1.5.375", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.375.tgz", + "integrity": "sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==", "dev": true, "license": "ISC" }, @@ -2527,9 +2570,9 @@ } }, "node_modules/es-abstract": { - "version": "1.24.1", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", - "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", "dev": true, "license": "MIT", "dependencies": { @@ -3137,10 +3180,20 @@ } }, "node_modules/eslint/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -3277,14 +3330,14 @@ } }, "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "~1.20.3", + "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", @@ -3303,7 +3356,7 @@ "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "~6.14.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -3373,21 +3426,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/express/node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3416,9 +3454,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -3635,17 +3673,17 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -3676,6 +3714,16 @@ "node": ">= 0.6" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -4167,9 +4215,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -4476,12 +4524,12 @@ } }, "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", "license": "MIT", "dependencies": { - "hasown": "^2.0.2" + "hasown": "^2.0.3" }, "engines": { "node": ">= 0.4" @@ -5611,10 +5659,20 @@ "license": "Python-2.0" }, "node_modules/mocha/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -6053,11 +6111,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.36", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", - "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/normalize-package-data": { "version": "2.5.0", @@ -7015,9 +7076,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "dev": true, "funding": [ { @@ -7147,10 +7208,9 @@ } }, "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", - "dev": true, + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -8249,6 +8309,19 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "license": "BSD-3-Clause" }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/stack-trace": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", @@ -9777,6 +9850,26 @@ "node": ">= 12.0.0" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -9840,6 +9933,28 @@ "node": ">=8" } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index bb908ae6c..29f89762c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "cve-services", "author": "Automation Working Group", - "version": "2.8.0", + "version": "2.8.1", "license": "(CC0)", "devDependencies": { "@faker-js/faker": "^7.6.0", @@ -23,7 +23,8 @@ "mocha": "^10.8.2", "nyc": "^15.1.0", "sinon": "^15.0.4", - "standard": "^16.0.3" + "standard": "^16.0.3", + "xlsx": "^0.18.5" }, "dependencies": { "ajv": "^8.6.2", @@ -34,7 +35,7 @@ "cors": "^2.8.5", "crypto-random-string": "^3.3.1", "dotenv": "^5.0.1", - "express": "^4.22.1", + "express": "^4.22.2", "express-jsonschema": "^1.1.6", "express-rate-limit": "^6.5.2", "express-validator": "^6.14.2", @@ -81,6 +82,7 @@ "lint:test-utils": "node node_modules/eslint/bin/eslint.js test-utils/ --fix", "populate:dev": "NODE_ENV=development node-dev src/scripts/populate.js", "migrate:dev": "NODE_ENV=development MONGO_CONN_STRING=mongodb://localhost:27017 MONGO_DB_NAME=cve_dev node-dev src/scripts/migrate.js", + "migrate:dev:monday": "NODE_ENV=development MONGO_CONN_STRING=mongodb://localhost:27017 MONGO_DB_NAME=cve_dev node-dev src/scripts/MondayMigrate.js", "migrate:test-black-box": "NODE_ENV=development MONGO_CONN_STRING=mongodb://docdb:27017 MONGO_DB_NAME=cve_dev node-dev src/scripts/migrate.js", "migrate:test": "NODE_ENV=test MONGO_CONN_STRING=mongodb://localhost:27017 MONGO_DB_NAME=cve_test node-dev src/scripts/migrate.js", "populate:stage": "NODE_ENV=staging node src/scripts/populate.js", diff --git a/schemas/registry-org/BaseOrg.json b/schemas/registry-org/BaseOrg.json index c8776d226..aab61eba1 100644 --- a/schemas/registry-org/BaseOrg.json +++ b/schemas/registry-org/BaseOrg.json @@ -43,7 +43,7 @@ "ROOT" ] }, - "partnerRoleType": { + "partnerRoleTypeValue": { "description": "The type of role a partner holds", "type": "string", "enum": [ @@ -57,6 +57,14 @@ "Researcher", "Vendor" ] + }, + "partnerRoleType": { + "description": "The types of roles a partner holds", + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/partnerRoleTypeValue" + } } }, "properties": { diff --git a/schemas/registry-org/create-registry-org-request.json b/schemas/registry-org/create-registry-org-request.json index 999a7d036..b1917cf18 100644 --- a/schemas/registry-org/create-registry-org-request.json +++ b/schemas/registry-org/create-registry-org-request.json @@ -139,19 +139,23 @@ "description": "Indicates if part of the CNA discussion list" }, "partner_role_type": { - "type": "string", - "enum": [ - "", - "Bug Bounty Provider", - "CERT", - "Consortium", - "Hosted Service", - "N/A", - "Open Source", - "Researcher", - "Vendor" - ], - "description": "The type of role a partner holds" + "type": "array", + "items": { + "type": "string", + "enum": [ + "", + "Bug Bounty Provider", + "CERT", + "Consortium", + "Hosted Service", + "N/A", + "Open Source", + "Researcher", + "Vendor" + ] + }, + "uniqueItems": true, + "description": "The types of roles a partner holds" }, "partner_number": { "type": "string", diff --git a/schemas/registry-org/get-registry-org-response.json b/schemas/registry-org/get-registry-org-response.json index 006b02616..86fc75326 100644 --- a/schemas/registry-org/get-registry-org-response.json +++ b/schemas/registry-org/get-registry-org-response.json @@ -109,6 +109,13 @@ }, "phone": { "type": "string" + }, + "additional_contacts": { + "type": "array", + "items": { + "type": "string" + }, + "description": "UUIDs of additional contact users" } }, "additionalProperties": false @@ -220,6 +227,49 @@ } }, "description": "List of conversation messages associated with the organization" + }, + "_userMap": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "username": { + "type": "string", + "description": "User's identifier or username" + }, + "name": { + "type": "object", + "properties": { + "first": { + "type": "string", + "description": "User's first name" + }, + "last": { + "type": "string", + "description": "User's last name" + }, + "middle": { + "type": "string", + "description": "User's middle name" + }, + "suffix": { + "type": "string", + "description": "User's name suffix" + } + } + }, + "org": { + "type": "object", + "properties": { + "short_name": { + "type": "string", + "description": "Short name of the organization associated with the user" + } + } + } + } + }, + "description": "Map of expanded user UUIDs to display metadata, included when expand=users is requested" } } } diff --git a/schemas/registry-org/update-registry-org-request.json b/schemas/registry-org/update-registry-org-request.json index 8690311f6..f388a40f7 100644 --- a/schemas/registry-org/update-registry-org-request.json +++ b/schemas/registry-org/update-registry-org-request.json @@ -155,19 +155,23 @@ "description": "Indicates if part of the CNA discussion list" }, "partner_role_type": { - "type": "string", - "enum": [ - "", - "Bug Bounty Provider", - "CERT", - "Consortium", - "Hosted Service", - "N/A", - "Open Source", - "Researcher", - "Vendor" - ], - "description": "The type of role a partner holds" + "type": "array", + "items": { + "type": "string", + "enum": [ + "", + "Bug Bounty Provider", + "CERT", + "Consortium", + "Hosted Service", + "N/A", + "Open Source", + "Researcher", + "Vendor" + ] + }, + "uniqueItems": true, + "description": "The types of roles a partner holds" }, "partner_number": { "type": "string", diff --git a/schemas/registry-user/BaseUser.json b/schemas/registry-user/BaseUser.json index 5fa85687e..65db8c71e 100644 --- a/schemas/registry-user/BaseUser.json +++ b/schemas/registry-user/BaseUser.json @@ -101,4 +101,4 @@ "required": [ "username" ] -} \ No newline at end of file +} diff --git a/schemas/registry-user/create-registry-user-request.json b/schemas/registry-user/create-registry-user-request.json index 652bf1d39..15277cb8b 100644 --- a/schemas/registry-user/create-registry-user-request.json +++ b/schemas/registry-user/create-registry-user-request.json @@ -31,20 +31,6 @@ }, "required": ["first", "last"] }, - "org_affiliations": { - "type": "array", - "items": { - "type": "string" - }, - "description": "UUIDs of organizations the user is affiliated with" - }, - "cve_program_org_membership": { - "type": "array", - "items": { - "type": "string" - }, - "description": "UUIDs of CVE program organizations the user is a member of" - }, "authority": { "type": "object", "properties": { diff --git a/schemas/registry-user/create-registry-user-response.json b/schemas/registry-user/create-registry-user-response.json index 7cc963a9c..fe783f2fc 100644 --- a/schemas/registry-user/create-registry-user-response.json +++ b/schemas/registry-user/create-registry-user-response.json @@ -42,20 +42,6 @@ }, "required": ["first", "last"] }, - "org_affiliations": { - "type": "array", - "items": { - "type": "string" - }, - "description": "UUIDs of organizations the user is affiliated with" - }, - "cve_program_org_membership": { - "type": "array", - "items": { - "type": "string" - }, - "description": "UUIDs of CVE program organizations the user is a member of" - }, "authority": { "type": "object", "properties": { @@ -115,4 +101,4 @@ } } } -} \ No newline at end of file +} diff --git a/schemas/registry-user/get-registry-user-response.json b/schemas/registry-user/get-registry-user-response.json index 7b23db936..527c6c4fe 100644 --- a/schemas/registry-user/get-registry-user-response.json +++ b/schemas/registry-user/get-registry-user-response.json @@ -43,20 +43,6 @@ "enum": ["ADMIN"], "description": "The role of the user in the organization. Currently only 'ADMIN' is supported." }, - "org_affiliations": { - "type": "array", - "items": { - "type": "string" - }, - "description": "UUIDs of organizations the user is affiliated with" - }, - "cve_program_org_membership": { - "type": "array", - "items": { - "type": "string" - }, - "description": "UUIDs of CVE program organizations the user is a member of" - }, "authority": { "type": "object", "properties": { @@ -127,4 +113,4 @@ "description": "Timestamp of the last update to the user data" } } -} \ No newline at end of file +} diff --git a/schemas/registry-user/update-registry-user-request.json b/schemas/registry-user/update-registry-user-request.json index 3a084282f..0e305f407 100644 --- a/schemas/registry-user/update-registry-user-request.json +++ b/schemas/registry-user/update-registry-user-request.json @@ -30,20 +30,6 @@ } } }, - "org_affiliations": { - "type": "array", - "items": { - "type": "string" - }, - "description": "UUIDs of organizations the user is affiliated with" - }, - "cve_program_org_membership": { - "type": "array", - "items": { - "type": "string" - }, - "description": "UUIDs of CVE program organizations the user is a member of" - }, "authority": { "type": "object", "properties": { diff --git a/schemas/registry-user/update-registry-user-response.json b/schemas/registry-user/update-registry-user-response.json index 56d6d2802..709698e65 100644 --- a/schemas/registry-user/update-registry-user-response.json +++ b/schemas/registry-user/update-registry-user-response.json @@ -42,20 +42,6 @@ }, "required": ["first", "last"] }, - "org_affiliations": { - "type": "array", - "items": { - "type": "string" - }, - "description": "UUIDs of organizations the user is affiliated with" - }, - "cve_program_org_membership": { - "type": "array", - "items": { - "type": "string" - }, - "description": "UUIDs of CVE program organizations the user is a member of" - }, "authority": { "type": "object", "properties": { @@ -115,4 +101,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/controller/audit.controller/audit.controller.js b/src/controller/audit.controller/audit.controller.js index dd3103a2d..467b698f1 100644 --- a/src/controller/audit.controller/audit.controller.js +++ b/src/controller/audit.controller/audit.controller.js @@ -291,10 +291,10 @@ async function getOrgAuditByOrgIdentifier (req, res, next) { if (!targetOrg) { logger.info({ uuid: req.ctx.uuid, - message: `No organization found with ${identifierIsUUID ? 'UUID' : 'shortname'} ${identifier}` + message: `No organization found with ${identifierIsUUID ? 'UUID' : 'shortname'} ${identifier}; returning empty audit history.` }) await session.abortTransaction() - return res.status(404).json(error.orgDne(identifier)) + return res.status(200).json([]) } // Get the org's UUID for audit lookup diff --git a/src/controller/conversation.controller/conversation.controller.js b/src/controller/conversation.controller/conversation.controller.js index ad49bc965..e79d60227 100644 --- a/src/controller/conversation.controller/conversation.controller.js +++ b/src/controller/conversation.controller/conversation.controller.js @@ -4,6 +4,7 @@ const getConstants = require('../../../src/constants').getConstants const CONSTANTS = getConstants() const errors = require('./error') const error = new errors.ConversationControllerError() +const authContext = require('../../utils/authContext') async function getAllConversations (req, res, next) { const repo = req.ctx.repositories.getConversationRepository() @@ -38,21 +39,19 @@ async function createConversationForTargetUUID (req, res, next) { const repo = req.ctx.repositories.getConversationRepository() const userRepo = req.ctx.repositories.getBaseUserRepository() const orgRepo = req.ctx.repositories.getBaseOrgRepository() - const requesterOrg = req.ctx.org - const requesterUsername = req.ctx.user const targetUUID = req.params.uuid const body = req.body - const user = await userRepo.findOneByUsernameAndOrgShortname(requesterUsername, requesterOrg, { session }) + const user = await authContext.getRequesterUser(req, userRepo, orgRepo, { session }) if (typeof body !== 'object' || !body.body || !repo.validateConversation(body)) { return res.status(400).json(error.invalidConversationObject()) } - const isSecretariat = await orgRepo.isSecretariatByShortName(req.ctx.org) + const isSecretariat = await authContext.isRequesterSecretariat(req, orgRepo, { session }) if (!isSecretariat) { - const orgUUID = await orgRepo.getOrgUUID(req.ctx.org) + const orgUUID = await authContext.getRequesterOrgUUID(req, orgRepo, { session }) if (targetUUID !== orgUUID) { return res.status(403).json({ error: 'UNAUTHORIZED', message: 'Unauthorized' }) } diff --git a/src/controller/cve-id.controller/cve-id.controller.js b/src/controller/cve-id.controller/cve-id.controller.js index d31790710..b10497265 100644 --- a/src/controller/cve-id.controller/cve-id.controller.js +++ b/src/controller/cve-id.controller/cve-id.controller.js @@ -4,6 +4,7 @@ const logger = require('../../middleware/logger') const getConstants = require('../../constants').getConstants const errors = require('./error') const error = new errors.CveIdControllerError() +const authContext = require('../../utils/authContext') // Called by GET /api/cve-id async function getFilteredCveId (req, res, next) { @@ -19,7 +20,6 @@ async function getFilteredCveId (req, res, next) { options.sort = { owning_cna: 'asc', cve_id: 'asc' } try { - const orgShortName = req.ctx.org let state let year const timeReserved = { @@ -34,8 +34,9 @@ async function getFilteredCveId (req, res, next) { const cveIdRepo = req.ctx.repositories.getCveIdRepository() const orgRepo = req.ctx.repositories.getOrgRepository() const userRepo = req.ctx.repositories.getUserRepository() - const isSecretariat = await orgRepo.isSecretariat(orgShortName) - const isBulkDownload = await orgRepo.isBulkDownload(orgShortName) + const isSecretariat = await authContext.isRequesterSecretariat(req, orgRepo) + const isBulkDownload = await authContext.isRequesterBulkDownload(req, orgRepo) + const requesterOrgUUID = await authContext.getRequesterOrgUUID(req, orgRepo) // Create map of orgUUID to shortnames and users to simplify aggregation later const orgs = await orgRepo.getAllOrgs() @@ -84,7 +85,7 @@ async function getFilteredCveId (req, res, next) { // Secretariat and BulkDownload get results for all CNAs if (!(isSecretariat || isBulkDownload)) { - query.owning_cna = await orgRepo.getOrgUUID(orgShortName) + query.owning_cna = requesterOrgUUID } if (year) { @@ -203,8 +204,9 @@ async function reserveCveId (req, res, next) { } }) - const isSecretariat = await orgRepo.isSecretariat(orgShortName) - if (orgShortName !== shortName && !isSecretariat) { + const isSecretariat = await authContext.isRequesterSecretariat(req, orgRepo) + const requesterSameOrg = await authContext.isRequesterSameOrg(req, orgRepo, shortName) + if (!requesterSameOrg && !isSecretariat) { return res.status(403).json(error.orgCannotReserveForOther()) } @@ -281,6 +283,7 @@ async function getCveId (req, res, next) { const id = req.ctx.params.id const cveIdRepo = req.ctx.repositories.getCveIdRepository() const orgRepo = req.ctx.repositories.getOrgRepository() + const rawResult = typeof cveIdRepo.findOneByCveId === 'function' ? await cveIdRepo.findOneByCveId(id) : null const agt = setAggregateObj({ cve_id: id }) let result = await cveIdRepo.aggregate(agt) @@ -299,12 +302,12 @@ async function getCveId (req, res, next) { if (auth) { loggerUuid = req.ctx.uuid orgShortName = req.ctx.org - orgUUID = await orgRepo.getOrgUUID(orgShortName) // orgShortName is not null - isSecretariat = await orgRepo.isSecretariatUUID(orgUUID) + orgUUID = await authContext.getRequesterOrgUUID(req, orgRepo) // orgShortName is not null + isSecretariat = await authContext.isRequesterSecretariat(req, orgRepo) } // Secretariat and owning org are allowed to see complete results - if (isSecretariat || (orgShortName === result.owning_cna)) { + if (isSecretariat || (orgUUID && rawResult?.owning_cna && orgUUID === rawResult.owning_cna) || ((!orgUUID || !rawResult?.owning_cna) && orgShortName === result.owning_cna)) { finalResult = result } else { // otherwise, remove Requested by information, and redact owning_cna for RESERVED ids finalResult = { @@ -338,7 +341,13 @@ async function modifyCveId (req, res, next) { const cveIdRepo = req.ctx.repositories.getCveIdRepository() const userRepo = req.ctx.repositories.getUserRepository() const cveRepo = req.ctx.repositories.getCveRepository() - const org = await orgRepo.findOneByShortName(req.ctx.org) + const requesterOrgUUID = await authContext.getRequesterOrgUUID(req, orgRepo) + const org = requesterOrgUUID && typeof orgRepo.findOneByUUID === 'function' + ? await orgRepo.findOneByUUID(requesterOrgUUID) + : (!req.ctx.authenticated ? await orgRepo.findOneByShortName(req.ctx.org) : null) + if (!org) { + return res.status(403).json(error.orgCannotReserveForOther()) + } // Get remaining org quota const totalReserved = await cveIdRepo.countDocuments({ owning_cna: org.UUID, state: 'RESERVED' }) @@ -408,10 +417,10 @@ async function modifyCveId (req, res, next) { action: 'update_cveid', change: id + ' was successfully updated.', req_UUID: req.ctx.uuid, - org_UUID: await orgRepo.getOrgUUID(req.ctx.org), + org_UUID: requesterOrgUUID, cve_id: result } - payload.user_UUID = await userRepo.getUserUUID(req.ctx.user, payload.org_UUID) + payload.user_UUID = await authContext.getRequesterUserUUID(req, userRepo, orgRepo) logger.info(JSON.stringify(payload)) return res.status(200).json(responseMessage) } catch (err) { @@ -441,9 +450,9 @@ async function createCveIdRange (req, res, next) { action: 'create_cveIdRange', change: 'CVE Id Range document for year ' + year + ' was created.', req_UUID: req.ctx.uuid, - org_UUID: await orgRepo.getOrgUUID(req.ctx.org) + org_UUID: await authContext.getRequesterOrgUUID(req, orgRepo) } - payload.user_UUID = await userRepo.getUserUUID(req.ctx.user, payload.org_UUID) + payload.user_UUID = await authContext.getRequesterUserUUID(req, userRepo, orgRepo) logger.info(JSON.stringify(payload)) return res.status(200).send() @@ -496,8 +505,8 @@ async function priorityReservation (year, amount, shortName, orgShortName, reque const cveIdRepo = req.ctx.repositories.getCveIdRepository() const id = generateSequentialIds(year, result.ranges.priority.top_id, amount) const owningOrgUUID = await orgRepo.getOrgUUID(shortName) - const orgUUID = await orgRepo.getOrgUUID(orgShortName) - const requesterUUID = (await userRepo.findOneByUserNameAndOrgUUID(requester, orgUUID)).UUID + const orgUUID = await authContext.getRequesterOrgUUID(req, orgRepo) + const requesterUUID = await authContext.getRequesterUserUUID(req, userRepo, orgRepo) let cveIdDocuments = [] const cveIdDocumentsUUID = [] @@ -593,8 +602,8 @@ async function sequentialReservation (year, amount, shortName, orgShortName, req const cveIdRepo = req.ctx.repositories.getCveIdRepository() const ids = generateSequentialIds(year, result.ranges.general.top_id, amount) const owningOrgUUID = await orgRepo.getOrgUUID(shortName) - const orgUUID = await orgRepo.getOrgUUID(orgShortName) - const requesterUUID = (await userRepo.findOneByUserNameAndOrgUUID(requester, orgUUID)).UUID + const orgUUID = await authContext.getRequesterOrgUUID(req, orgRepo) + const requesterUUID = await authContext.getRequesterUserUUID(req, userRepo, orgRepo) let cveIdDocuments = [] const cveIdDocumentsUUID = [] let cveIdUUID @@ -705,8 +714,8 @@ async function nonSequentialReservation (year, amount, shortName, orgShortName, const cveIdDocuments = [] const cveIdDocumentsUUID = [] const owningOrgUUID = await orgRepo.getOrgUUID(shortName) - const orgUUID = await orgRepo.getOrgUUID(orgShortName) - const requesterUUID = (await userRepo.findOneByUserNameAndOrgUUID(requester, orgUUID)).UUID + const orgUUID = await authContext.getRequesterOrgUUID(req, orgRepo) + const requesterUUID = await authContext.getRequesterUserUUID(req, userRepo, orgRepo) const q = availableIds - amount while ((counter < amount) && !isFull) { diff --git a/src/controller/cve.controller/cve.controller.js b/src/controller/cve.controller/cve.controller.js index 86ce4b97e..0ac52b983 100644 --- a/src/controller/cve.controller/cve.controller.js +++ b/src/controller/cve.controller/cve.controller.js @@ -6,6 +6,7 @@ const error = new errors.CveControllerError() const booleanIsTrue = require('../../utils/utils').booleanIsTrue const convertDatesToISO = require('../../utils/utils').convertDatesToISO const isEnrichedContainer = require('../../utils/utils').isEnrichedContainer +const authContext = require('../../utils/authContext') const url = process.env.NODE_ENV === 'staging' ? 'https://test.cve.org/' : 'https://cve.org/' const _ = require('lodash') @@ -412,11 +413,11 @@ async function submitCve (req, res, next) { action: 'create_cve_record', change: cveId + ' record was successfully created.', req_UUID: req.ctx.uuid, - org_UUID: await orgRepo.getOrgUUID(req.ctx.org), + org_UUID: await authContext.getRequesterOrgUUID(req, orgRepo), cve: cveId } const userRepo = req.ctx.repositories.getUserRepository() - payload.user_UUID = await userRepo.getUserUUID(req.ctx.user, payload.org_UUID) + payload.user_UUID = await authContext.getRequesterUserUUID(req, userRepo, orgRepo) logger.info(JSON.stringify(payload)) return res.status(200).json(responseMessage) } catch (err) { @@ -475,12 +476,12 @@ async function updateCve (req, res, next) { action: 'update_cve_record', change: cveId + ' record was successfully updated.', req_UUID: req.ctx.uuid, - org_UUID: await orgRepo.getOrgUUID(req.ctx.org), + org_UUID: await authContext.getRequesterOrgUUID(req, orgRepo), cve: cveId } const userRepo = req.ctx.repositories.getUserRepository() - payload.user_UUID = await userRepo.getUserUUID(req.ctx.user, payload.org_UUID) + payload.user_UUID = await authContext.getRequesterUserUUID(req, userRepo, orgRepo) logger.info(JSON.stringify(payload)) return res.status(200).json(responseMessage) } catch (err) { @@ -498,8 +499,8 @@ async function submitCna (req, res, next) { const cveIdRepo = req.ctx.repositories.getCveIdRepository() const orgRepo = req.ctx.repositories.getOrgRepository() const userRepo = req.ctx.repositories.getUserRepository() - const orgUuid = await orgRepo.getOrgUUID(req.ctx.org) - const userUuid = await userRepo.getUserUUID(req.ctx.user, orgUuid) + const orgUuid = await authContext.getRequesterOrgUUID(req, orgRepo) + const userUuid = await authContext.getRequesterUserUUID(req, userRepo, orgRepo) // To avoid breaking legacy behavior in the "booleanIsTrue" function, we need to check to make sure that undefined is set to false let erlCheck @@ -517,7 +518,7 @@ async function submitCna (req, res, next) { // check that cveId org matches user org const cveId = result - const isSecretariat = await orgRepo.isSecretariat(req.ctx.org) + const isSecretariat = await authContext.isRequesterSecretariat(req, orgRepo) if ((cveId.owning_cna !== orgUuid) && !isSecretariat) { return res.status(403).json(error.owningOrgDoesNotMatch()) } @@ -594,8 +595,8 @@ async function updateCna (req, res, next) { const cveIdRepo = req.ctx.repositories.getCveIdRepository() const orgRepo = req.ctx.repositories.getOrgRepository() const userRepo = req.ctx.repositories.getUserRepository() - const orgUuid = await orgRepo.getOrgUUID(req.ctx.org) - const userUuid = await userRepo.getUserUUID(req.ctx.user, orgUuid) + const orgUuid = await authContext.getRequesterOrgUUID(req, orgRepo) + const userUuid = await authContext.getRequesterUserUUID(req, userRepo, orgRepo) // To avoid breaking legacy behavior in the "booleanIsTrue" function, we need to check to make sure that undefined is set to false let erlCheck @@ -613,7 +614,7 @@ async function updateCna (req, res, next) { // check that cveId org matches user org const cveId = result - const isSecretariat = await orgRepo.isSecretariat(req.ctx.org) + const isSecretariat = await authContext.isRequesterSecretariat(req, orgRepo) if ((cveId.owning_cna !== orgUuid) && !isSecretariat) { return res.status(403).json(error.owningOrgDoesNotMatch()) } @@ -719,7 +720,7 @@ async function rejectCVE (req, res, next) { // Both orgs below should exist since they passed validation const orgRepo = req.ctx.repositories.getOrgRepository() - const providerOrgObj = await orgRepo.findOneByShortName(req.ctx.org) + const providerOrgObj = await authContext.getRequesterOrg(req, orgRepo) const owningCnaObj = await orgRepo.findOneByUUID(cveIdObj.owning_cna) const owningCnaShortName = owningCnaObj?.short_name @@ -755,11 +756,11 @@ async function rejectCVE (req, res, next) { action: 'submit_rejected_cve_record', change: id + ' record was successfully submitted.', req_UUID: req.ctx.uuid, - org_UUID: await orgRepo.getOrgUUID(req.ctx.org), + org_UUID: providerOrgObj.UUID, cve: id } const userRepo = req.ctx.repositories.getUserRepository() - payload.user_UUID = await userRepo.getUserUUID(req.ctx.user, payload.org_UUID) + payload.user_UUID = await authContext.getRequesterUserUUID(req, userRepo, orgRepo) logger.info(JSON.stringify(payload)) return res.status(200).json(responseMessage) } catch (err) { @@ -776,7 +777,7 @@ async function rejectExistingCve (req, res, next) { const cveIdRepo = req.ctx.repositories.getCveIdRepository() const cveRepo = req.ctx.repositories.getCveRepository() const orgRepo = req.ctx.repositories.getOrgRepository() - const providerOrgObj = await orgRepo.findOneByShortName(req.ctx.org) + const providerOrgObj = await authContext.getRequesterOrg(req, orgRepo) // check that cve id exists const cveIdObj = await cveIdRepo.findOneByCveId(id) @@ -834,7 +835,7 @@ async function rejectExistingCve (req, res, next) { cve: id } const userRepo = req.ctx.repositories.getUserRepository() - payload.user_UUID = await userRepo.getUserUUID(req.ctx.user, payload.org_UUID) + payload.user_UUID = await authContext.getRequesterUserUUID(req, userRepo, orgRepo) logger.info(JSON.stringify(payload)) return res.status(200).json(responseMessage) } catch (err) { @@ -852,8 +853,8 @@ async function insertAdp (req, res, next) { const cveIdRepo = req.ctx.repositories.getCveIdRepository() const orgRepo = req.ctx.repositories.getOrgRepository() const userRepo = req.ctx.repositories.getUserRepository() - const orgUuid = await orgRepo.getOrgUUID(req.ctx.org) - const userUuid = await userRepo.getUserUUID(req.ctx.user, orgUuid) + const orgUuid = await authContext.getRequesterOrgUUID(req, orgRepo) + const userUuid = await authContext.getRequesterUserUUID(req, userRepo, orgRepo) // check that cve id exists let result = await cveIdRepo.findOneByCveId(id) diff --git a/src/controller/org.controller/index.js b/src/controller/org.controller/index.js index e3ca0287a..c9c9cf60a 100644 --- a/src/controller/org.controller/index.js +++ b/src/controller/org.controller/index.js @@ -280,6 +280,15 @@ router.get('/registry/org/:identifier',

Regular, CNA & Admin Users: Retrieves registry organization record for the specified shortname or UUID if it is the user's organization

Secretariat: Retrieves information about any registry organization

" #swagger.parameters['identifier'] = { description: 'The shortname or UUID of the registry organization' } + #swagger.parameters['expand'] = { + in: 'query', + description: 'Optional expanded related data. Accepted value: users.', + required: false, + schema: { + type: 'string', + enum: ['users'] + } + } #swagger.parameters['$ref'] = [ '#/components/parameters/apiEntityHeader', '#/components/parameters/apiUserHeader', @@ -338,7 +347,9 @@ router.get('/registry/org/:identifier', */ mw.useRegistry(), mw.validateUser, - query().custom((query) => { return mw.validateQueryParameterNames(query, ['']) }), + query().custom((query) => { return mw.validateQueryParameterNames(query, ['expand']) }), + query(['expand']).custom((val) => { return mw.containsNoInvalidCharacters(val) }), + query(['expand']).optional().isIn(['users']), parseError, parseGetParams, registryOrgController.SINGLE_ORG diff --git a/src/controller/org.controller/org.controller.js b/src/controller/org.controller/org.controller.js index f2c79878c..035439b1d 100644 --- a/src/controller/org.controller/org.controller.js +++ b/src/controller/org.controller/org.controller.js @@ -6,6 +6,7 @@ const errors = require('./error') const error = new errors.OrgControllerError() const validateUUID = require('uuid').validate const _ = require('lodash') +const authContext = require('../../utils/authContext') /** * Get the details of all orgs. @@ -61,18 +62,20 @@ async function getOrg (req, res, next) { let returnValue try { - const requesterOrg = await repo.findOneByShortName(requesterOrgShortName, {}, returnLegacyFormat) + const requesterOrg = await authContext.getRequesterOrg(req, repo, {}, returnLegacyFormat) // Ensure requester org exists if (!requesterOrg) { return res.status(404).json(error.orgDne(requesterOrgShortName, 'requesterOrgShortName', 'header')) } - const requesterOrgIdentifier = identifierIsUUID ? requesterOrg.UUID : requesterOrgShortName - const isSecretariat = await repo.isSecretariat(requesterOrg, {}, returnLegacyFormat) + const isSecretariat = await authContext.isRequesterSecretariat(req, repo, {}, returnLegacyFormat) + const isRequesterSameOrg = identifierIsUUID + ? requesterOrg.UUID === identifier + : await authContext.isRequesterSameOrg(req, repo, identifier, {}, returnLegacyFormat) // Ensure that if the requester is not Secretariat, they can't view orgs other than their own - if (requesterOrgIdentifier !== identifier && !isSecretariat) { + if (!isRequesterSameOrg && !isSecretariat) { logger.info({ uuid: req.ctx.uuid, message: identifier + ' organization can only be viewed by same-org users or Secretariat.' }) return res.status(403).json(error.notSameOrgOrSecretariat()) } @@ -124,20 +127,20 @@ async function getUsers (req, res, next) { const options = CONSTANTS.PAGINATOR_OPTIONS // options.sort = { username: 'asc' } options.page = req.ctx.query.page ? parseInt(req.ctx.query.page) : CONSTANTS.PAGINATOR_PAGE // if 'page' query parameter is not defined, set 'page' to the default page value - const shortName = req.ctx.org const orgShortName = req.ctx.params.shortname const orgRepo = req.ctx.repositories.getBaseOrgRepository() const userRepo = req.ctx.repositories.getBaseUserRepository() const orgUUID = await orgRepo.getOrgUUID(orgShortName) - const isSecretariat = await orgRepo.isSecretariatByShortName(shortName) + const isSecretariat = await authContext.isRequesterSecretariat(req, orgRepo) if (!orgUUID) { logger.info({ uuid: req.ctx.uuid, message: orgShortName + ' organization does not exist.' }) return res.status(404).json(error.orgDnePathParam(orgShortName)) } - if (orgShortName !== shortName && !isSecretariat) { + const isSameOrg = await authContext.isRequesterSameOrg(req, orgRepo, { UUID: orgUUID, short_name: orgShortName }, {}, !req.useRegistry) + if (!isSameOrg && !isSecretariat) { logger.info({ uuid: req.ctx.uuid, message: orgShortName + ' organization can only be viewed by the users of the same organization or the Secretariat.' }) return res.status(403).json(error.notSameOrgOrSecretariat()) } @@ -162,19 +165,19 @@ async function getUsers (req, res, next) { */ async function getUser (req, res, next) { try { - const shortName = req.ctx.org const username = req.ctx.params.username const orgShortName = req.ctx.params.shortname const orgRepo = req.ctx.repositories.getBaseOrgRepository() - const isSecretariat = await orgRepo.isSecretariatByShortName(shortName, {}, !req.useRegistry) + const isSecretariat = await authContext.isRequesterSecretariat(req, orgRepo, {}, !req.useRegistry) + const orgUUID = await orgRepo.getOrgUUID(orgShortName) - if (orgShortName !== shortName && !isSecretariat) { - logger.info({ uuid: req.ctx.uuid, message: shortName + ' organization can only be viewed by that organization\'s users or the Secretariat.' }) + const isSameOrg = await authContext.isRequesterSameOrg(req, orgRepo, { UUID: orgUUID, short_name: orgShortName }, {}, !req.useRegistry) + if (!isSameOrg && !isSecretariat) { + logger.info({ uuid: req.ctx.uuid, message: req.ctx.org + ' organization can only be viewed by that organization\'s users or the Secretariat.' }) return res.status(403).json(error.notSameOrgOrSecretariat()) } - const orgUUID = await orgRepo.getOrgUUID(orgShortName) if (!orgUUID) { // the org can only be non-existent if the requestor is the Secretariat logger.info({ uuid: req.ctx.uuid, message: orgShortName + ' organization does not exist.' }) return res.status(404).json(error.orgDnePathParam(orgShortName)) @@ -214,13 +217,12 @@ async function getUser (req, res, next) { async function getOrgIdQuota (req, res, next) { try { const orgRepo = req.ctx.repositories.getBaseOrgRepository() - const requesterOrgShortName = req.ctx.org const shortName = req.ctx.params.shortname - const requesterOrg = await orgRepo.findOneByShortName(requesterOrgShortName) - const isSecretariat = await orgRepo.isSecretariat(requesterOrg, !req.useRegistry) + const isSecretariat = await authContext.isRequesterSecretariat(req, orgRepo, {}, !req.useRegistry) + const isSameOrg = await authContext.isRequesterSameOrg(req, orgRepo, shortName, {}, !req.useRegistry) - if (requesterOrgShortName !== shortName && !isSecretariat) { + if (!isSameOrg && !isSecretariat) { logger.info({ uuid: req.ctx.uuid, message: shortName + ' organization id quota can only be viewed by the users of the same organization or the Secretariat.' }) return res.status(403).json(error.notSameOrgOrSecretariat()) } @@ -292,8 +294,8 @@ async function createOrg (req, res, next) { return res.status(400).json(error.aliasCollision(collisionString)) } const userRepo = req.ctx.repositories.getBaseUserRepository() - const isSecretariat = await repo.isSecretariatByShortName(req.ctx.org, { session }) - const requestingUserUUID = await userRepo.getUserUUID(req.ctx.user, req.ctx.org, { session }) + const isSecretariat = await authContext.isRequesterSecretariat(req, repo, { session }, !req.useRegistry) + const requestingUserUUID = await authContext.getRequesterUserUUID(req, userRepo, repo, { session }, !!req.useRegistry) returnValue = await repo.createOrg(req.ctx.body, { session, upsert: true }, !req.useRegistry, requestingUserUUID, isSecretariat) await session.commitTransaction() @@ -339,6 +341,7 @@ async function updateOrg (req, res, next) { const session = await mongoose.startSession({ causalConsistency: false }) let responseMessage + let payload // Get the query parameters as JSON // These are validated by the middleware in org/index.js const queryParametersJson = req.ctx.query @@ -386,9 +389,9 @@ async function updateOrg (req, res, next) { } const userRepo = req.ctx.repositories.getBaseUserRepository() - const requestingUserUUID = await userRepo.getUserUUID(req.ctx.user, req.ctx.org, { session }) - const isSecretariat = await orgRepository.isSecretariatByShortName(req.ctx.org, { session }) - const isAdmin = await userRepo.isAdmin(req.ctx.user, req.ctx.org, { session }) + const requestingUserUUID = await authContext.getRequesterUserUUID(req, userRepo, orgRepository, { session }, !!req.useRegistry) + const isSecretariat = await authContext.isRequesterSecretariat(req, orgRepository, { session }, !req.useRegistry) + const isAdmin = await authContext.isRequesterAdmin(req, userRepo, orgRepository, { session }, !!req.useRegistry) if (!isSecretariat) { const secretariatOnlyFields = getConstants().SECRETARIAT_ONLY_FIELDS @@ -402,10 +405,14 @@ async function updateOrg (req, res, next) { const updatedOrg = await orgRepository.updateOrg(shortNameUrlParameter, queryParametersJson, { session }, !req.useRegistry, requestingUserUUID, isAdmin, isSecretariat) responseMessage = { message: `${updatedOrg.short_name} organization was successfully updated.`, updated: updatedOrg } // Clarify message - const payload = { action: 'update_org', change: `${updatedOrg.short_name} organization was successfully updated.`, org: updatedOrg } - payload.user_UUID = await userRepo.getUserUUID(req.ctx.user, updatedOrg.UUID) - payload.org_UUID = updatedOrg.UUID - payload.req_UUID = req.ctx.uuid + payload = { + action: 'update_org', + change: `${updatedOrg.short_name} organization was successfully updated.`, + req_UUID: req.ctx.uuid, + org_UUID: updatedOrg.UUID, + user_UUID: requestingUserUUID, + org: updatedOrg + } await session.commitTransaction() } catch (error) { await session.abortTransaction() @@ -414,6 +421,7 @@ async function updateOrg (req, res, next) { await session.endSession() } + logger.info(JSON.stringify(payload)) return res.status(200).json(responseMessage) } catch (err) { next(err) @@ -483,7 +491,16 @@ async function createUser (req, res, next) { return res.status(400).json(error.userExists(body?.username)) } - if (!await userRepo.isAdminOrSecretariat(orgShortName, req.ctx.user, req.ctx.org, { session }, !!req.useRegistry)) { + let isRequesterAdminOrSecretariat + if (!req.ctx.authenticated && !req.ctx.orgUUID && typeof userRepo.isAdminOrSecretariat === 'function') { + isRequesterAdminOrSecretariat = await userRepo.isAdminOrSecretariat(orgShortName, req.ctx.user, req.ctx.org, { session }, !!req.useRegistry) + } else { + const isRequesterSecretariat = await authContext.isRequesterSecretariat(req, orgRepo, { session }, !req.useRegistry) + const isRequesterAdminOfTargetOrg = await authContext.isRequesterAdminOfOrg(req, userRepo, orgRepo, orgShortName, { session }, !!req.useRegistry) + isRequesterAdminOrSecretariat = isRequesterSecretariat || isRequesterAdminOfTargetOrg + } + + if (!isRequesterAdminOrSecretariat) { await session.abortTransaction() return res.status(403).json(error.notOrgAdminOrSecretariat()) // The Admin user must belong to the new user's organization } @@ -494,7 +511,7 @@ async function createUser (req, res, next) { return res.status(400).json(error.userLimitReached()) } - const requestingUserUUID = await userRepo.getUserUUID(req.ctx.user, req.ctx.org, { session }) + const requestingUserUUID = await authContext.getRequesterUserUUID(req, userRepo, orgRepo, { session }, !!req.useRegistry) returnValue = await userRepo.createUser(orgShortName, body, { session, upsert: true }, !!req.useRegistry, requestingUserUUID) await session.commitTransaction() } catch (error) { @@ -541,7 +558,6 @@ async function updateUser (req, res, next) { try { session.startTransaction() - const requesterShortName = req.ctx.org const requesterUsername = req.ctx.user const usernameParams = req.ctx.params.username const shortNameParams = req.ctx.params.shortname @@ -552,11 +568,11 @@ async function updateUser (req, res, next) { const queryParametersJson = req.ctx.query // Get requester UUID for later - const requesterUUID = await userRepo.getUserUUID(requesterUsername, requesterShortName, { session }, !!req.useRegistry) + const requesterUUID = await authContext.getRequesterUserUUID(req, userRepo, orgRepo, { session }, !!req.useRegistry) const targetUserUUID = await userRepo.getUserUUID(usernameParams, shortNameParams, { session }, !!req.useRegistry) - const isRequesterSecretariat = await orgRepo.isSecretariatByShortName(requesterShortName, { session }) - const isAdmin = await userRepo.isAdmin(requesterUsername, requesterShortName, { session }) + const isRequesterSecretariat = await authContext.isRequesterSecretariat(req, orgRepo, { session }, !req.useRegistry) + const isAdmin = await authContext.isRequesterAdmin(req, userRepo, orgRepo, { session }, !!req.useRegistry) const targetOrgUUID = await orgRepo.getOrgUUID(shortNameParams, { session }) // if (req.useRegistry) { @@ -571,7 +587,8 @@ async function updateUser (req, res, next) { return res.status(404).json(error.orgDnePathParam(shortNameParams)) } - if (shortNameParams !== requesterShortName && !isRequesterSecretariat) { + const requesterSameOrg = await authContext.isRequesterSameOrg(req, orgRepo, { UUID: targetOrgUUID, short_name: shortNameParams }, { session }, !req.useRegistry) + if (!requesterSameOrg && !isRequesterSecretariat) { logger.info({ uuid: req.ctx.uuid, message: `${shortNameParams} organization data can only be modified by users of the same organization or the Secretariat.` }) await session.abortTransaction() return res.status(403).json(error.notSameOrgOrSecretariat()) @@ -670,8 +687,7 @@ async function updateUser (req, res, next) { } } - const requestingUserUUID = await userRepo.getUserUUID(req.ctx.user, req.ctx.org, { session }) - const payload = await userRepo.updateUser(usernameParams, shortNameParams, queryParametersJson, { session }, !!req.useRegistry, requestingUserUUID) + const payload = await userRepo.updateUser(usernameParams, shortNameParams, queryParametersJson, { session }, !!req.useRegistry, requesterUUID) await session.commitTransaction() return res.status(200).json({ message: `${usernameParams} was successfully updated.`, updated: payload }) } catch (err) { @@ -702,8 +718,6 @@ async function updateUser (req, res, next) { async function resetSecret (req, res, next) { try { const session = await mongoose.startSession({ causalConsistency: false }) - const requesterOrgShortName = req.ctx.org - const requesterUsername = req.ctx.user const targetOrgShortName = req.ctx.params.shortname const targetUsername = req.ctx.params.username @@ -729,19 +743,21 @@ async function resetSecret (req, res, next) { return res.status(404).json(error.userDne(targetUsername)) } - const requesterUserUUID = await userRepo.getUserUUID(requesterUsername, requesterOrgShortName, { session }, !!req.useRegistry) + const requesterUserUUID = await authContext.getRequesterUserUUID(req, userRepo, orgRepo, { session }, !!req.useRegistry) - const isRequesterSecretariat = await orgRepo.isSecretariatByShortName(requesterOrgShortName, { session }) + const isRequesterSecretariat = await authContext.isRequesterSecretariat(req, orgRepo, { session }, !req.useRegistry) if (!isRequesterSecretariat) { // If they are in the same organization, they must be the target user themselves OR an admin of the target org. + const targetOrg = { UUID: targetOrgUUID, short_name: targetOrgShortName } // 1. WE are not the same user if (requesterUserUUID !== targetUserUUID) { // Check to see if we are the admin of the target organization - const isAdminOfTargetOrg = await userRepo.isAdmin(requesterUsername, targetOrgShortName, { session }) + const isAdminOfTargetOrg = await authContext.isRequesterAdminOfOrg(req, userRepo, orgRepo, targetOrg, { session }, !!req.useRegistry) // The tests say we have to check the org next: - if (requesterOrgShortName !== targetOrgShortName && !isAdminOfTargetOrg) { + const requesterSameOrg = await authContext.isRequesterSameOrg(req, orgRepo, targetOrg, { session }, !req.useRegistry) + if (!requesterSameOrg && !isAdminOfTargetOrg) { logger.info({ uuid: req.ctx.uuid, message: 'The api secret can only be reset by the Secretariat, an Org admin or if the requester is the user.' }) await session.abortTransaction() return res.status(403).json(error.notSameOrgOrSecretariat()) diff --git a/src/controller/org.controller/org.middleware.js b/src/controller/org.controller/org.middleware.js index dbee8ab7a..f3c7eb936 100644 --- a/src/controller/org.controller/org.middleware.js +++ b/src/controller/org.controller/org.middleware.js @@ -376,7 +376,7 @@ function parsePostParams (req, res, next) { function parseGetParams (req, res, next) { utils.reqCtxMapping(req, 'params', ['shortname', 'username', 'identifier']) - utils.reqCtxMapping(req, 'query', ['page']) + utils.reqCtxMapping(req, 'query', ['page', 'expand']) next() } diff --git a/src/controller/registry-org.controller/error.js b/src/controller/registry-org.controller/error.js index b105e373e..1b350b417 100644 --- a/src/controller/registry-org.controller/error.js +++ b/src/controller/registry-org.controller/error.js @@ -22,6 +22,13 @@ class RegistryOrgControllerError extends idrErr.IDRError { return err } + notOrgAdminOrSecretariatUpdate () { + const err = {} + err.error = 'NOT_ORG_ADMIN_OR_SECRETARIAT_UPDATE' + err.message = 'Organizations can only be updated by the Secretariat or an Org Admin.' + return err + } + notAllowedToChangeOrganization () { const err = {} err.error = 'NOT_ALLOWED_TO_CHANGE_ORGANIZATION' diff --git a/src/controller/registry-org.controller/registry-org.controller.js b/src/controller/registry-org.controller/registry-org.controller.js index 027c20c8e..ad66e2c25 100644 --- a/src/controller/registry-org.controller/registry-org.controller.js +++ b/src/controller/registry-org.controller/registry-org.controller.js @@ -7,6 +7,94 @@ const error = new errors.RegistryOrgControllerError() const conversationErrors = require('../conversation.controller/error') const convoError = new conversationErrors.ConversationControllerError() const validateUUID = require('uuid').validate +const authContext = require('../../utils/authContext') + +function addUUIDsToSet (uuidSet, values) { + if (!Array.isArray(values)) return + values.filter(Boolean).forEach(uuid => uuidSet.add(uuid)) +} + +function addConversationAuthorUUIDsToSet (uuidSet, conversations) { + if (!Array.isArray(conversations)) return + conversations.forEach(conversation => { + if (conversation.author_id) { + uuidSet.add(conversation.author_id) + } + }) +} + +function asPlainObject (value) { + return typeof value?.toObject === 'function' ? value.toObject() : value +} + +function buildOrgShortNameByUserUUID (orgs, requestedUserUUIDs) { + const orgShortNameByUserUUID = {} + orgs.forEach(org => { + const orgObject = asPlainObject(org) + if (!Array.isArray(orgObject.users)) return + + orgObject.users.forEach(userUUID => { + if (requestedUserUUIDs.has(userUUID)) { + orgShortNameByUserUUID[userUUID] = orgObject.short_name + } + }) + }) + return orgShortNameByUserUUID +} + +async function buildUserMapForUUIDs (userUUIDs, userRepo, orgRepo) { + if (userUUIDs.size === 0) { + return {} + } + + const userUUIDList = Array.from(userUUIDs) + const users = await userRepo.findUsersByUUIDs(userUUIDList) + const orgs = await orgRepo.findOrgsByUserUUIDs(userUUIDList) + const orgShortNameByUserUUID = buildOrgShortNameByUserUUID(orgs, userUUIDs) + + return users.reduce((userMap, user) => { + const userObject = asPlainObject(user) + if (!userUUIDs.has(userObject.UUID)) return userMap + const orgShortName = orgShortNameByUserUUID[userObject.UUID] + userMap[userObject.UUID] = _.omitBy({ + username: userObject.username, + name: userObject.name, + org: orgShortName ? { short_name: orgShortName } : undefined + }, _.isUndefined) + return userMap + }, {}) +} + +async function buildOrgUserMap (org, userRepo, orgRepo) { + const orgObject = asPlainObject(org) + const userUUIDs = new Set() + addUUIDsToSet(userUUIDs, orgObject.users) + addUUIDsToSet(userUUIDs, orgObject.admins) + addUUIDsToSet(userUUIDs, orgObject.contact_info?.additional_contacts) + + return buildUserMapForUUIDs(userUUIDs, userRepo, orgRepo) +} + +async function buildUserMap (org, userRepo, orgRepo) { + const orgObject = asPlainObject(org) + const userUUIDs = new Set() + addUUIDsToSet(userUUIDs, orgObject.users) + addUUIDsToSet(userUUIDs, orgObject.admins) + addUUIDsToSet(userUUIDs, orgObject.contact_info?.additional_contacts) + addConversationAuthorUUIDsToSet(userUUIDs, orgObject.conversation) + + return buildUserMapForUUIDs(userUUIDs, userRepo, orgRepo) +} + +function removeUserUUIDFields (org) { + delete org.users + delete org.admins + removeAdditionalContactUUIDFields(org) +} + +function removeAdditionalContactUUIDFields (org) { + _.unset(org, 'contact_info.additional_contacts') +} /** * Retrieves information about all registry organizations. @@ -24,7 +112,7 @@ async function getAllOrgs (req, res, next) { try { const repo = req.ctx.repositories.getBaseOrgRepository() const conversationRepo = req.ctx.repositories.getConversationRepository() - const isSecretariat = await repo.isSecretariatByShortName(req.ctx.org) + const isSecretariat = await authContext.isRequesterSecretariat(req, repo) const CONSTANTS = getConstants() let returnValue @@ -78,28 +166,52 @@ async function getOrg (req, res, next) { const conversationRepo = req.ctx.repositories.getConversationRepository() // User passed in parameter to filter for const identifier = req.ctx.params.identifier - const requesterOrgShortName = req.ctx.org const identifierIsUUID = validateUUID(identifier) let returnValue try { - const requesterOrg = await repo.findOneByShortName(requesterOrgShortName) - const requesterOrgIdentifier = identifierIsUUID ? requesterOrg.UUID : requesterOrgShortName - const isSecretariat = await repo.isSecretariat(requesterOrg) + const requesterOrg = await authContext.getRequesterOrg(req, repo) + const isSecretariat = await authContext.isRequesterSecretariat(req, repo) + const isRequesterSameOrg = identifierIsUUID + ? requesterOrg.UUID === identifier + : await authContext.isRequesterSameOrg(req, repo, identifier) - if (requesterOrgIdentifier !== identifier && !isSecretariat) { + if (!isRequesterSameOrg && !isSecretariat) { logger.info({ uuid: req.ctx.uuid, message: identifier + ' organization can only be viewed by the users of the same organization or the Secretariat.' }) return res.status(403).json(error.notSameOrgOrSecretariat()) } returnValue = await repo.getOrg(identifier, identifierIsUUID, {}, false, isSecretariat) if (returnValue) { + let userRepo + let isAdminOfRequestedOrg = false + + if (!isSecretariat) { + userRepo = req.ctx.repositories.getBaseUserRepository() + isAdminOfRequestedOrg = await userRepo.isAdmin(req.ctx.user, returnValue.short_name, {}) + } + // fetch conversation const conversation = await conversationRepo.getAllByTargetUUID(returnValue.UUID, isSecretariat) if (isSecretariat) { returnValue.conversation = conversation?.length ? _.map(conversation, c => _.omit(c, ['__v', '_id', 'previous_conversation_uuid', 'next_conversation_uuid', 'target_uuid'])) : undefined } else { - returnValue.conversation = conversation?.length ? _.map(conversation, c => _.omit(c, ['__v', '_id', 'UUID', 'previous_conversation_uuid', 'next_conversation_uuid', 'target_uuid', 'visibility'])) : undefined + returnValue.conversation = conversation?.length ? _.map(conversation, c => _.omit(c, ['__v', '_id', 'UUID', 'previous_conversation_uuid', 'next_conversation_uuid', 'target_uuid', 'visibility', 'author_id'])) : undefined + } + + if (req.ctx.query?.expand === 'users') { + userRepo = userRepo || req.ctx.repositories.getBaseUserRepository() + if (isSecretariat) { + returnValue._userMap = await buildUserMap(returnValue, userRepo, repo) + } else if (isAdminOfRequestedOrg) { + returnValue._userMap = await buildOrgUserMap(requesterOrg, userRepo, repo) + } + } + + if (!isSecretariat) { + if (!isAdminOfRequestedOrg) { + removeUserUUIDFields(returnValue) + } } } } catch (error) { @@ -138,7 +250,7 @@ async function createOrg (req, res, next) { const session = await mongoose.startSession({ causalConsistency: false }) const repo = req.ctx.repositories.getBaseOrgRepository() const body = req.ctx.body - const isSecretariat = await repo.isSecretariatByShortName(req.ctx.org, { session }) + const isSecretariat = await authContext.isRequesterSecretariat(req, repo, { session }) let createdOrg // Do not allow the user to pass in a UUID @@ -189,7 +301,7 @@ async function createOrg (req, res, next) { } const userRepo = req.ctx.repositories.getBaseUserRepository() - const requestingUserUUID = await userRepo.getUserUUID(req.ctx.user, req.ctx.org, { session }) + const requestingUserUUID = await authContext.getRequesterUserUUID(req, userRepo, repo, { session }) // Create the org – repo.createOrg will handle field mapping createdOrg = await repo.createOrg(body, { session, upsert: true }, false, requestingUserUUID, isSecretariat) @@ -239,6 +351,52 @@ async function createOrg (req, res, next) { } } +async function validateRequestedShortName (req, repo, body, shortName, session) { + const requestedShortName = body?.new_short_name || body?.short_name || shortName + + if (_.has(body, 'short_name') && _.has(body, 'new_short_name') && body.short_name !== shortName && body.short_name !== body.new_short_name) { + logger.info({ + uuid: req.ctx.uuid, + message: `${shortName} organization could not be updated because short_name and new_short_name identify different requested short names.` + }) + return { + status: 400, + response: { + message: 'Parameters were invalid', + errors: [{ + instancePath: '/short_name', + message: 'short_name must match the path shortname or new_short_name when new_short_name is provided' + }] + } + } + } + + if (requestedShortName !== shortName && await repo.orgExists(requestedShortName, { session })) { + logger.info({ + uuid: req.ctx.uuid, + message: `${shortName} organization could not be updated because new short name ${requestedShortName} already exists.` + }) + return { + status: 400, + response: error.duplicateShortname(requestedShortName) + } + } + + const collisionString = await repo.checkAliasCollisions(requestedShortName, body?.name, body?.aliases, shortName, { session }) + if (collisionString) { + logger.info({ + uuid: req.ctx.uuid, + message: `${shortName} organization could not be updated because the string '${collisionString}' collides with another organization's short_name, name, or alias.` + }) + return { + status: 400, + response: error.aliasCollision(collisionString) + } + } + + return { requestedShortName } +} + /** * Updates an existing registry organization. * @@ -264,17 +422,27 @@ async function updateOrg (req, res, next) { try { session.startTransaction() - const isSecretariat = await repo.isSecretariatByShortName(req.ctx.org, { session }) - const isAdmin = await userRepo.isAdmin(req.ctx.user, req.ctx.org, { session }) - const requestingUser = await userRepo.findOneByUsernameAndOrgShortname(req.ctx.user, req.ctx.org, { session }) + const requester = await authContext.getRequesterContext(req, { orgRepo: repo, userRepo }, { session }) + const isSecretariat = requester.isSecretariat + const isAdmin = requester.isAdmin + const requestingUser = requester.user const org = await repo.findOneByShortName(shortName, { session }) + const requesterSameOrg = org + ? await authContext.isRequesterSameOrg(req, repo, org, { session }) + : await authContext.isRequesterSameOrg(req, repo, shortName, { session }) - if (!isSecretariat && (!isAdmin || shortName !== req.ctx.org)) { + if (!isSecretariat && !requesterSameOrg) { logger.info({ uuid: req.ctx.uuid, message: shortName + ' organization can only be updated by the users of the same organization or the Secretariat.' }) await session.abortTransaction() return res.status(403).json(error.notSameOrgOrSecretariat()) } + if (!isSecretariat && !isAdmin) { + logger.info({ uuid: req.ctx.uuid, message: shortName + ' organization can only be updated by the Secretariat or an Org Admin.' }) + await session.abortTransaction() + return res.status(403).json(error.notOrgAdminOrSecretariatUpdate()) + } + if (!isSecretariat) { const secretariatOnlyFields = getConstants().SECRETARIAT_ONLY_FIELDS const restrictedFieldsSent = secretariatOnlyFields.filter(field => _.has(body, field)) @@ -344,26 +512,10 @@ async function updateOrg (req, res, next) { } } - // Check for duplicate short_name - if (body?.short_name !== shortName && await repo.orgExists(body?.short_name, { session })) { - logger.info({ - uuid: req.ctx.uuid, - message: `${shortName} organization could not be updated because new short name ${body?.short_name} already exists.` - }) - await session.abortTransaction() - return res.status(400).json(error.duplicateShortname(body?.short_name)) - } - - // Check for alias collisions - const shortNameToExclude = shortName - const collisionString = await repo.checkAliasCollisions(body?.short_name || shortName, body?.name, body?.aliases, shortNameToExclude, { session }) - if (collisionString) { - logger.info({ - uuid: req.ctx.uuid, - message: `${shortName} organization could not be updated because the string '${collisionString}' collides with another organization's short_name, name, or alias.` - }) + const shortNameValidation = await validateRequestedShortName(req, repo, body, shortName, session) + if (shortNameValidation.status) { await session.abortTransaction() - return res.status(400).json(error.aliasCollision(collisionString)) + return res.status(shortNameValidation.status).json(shortNameValidation.response) } // Handle secretariat "stomping" of pending review objects @@ -422,7 +574,7 @@ async function updateOrg (req, res, next) { action: 'update_registry_org', change: body?.short_name + 'organization was successfully updated, but joint approval is required for some fields. Check the ReviewObject for your org to check for a reply from the Secretariat about Joint Approval items.', req_UUID: req.ctx.uuid, - org_UUID: await repo.getOrgUUID(req.ctx.org), + org_UUID: updatedOrg.UUID, org: updatedOrg } @@ -438,7 +590,7 @@ async function updateOrg (req, res, next) { action: 'update_registry_org', change: body?.short_name + ' was successfully updated.', req_UUID: req.ctx.uuid, - org_UUID: await repo.getOrgUUID(req.ctx.org), + org_UUID: updatedOrg.UUID, org: updatedOrg } logger.info(JSON.stringify(payload)) @@ -466,6 +618,7 @@ async function deleteOrg (req, res, next) { const session = await mongoose.startSession({ causalConsistency: false }) const repo = req.ctx.repositories.getBaseOrgRepository() const shortName = req.ctx.params.identifier + let targetOrgUUID try { session.startTransaction() @@ -476,6 +629,7 @@ async function deleteOrg (req, res, next) { return res.status(404).json(error.orgDnePathParam(shortName)) } + targetOrgUUID = org.UUID await repo.deleteOrg(shortName, { session }) await session.commitTransaction() } catch (deleteErr) { @@ -493,7 +647,7 @@ async function deleteOrg (req, res, next) { action: 'delete_registry_org', change: shortName + ' was successfully deleted.', req_UUID: req.ctx.uuid, - org_UUID: await repo.getOrgUUID(req.ctx.org) + org_UUID: targetOrgUUID } logger.info(JSON.stringify(payload)) return res.status(200).json(responseMessage) @@ -528,19 +682,19 @@ async function getUsers (req, res, next) { const options = CONSTANTS.PAGINATOR_OPTIONS options.sort = { username: 'asc' } options.page = req.ctx.query.page ? parseInt(req.ctx.query.page) : CONSTANTS.PAGINATOR_PAGE // if 'page' query parameter is not defined, set 'page' to the default page value - const shortName = req.ctx.org const orgShortName = req.ctx.params.shortname const orgRepo = req.ctx.repositories.getBaseOrgRepository() const userRepo = req.ctx.repositories.getBaseUserRepository() const orgUUID = await orgRepo.getOrgUUID(orgShortName) - const isSecretariat = await orgRepo.isSecretariatByShortName(shortName) + const isSecretariat = await authContext.isRequesterSecretariat(req, orgRepo) if (!orgUUID) { logger.info({ uuid: req.ctx.uuid, message: orgShortName + ' organization does not exist.' }) return res.status(404).json(error.orgDnePathParam(orgShortName)) } - if (orgShortName !== shortName && !isSecretariat) { + const isSameOrg = await authContext.isRequesterSameOrg(req, orgRepo, { UUID: orgUUID, short_name: orgShortName }) + if (!isSameOrg && !isSecretariat) { logger.info({ uuid: req.ctx.uuid, message: orgShortName + ' organization can only be viewed by the users of the same organization or the Secretariat.' }) return res.status(403).json(error.notSameOrgOrSecretariat()) } @@ -619,7 +773,9 @@ async function createUserByOrg (req, res, next) { return res.status(400).json(error.userExists(body?.username)) } - if (!await userRepo.isAdminOrSecretariat(orgShortName, req.ctx.user, req.ctx.org, { session }, true)) { + const isRequesterSecretariat = await authContext.isRequesterSecretariat(req, orgRepo, { session }) + const isRequesterAdminOfTargetOrg = await authContext.isRequesterAdminOfOrg(req, userRepo, orgRepo, orgShortName, { session }) + if (!isRequesterSecretariat && !isRequesterAdminOfTargetOrg) { await session.abortTransaction() return res.status(403).json(error.notOrgAdminOrSecretariat()) // The Admin user must belong to the new user's organization } @@ -630,7 +786,7 @@ async function createUserByOrg (req, res, next) { return res.status(400).json(error.userLimitReached()) } - const requestingUserUUID = await userRepo.getUserUUID(req.ctx.user, req.ctx.org, { session }) + const requestingUserUUID = await authContext.getRequesterUserUUID(req, userRepo, orgRepo, { session }) returnValue = await userRepo.createUser(orgShortName, body, { session, upsert: true }, true, requestingUserUUID) await session.commitTransaction() } catch (error) { @@ -707,7 +863,7 @@ async function editConversationForOrg (req, res, next) { return res.status(404).json(convoError.conversationIndexDne(orgShortName, index)) } - const isSecretariat = await repo.isSecretariatByShortName(req.ctx.org, { session }) + const isSecretariat = await authContext.isRequesterSecretariat(req, repo, { session }) // Authorization Logic if (!isSecretariat) { @@ -718,7 +874,7 @@ async function editConversationForOrg (req, res, next) { return res.status(403).json({ message: 'Only the Secretariat is allowed to change the visibility of a conversation.' }) } - const requestingUser = await userRepo.findOneByUsernameAndOrgShortname(req.ctx.user, req.ctx.org, { session }) + const requestingUser = await authContext.getRequesterUser(req, userRepo, repo, { session }) if (!requestingUser) { await session.abortTransaction() await session.endSession() @@ -730,7 +886,7 @@ async function editConversationForOrg (req, res, next) { const authorUUID = conversation.author_uuid || conversation.author_id || conversation.author_UUID const isAuthor = String(authorUUID).toLowerCase() === String(userUUID).toLowerCase() - const isStillInOrg = req.ctx.org === orgShortName + const isStillInOrg = await authContext.isRequesterSameOrg(req, repo, { UUID: orgUUID, short_name: orgShortName }, { session }) if (!isAuthor || !isStillInOrg) { await session.abortTransaction() diff --git a/src/controller/registry-user.controller/registry-user.controller.js b/src/controller/registry-user.controller/registry-user.controller.js index 0cdc0d254..c1d01a1ca 100644 --- a/src/controller/registry-user.controller/registry-user.controller.js +++ b/src/controller/registry-user.controller/registry-user.controller.js @@ -5,6 +5,15 @@ const errors = require('../user.controller/error') const error = new errors.UserControllerError() const validateUUID = require('uuid').validate const _ = require('lodash') +const authContext = require('../../utils/authContext') + +const immutableUpdateFields = ['created', 'last_updated'] + +function removeImmutableUpdateFields (body) { + immutableUpdateFields.forEach(field => { + delete body[field] + }) +} /** * Retrieves information about all registry users. @@ -100,7 +109,7 @@ async function getUser (req, res, next) { const userRepo = req.ctx.repositories.getBaseUserRepository() const repo = req.ctx.repositories.getBaseOrgRepository() - const isSecretariat = await repo.isSecretariatByShortName(req.ctx.org) + const isSecretariat = await authContext.isRequesterSecretariat(req, repo) try { let result @@ -119,7 +128,7 @@ async function getUser (req, res, next) { return res.status(404).json(error.userDne(identifier)) } - org = await repo.getOrg(orgUUID, true) + org = await repo.findOneByUUID(orgUUID) userToGetParameters = { org: org.short_name, @@ -132,14 +141,15 @@ async function getUser (req, res, next) { return res.status(404).json(error.userDne(userToGetParameters.username)) } - org = await repo.getOrg(req.ctx.params.shortname) + org = await repo.findOneByShortName(req.ctx.params.shortname) userToGetParameters = { org: org.short_name, username: result.username } } - if (!isSecretariat && req.ctx.org !== userToGetParameters.org) { + const isSameOrg = await authContext.isRequesterSameOrg(req, repo, org) + if (!isSecretariat && !isSameOrg) { logger.info({ uuid: req.ctx.uuid, message: identifier + ' organization can only be viewed by the users of the same organization or the Secretariat.' }) return res.status(403).json(error.notSameOrgOrSecretariat()) } @@ -205,7 +215,7 @@ async function createUser (req, res, next) { return res.status(400).json(error.userLimitReached()) } - const requestingUserUUID = await userRepo.getUserUUID(req.ctx.user, req.ctx.org, { session }) + const requestingUserUUID = await authContext.getRequesterUserUUID(req, userRepo, orgRepo, { session }) returnValue = await userRepo.createUser(orgShortName, body, { session, upsert: true }, true, requestingUserUUID) await session.commitTransaction() } catch (error) { @@ -247,6 +257,9 @@ async function updateUser (req, res, next) { const body = req.ctx.body + // These fields are returned by GET responses, but remain server-managed on update. + removeImmutableUpdateFields(body) + if ('secret' in body) { logger.info({ uuid: req.ctx.uuid, message: 'User attempted to update the secret.' }) return res.status(400).json(error.secretUpdateNotAllowed()) @@ -262,8 +275,7 @@ async function updateUser (req, res, next) { username: req.ctx.params.username } - const isSecretariat = await orgRepo.isSecretariatByShortName(requestingUserParameters.org, { session }) - const isAdmin = await userRepo.isAdmin(requestingUserParameters.username, userToEditParameters.org, { session }) + const isSecretariat = await authContext.isRequesterSecretariat(req, orgRepo, { session }) // TODO: This will need to be atomic at some point like revoke or grant // Specific check for org_short_name (Secretariat only) @@ -295,6 +307,9 @@ async function updateUser (req, res, next) { } } + const isAdmin = await authContext.isRequesterAdminOfOrg(req, userRepo, orgRepo, org, { session }) + const requesterUserUUID = await authContext.getRequesterUserUUID(req, userRepo, orgRepo, { session }) + // Allow existing UUIDs to be passed, but block any attempts to mutate them if (userToEdit) { if (body?.UUID || body?.uuid) { @@ -331,13 +346,14 @@ async function updateUser (req, res, next) { return res.status(404).json(error.orgDnePathParam(userToEditParameters.org)) } - if (!isSecretariat && !isAdmin && requestingUserParameters.org !== userToEditParameters.org) { + const requesterSameOrg = await authContext.isRequesterSameOrg(req, orgRepo, org, { session }) + if (!isSecretariat && !isAdmin && !requesterSameOrg) { logger.info({ uuid: req.ctx.uuid, message: requestingUserParameters.org + ' user can only be updated by the user or admins of the same organization or the Secretariat.' }) return res.status(403).json(error.notSameOrgOrSecretariat()) } if (!isSecretariat && !isAdmin) { - if (requestingUserParameters.username !== userToEditParameters.username) { + if (requesterUserUUID !== userToEdit?.UUID) { if (!userToEdit) { logger.info({ uuid: req.ctx.uuid, message: 'User DNE' }) return res.status(404).json(error.userDne(userToEditParameters.username)) @@ -356,13 +372,6 @@ async function updateUser (req, res, next) { logger.info({ uuid: req.ctx.uuid, message: userToEditParameters.username + ' user could not be found.' }) return res.status(404).json(error.userDne(userToEditParameters.username)) } - - if (!isSecretariat) { - // For now, we want to make sure that no one, other than a secretariat can edit time fields - delete body.created - delete body.last_updated - } - let result let updatedUser let updatedUserUUID @@ -401,8 +410,8 @@ async function updateUser (req, res, next) { } // Move lookups of immutable properties BEFORE the transaction mutation writes to completely bypass read-after-write anomalies - const requestingUserUUID = await userRepo.getUserUUID(req.ctx.user, req.ctx.org, { session }) - updatedUserUUID = await userRepo.getUserUUID(req.ctx.user, org.UUID, { session }) + const requestingUserUUID = requesterUserUUID + updatedUserUUID = userToEdit.UUID updatedUser = await userRepo.updateUserFull(userToEdit.UUID, body, { session }, true, requestingUserUUID) await session.commitTransaction() @@ -447,16 +456,16 @@ async function deleteUser (req, res, next) { return res.status(404).json(error.userDne(userUUID)) } - const requestingUserUUID = await userRepo.getUserUUID(req.ctx.user, req.ctx.org) + const requestingUserUUID = await authContext.getRequesterUserUUID(req, userRepo, orgRepo) await userRepo.deleteUserByUUID(userUUID, {}, requestingUserUUID) const payload = { action: 'delete_registry_user', change: user.username + ' was successfully deleted.', req_UUID: req.ctx.uuid, - org_UUID: await orgRepo.getOrgUUID(req.ctx.org) + org_UUID: await authContext.getRequesterOrgUUID(req, orgRepo) } - payload.user_UUID = await userRepo.getUserUUID(req.ctx.user, payload.org_UUID) + payload.user_UUID = user.UUID logger.info(JSON.stringify(payload)) const responseMessage = { @@ -476,8 +485,6 @@ async function grantRole (req, res, next) { const orgShortName = req.ctx.params.shortname const username = req.ctx.params.username const role = req.ctx.body.role - const callingUser = req.ctx.user - const callingOrg = req.ctx.org const userRepo = req.ctx.repositories.getBaseUserRepository() const orgRepo = req.ctx.repositories.getBaseOrgRepository() @@ -503,10 +510,11 @@ async function grantRole (req, res, next) { return res.status(404).json(error.userDne(username)) } - const isSecretariat = await orgRepo.isSecretariatByShortName(callingOrg) - const isAdmin = await userRepo.isAdmin(callingUser, callingOrg) + const isSecretariat = await authContext.isRequesterSecretariat(req, orgRepo) + const isAdmin = await authContext.isRequesterAdminOfOrg(req, userRepo, orgRepo, orgShortName) - if (callingOrg !== orgShortName && !isSecretariat) { + const requesterSameOrg = await authContext.isRequesterSameOrg(req, orgRepo, { UUID: targetOrgUUID, short_name: orgShortName }) + if (!requesterSameOrg && !isSecretariat) { return res.status(403).json(error.notSameOrgOrSecretariat()) } @@ -516,7 +524,7 @@ async function grantRole (req, res, next) { try { session.startTransaction() - const requestingUserUUID = await userRepo.getUserUUID(req.ctx.user, req.ctx.org, { session }) + const requestingUserUUID = await authContext.getRequesterUserUUID(req, userRepo, orgRepo, { session }) await orgRepo.addAdmin(orgShortName, targetUser.UUID, { session }, requestingUserUUID) await session.commitTransaction() } catch (error) { @@ -540,8 +548,6 @@ async function revokeRole (req, res, next) { const orgShortName = req.ctx.params.shortname const username = req.ctx.params.username const role = req.ctx.body.role - const callingUser = req.ctx.user - const callingOrg = req.ctx.org const userRepo = req.ctx.repositories.getBaseUserRepository() const orgRepo = req.ctx.repositories.getBaseOrgRepository() @@ -567,10 +573,11 @@ async function revokeRole (req, res, next) { return res.status(404).json(error.userDne(username)) } - const isSecretariat = await orgRepo.isSecretariatByShortName(callingOrg) - const isAdmin = await userRepo.isAdmin(callingUser, callingOrg) + const isSecretariat = await authContext.isRequesterSecretariat(req, orgRepo) + const isAdmin = await authContext.isRequesterAdminOfOrg(req, userRepo, orgRepo, orgShortName) - if (callingOrg !== orgShortName && !isSecretariat) { + const requesterSameOrg = await authContext.isRequesterSameOrg(req, orgRepo, { UUID: targetOrgUUID, short_name: orgShortName }) + if (!requesterSameOrg && !isSecretariat) { return res.status(403).json(error.notSameOrgOrSecretariat()) } @@ -579,14 +586,14 @@ async function revokeRole (req, res, next) { } // Prevent Self-Demotion - const callingUserUUID = await userRepo.getUserUUID(callingUser, callingOrg) + const callingUserUUID = await authContext.getRequesterUserUUID(req, userRepo, orgRepo) if (callingUserUUID === targetUser.UUID) { return res.status(403).json({ error: 'NOT_ALLOWED_TO_SELF_DEMOTE', message: 'You cannot remove the ADMIN role from yourself.' }) } try { session.startTransaction() - const requestingUserUUID = await userRepo.getUserUUID(req.ctx.user, req.ctx.org, { session }) + const requestingUserUUID = await authContext.getRequesterUserUUID(req, userRepo, orgRepo, { session }) await orgRepo.removeAdmin(orgShortName, targetUser.UUID, { session }, requestingUserUUID) await session.commitTransaction() } catch (error) { diff --git a/src/controller/registry-user.controller/registry-user.middleware.js b/src/controller/registry-user.controller/registry-user.middleware.js index e39b721c0..cffcb7e6f 100644 --- a/src/controller/registry-user.controller/registry-user.middleware.js +++ b/src/controller/registry-user.controller/registry-user.middleware.js @@ -8,9 +8,7 @@ function parsePostParams (req, res, next) { utils.reqCtxMapping(req, 'params', ['identifier', 'shortname']) utils.reqCtxMapping(req, 'query', [ 'new_username', - 'name.first', 'name.last', 'name.middle', 'name.suffix', - 'org_affiliations.add', 'org_affiliations.remove', - 'cve_program_org_membership.add', 'cve_program_org_membership.remove' + 'name.first', 'name.last', 'name.middle', 'name.suffix' ]) next() } diff --git a/src/controller/review-object.controller/review-object.controller.js b/src/controller/review-object.controller/review-object.controller.js index 2bc95059e..0a57fb37c 100644 --- a/src/controller/review-object.controller/review-object.controller.js +++ b/src/controller/review-object.controller/review-object.controller.js @@ -5,6 +5,7 @@ const { getConstants } = require('../../constants') const errors = require('./error') const error = new errors.ReviewObjectControllerError() const _ = require('lodash') +const authContext = require('../../utils/authContext') /** * Retrieves the PENDING review object for an organization by identifier (short_name or UUID). @@ -13,7 +14,7 @@ const _ = require('lodash') async function getReviewObjectByOrgIdentifier (req, res, next) { const repo = req.ctx.repositories.getReviewObjectRepository() const orgRepo = req.ctx.repositories.getBaseOrgRepository() - const isSecretariat = await orgRepo.isSecretariatByShortName(req.ctx.org) + const isSecretariat = await authContext.isRequesterSecretariat(req, orgRepo) const identifier = req.params.identifier const identifierIsUUID = validateUUID(identifier) if (!identifier) { @@ -22,10 +23,10 @@ async function getReviewObjectByOrgIdentifier (req, res, next) { let value if (!isSecretariat) { - const orgUUID = await orgRepo.getOrgUUID(req.ctx.org) + const orgUUID = await authContext.getRequesterOrgUUID(req, orgRepo) if (identifierIsUUID && identifier !== orgUUID) { return res.status(403).json({ error: 'NOT_SAME_ORG_OR_SECRETARIAT', message: 'This information can only be viewed by the users of the same organization or the Secretariat.' }) - } else if (!identifierIsUUID && identifier !== req.ctx.org) { + } else if (!identifierIsUUID && !await authContext.isRequesterSameOrg(req, orgRepo, identifier)) { return res.status(403).json({ error: 'NOT_SAME_ORG_OR_SECRETARIAT', message: 'This information can only be viewed by the users of the same organization or the Secretariat.' }) } } @@ -45,7 +46,7 @@ async function getReviewObjectByOrgIdentifier (req, res, next) { async function getReviewObjectByUUID (req, res, next) { const repo = req.ctx.repositories.getReviewObjectRepository() const orgRepo = req.ctx.repositories.getBaseOrgRepository() - const isSecretariat = await orgRepo.isSecretariatByShortName(req.ctx.org) + const isSecretariat = await authContext.isRequesterSecretariat(req, orgRepo) const UUID = req.params.uuid const value = await repo.findOneByUUIDWithConversation(UUID, isSecretariat) @@ -54,7 +55,7 @@ async function getReviewObjectByUUID (req, res, next) { } if (!isSecretariat) { - const orgUUID = await orgRepo.getOrgUUID(req.ctx.org) + const orgUUID = await authContext.getRequesterOrgUUID(req, orgRepo) if (value.target_object_uuid !== orgUUID) { return res.status(403).json({ error: 'NOT_SAME_ORG_OR_SECRETARIAT', message: 'This information can only be viewed by the users of the same organization or the Secretariat.' }) } @@ -83,7 +84,7 @@ async function approveReviewObject (req, res, next) { const reviewRepo = req.ctx.repositories.getReviewObjectRepository() const baseOrgRepo = req.ctx.repositories.getBaseOrgRepository() const userRepo = req.ctx.repositories.getBaseUserRepository() - const isSecretariat = await baseOrgRepo.isSecretariatByShortName(req.ctx.org) + const isSecretariat = await authContext.isRequesterSecretariat(req, baseOrgRepo) const isPendingReview = true const UUID = req.params.uuid const body = req.body @@ -115,14 +116,14 @@ async function approveReviewObject (req, res, next) { ? _.merge({}, org.toObject(), body) : reviewObject.new_review_data - const requestingUserUUID = await userRepo.getUserUUID(req.ctx.user, req.ctx.org, { session }) + const requestingUserUUID = await authContext.getRequesterUserUUID(req, userRepo, baseOrgRepo, { session }) const reviewObj = await reviewRepo.approveReviewOrgObject(UUID, req.ctx.user, { session }, body) if (!reviewObj) { await session.abortTransaction() return res.status(404).json({ message: `Review object not approved with UUID ${UUID}` }) } - updatedOrgObj = await baseOrgRepo.updateOrgFull(org.short_name, dataToUpdate, { session }, false, requestingUserUUID, false, true) + updatedOrgObj = await baseOrgRepo.updateOrgFull(org.short_name, dataToUpdate, { session, skipReviewObjectProcessing: true }, false, requestingUserUUID, false, true) if (!updatedOrgObj) { await session.abortTransaction() return res.status(404).json({ message: `Org Object not updated with UUID ${UUID}` }) @@ -211,7 +212,7 @@ async function getReviewHistoryByOrgShortNamePaginated (req, res, next) { const orgRepo = req.ctx.repositories.getBaseOrgRepository() const orgShortName = req.params.identifier const includeConversations = req.query?.include_conversations - const isSecretariat = await orgRepo.isSecretariatByShortName(req.ctx.org) + const isSecretariat = await authContext.isRequesterSecretariat(req, orgRepo) const CONSTANTS = getConstants() const orgExists = await orgRepo.orgExists(orgShortName) @@ -219,7 +220,7 @@ async function getReviewHistoryByOrgShortNamePaginated (req, res, next) { return res.status(404).json(error.orgDnePathParam(orgShortName)) } - if (!isSecretariat && req.ctx.org !== orgShortName) { + if (!isSecretariat && !await authContext.isRequesterSameOrg(req, orgRepo, orgShortName)) { return res.status(403).json({ error: 'NOT_SAME_ORG_OR_SECRETARIAT', message: 'This information can only be viewed by the users of the same organization or the Secretariat.' }) } @@ -245,7 +246,7 @@ async function rejectReviewObject (req, res, next) { const UUID = req.params.uuid const session = await mongoose.startSession({ causalConsistency: false }) - const isSecretariat = await baseOrgRepo.isSecretariatByShortName(req.ctx.org, { session }) + const isSecretariat = await authContext.isRequesterSecretariat(req, baseOrgRepo, { session }) const isPendingReview = true let value diff --git a/src/controller/user.controller/error.js b/src/controller/user.controller/error.js index 7e0f7006f..7c6b60a78 100644 --- a/src/controller/user.controller/error.js +++ b/src/controller/user.controller/error.js @@ -37,6 +37,27 @@ class UserControllerError extends idrErr.IDRError { return err } + userExists (username) { // org + const err = {} + err.error = 'USER_EXISTS' + err.message = `The user '${username}' already exists.` + return err + } + + uuidProvided (creationType) { + const err = {} + err.error = 'UUID_PROVIDED' + err.message = `Providing UUIDs for ${creationType} creation or update is not allowed.` + return err + } + + userLimitReached () { + const err = {} + err.error = 'NUMBER_OF_USERS_IN_ORG_LIMIT_REACHED' + err.message = 'The requested user can not be created and added to the organization because the organization has hit its limit of 100 users. Contact the Secretariat.' + return err + } + duplicateUsername () { // org const err = {} err.error = 'DUPLICATE_USERNAME' diff --git a/src/middleware/middleware.js b/src/middleware/middleware.js index 6cd272531..6a9206b6b 100644 --- a/src/middleware/middleware.js +++ b/src/middleware/middleware.js @@ -13,6 +13,7 @@ const errors = require('./error') const error = new errors.MiddlewareError() const RepositoryFactory = require('../repositories/repositoryFactory') const rateLimit = require('express-rate-limit') +const authContext = require('../utils/authContext') function setCacheControl (req, res, next) { res.set('Cache-Control', 'no-store') @@ -27,7 +28,9 @@ function createCtxAndReqUUID (req, res, next) { authenticated: false, uuid: uuid.v4(), org: req.header(CONSTANTS.AUTH_HEADERS.ORG), + orgUUID: null, user: req.header(CONSTANTS.AUTH_HEADERS.USER), + userUUID: null, key: req.header(CONSTANTS.AUTH_HEADERS.KEY), repositories: new RepositoryFactory() } @@ -67,6 +70,9 @@ async function optionallyValidateUser (req, res, next) { const isPwd = await argon2.verify(result.secret, key) if (!isPwd) { authenticated = false + } else { + req.ctx.orgUUID = orgUUID + req.ctx.userUUID = result.UUID } } } @@ -144,6 +150,9 @@ async function validateUser (req, res, next) { return res.status(401).json(error.unauthorized()) } + req.ctx.orgUUID = orgUUID + req.ctx.userUUID = result.UUID + req.ctx.authenticated = true logger.info({ uuid: req.ctx.uuid, message: 'SUCCESSFUL user authentication for ' + user }) next() } catch (err) { @@ -158,8 +167,8 @@ async function onlySecretariatOrBulkDownload (req, res, next) { const CONSTANTS = getConstants() try { - const isSec = await orgRepo.isSecretariatByShortName(org) - const isBulkDownload = await orgRepo.isBulkDownloadByShortname(org) + const isSec = await authContext.isRequesterSecretariat(req, orgRepo) + const isBulkDownload = await authContext.isRequesterBulkDownload(req, orgRepo) if (!(isSec || isBulkDownload)) { // error message should only mention Secretariat logger.info({ uuid: req.ctx.uuid, message: org + ' is NOT a ' + CONSTANTS.AUTH_ROLE_ENUM.SECRETARIAT }) return res.status(403).json(error.secretariatOnly()) @@ -187,7 +196,7 @@ async function onlySecretariat (req, res, next) { const CONSTANTS = getConstants() try { - const isSec = await orgRepo.isSecretariatByShortName(org) + const isSec = await authContext.isRequesterSecretariat(req, orgRepo) if (!isSec) { logger.info({ uuid: req.ctx.uuid, message: org + ' is NOT a ' + CONSTANTS.AUTH_ROLE_ENUM.SECRETARIAT }) return res.status(403).json(error.secretariatOnly()) @@ -214,8 +223,8 @@ async function onlySecretariatOrAdmin (req, res, next) { const CONSTANTS = getConstants() try { - const isSec = await orgRepo.isSecretariatByShortName(org) - const isAdmin = await userRepo.isAdmin(username, org) + const isSec = await authContext.isRequesterSecretariat(req, orgRepo) + const isAdmin = await authContext.isRequesterAdmin(req, userRepo, orgRepo) if (!isSec && !isAdmin) { logger.info({ uuid: req.ctx.uuid, message: 'Request denied because \'' + org + '\' is NOT a ' + CONSTANTS.AUTH_ROLE_ENUM.SECRETARIAT + ' and \'' + username + '\' is not an ' + CONSTANTS.USER_ROLE_ENUM.ADMIN + ' user.' }) return res.status(403).json(error.notOrgAdminOrSecretariat()) @@ -235,14 +244,14 @@ async function onlyCnas (req, res, next) { const CONSTANTS = getConstants() try { - const org = await orgRepo.findOneByShortName(shortName) // org exists - if (org === null) { + const org = await authContext.getRequesterOrg(req, orgRepo) // org exists + if (!org) { logger.info({ uuid: req.ctx.uuid, message: shortName + ' is NOT a ' + CONSTANTS.AUTH_ROLE_ENUM.CNA }) return res.status(404).json(error.cnaDoesNotExist(shortName)) - } else if (org.authority.active_roles.includes(CONSTANTS.AUTH_ROLE_ENUM.SECRETARIAT)) { + } else if (authContext.orgHasRole(org, CONSTANTS.AUTH_ROLE_ENUM.SECRETARIAT)) { logger.info({ uuid: req.ctx.uuid, message: org.short_name + ' is a ' + CONSTANTS.AUTH_ROLE_ENUM.SECRETARIAT + ' so until Root organizations are implemented this role is allowed.' }) next() - } else if (org.authority.active_roles.includes(CONSTANTS.AUTH_ROLE_ENUM.CNA)) { // the org is a CNA + } else if (authContext.orgHasRole(org, CONSTANTS.AUTH_ROLE_ENUM.CNA)) { // the org is a CNA logger.info({ uuid: req.ctx.uuid, message: 'Confirmed ' + org.short_name + ' as a ' + CONSTANTS.AUTH_ROLE_ENUM.CNA }) next() } else { @@ -261,14 +270,14 @@ async function onlyAdps (req, res, next) { const CONSTANTS = getConstants() try { - const org = await orgRepo.findOneByShortName(shortName) // org exists - if (org === null) { + const org = await authContext.getRequesterOrg(req, orgRepo) // org exists + if (!org) { logger.info({ uuid: req.ctx.uuid, message: shortName + ' is NOT an ' + CONSTANTS.AUTH_ROLE_ENUM.ADP }) return res.status(404).json(error.adpDoesNotExist(shortName)) - } else if (org.authority.active_roles.includes(CONSTANTS.AUTH_ROLE_ENUM.SECRETARIAT)) { + } else if (authContext.orgHasRole(org, CONSTANTS.AUTH_ROLE_ENUM.SECRETARIAT)) { logger.info({ uuid: req.ctx.uuid, message: org.short_name + ' is a ' + CONSTANTS.AUTH_ROLE_ENUM.SECRETARIAT + ' so until Root organizations are implemented this role is allowed.' }) next() - } else if (org.authority.active_roles.includes(CONSTANTS.AUTH_ROLE_ENUM.ADP)) { // the org is an ADP + } else if (authContext.orgHasRole(org, CONSTANTS.AUTH_ROLE_ENUM.ADP)) { // the org is an ADP logger.info({ uuid: req.ctx.uuid, message: 'Confirmed ' + org.short_name + ' as an ' + CONSTANTS.AUTH_ROLE_ENUM.ADP }) next() } else { @@ -285,14 +294,15 @@ async function onlyOrgWithPartnerRole (req, res, next) { const orgRepo = req.ctx.repositories.getBaseOrgRepository() try { - const org = await orgRepo.findOneByShortName(shortName) - if (org === null) { + const org = await authContext.getRequesterOrg(req, orgRepo) + const roles = Array.isArray(org?.authority) ? org.authority : (org?.authority?.active_roles || []) + if (!org) { logger.info({ uuid: req.ctx.uuid, message: shortName + ' does NOT exist ' }) return res.status(404).json(error.orgDoesNotExist(shortName)) - } else if ((org.authority.length === 1 && org.authority[0] === 'BULK_DOWNLOAD') || (org.authority?.active_roles?.length === 1 && org.authority.active_roles[0] === 'BULK_DOWNLOAD')) { + } else if (roles.length === 1 && roles[0] === 'BULK_DOWNLOAD') { logger.info({ uuid: req.ctx.uuid, message: org.short_name + 'only has BULK_DOWNLOAD role ' }) return res.status(403).json(error.orgHasNoPartnerRole(shortName)) - } else if (org.authority.length > 0 || org.authority?.active_roles.length > 0) { + } else if (roles.length > 0) { logger.info({ uuid: req.ctx.uuid, message: org.short_name + ' has a role ' }) next() } else { @@ -318,13 +328,13 @@ async function cnaMustOwnID (req, res, next) { try { const requestingOrg = req.ctx.org const orgRepo = req.ctx.repositories.getOrgRepository() - const isSecretariat = await orgRepo.isSecretariat(requestingOrg) - const requestingOrgInfo = await orgRepo.findOneByShortName(requestingOrg) + const isSecretariat = await authContext.isRequesterSecretariat(req, orgRepo) + const requestingOrgInfo = await authContext.getRequesterOrg(req, orgRepo) const id = req.ctx.params.id const cveIdRepo = req.ctx.repositories.getCveIdRepository() const cveId = await cveIdRepo.findOneByCveId(id) - if (!cveId || ((cveId.owning_cna !== requestingOrgInfo.UUID) && !isSecretariat)) { + if (!cveId || ((cveId.owning_cna !== requestingOrgInfo?.UUID) && !isSecretariat)) { return res.status(403).json(error.orgDoesNotOwnId(requestingOrg, id)) } next() diff --git a/src/model/baseorg.js b/src/model/baseorg.js index e7e89e82f..ee690878c 100644 --- a/src/model/baseorg.js +++ b/src/model/baseorg.js @@ -26,7 +26,7 @@ const schema = { poc: String, poc_email: String }], - partner_role_type: String, + partner_role_type: { type: [String], default: undefined }, partner_number: String, partner_country: String, program_data: { diff --git a/src/repositories/baseOrgRepository.js b/src/repositories/baseOrgRepository.js index d86a30e6b..daf126ea9 100644 --- a/src/repositories/baseOrgRepository.js +++ b/src/repositories/baseOrgRepository.js @@ -17,6 +17,43 @@ const { handleAuthorityModelChange } = require('./baseOrgRepositoryHelpers') const { normalizeOrgCveWebsiteUpdateDate } = require('../utils/dateOnly') +const RegistryOrgResponseSchema = require('../../schemas/registry-org/get-registry-org-response.json') + +const INTERNAL_UNDERSCORE_FIELDS = ['_id', '__t', '__v'] + +function isResponseExtensionField (key) { + return key.startsWith('_') && !INTERNAL_UNDERSCORE_FIELDS.includes(key) +} + +function maskObjectToSchemaProperties (obj, schema) { + if (!_.isPlainObject(obj) || !schema?.properties) return obj + + const maskedObj = {} + Object.keys(obj).forEach(key => { + const propertySchema = schema.properties[key] + if (!propertySchema) return + + if (_.isPlainObject(obj[key]) && propertySchema.properties) { + maskedObj[key] = maskObjectToSchemaProperties(obj[key], propertySchema) + } else { + maskedObj[key] = obj[key] + } + }) + + return maskedObj +} + +function maskOrgForResponseSchema (orgObj, fieldsToPreserve = []) { + const preservedFields = _.pick(orgObj, fieldsToPreserve) + const extensionFields = _.pickBy(orgObj, (_value, key) => isResponseExtensionField(key)) + const maskedOrg = maskObjectToSchemaProperties(_.cloneDeep(orgObj), RegistryOrgResponseSchema) + + return { + ...maskedOrg, + ...extensionFields, + ...preservedFields + } +} /** * @function setAggregateOrgObj @@ -109,7 +146,7 @@ function getOrgProjection (isSecretariat = false) { return projection } -function filterOrg (orgObj, isSecretariat = false) { +function filterOrg (orgObj, isSecretariat = false, applyResponseMask = false, fieldsToPreserve = []) { const CONSTANTS = getConstants() const _ = require('lodash') normalizeOrgCveWebsiteUpdateDate(orgObj, { output: true }) @@ -117,7 +154,8 @@ function filterOrg (orgObj, isSecretariat = false) { if (!isSecretariat) { fieldsToOmit = [...fieldsToOmit, ...CONSTANTS.ORG_RESTRICTED_FIELDS] } - return _.omit(orgObj, fieldsToOmit) + const filteredOrg = _.omit(orgObj, fieldsToOmit) + return applyResponseMask ? maskOrgForResponseSchema(filteredOrg, fieldsToPreserve) : filteredOrg } class BaseOrgRepository extends BaseRepository { @@ -165,12 +203,13 @@ class BaseOrgRepository extends BaseRepository { * @param {string} UUID - The UUID of the organization. * @param {object} [options={}] - Optional settings for the repository query. * @param {boolean} [returnLegacyFormat=false] - If true, returns the legacy format. + * @param {object} [projection={}] - Optional projection for fields to include or exclude. * @returns {Promise} The organization object. */ async findOneByUUID (UUID, options = {}, returnLegacyFormat = false, projection = {}) { const OrgRepository = require('./orgRepository') const legacyOrgRepo = new OrgRepository() - if (returnLegacyFormat) return await legacyOrgRepo.findOneByUUID(UUID, options) + if (returnLegacyFormat) return await legacyOrgRepo.findOneByUUID(UUID, options, projection) return await BaseOrgModel.findOne({ UUID: UUID }, projection, options) } @@ -204,6 +243,57 @@ class BaseOrgRepository extends BaseRepository { return null } + async findOrgsByUserUUIDs (userUUIDs, options = {}) { + if (!Array.isArray(userUUIDs) || userUUIDs.length === 0) { + return [] + } + + return await BaseOrgModel.find( + { users: { $in: userUUIDs } }, + { _id: 0, UUID: 1, short_name: 1, users: 1 }, + options + ) + } + + /** + * @function hasRole + * @description Checks if an organization object has the requested role. + * @param {object} org - The organization object. + * @param {string} role - The role to check. + * @param {boolean} [isLegacyObject=false] - If true, checks legacy active_roles. + * @returns {boolean} True if the org has the role, false otherwise. + */ + hasRole (org, role, isLegacyObject = false) { + if (!org) return false + + if (isLegacyObject) { + return Array.isArray(org.authority?.active_roles) && org.authority.active_roles.includes(role) + } + + if (Array.isArray(org.authority)) { + return org.authority.includes(role) + } + + return org.authority === role + } + + /** + * @async + * @function hasRoleByUUID + * @description Checks if an organization has the requested role using its immutable UUID. + * @param {string} orgUUID - The UUID of the organization. + * @param {string} role - The role to check. + * @param {object} [options={}] - Optional settings for the repository query. + * @param {boolean} [returnLegacyFormat=false] - If true, checks the legacy organization format. + * @returns {Promise} True if the org has the role, false otherwise. + */ + async hasRoleByUUID (orgUUID, role, options = {}, returnLegacyFormat = false) { + if (!orgUUID) return false + + const org = await this.findOneByUUID(orgUUID, options, returnLegacyFormat) + return this.hasRole(org, role, returnLegacyFormat) + } + /** * @async * @function orgExists @@ -397,11 +487,12 @@ class BaseOrgRepository extends BaseRepository { // Strip nulls returned by DocumentDB to prevent schema validation errors if (pg.itemsList) { - pg.itemsList.forEach(org => { + pg.itemsList = pg.itemsList.map(org => { if (org.reports_to === null) { delete org.reports_to } normalizeOrgCveWebsiteUpdateDate(org, { output: true }) + return returnLegacyFormat ? org : filterOrg(org, isSecretariat, true) }) } @@ -478,8 +569,11 @@ class BaseOrgRepository extends BaseRepository { } } - normalizeOrgCveWebsiteUpdateDate(result, { output: true }) - return deepRemoveEmpty(result) + if (returnLegacyFormat) { + normalizeOrgCveWebsiteUpdateDate(result, { output: true }) + return deepRemoveEmpty(result) + } + return deepRemoveEmpty(filterOrg(result, isSecretariat, true)) } /** @@ -698,7 +792,7 @@ class BaseOrgRepository extends BaseRepository { } const rawRegistryOrgObject = registryObject.toObject() - return filterOrg(deepRemoveEmpty(rawRegistryOrgObject), isSecretariat) + return filterOrg(deepRemoveEmpty(rawRegistryOrgObject), isSecretariat, true) } /** @@ -889,7 +983,7 @@ class BaseOrgRepository extends BaseRepository { return filterOrg(deepRemoveEmpty(legacyOrg.toObject()), isSecretariat) } - return filterOrg(deepRemoveEmpty(registryOrg.toObject()), isSecretariat) + return filterOrg(deepRemoveEmpty(registryOrg.toObject()), isSecretariat, true) } /** @@ -947,18 +1041,21 @@ class BaseOrgRepository extends BaseRepository { const ReviewObjectRepository = require('./reviewObjectRepository') const BaseUserRepository = require('./baseUserRepository') const ConversationRepository = require('./conversationRepository') + const { skipReviewObjectProcessing = false, ...executeOptions } = options const legacyOrgRepo = new OrgRepository() const reviewObjectRepo = new ReviewObjectRepository() const userRepo = new BaseUserRepository() const conversationRepo = new ConversationRepository() - const legacyOrg = await legacyOrgRepo.findOneByShortName(shortName, options) - const registryOrg = await this.findOneByShortName(shortName, options) + const legacyOrg = await legacyOrgRepo.findOneByShortName(shortName, executeOptions) + const registryOrg = await this.findOneByShortName(shortName, executeOptions) const originalRegistryOrgObject = registryOrg.toObject() const originalRoles = registryOrg.authority - const reviewObject = await reviewObjectRepo.getOrgReviewObjectByOrgShortname(shortName, isSecretariat, options) + const reviewObject = skipReviewObjectProcessing + ? null + : await reviewObjectRepo.getOrgReviewObjectByOrgShortname(shortName, isSecretariat, executeOptions) const { conversation, ...incomingOrgBody } = incomingOrg let legacyObjectRaw @@ -972,30 +1069,30 @@ class BaseOrgRepository extends BaseRepository { legacyObjectRaw = this.convertRegistryToLegacy(incomingOrgBody) } - handleShortNameUpdate(incomingOrg, registryOrg, legacyOrg, registryObjectRaw, legacyObjectRaw) + handleShortNameUpdate(incomingOrg, registryObjectRaw, legacyObjectRaw) - const requestingUser = requestingUserUUID ? await userRepo.findUserByUUID(requestingUserUUID, options) : null + const requestingUser = requestingUserUUID ? await userRepo.findUserByUUID(requestingUserUUID, executeOptions) : null const requestingUsername = requestingUser ? requestingUser.username : null const jointApprovalFieldsRegistry = this.getJointApprovalFields(registryOrg, registryObjectRaw) const jointApprovalFieldsLegacy = this.getJointApprovalFields(legacyOrg, legacyObjectRaw, true) let { updatedRegistryOrg, updatedLegacyOrg } = await processJointApprovalAndMerge( - registryOrg, legacyOrg, registryObjectRaw, legacyObjectRaw, reviewObject, isSecretariat, options, requestingUsername, jointApprovalFieldsRegistry, jointApprovalFieldsLegacy + registryOrg, legacyOrg, registryObjectRaw, legacyObjectRaw, reviewObject, isSecretariat, executeOptions, requestingUsername, jointApprovalFieldsRegistry, jointApprovalFieldsLegacy ) const conversationArray = [] if (conversation) { - conversationArray.push(await conversationRepo.createConversation(registryOrg.UUID, conversation, requestingUser, isSecretariat, options)) + conversationArray.push(await conversationRepo.createConversation(registryOrg.UUID, conversation, requestingUser, isSecretariat, executeOptions)) } - await createAuditLogEntry(registryOrg, originalRegistryOrgObject, requestingUserUUID, options) + await createAuditLogEntry(registryOrg, originalRegistryOrgObject, requestingUserUUID, executeOptions) - updatedRegistryOrg = await handleAuthorityModelChange(updatedRegistryOrg, originalRoles, options) + updatedRegistryOrg = await handleAuthorityModelChange(updatedRegistryOrg, originalRoles, executeOptions) try { - await updatedLegacyOrg.save(options) - await updatedRegistryOrg.save(options) + await updatedLegacyOrg.save(executeOptions) + await updatedRegistryOrg.save(executeOptions) } catch (error) { throw new Error(`Failed to update organization ${shortName}. Error: ${error.message}`) } @@ -1009,7 +1106,7 @@ class BaseOrgRepository extends BaseRepository { const plainJavascriptRegistryOrg = updatedRegistryOrg.toObject() plainJavascriptRegistryOrg.conversation = conversationArray plainJavascriptRegistryOrg.joint_approval_required = !(isSecretariat || _.isEmpty(jointApprovalFieldsRegistry)) - return filterOrg(deepRemoveEmpty(plainJavascriptRegistryOrg), isSecretariat) + return filterOrg(deepRemoveEmpty(plainJavascriptRegistryOrg), isSecretariat, true, ['joint_approval_required']) } /** @@ -1042,8 +1139,23 @@ class BaseOrgRepository extends BaseRepository { return { isValid: false, errors: [{ instancePath: '/authority', message: 'authority is required' }] } } + const validAuthorityRoles = getConstants().ORG_ROLES + const authorityRoleError = instancePath => ({ + isValid: false, + errors: [{ + instancePath, + message: 'must be equal to one of the allowed values', + params: { allowedValues: validAuthorityRoles } + }] + }) + let validateObject = {} if (Array.isArray(org.authority)) { + const invalidAuthorityIndex = org.authority.findIndex(role => !validAuthorityRoles.includes(role)) + if (invalidAuthorityIndex !== -1) { + return authorityRoleError(`/authority/${invalidAuthorityIndex}`) + } + // User passed in an array, we need to decide how we handle this. if (org.authority.includes('SECRETARIAT')) { org.authority = ['SECRETARIAT'] @@ -1068,6 +1180,10 @@ class BaseOrgRepository extends BaseRepository { } } } else { + if (!validAuthorityRoles.includes(org.authority)) { + return authorityRoleError('/authority') + } + if (org.authority === 'ADP') { validateObject = ADPOrgModel.validateOrg(org) } @@ -1112,11 +1228,7 @@ class BaseOrgRepository extends BaseRepository { * @returns {boolean} True if the organization is a Secretariat, false otherwise. */ isSecretariat (org, options = {}, isLegacyObject = false) { - if (isLegacyObject) { - return org.authority && org.authority.active_roles.includes('SECRETARIAT') - } else { - return org.authority && org.authority.includes('SECRETARIAT') - } + return this.hasRole(org, 'SECRETARIAT', isLegacyObject) } /** @@ -1144,11 +1256,7 @@ class BaseOrgRepository extends BaseRepository { * @returns {boolean} True if the organization is a Bulk Download provider, false otherwise. */ isBulkDownload (org, isLegacyObject = false) { - if (isLegacyObject) { - return org.authority && org.authority.active_roles.includes('BULK_DOWNLOAD') - } else { - return org.authority && org.authority.includes('BULK_DOWNLOAD') - } + return this.hasRole(org, 'BULK_DOWNLOAD', isLegacyObject) } /** diff --git a/src/repositories/baseOrgRepositoryHelpers.js b/src/repositories/baseOrgRepositoryHelpers.js index bfd7c1ccd..0f9ae264e 100644 --- a/src/repositories/baseOrgRepositoryHelpers.js +++ b/src/repositories/baseOrgRepositoryHelpers.js @@ -25,21 +25,16 @@ const skipNulls = (objValue, srcValue) => { * @function handleShortNameUpdate * @description Manages the complex process of updating an organization's short name, ensuring all instances and references (legacy and registry) are synchronized with the new short name. * @param {object} incomingOrg - The payload containing the requested updates. - * @param {object} registryOrg - The current registry organization Mongoose document. - * @param {object} legacyOrg - The current legacy organization Mongoose document. * @param {object} registryObjectRaw - The raw data payload mapped for the registry schema. * @param {object} legacyObjectRaw - The raw data payload mapped for the legacy schema. */ -function handleShortNameUpdate (incomingOrg, registryOrg, legacyOrg, registryObjectRaw, legacyObjectRaw) { +function handleShortNameUpdate (incomingOrg, registryObjectRaw, legacyObjectRaw) { if (incomingOrg?.new_short_name) { const newName = incomingOrg.new_short_name - registryOrg.short_name = newName - legacyOrg.short_name = newName registryObjectRaw.short_name = newName legacyObjectRaw.short_name = newName delete registryObjectRaw.new_short_name delete legacyObjectRaw.new_short_name - delete incomingOrg.new_short_name } } diff --git a/src/repositories/baseUserRepository.js b/src/repositories/baseUserRepository.js index e8e46029d..36e057b30 100644 --- a/src/repositories/baseUserRepository.js +++ b/src/repositories/baseUserRepository.js @@ -17,6 +17,15 @@ const skipNulls = (objValue, srcValue) => { return undefined } +const toPlainObject = (value) => { + return value?.toObject ? value.toObject() : value +} + +const userHasAdminRole = (user) => { + const roles = _.flattenDeep(_.compact([user?.role, user?.authority?.active_roles])) + return roles.some(role => String(role).toUpperCase() === 'ADMIN') +} + /** * @function setAggregateUserObj * @description Constructs the aggregation pipeline for legacy user objects. @@ -159,6 +168,20 @@ class BaseUserRepository extends BaseRepository { return user || null } + /** + * @async + * @function findUserByUsernameAndOrgUUID + * @description Finds a user by username and organization UUID. + * @param {string} username - The username to find. + * @param {string} orgUUID - The UUID of the organization. + * @param {object} [options={}] - Optional settings for the repository query. + * @param {boolean} [isRegistryObject=true] - If false, returns a legacy user object if found. + * @returns {Promise} The user object or null if not found. + */ + async findUserByUsernameAndOrgUUID (username, orgUUID, options = {}, isRegistryObject = true) { + return this.findOneByUserNameAndOrgUUID(username, orgUUID, options, isRegistryObject) + } + /** * @async * @function findUserByUUID @@ -171,12 +194,60 @@ class BaseUserRepository extends BaseRepository { async findUserByUUID (uuid, options = {}, isRegistryObject = true) { const legacyUserRepo = new UserRepository() const user = await BaseUser.findOne({ UUID: uuid }, null, options) + if (!user) { + return null + } + if (!isRegistryObject) { return await legacyUserRepo.findOneByUUID(user.UUID) || null } return user || null } + async findUsersByUUIDs (uuids, options = {}) { + if (!Array.isArray(uuids) || uuids.length === 0) { + return [] + } + + return await BaseUser.find( + { UUID: { $in: uuids } }, + { _id: 0, UUID: 1, username: 1, name: 1 }, + options + ) + } + + /** + * @async + * @function isUserAdminOfOrgUUID + * @description Checks if a user UUID is an admin of the organization identified by UUID. + * @param {string} userUUID - The user UUID to check. + * @param {string} orgUUID - The organization UUID to check. + * @param {object} [options={}] - Optional settings for the repository query. + * @param {boolean} [isRegistryObject=true] - If false, retrieves the legacy user object for role fallback. + * @returns {Promise} True if the user is an admin of the org, false otherwise. + */ + async isUserAdminOfOrgUUID (userUUID, orgUUID, options = {}, isRegistryObject = true) { + if (!userUUID || !orgUUID) { + return false + } + + const org = await BaseOrgModel.findOne({ UUID: orgUUID }, null, options).select('admins users') + if (!org) { + return false + } + + if (Array.isArray(org.admins) && org.admins.includes(userUUID)) { + return true + } + + if (!Array.isArray(org.users) || !org.users.includes(userUUID)) { + return false + } + + const user = await this.findUserByUUID(userUUID, options, isRegistryObject) + return user?.role === 'ADMIN' || (Array.isArray(user?.authority?.active_roles) && user.authority.active_roles.includes('ADMIN')) + } + /** * @async * @function deleteUserByUUID @@ -351,38 +422,49 @@ class BaseUserRepository extends BaseRepository { const sharedUUID = uuid.v4() incomingUser.UUID = sharedUUID - if (!isRegistryObject) { - legacyObjectRaw = incomingUser - registryObjectRaw = this.convertLegacyToRegistry(incomingUser) - } else { - registryObjectRaw = incomingUser - legacyObjectRaw = this.convertRegistryToLegacy(incomingUser) - } - const randomKey = cryptoRandomString({ length: getConstants().CRYPTO_RANDOM_STRING_LENGTH }) const secret = await argon2.hash(randomKey) - registryObjectRaw.secret = secret - legacyObjectRaw.secret = secret // Allow user to provide initial status, default to active let isConsideredInactive = false if (isRegistryObject && incomingUser.status === 'inactive') isConsideredInactive = true if (!isRegistryObject && (incomingUser.active === false || String(incomingUser.active).toLowerCase() === 'false')) isConsideredInactive = true - // Registry Only Fields - registryObjectRaw.status = isConsideredInactive ? 'inactive' : 'active' - // Legacy Specific fields - legacyObjectRaw.active = !isConsideredInactive - // Get UUID of org, that is having the user added to it. const existingOrg = await baseOrgRepository.findOneByShortName(orgShortName) + if (!isRegistryObject) { + legacyObjectRaw = incomingUser + legacyObjectRaw.secret = secret + legacyObjectRaw.active = !isConsideredInactive + legacyObjectRaw.org_UUID = existingOrg.UUID + + const legacyObject = await legacyUserRepo.updateByUserNameAndOrgUUID(incomingUser.username, existingOrg.UUID, legacyObjectRaw, { ...options, upsert: true, new: true }) + legacyObjectRaw = toPlainObject(legacyObject) + registryObjectRaw = this.convertLegacyToRegistry(legacyObjectRaw) + registryObjectRaw.secret = secret + registryObjectRaw.status = isConsideredInactive ? 'inactive' : 'active' + } else { + registryObjectRaw = incomingUser + registryObjectRaw.secret = secret + registryObjectRaw.status = isConsideredInactive ? 'inactive' : 'active' + } + const registryUserToSave = new RegistryUser(registryObjectRaw) registryObject = await registryUserToSave.save(options) - await baseOrgRepository.addUserToOrg(orgShortName, incomingUser.UUID, (incomingUser.role === 'ADMIN' || incomingUser.authority?.active_roles?.includes('ADMIN')), options, false, requestingUserUUID) - // We now have to make sure the user is added to the ORG's user array - await legacyUserRepo.updateByUserNameAndOrgUUID(incomingUser.username, existingOrg.UUID, legacyObjectRaw, { ...options, upsert: true }) + const registryObjectPlain = toPlainObject(registryObject) + + if (isRegistryObject) { + const legacyRole = userHasAdminRole(incomingUser) ? 'ADMIN' : incomingUser.role + legacyObjectRaw = this.convertRegistryToLegacy({ ...registryObjectPlain, role: legacyRole }) + legacyObjectRaw.secret = secret + legacyObjectRaw.active = !isConsideredInactive + legacyObjectRaw.org_UUID = existingOrg.UUID + await legacyUserRepo.updateByUserNameAndOrgUUID(incomingUser.username, existingOrg.UUID, legacyObjectRaw, { ...options, upsert: true }) + } + + await baseOrgRepository.addUserToOrg(orgShortName, incomingUser.UUID, (userHasAdminRole(incomingUser) || userHasAdminRole(legacyObjectRaw)), options, false, requestingUserUUID) if (!isRegistryObject) { legacyObjectRaw.secret = randomKey @@ -598,6 +680,7 @@ class BaseUserRepository extends BaseRepository { const currentOrgUUID = legacyUser ? legacyUser.org_UUID : registryUser?.org_UUID // Fallback if schema supports it const currentOrg = currentOrgUUID ? await baseOrgRepository.findOneByUUID(currentOrgUUID) : null const newOrg = await baseOrgRepository.findOneByShortName(incomingUser.org_short_name) + const wasAdmin = currentOrg?.admins?.includes(identifier) ?? false if (!newOrg) { throw new Error(`Organization ${incomingUser.org_short_name} not found`) @@ -617,7 +700,7 @@ class BaseUserRepository extends BaseRepository { if (!Array.isArray(newOrg.users)) newOrg.users = [] newOrg.users.addToSet(identifier) - const isAdmin = updatedRegistryUser?.role === 'ADMIN' || (updatedLegacyUser?.authority?.active_roles?.includes('ADMIN')) + const isAdmin = wasAdmin || updatedRegistryUser?.role === 'ADMIN' || (updatedLegacyUser?.authority?.active_roles?.includes('ADMIN')) if (isAdmin) { if (!Array.isArray(newOrg.admins)) { newOrg.admins = [] diff --git a/src/repositories/conversationRepository.js b/src/repositories/conversationRepository.js index 4ab21cf57..7835990b6 100644 --- a/src/repositories/conversationRepository.js +++ b/src/repositories/conversationRepository.js @@ -2,6 +2,15 @@ const uuid = require('uuid') const ConversationModel = require('../model/conversation') const BaseRepository = require('./baseRepository') +const SECRETARIAT_AUTHOR_NAME = 'Secretariat' + +function normalizeConversationAuthorName (conversation) { + if (conversation.author_role === 'Secretariat') { + conversation.author_name = SECRETARIAT_AUTHOR_NAME + } + return conversation +} + class ConversationRepository extends BaseRepository { constructor () { super(ConversationModel) @@ -23,7 +32,11 @@ class ConversationRepository extends BaseRepository { } ] const pg = await this.aggregatePaginate(agt, options) - const data = { conversations: pg.itemsList } + const data = { + conversations: pg.itemsList.map(conversation => normalizeConversationAuthorName( + typeof conversation.toObject === 'function' ? conversation.toObject() : conversation + )) + } if (pg.itemCount >= options.limit) { data.totalCount = pg.itemCount data.itemsPerPage = pg.itemsPerPage @@ -48,6 +61,7 @@ class ConversationRepository extends BaseRepository { } }) return conversations.map(convo => convo.toObject()).filter(conv => isSecretariat || conv.visibility === 'public').map(conv => { + normalizeConversationAuthorName(conv) if (!isSecretariat && conv.author_role === 'Secretariat') { delete conv.author_id delete conv.author_name @@ -89,7 +103,7 @@ class ConversationRepository extends BaseRepository { previous_conversation_uuid: latestConversation?.UUID || null, next_conversation_uuid: null, author_id: user.UUID, - author_name: getUserFullName(user), + author_name: isSecretariat ? SECRETARIAT_AUTHOR_NAME : getUserFullName(user), author_role: isSecretariat ? 'Secretariat' : 'Partner', edited_at: null, visibility: !isSecretariat ? 'public' : (['public', 'private'].includes(body.visibility?.toLowerCase()) ? body.visibility.toLowerCase() : 'private'), diff --git a/src/repositories/orgRepository.js b/src/repositories/orgRepository.js index d514434dd..3eb5d7684 100644 --- a/src/repositories/orgRepository.js +++ b/src/repositories/orgRepository.js @@ -12,14 +12,21 @@ class OrgRepository extends BaseRepository { return this.collection.findOne(query, projection, options) } - async findOneByUUID (UUID) { - return this.collection.findOne().byUUID(UUID) + async findOneByUUID (UUID, options = {}, projection = {}) { + return this.collection.findOne({ UUID: UUID }, projection, options) } async getOrgUUID (shortName, options = {}) { return utils.getOrgUUID(shortName, false, options) } + async hasRoleByUUID (orgUUID, role, options = {}) { + if (!orgUUID) return false + + const org = await this.collection.findOne({ UUID: orgUUID }, null, options) + return Array.isArray(org?.authority?.active_roles) && org.authority.active_roles.includes(role) + } + async updateByOrgUUID (orgUUID, updateData, executeOptions = {}) { // The filter to find the document const filter = { UUID: orgUUID } diff --git a/src/repositories/userRepository.js b/src/repositories/userRepository.js index 356576d34..9cd152b28 100644 --- a/src/repositories/userRepository.js +++ b/src/repositories/userRepository.js @@ -42,6 +42,19 @@ class UserRepository extends BaseRepository { return this.collection.findOne(query, projection, options) } + async findUserByUsernameAndOrgUUID (username, orgUUID, options = {}) { + return this.findOneByUserNameAndOrgUUID(username, orgUUID, null, options) + } + + async isUserAdminOfOrgUUID (userUUID, orgUUID, options = {}) { + if (!userUUID || !orgUUID) { + return false + } + + const user = await this.collection.findOne({ UUID: userUUID, org_UUID: orgUUID }, null, options) + return Array.isArray(user?.authority?.active_roles) && user.authority.active_roles.includes('ADMIN') + } + async updateByUserNameAndOrgUUID (username, orgUUID, user, options = {}) { const filter = { username: username, org_UUID: orgUUID } const updatePayload = { $set: user } diff --git a/src/scripts/MondayHelpers.js b/src/scripts/MondayHelpers.js new file mode 100644 index 000000000..4c30b5d76 --- /dev/null +++ b/src/scripts/MondayHelpers.js @@ -0,0 +1,337 @@ +const _ = require('lodash') +const XLSX = require('xlsx') +const path = require('path') +const { countryList } = require('./countries') +const { normalizeDateOnlyInput } = require('../utils/dateOnly') + +const mondayExportPath = path.join(__dirname, 'export.xlsx') + +const mondayTracker = { + dbOnlyShortNames: [], + mondayDuplicateShortNames: [], + mondayOnlyShortNames: [] +} +const pocEmailColumnNumbers = Array.from( + { length: 10 }, + (_, index) => index + 1 +) +const partnerRoleTypeValues = new Set([ + '', + 'Bug Bounty Provider', + 'CERT', + 'Consortium', + 'Hosted Service', + 'N/A', + 'Open Source', + 'Researcher', + 'Vendor' +]) + +const countryNames = new Set(countryList.map((country) => country.name)) +const countryAliases = new Map([ + ['UK', 'United Kingdom'], + ['Slovak Republic', 'Slovakia'], + ['Vietnam', 'Viet Nam'], + ['Russia', 'Russian Federation'], + ['South Korea', 'Korea, Republic of'], + ['T\u00fcrkiye', 'Turkey'], + ['Turkiye', 'Turkey'], + ['Czechia', 'Czech Republic'] +]) + +function toSnakeCase (header) { + return _.snakeCase( + String(header) + .replace( + /([A-Z]+)['\u2019]s\b/g, + (match, acronym) => `${acronym.toLowerCase()}s` + ) + .replace(/['\u2019]/g, '') + ) +} + +function loadMondayExport (exportPath = mondayExportPath) { + const workbook = XLSX.readFile(exportPath) + const worksheet = workbook.Sheets[workbook.SheetNames[0]] + const rows = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: '' }) + const headerIndex = rows.findIndex( + (row) => String(row[10]).trim() === 'Short Name' + ) + + if (headerIndex === -1) { + throw new Error( + 'Could not find Monday export header row with column K "Short Name".' + ) + } + + const headers = rows[headerIndex].map(toSnakeCase) + const shortNameIndex = headers.indexOf('short_name') + + if (shortNameIndex === -1) { + throw new Error('Could not find "short_name" column in Monday export.') + } + + const rawRows = rows.slice(headerIndex + 1).reduce((result, row) => { + const shortName = String(row[shortNameIndex]).trim() + if (!shortName || shortName === 'Short Name') return result + + result.push( + headers.reduce((record, header, index) => { + if (header) { + record[header] = index === shortNameIndex ? shortName : row[index] + } + return record + }, {}) + ) + + return result + }, []) + + findDuplicateShortNames(rawRows) + + return { + rawRows, + asOrgRows: buildAsOrgRows(rawRows) + } +} + +function buildAsOrgRows (rawRows) { + const unknownCountries = new Map() + const asOrgRows = rawRows.map((row) => ({ + short_name: row.short_name, + long_name: row.cna_long_name, + authority: [row.org_level], + top_level_root: row.top_level_root, + charter_or_scope: row.scope, + disclosure_policy: row.disclosure_policy, + advisory_locations: toSingleItemArray(row.advisory_location), + advisory_location_require_credentials: + formatAdvisoryLocationRequireCredentials( + row.does_advisory_location_require_login_credentials + ), + vulnerability_advisory_location_for_web_scraping: toOptionalSingleItemArray( + row.vulnerability_advisory_location_s_for_web_scraping + ), + partner_role_type: formatPartnerRoleType(row.cna_role_type), + partner_number: formatPartnerNumber(row), + partner_country: normalizePartnerCountry(row, unknownCountries), + primary_poc_name: formatPocName(row.primary_poc_name), + poc_phone_1: formatPocPhone(row.poc_phone_1), + secondary_poc_name: formatPocName(row.secondary_poc_name), + poc_phone_2: formatPocPhone(row.poc_phone_2), + third_poc_name: formatPocName(row.third_poc_name), + ...buildPocEmailFields(row), + poc_emails: formatPocEmails(row), + industry: row.industry, + tl_root_start_date: formatDate(row.tl_root_start_date), + program_data: { + cve_website_update_date: formatDateOnly(row.date_website_updates), + partner_active_date: formatDateOnly(row.partner_active_date), + partner_inactive_date: formatDateOnly(row.partner_inactive_date), + status: row.status + } + })) + + for (const [country, shortNames] of unknownCountries.entries()) { + console.warn( + `Unknown CNA country "${country}" for short_name(s): ${shortNames.join(', ')}` + ) + } + + return asOrgRows +} + +function findDuplicateShortNames (rawRows) { + const shortNameCounts = new Map() + + for (const mondayOrg of rawRows) { + const shortName = String(mondayOrg.short_name || '').trim() + if (!shortName) continue + + shortNameCounts.set(shortName, (shortNameCounts.get(shortName) || 0) + 1) + } + + mondayTracker.mondayDuplicateShortNames = Array.from( + shortNameCounts.entries() + ) + .filter(([, count]) => count > 1) + .map(([shortName, count]) => ({ shortName, count })) +} + +function formatPartnerNumber (mondayOrg) { + const partnerName = String(mondayOrg.name || '').trim() + if (!partnerName) return '' + + const match = partnerName.match( + /^([^-]+)-(\d{4})-(\d+)(?:\s*\(([^)]*)\)?)?$/ + ) + if (!match) return partnerName + + const [, role, year, number, parentheticalRole] = match + const orgLevel = mondayOrg.org_level || parentheticalRole || role + const normalizedOrgLevel = String(orgLevel) + .trim() + .replace(/-/g, '') + .replace(/\s+/g, '') + .toUpperCase() + + return `${normalizedOrgLevel}-${year}-${number}` +} + +function formatPartnerRoleType (value) { + const values = Array.isArray(value) ? value : String(value || '').split(/[,\n;]/) + const validValues = values + .map((roleType) => String(roleType || '').trim()) + .filter((roleType) => partnerRoleTypeValues.has(roleType)) + + return validValues.length ? _.uniq(validValues) : undefined +} + +function normalizePartnerCountry (mondayOrg, unknownCountries) { + const shortName = String(mondayOrg.short_name || '').trim() + const country = String(mondayOrg.cna_country || '').trim() + const partnerCountry = countryAliases.get(country) || country + + if (!country) { + return '' + } else if (country === 'USA') { + return 'United States' + } else if (countryNames.has(partnerCountry)) { + return partnerCountry + } + + const shortNames = unknownCountries.get(partnerCountry) || [] + shortNames.push(shortName) + unknownCountries.set(partnerCountry, shortNames) + + return partnerCountry +} + +function toSingleItemArray (value) { + if (value == null) return [] + + const trimmedValue = String(value).trim() + if (!trimmedValue) return [] + + return [trimmedValue] +} + +function toOptionalSingleItemArray (value) { + const items = toSingleItemArray(value) + return items.length ? items : undefined +} + +function buildPocEmailFields (mondayOrg) { + return pocEmailColumnNumbers.reduce((fields, number) => { + fields[`poc_email_${number}`] = formatPocEmail( + mondayOrg[`poc_email_${number}`] + ) + return fields + }, {}) +} + +function formatPocEmails (mondayOrg) { + return _.uniqBy( + pocEmailColumnNumbers + .map((number) => mondayOrg[`poc_email_${number}`]) + .flatMap(formatPocEmailList), + (email) => email.toLowerCase() + ) +} + +function formatPocEmail (email) { + return String(email || '').trim() +} + +function formatPocName (name) { + return String(name || '').trim() +} + +function formatPocPhone (phone) { + return String(phone || '').trim() +} + +function formatPocEmailList (value) { + return formatPocEmail(value) + .split(',') + .map(formatPocEmail) + .filter(Boolean) +} + +function formatAdvisoryLocationRequireCredentials (value) { + if (value == null) return undefined + + const normalizedValue = String(value).trim().toLowerCase() + if (!normalizedValue) return undefined + if (normalizedValue.includes('yes')) return true + if (normalizedValue === 'no') return false + + return undefined +} + +function formatDate (value) { + if (value == null || value === '') return undefined + + if (value instanceof Date) { + return isNaN(value) ? undefined : value + } + + if (typeof value === 'number') { + const parsedDate = XLSX.SSF.parse_date_code(value) + if (!parsedDate) return undefined + + return new Date(Date.UTC(parsedDate.y, parsedDate.m - 1, parsedDate.d)) + } + + const date = new Date(String(value).trim()) + return isNaN(date) ? undefined : date +} + +function formatDateOnly (value) { + if (value == null || value === '') return null + + if (value instanceof Date) { + return normalizeDateOnlyInput(value) + } + + if (typeof value === 'number') { + const parsedDate = XLSX.SSF.parse_date_code(value) + if (!parsedDate) return value + + return [ + String(parsedDate.y).padStart(4, '0'), + String(parsedDate.m).padStart(2, '0'), + String(parsedDate.d).padStart(2, '0') + ].join('-') + } + + return normalizeDateOnlyInput(String(value).trim()) +} + +function runCli () { + const mondayExport = loadMondayExport() + + console.log( + `Loaded ${mondayExport.rawRows.length} raw Monday rows and ${mondayExport.asOrgRows.length} converted org rows.` + ) + console.log( + JSON.stringify( + { + firstRawRow: mondayExport.rawRows[0], + firstAsOrgRow: mondayExport.asOrgRows[0], + duplicateShortNames: mondayTracker.mondayDuplicateShortNames + }, + null, + 2 + ) + ) +} + +if (require.main === module) { + runCli() +} + +module.exports = { + loadMondayExport, + mondayTracker +} diff --git a/src/scripts/MondayMigrate.js b/src/scripts/MondayMigrate.js new file mode 100644 index 000000000..630b02baa --- /dev/null +++ b/src/scripts/MondayMigrate.js @@ -0,0 +1,317 @@ +/** + * MondayMigrate.js + * + * Optional migration step for applying Monday export data to already migrated BaseOrg records. + * + * Before running this, run the base migrate.js script and make sure export.xlsx is available in src/scripts. + */ + +require('dotenv').config() +const { MongoClient } = require('mongodb') +const _ = require('lodash') +const validator = require('validator') +const { loadMondayExport } = require('./MondayHelpers') + +const dbConnStr = process.env.MONGO_CONN_STRING + +const mondayMigrationStats = { + matchedCount: 0, + dbOnlyShortNames: [], + duplicateMatchesResolved: [], + duplicateMatchesUnresolved: [] +} +const pocContactFields = [ + { + emailField: 'poc_email_1', + nameField: 'primary_poc_name', + phoneField: 'poc_phone_1' + }, + { + emailField: 'poc_email_2', + nameField: 'secondary_poc_name', + phoneField: 'poc_phone_2' + }, + { + emailField: 'poc_email_3', + nameField: 'third_poc_name' + }, + ...Array.from({ length: 7 }, (_, index) => ({ + emailField: `poc_email_${index + 4}` + })) +] +let allBaseOrgs = [] + +function deepRemoveEmpty (obj) { + if (_.isArray(obj)) { + return obj + .map(v => deepRemoveEmpty(v)) + .filter(v => { + return !( + v === undefined || + v === null || + (_.isArray(v) && v.length === 0) || + (_.isPlainObject(v) && _.isEmpty(v)) + ) + }) + } else if (_.isPlainObject(obj)) { + return _.transform(obj, (result, value, key) => { + const cleaned = deepRemoveEmpty(value) + if ( + !( + cleaned === undefined || + cleaned === null || + (_.isArray(cleaned) && cleaned.length === 0) || + (_.isPlainObject(cleaned) && _.isEmpty(cleaned)) + ) + ) { + result[key] = cleaned + } + }) + } + return obj +} + +async function run () { + const dbClient = new MongoClient(dbConnStr) + try { + await dbClient.connect() + + const db = await dbClient.db(process.env.MONGO_DB_NAME) + const mondayExport = loadMondayExport() + const unmatchedAsOrgRows = [...mondayExport.asOrgRows] + console.log( + `Loaded ${mondayExport.rawRows.length} raw Monday rows and ${mondayExport.asOrgRows.length} converted org rows.` + ) + + await mondayOrgHelper(db, unmatchedAsOrgRows) + printMondayMigrationStats(unmatchedAsOrgRows) + } catch (err) { + await dbClient.close() + + console.error(err) + } finally { + await dbClient.close() + } +} + +function normalizeAuthority (authority) { + return String(authority || '') + .trim() + .replace(/[^a-zA-Z0-9]/g, '') + .toUpperCase() +} + +function hasMatchingAuthority (doc, asOrgRow) { + const docAuthorities = new Set( + (doc.authority || []).map(normalizeAuthority) + ) + return (asOrgRow.authority || []).some((authority) => + docAuthorities.has(normalizeAuthority(authority)) + ) +} + +function describeAsOrgRow (asOrgRow) { + return `${asOrgRow.short_name} [${(asOrgRow.authority || []).join(', ') || 'no authority'}] ${asOrgRow.partner_number || 'no partner_number'}` +} + +function consumeAsOrgRow (asOrgRows, asOrgRow) { + const index = asOrgRows.indexOf(asOrgRow) + + if (index !== -1) { + asOrgRows.splice(index, 1) + } + + mondayMigrationStats.matchedCount += 1 + return asOrgRow +} + +function findAsOrgRowForOrg (doc, asOrgRows) { + const shortNameMatches = asOrgRows.filter( + (asOrgRow) => asOrgRow.short_name === doc.short_name + ) + + if (shortNameMatches.length === 0) { + mondayMigrationStats.dbOnlyShortNames.push(doc.short_name) + return null + } + + if (shortNameMatches.length === 1) { + return consumeAsOrgRow(asOrgRows, shortNameMatches[0]) + } + + const authorityMatches = shortNameMatches.filter((asOrgRow) => + hasMatchingAuthority(doc, asOrgRow) + ) + + if (authorityMatches.length === 1) { + mondayMigrationStats.duplicateMatchesResolved.push({ + short_name: doc.short_name, + db_authority: doc.authority || [], + matched: describeAsOrgRow(authorityMatches[0]) + }) + return consumeAsOrgRow(asOrgRows, authorityMatches[0]) + } + + mondayMigrationStats.duplicateMatchesUnresolved.push({ + short_name: doc.short_name, + db_authority: doc.authority || [], + candidates: shortNameMatches.map(describeAsOrgRow) + }) + return null +} + +function normalizeEmail (email) { + return String(email || '').trim().toLowerCase() +} + +function isValidEmail (email) { + return validator.isEmail(String(email || '').trim()) +} + +function splitEmailList (value) { + return String(value || '') + .split(',') + .map((email) => email.trim()) + .filter(isValidEmail) +} + +function formatBaseUserName (name) { + if (typeof name === 'string') return name.trim() + if (!name || typeof name !== 'object') return '' + + return [name.suffix, name.first, name.middle, name.last] + .map((value) => String(value || '').trim()) + .filter(Boolean) + .join(' ') +} + +function buildUsersByUsername (users) { + return users.reduce((usersByUsername, user) => { + const username = normalizeEmail(user.username) + if (username && !usersByUsername.has(username)) { + usersByUsername.set(username, user) + } + + return usersByUsername + }, new Map()) +} + +function buildPrivateContacts (asOrgRow, usersByUsername) { + return pocContactFields.flatMap((pocContactField) => { + const emails = splitEmailList(asOrgRow[pocContactField.emailField]) + const fallbackName = pocContactField.nameField + ? String(asOrgRow[pocContactField.nameField] || '').trim() + : '' + const phone = pocContactField.phoneField + ? String(asOrgRow[pocContactField.phoneField] || '').trim() + : '' + + return emails.map((email) => { + const user = usersByUsername.get(normalizeEmail(email)) + const poc = formatBaseUserName(user?.name) || fallbackName + + return { + poc_email: email, + poc, + phone + } + }) + }) +} + +function buildMondayOrgUpdateFields (asOrgRow, usersByUsername = new Map()) { + if (!asOrgRow) return {} + + return { + long_name: asOrgRow.long_name, + authority: asOrgRow.authority, + top_level_root: asOrgRow.top_level_root, + charter_or_scope: asOrgRow.charter_or_scope, + disclosure_policy: asOrgRow.disclosure_policy, + advisory_locations: asOrgRow.advisory_locations, + advisory_location_require_credentials: + asOrgRow.advisory_location_require_credentials, + vulnerability_advisory_location_for_web_scraping: + asOrgRow.vulnerability_advisory_location_for_web_scraping, + partner_role_type: asOrgRow.partner_role_type, + partner_number: asOrgRow.partner_number, + partner_country: asOrgRow.partner_country, + industry: asOrgRow.industry, + tl_root_start_date: asOrgRow.tl_root_start_date, + private_contacts: buildPrivateContacts(asOrgRow, usersByUsername), + program_data: asOrgRow.program_data + } +} + +function printMondayMigrationStats (unmatchedAsOrgRows) { + console.log( + `Matched BaseOrg records to Monday rows: ${mondayMigrationStats.matchedCount}/${allBaseOrgs.length}.` + ) + + if (mondayMigrationStats.dbOnlyShortNames.length) { + console.log( + `BaseOrg records without Monday row (${mondayMigrationStats.dbOnlyShortNames.length}): ${mondayMigrationStats.dbOnlyShortNames.join(', ')}` + ) + } + + if (mondayMigrationStats.duplicateMatchesResolved.length) { + console.log( + `Duplicate Monday rows resolved by authority (${mondayMigrationStats.duplicateMatchesResolved.length}): ${mondayMigrationStats.duplicateMatchesResolved.map((match) => `${match.short_name} -> ${match.matched}`).join(', ')}` + ) + } + + if (mondayMigrationStats.duplicateMatchesUnresolved.length) { + console.log( + `Duplicate Monday rows not resolved by authority (${mondayMigrationStats.duplicateMatchesUnresolved.length}): ${mondayMigrationStats.duplicateMatchesUnresolved.map((match) => `${match.short_name} [DB: ${(match.db_authority || []).join(', ') || 'no authority'}] candidates: ${match.candidates.join(' | ')}`).join('; ')}` + ) + } + + console.log(`Unconsumed Monday org rows: ${unmatchedAsOrgRows.length}.`) + + if (unmatchedAsOrgRows.length) { + console.log( + `Unconsumed Monday org row(s): ${unmatchedAsOrgRows.map(describeAsOrgRow).join(', ')}` + ) + } +} + +async function mondayOrgHelper (db, asOrgRows) { + console.log('Running Monday Org sync...') + const trgOrgCol = await db.collection('BaseOrg') + const trgUserCol = await db.collection('BaseUser') + + allBaseOrgs = await trgOrgCol.find().toArray() + const allBaseUsers = await trgUserCol.find().toArray() + const usersByUsername = buildUsersByUsername(allBaseUsers) + + for (const doc of allBaseOrgs) { + const asOrgRow = findAsOrgRowForOrg(doc, asOrgRows) + if (!asOrgRow) continue + + const trgQuery = { + short_name: doc.short_name + } + const mondayFields = buildMondayOrgUpdateFields(asOrgRow, usersByUsername) + const updateDoc = deepRemoveEmpty({ + $set: mondayFields + }) + if (mondayFields.private_contacts.length === 0) { + updateDoc.$set = updateDoc.$set || {} + updateDoc.$set.private_contacts = [] + } + + await trgOrgCol.updateOne(trgQuery, updateDoc) + } +} + +if (require.main === module) { + run() +} + +module.exports = { + buildMondayOrgUpdateFields, + buildPrivateContacts, + findAsOrgRowForOrg, + mondayMigrationStats, + run +} diff --git a/src/scripts/countries.js b/src/scripts/countries.js new file mode 100644 index 000000000..3fd494f64 --- /dev/null +++ b/src/scripts/countries.js @@ -0,0 +1,249 @@ +const countryList = [ + { name: 'Non Affiliate', code: 'N/A' }, + { name: 'Afghanistan', code: 'AF' }, + { name: 'land Islands', code: 'AX' }, + { name: 'Albania', code: 'AL' }, + { name: 'Algeria', code: 'DZ' }, + { name: 'American Samoa', code: 'AS' }, + { name: 'AndorrA', code: 'AD' }, + { name: 'Angola', code: 'AO' }, + { name: 'Anguilla', code: 'AI' }, + { name: 'Antarctica', code: 'AQ' }, + { name: 'Antigua and Barbuda', code: 'AG' }, + { name: 'Argentina', code: 'AR' }, + { name: 'Armenia', code: 'AM' }, + { name: 'Aruba', code: 'AW' }, + { name: 'Australia', code: 'AU' }, + { name: 'Austria', code: 'AT' }, + { name: 'Azerbaijan', code: 'AZ' }, + { name: 'Bahamas', code: 'BS' }, + { name: 'Bahrain', code: 'BH' }, + { name: 'Bangladesh', code: 'BD' }, + { name: 'Barbados', code: 'BB' }, + { name: 'Belarus', code: 'BY' }, + { name: 'Belgium', code: 'BE' }, + { name: 'Belize', code: 'BZ' }, + { name: 'Benin', code: 'BJ' }, + { name: 'Bermuda', code: 'BM' }, + { name: 'Bhutan', code: 'BT' }, + { name: 'Bolivia', code: 'BO' }, + { name: 'Bosnia and Herzegovina', code: 'BA' }, + { name: 'Botswana', code: 'BW' }, + { name: 'Bouvet Island', code: 'BV' }, + { name: 'Brazil', code: 'BR' }, + { name: 'British Indian Ocean Territory', code: 'IO' }, + { name: 'Brunei Darussalam', code: 'BN' }, + { name: 'Bulgaria', code: 'BG' }, + { name: 'Burkina Faso', code: 'BF' }, + { name: 'Burundi', code: 'BI' }, + { name: 'Cambodia', code: 'KH' }, + { name: 'Cameroon', code: 'CM' }, + { name: 'Canada', code: 'CA' }, + { name: 'Cape Verde', code: 'CV' }, + { name: 'Cayman Islands', code: 'KY' }, + { name: 'Central African Republic', code: 'CF' }, + { name: 'Chad', code: 'TD' }, + { name: 'Chile', code: 'CL' }, + { name: 'China', code: 'CN' }, + { name: 'Christmas Island', code: 'CX' }, + { name: 'Cocos (Keeling) Islands', code: 'CC' }, + { name: 'Colombia', code: 'CO' }, + { name: 'Comoros', code: 'KM' }, + { name: 'Congo', code: 'CG' }, + { name: 'Congo, The Democratic Republic of the', code: 'CD' }, + { name: 'Cook Islands', code: 'CK' }, + { name: 'Costa Rica', code: 'CR' }, + { name: 'Cote D"Ivoire', code: 'CI' }, + { name: 'Croatia', code: 'HR' }, + { name: 'Cuba', code: 'CU' }, + { name: 'Cyprus', code: 'CY' }, + { name: 'Czech Republic', code: 'CZ' }, + { name: 'Denmark', code: 'DK' }, + { name: 'Djibouti', code: 'DJ' }, + { name: 'Dominica', code: 'DM' }, + { name: 'Dominican Republic', code: 'DO' }, + { name: 'Ecuador', code: 'EC' }, + { name: 'Egypt', code: 'EG' }, + { name: 'El Salvador', code: 'SV' }, + { name: 'Equatorial Guinea', code: 'GQ' }, + { name: 'Eritrea', code: 'ER' }, + { name: 'Estonia', code: 'EE' }, + { name: 'Ethiopia', code: 'ET' }, + { name: 'Falkland Islands (Malvinas)', code: 'FK' }, + { name: 'Faroe Islands', code: 'FO' }, + { name: 'Fiji', code: 'FJ' }, + { name: 'Finland', code: 'FI' }, + { name: 'France', code: 'FR' }, + { name: 'French Guiana', code: 'GF' }, + { name: 'French Polynesia', code: 'PF' }, + { name: 'French Southern Territories', code: 'TF' }, + { name: 'Gabon', code: 'GA' }, + { name: 'Gambia', code: 'GM' }, + { name: 'Georgia', code: 'GE' }, + { name: 'Germany', code: 'DE' }, + { name: 'Ghana', code: 'GH' }, + { name: 'Gibraltar', code: 'GI' }, + { name: 'Greece', code: 'GR' }, + { name: 'Greenland', code: 'GL' }, + { name: 'Grenada', code: 'GD' }, + { name: 'Guadeloupe', code: 'GP' }, + { name: 'Guam', code: 'GU' }, + { name: 'Guatemala', code: 'GT' }, + { name: 'Guernsey', code: 'GG' }, + { name: 'Guinea', code: 'GN' }, + { name: 'Guinea-Bissau', code: 'GW' }, + { name: 'Guyana', code: 'GY' }, + { name: 'Haiti', code: 'HT' }, + { name: 'Heard Island and Mcdonald Islands', code: 'HM' }, + { name: 'Holy See (Vatican City State)', code: 'VA' }, + { name: 'Honduras', code: 'HN' }, + { name: 'Hong Kong', code: 'HK' }, + { name: 'Hungary', code: 'HU' }, + { name: 'Iceland', code: 'IS' }, + { name: 'India', code: 'IN' }, + { name: 'Indonesia', code: 'ID' }, + { name: 'Iran, Islamic Republic Of', code: 'IR' }, + { name: 'Iraq', code: 'IQ' }, + { name: 'Ireland', code: 'IE' }, + { name: 'Isle of Man', code: 'IM' }, + { name: 'Israel', code: 'IL' }, + { name: 'Italy', code: 'IT' }, + { name: 'Jamaica', code: 'JM' }, + { name: 'Japan', code: 'JP' }, + { name: 'Jersey', code: 'JE' }, + { name: 'Jordan', code: 'JO' }, + { name: 'Kazakhstan', code: 'KZ' }, + { name: 'Kenya', code: 'KE' }, + { name: 'Kiribati', code: 'KI' }, + { name: 'Korea, Democratic People"S Republic of', code: 'KP' }, + { name: 'Korea, Republic of', code: 'KR' }, + { name: 'Kuwait', code: 'KW' }, + { name: 'Kyrgyzstan', code: 'KG' }, + { name: 'Lao People"S Democratic Republic', code: 'LA' }, + { name: 'Latvia', code: 'LV' }, + { name: 'Lebanon', code: 'LB' }, + { name: 'Lesotho', code: 'LS' }, + { name: 'Liberia', code: 'LR' }, + { name: 'Libyan Arab Jamahiriya', code: 'LY' }, + { name: 'Liechtenstein', code: 'LI' }, + { name: 'Lithuania', code: 'LT' }, + { name: 'Luxembourg', code: 'LU' }, + { name: 'Macao', code: 'MO' }, + { name: 'Macedonia, The Former Yugoslav Republic of', code: 'MK' }, + { name: 'Madagascar', code: 'MG' }, + { name: 'Malawi', code: 'MW' }, + { name: 'Malaysia', code: 'MY' }, + { name: 'Maldives', code: 'MV' }, + { name: 'Mali', code: 'ML' }, + { name: 'Malta', code: 'MT' }, + { name: 'Marshall Islands', code: 'MH' }, + { name: 'Martinique', code: 'MQ' }, + { name: 'Mauritania', code: 'MR' }, + { name: 'Mauritius', code: 'MU' }, + { name: 'Mayotte', code: 'YT' }, + { name: 'Mexico', code: 'MX' }, + { name: 'Micronesia, Federated States of', code: 'FM' }, + { name: 'Moldova, Republic of', code: 'MD' }, + { name: 'Monaco', code: 'MC' }, + { name: 'Mongolia', code: 'MN' }, + { name: 'Montenegro', code: 'ME' }, + { name: 'Montserrat', code: 'MS' }, + { name: 'Morocco', code: 'MA' }, + { name: 'Mozambique', code: 'MZ' }, + { name: 'Myanmar', code: 'MM' }, + { name: 'Namibia', code: 'NA' }, + { name: 'Nauru', code: 'NR' }, + { name: 'Nepal', code: 'NP' }, + { name: 'Netherlands', code: 'NL' }, + { name: 'Netherlands Antilles', code: 'AN' }, + { name: 'New Caledonia', code: 'NC' }, + { name: 'New Zealand', code: 'NZ' }, + { name: 'Nicaragua', code: 'NI' }, + { name: 'Niger', code: 'NE' }, + { name: 'Nigeria', code: 'NG' }, + { name: 'Niue', code: 'NU' }, + { name: 'Norfolk Island', code: 'NF' }, + { name: 'Northern Mariana Islands', code: 'MP' }, + { name: 'Norway', code: 'NO' }, + { name: 'Oman', code: 'OM' }, + { name: 'Pakistan', code: 'PK' }, + { name: 'Palau', code: 'PW' }, + { name: 'Palestinian Territory, Occupied', code: 'PS' }, + { name: 'Panama', code: 'PA' }, + { name: 'Papua New Guinea', code: 'PG' }, + { name: 'Paraguay', code: 'PY' }, + { name: 'Peru', code: 'PE' }, + { name: 'Philippines', code: 'PH' }, + { name: 'Pitcairn', code: 'PN' }, + { name: 'Poland', code: 'PL' }, + { name: 'Portugal', code: 'PT' }, + { name: 'Puerto Rico', code: 'PR' }, + { name: 'Qatar', code: 'QA' }, + { name: 'Reunion', code: 'RE' }, + { name: 'Romania', code: 'RO' }, + { name: 'Russian Federation', code: 'RU' }, + { name: 'RWANDA', code: 'RW' }, + { name: 'Saint Helena', code: 'SH' }, + { name: 'Saint Kitts and Nevis', code: 'KN' }, + { name: 'Saint Lucia', code: 'LC' }, + { name: 'Saint Pierre and Miquelon', code: 'PM' }, + { name: 'Saint Vincent and the Grenadines', code: 'VC' }, + { name: 'Samoa', code: 'WS' }, + { name: 'San Marino', code: 'SM' }, + { name: 'Sao Tome and Principe', code: 'ST' }, + { name: 'Saudi Arabia', code: 'SA' }, + { name: 'Senegal', code: 'SN' }, + { name: 'Serbia', code: 'RS' }, + { name: 'Seychelles', code: 'SC' }, + { name: 'Sierra Leone', code: 'SL' }, + { name: 'Singapore', code: 'SG' }, + { name: 'Slovakia', code: 'SK' }, + { name: 'Slovenia', code: 'SI' }, + { name: 'Solomon Islands', code: 'SB' }, + { name: 'Somalia', code: 'SO' }, + { name: 'South Africa', code: 'ZA' }, + { name: 'South Georgia and the South Sandwich Islands', code: 'GS' }, + { name: 'Spain', code: 'ES' }, + { name: 'Sri Lanka', code: 'LK' }, + { name: 'Sudan', code: 'SD' }, + { name: 'Suriname', code: 'SR' }, + { name: 'Svalbard and Jan Mayen', code: 'SJ' }, + { name: 'Swaziland', code: 'SZ' }, + { name: 'Sweden', code: 'SE' }, + { name: 'Switzerland', code: 'CH' }, + { name: 'Syrian Arab Republic', code: 'SY' }, + { name: 'Taiwan', code: 'TW' }, + { name: 'Tajikistan', code: 'TJ' }, + { name: 'Tanzania, United Republic of', code: 'TZ' }, + { name: 'Thailand', code: 'TH' }, + { name: 'Timor-Leste', code: 'TL' }, + { name: 'Togo', code: 'TG' }, + { name: 'Tokelau', code: 'TK' }, + { name: 'Tonga', code: 'TO' }, + { name: 'Trinidad and Tobago', code: 'TT' }, + { name: 'Tunisia', code: 'TN' }, + { name: 'Turkey', code: 'TR' }, + { name: 'Turkmenistan', code: 'TM' }, + { name: 'Turks and Caicos Islands', code: 'TC' }, + { name: 'Tuvalu', code: 'TV' }, + { name: 'Uganda', code: 'UG' }, + { name: 'Ukraine', code: 'UA' }, + { name: 'United Arab Emirates', code: 'AE' }, + { name: 'United Kingdom', code: 'GB' }, + { name: 'United States', code: 'US' }, + { name: 'United States Minor Outlying Islands', code: 'UM' }, + { name: 'Uruguay', code: 'UY' }, + { name: 'Uzbekistan', code: 'UZ' }, + { name: 'Vanuatu', code: 'VU' }, + { name: 'Venezuela', code: 'VE' }, + { name: 'Viet Nam', code: 'VN' }, + { name: 'Virgin Islands, British', code: 'VG' }, + { name: 'Virgin Islands, U.S.', code: 'VI' }, + { name: 'Wallis and Futuna', code: 'WF' }, + { name: 'Western Sahara', code: 'EH' }, + { name: 'Yemen', code: 'YE' }, + { name: 'Zambia', code: 'ZM' }, + { name: 'Zimbabwe', code: 'ZW' } +] + +module.exports = { countryList } diff --git a/src/scripts/test_data/testData.js b/src/scripts/test_data/testData.js index 895ea8f78..ee8d7bb5a 100644 --- a/src/scripts/test_data/testData.js +++ b/src/scripts/test_data/testData.js @@ -374,12 +374,15 @@ export const orgs = [ partner_number: 'CNA-2015-0001', top_level_root: 'MITRE TLR', aliases: ['Acme Research', 'ASRG Security'], - partner_role_type: 'Vendor', + partner_role_type: ['Vendor'], partner_country: 'United States', industry: 'Information Technology', - hard_quota: 500, - soft_quota: 400, + id_quota: 500, advisory_locations: ['https://acmesecurity.example.com/advisories'], + advisory_location_require_credentials: false, + vulnerability_advisory_location_for_web_scraping: [ + 'https://acmesecurity.example.com/advisories' + ], is_cna_discussion_list: true, contact_info: { phone: '+1-555-210-3344', @@ -399,11 +402,7 @@ export const orgs = [ status: 'Active', partner_active_date: '2015-03-15', cve_website_update_needed: false, - cve_website_update_date: '2024-11-01T10:00:00Z', - vulnerability_advisory_location_for_web_scraping: [ - 'https://acmesecurity.example.com/advisories' - ], - advisory_location_require_credentials: false + cve_website_update_date: '2024-11-01T10:00:00Z' }, charter_or_scope: 'https://acmesecurity.example.com/charter', disclosure_policy: 'https://acmesecurity.example.com/disclosure', @@ -416,11 +415,14 @@ export const orgs = [ partner_number: 'CNA-2015-0002', top_level_root: 'CISA TLR', aliases: ['CETIL Labs'], - partner_role_type: 'Researcher', + partner_role_type: ['Researcher'], partner_country: 'Germany', - hard_quota: 400, - soft_quota: 350, + id_quota: 400, advisory_locations: ['https://cetil.example.de/advisories'], + advisory_location_require_credentials: true, + vulnerability_advisory_location_for_web_scraping: [ + 'https://cetil.example.de/advisories' + ], is_cna_discussion_list: false, contact_info: { phone: '+49-30-555-2211', @@ -440,11 +442,7 @@ export const orgs = [ status: 'Active', partner_active_date: '2015-05-12', cve_website_update_needed: true, - cve_website_update_date: '2025-01-15T08:30:00Z', - vulnerability_advisory_location_for_web_scraping: [ - 'https://cetil.example.de/advisories' - ], - advisory_location_require_credentials: true + cve_website_update_date: '2025-01-15T08:30:00Z' }, charter_or_scope: 'https://cetil.example.de/scope', disclosure_policy: 'https://cetil.example.de/disclosure' @@ -455,11 +453,14 @@ export const orgs = [ short_name: 'KVIC_FAKE', partner_number: 'CNA-2016-0001', top_level_root: 'MITRE TLR', - partner_role_type: 'Vendor', + partner_role_type: ['Vendor'], partner_country: 'South Korea', - hard_quota: 380, - soft_quota: 320, + id_quota: 380, advisory_locations: ['https://kvic.example.kr/advisories'], + advisory_location_require_credentials: false, + vulnerability_advisory_location_for_web_scraping: [ + 'https://kvic.example.kr/advisories' + ], is_cna_discussion_list: false, contact_info: { phone: '+82-2-5555-1122', @@ -479,11 +480,7 @@ export const orgs = [ status: 'Active', partner_active_date: '2016-03-20', cve_website_update_needed: false, - cve_website_update_date: '2024-10-15T12:00:00Z', - advisory_location_require_credentials: false, - vulnerability_advisory_location_for_web_scraping: [ - 'https://kvic.example.kr/advisories' - ] + cve_website_update_date: '2024-10-15T12:00:00Z' }, charter_or_scope: 'https://kvic.example.kr/charter', disclosure_policy: 'https://kvic.example.kr/disclosure', @@ -495,11 +492,11 @@ export const orgs = [ short_name: 'NAVE_FAKE', partner_number: 'CNA-2016-0002', top_level_root: 'CISA TLR', - partner_role_type: 'Consortium', + partner_role_type: ['Consortium'], partner_country: 'Canada', industry: 'Financials', - hard_quota: 600, - soft_quota: 500, + id_quota: 600, + advisory_location_require_credentials: false, is_cna_discussion_list: true, contact_info: { emails: ['pmehta@nave.example.ca'], @@ -509,8 +506,7 @@ export const orgs = [ program_data: { status: 'Active', partner_active_date: '2016-11-30', - cve_website_update_needed: false, - advisory_location_require_credentials: false + cve_website_update_needed: false }, charter_or_scope: 'https://nave.example.ca/charter', product_list: 'https://nave.example.ca/products' @@ -521,12 +517,15 @@ export const orgs = [ short_name: 'SPSG_FAKE', partner_number: 'CNA-2016-0003', top_level_root: 'CISA TLR', - partner_role_type: 'Vendor', + partner_role_type: ['Vendor'], partner_country: 'Switzerland', industry: 'Financials', - hard_quota: 420, - soft_quota: 380, + id_quota: 420, advisory_locations: ['https://spsg.example.ch/advisories'], + advisory_location_require_credentials: true, + vulnerability_advisory_location_for_web_scraping: [ + 'https://spsg.example.ch/advisories' + ], is_cna_discussion_list: true, contact_info: { phone: '+41-44-555-7766', @@ -543,11 +542,7 @@ export const orgs = [ status: 'Active', partner_active_date: '2016-09-30', cve_website_update_needed: true, - cve_website_update_date: '2025-02-01T08:00:00Z', - advisory_location_require_credentials: true, - vulnerability_advisory_location_for_web_scraping: [ - 'https://spsg.example.ch/advisories' - ] + cve_website_update_date: '2025-02-01T08:00:00Z' }, charter_or_scope: 'https://spsg.example.ch/charter', disclosure_policy: 'https://spsg.example.ch/disclosure' @@ -558,11 +553,14 @@ export const orgs = [ short_name: 'FNCAP_FAKE', partner_number: 'CNA-2018-0001', top_level_root: 'CISA TLR', - partner_role_type: 'CERT', + partner_role_type: ['CERT'], partner_country: 'France', - hard_quota: 325, - soft_quota: 275, + id_quota: 325, advisory_locations: ['https://fncap.example.fr/advisories'], + advisory_location_require_credentials: true, + vulnerability_advisory_location_for_web_scraping: [ + 'https://fncap.example.fr/advisories' + ], is_cna_discussion_list: true, contact_info: { phone: '+33-1-5555-9988', @@ -578,11 +576,7 @@ export const orgs = [ program_data: { status: 'Active', partner_active_date: '2018-07-14', - cve_website_update_needed: false, - advisory_location_require_credentials: true, - vulnerability_advisory_location_for_web_scraping: [ - 'https://fncap.example.fr/advisories' - ] + cve_website_update_needed: false } }, { @@ -591,9 +585,9 @@ export const orgs = [ short_name: 'IVCC_FAKE', partner_number: 'CNA-2018-0002', aliases: ['IVCC Spain'], - partner_role_type: 'CERT', + partner_role_type: ['CERT'], partner_country: 'Spain', - hard_quota: 250, + id_quota: 250, is_cna_discussion_list: true, contact_info: { phone: '+34-91-555-9900', @@ -616,12 +610,15 @@ export const orgs = [ short_name: 'UKHSSL_FAKE', partner_number: 'CNA-2018-0003', top_level_root: 'MITRE TLR', - partner_role_type: 'Hosted Service', + partner_role_type: ['Hosted Service'], partner_country: 'United Kingdom', industry: 'Information Technology', - hard_quota: 450, - soft_quota: 400, + id_quota: 450, advisory_locations: ['https://ukhssl.example.co.uk/security'], + advisory_location_require_credentials: true, + vulnerability_advisory_location_for_web_scraping: [ + 'https://ukhssl.example.co.uk/security' + ], is_cna_discussion_list: false, contact_info: { phone: '+44-20-5555-1234', @@ -637,11 +634,7 @@ export const orgs = [ status: 'Active', partner_active_date: '2018-04-17', cve_website_update_needed: true, - cve_website_update_date: '2024-12-01T09:00:00Z', - vulnerability_advisory_location_for_web_scraping: [ - 'https://ukhssl.example.co.uk/security' - ], - advisory_location_require_credentials: true + cve_website_update_date: '2024-12-01T09:00:00Z' }, charter_or_scope: 'https://ukhssl.example.co.uk/scope', disclosure_policy: 'https://ukhssl.example.co.uk/disclosure', @@ -654,11 +647,11 @@ export const orgs = [ partner_number: 'CNA-2019-0001', top_level_root: 'MITRE TLR', aliases: ['EE Bug Bounty'], - partner_role_type: 'Bug Bounty Provider', + partner_role_type: ['Bug Bounty Provider'], partner_country: 'Poland', - hard_quota: 175, - soft_quota: 150, + id_quota: 175, advisory_locations: ['https://eebbn.example.pl/advisories'], + advisory_location_require_credentials: false, is_cna_discussion_list: false, contact_info: { phone: '+48-22-555-4321', @@ -673,8 +666,7 @@ export const orgs = [ program_data: { status: 'Active', partner_active_date: '2019-03-22', - cve_website_update_needed: false, - advisory_location_require_credentials: false + cve_website_update_needed: false }, charter_or_scope: 'https://eebbn.example.pl/charter', disclosure_policy: 'https://eebbn.example.pl/disclosure', @@ -685,10 +677,10 @@ export const orgs = [ long_name: 'Brazilian Cybersecurity Operations Center', short_name: 'BCOC_FAKE', partner_number: 'CNA-2019-0002', - partner_role_type: 'CERT', + partner_role_type: ['CERT'], partner_country: 'Brazil', - hard_quota: 350, - soft_quota: 300, + id_quota: 350, + advisory_location_require_credentials: false, is_cna_discussion_list: true, contact_info: { phone: '+55-11-5555-3344', @@ -707,8 +699,7 @@ export const orgs = [ program_data: { status: 'Active', partner_active_date: '2019-11-05', - cve_website_update_needed: false, - advisory_location_require_credentials: false + cve_website_update_needed: false } }, { @@ -717,12 +708,12 @@ export const orgs = [ short_name: 'GVSA_FAKE', partner_number: 'CNA-2010-0001', top_level_root: 'MITRE TLR', - partner_role_type: 'N/A', + partner_role_type: ['N/A'], partner_country: 'United States', industry: 'Information Technology', - hard_quota: 9999, - soft_quota: 9000, + id_quota: 9999, advisory_locations: ['https://gvsa.example.org/advisories'], + advisory_location_require_credentials: false, is_cna_discussion_list: true, contact_info: { phone: '+1-202-555-0100', @@ -742,8 +733,7 @@ export const orgs = [ status: 'Active', partner_active_date: '2010-01-01', cve_website_update_needed: false, - cve_website_update_date: '2025-03-01T00:00:00Z', - advisory_location_require_credentials: false + cve_website_update_date: '2025-03-01T00:00:00Z' } }, { @@ -752,10 +742,11 @@ export const orgs = [ short_name: 'NCDI_FAKE', partner_number: 'CNA-2020-0001', top_level_root: 'MITRE TLR', - partner_role_type: 'CERT', + partner_role_type: ['CERT'], partner_country: 'Norway', - hard_quota: 300, + id_quota: 300, advisory_locations: ['https://ncdi.example.no/advisories'], + advisory_location_require_credentials: false, is_cna_discussion_list: false, contact_info: { phone: '+47-21-555-678', @@ -771,8 +762,7 @@ export const orgs = [ program_data: { status: 'Active', partner_active_date: '2020-06-01', - cve_website_update_needed: true, - advisory_location_require_credentials: false + cve_website_update_needed: true }, charter_or_scope: 'https://ncdi.example.no/scope', disclosure_policy: 'https://ncdi.example.no/disclosure' @@ -783,7 +773,8 @@ export const orgs = [ short_name: 'SCDA_FAKE', partner_number: 'CNA-2020-0002', partner_country: 'Australia', - hard_quota: 150, + id_quota: 150, + advisory_location_require_credentials: false, contact_info: { emails: ['ochen@scda.example.au'], websites: ['https://scda.example.au'] @@ -793,8 +784,7 @@ export const orgs = [ status: 'Inactive', partner_active_date: '2020-03-10', partner_inactive_date: '2023-07-01', - cve_website_update_needed: false, - advisory_location_require_credentials: false + cve_website_update_needed: false } }, { @@ -802,9 +792,10 @@ export const orgs = [ long_name: 'Indian Subcontinent Cyber Response Unit', short_name: 'ISCRU_FAKE', partner_number: 'CNA-2020-0003', - partner_role_type: 'CERT', + partner_role_type: ['CERT'], partner_country: 'India', - hard_quota: 275, + id_quota: 275, + advisory_location_require_credentials: false, is_cna_discussion_list: false, contact_info: { phone: '+91-11-5555-2233', @@ -820,8 +811,7 @@ export const orgs = [ program_data: { status: 'Active', partner_active_date: '2020-09-01', - cve_website_update_needed: true, - advisory_location_require_credentials: false + cve_website_update_needed: true }, charter_or_scope: 'https://iscru.example.in/charter', disclosure_policy: 'https://iscru.example.in/disclosure' @@ -831,10 +821,10 @@ export const orgs = [ long_name: 'Pacific Rim Open Source Consortium', short_name: 'PROSC_FAKE', partner_number: 'CNA-2021-0001 {BULK_DOWNLOAD}', - partner_role_type: 'Open Source', + partner_role_type: ['Open Source'], partner_country: 'Japan', - hard_quota: 1000, - soft_quota: 800, + id_quota: 1000, + advisory_location_require_credentials: false, contact_info: { emails: ['hyamamoto@prosc.example.jp'], websites: ['https://prosc.example.jp'] @@ -843,8 +833,7 @@ export const orgs = [ program_data: { status: 'Active', partner_active_date: '2021-01-10', - cve_website_update_needed: false, - advisory_location_require_credentials: false + cve_website_update_needed: false } }, { @@ -853,9 +842,9 @@ export const orgs = [ short_name: 'SACA_FAKE', partner_number: 'CNA-2021-0002', aliases: ['SEA Cyber Alliance'], - partner_role_type: 'CERT', + partner_role_type: ['CERT'], partner_country: 'Singapore', - hard_quota: 200, + id_quota: 200, is_cna_discussion_list: false, contact_info: { phone: '+65-6555-8877', @@ -882,11 +871,11 @@ export const orgs = [ short_name: 'MEVRC_FAKE', partner_number: 'CNA-2022-0001', top_level_root: 'MITRE TLR', - partner_role_type: 'Researcher', + partner_role_type: ['Researcher'], partner_country: 'Israel', - hard_quota: 300, - soft_quota: 250, + id_quota: 300, advisory_locations: ['https://mevrc.example.il/advisories'], + advisory_location_require_credentials: false, is_cna_discussion_list: false, contact_info: { phone: '+972-3-555-7890', @@ -902,8 +891,7 @@ export const orgs = [ program_data: { status: 'Active', partner_active_date: '2022-02-10', - cve_website_update_needed: false, - advisory_location_require_credentials: false + cve_website_update_needed: false }, charter_or_scope: 'https://mevrc.example.il/charter', disclosure_policy: 'https://mevrc.example.il/disclosure' @@ -913,10 +901,10 @@ export const orgs = [ long_name: 'Open Vulnerability Data Initiative', short_name: 'OVDI_FAKE', partner_number: 'CNA-2022-0002 {BULK_DOWNLOAD}', - partner_role_type: 'Open Source', + partner_role_type: ['Open Source'], partner_country: 'Netherlands', - hard_quota: 2000, - soft_quota: 1800, + id_quota: 2000, + advisory_location_require_credentials: false, contact_info: { emails: ['dvandenberg@ovdi.example.nl'], websites: ['https://ovdi.example.nl'] @@ -925,8 +913,7 @@ export const orgs = [ program_data: { status: 'Active', partner_active_date: '2022-06-15', - cve_website_update_needed: false, - advisory_location_require_credentials: false + cve_website_update_needed: false } }, { @@ -934,9 +921,10 @@ export const orgs = [ long_name: 'African Cyber Threat Exchange', short_name: 'ACTE_FAKE', partner_number: 'CNA-2023-0001', - partner_role_type: 'Consortium', + partner_role_type: ['Consortium'], partner_country: 'South Africa', - hard_quota: 200, + id_quota: 200, + advisory_location_require_credentials: false, is_cna_discussion_list: false, contact_info: { phone: '+27-11-555-6677', @@ -947,8 +935,7 @@ export const orgs = [ program_data: { status: 'Active', partner_active_date: '2023-01-15', - cve_website_update_needed: false, - advisory_location_require_credentials: false + cve_website_update_needed: false }, charter_or_scope: 'https://acte.example.za/charter', disclosure_policy: 'https://acte.example.za/disclosure' @@ -959,11 +946,12 @@ export const orgs = [ short_name: 'ACRA_FAKE', aliases: ['ACRA Labs'], partner_number: 'CNA-2023-0002', - partner_role_type: 'Researcher', + partner_role_type: ['Researcher'], partner_country: 'Colombia', top_level_root: 'MITRE TLR', - hard_quota: 150, + id_quota: 150, industry: 'Technology Hardware & Equipment (4520)', + advisory_location_require_credentials: false, is_cna_discussion_list: false, contact_info: { phone: '+57-1-555-4422', @@ -979,8 +967,7 @@ export const orgs = [ program_data: { status: 'Active', partner_active_date: '2023-05-20', - cve_website_update_needed: false, - advisory_location_require_credentials: false + cve_website_update_needed: false }, charter_or_scope: 'https://acra.example.co/charter', disclosure_policy: 'https://acra.example.co/disclosure' diff --git a/src/utils/authContext.js b/src/utils/authContext.js new file mode 100644 index 000000000..9517eac02 --- /dev/null +++ b/src/utils/authContext.js @@ -0,0 +1,461 @@ +function getAuthorityRoles (org) { + if (!org) return [] + + if (Array.isArray(org.authority)) { + return org.authority + } + + if (Array.isArray(org.authority?.active_roles)) { + return org.authority.active_roles + } + + if (typeof org.authority === 'string') { + return [org.authority] + } + + return [] +} + +function orgHasRole (org, role) { + return getAuthorityRoles(org).includes(role) +} + +function userHasAdminRole (user) { + if (!user) return false + + if (user.role === 'ADMIN') { + return true + } + + return Array.isArray(user.authority?.active_roles) && user.authority.active_roles.includes('ADMIN') +} + +function isBaseOrgRepository (orgRepo) { + return typeof orgRepo?.isSecretariatByShortName === 'function' || + typeof orgRepo?.getOrg === 'function' +} + +function isBaseUserRepository (userRepo) { + return typeof userRepo?.findUserByUUID === 'function' || + typeof userRepo?.findOneByUsernameAndOrgShortname === 'function' +} + +function hasOwnMethod (obj, methodName) { + return Object.prototype.hasOwnProperty.call(obj || {}, methodName) && typeof obj[methodName] === 'function' +} + +function isAuthenticatedRequest (req) { + return req?.ctx?.authenticated === true +} + +async function findOrgByUUID (orgRepo, orgUUID, options = {}, returnLegacyFormat = false) { + if (!orgUUID || typeof orgRepo?.findOneByUUID !== 'function') { + return null + } + + if (isBaseOrgRepository(orgRepo)) { + return orgRepo.findOneByUUID(orgUUID, options, returnLegacyFormat) + } + + return orgRepo.findOneByUUID(orgUUID, options) +} + +async function findOrgByShortName (orgRepo, orgShortName, options = {}, returnLegacyFormat = false) { + if (!orgShortName || typeof orgRepo?.findOneByShortName !== 'function') { + return null + } + + if (isBaseOrgRepository(orgRepo)) { + return orgRepo.findOneByShortName(orgShortName, options, returnLegacyFormat) + } + + return orgRepo.findOneByShortName(orgShortName, options) +} + +async function findUserByUUID (userRepo, userUUID, options = {}, isRegistryObject = true) { + if (!userUUID) { + return null + } + + if (typeof userRepo?.findUserByUUID === 'function') { + return userRepo.findUserByUUID(userUUID, options, isRegistryObject) + } + + if (typeof userRepo?.findOneByUUID === 'function') { + return userRepo.findOneByUUID(userUUID, options) + } + + return null +} + +async function findUserByUsernameAndOrgUUID (userRepo, username, orgUUID, options = {}, isRegistryObject = true) { + if (!username || !orgUUID) { + return null + } + + if (typeof userRepo?.findUserByUsernameAndOrgUUID === 'function') { + return userRepo.findUserByUsernameAndOrgUUID(username, orgUUID, options, isRegistryObject) + } + + if (typeof userRepo?.findOneByUserNameAndOrgUUID === 'function') { + if (isBaseUserRepository(userRepo)) { + return userRepo.findOneByUserNameAndOrgUUID(username, orgUUID, options, isRegistryObject) + } + + return userRepo.findOneByUserNameAndOrgUUID(username, orgUUID, null, options) + } + + return null +} + +async function orgHasRoleByUUID (orgRepo, orgUUID, role, options = {}, returnLegacyFormat = false) { + if (!orgUUID) { + return false + } + + if (typeof orgRepo?.hasRoleByUUID === 'function') { + return orgRepo.hasRoleByUUID(orgUUID, role, options, returnLegacyFormat) + } + + const org = await findOrgByUUID(orgRepo, orgUUID, options, returnLegacyFormat) + return orgHasRole(org, role) +} + +async function isUserAdminOfOrgUUID (userRepo, orgRepo, userUUID, orgUUID, options = {}, isRegistryObject = true) { + if (!userUUID || !orgUUID) { + return false + } + + if (typeof userRepo?.isUserAdminOfOrgUUID === 'function') { + return userRepo.isUserAdminOfOrgUUID(userUUID, orgUUID, options, isRegistryObject) + } + + const org = await findOrgByUUID(orgRepo, orgUUID, options, !isRegistryObject) + if (!org) { + return false + } + + if (Array.isArray(org?.admins) && org.admins.includes(userUUID)) { + return true + } + + if (Array.isArray(org?.users) && !org.users.includes(userUUID)) { + return false + } + + const user = await findUserByUUID(userRepo, userUUID, options, isRegistryObject) + return userHasAdminRole(user) +} + +async function getRequesterOrgUUID (req, orgRepo, options = {}, useLegacy = false) { + if (req.ctx.orgUUID) { + return req.ctx.orgUUID + } + + if (isAuthenticatedRequest(req)) { + return null + } + + if (!req.ctx.org) { + return null + } + + if (typeof orgRepo?.getOrgUUID === 'function') { + return orgRepo.getOrgUUID(req.ctx.org, options, useLegacy) + } + + const org = await getRequesterOrg(req, orgRepo, options, useLegacy) + return org?.UUID || null +} + +async function getRequesterOrg (req, orgRepo, options = {}, returnLegacyFormat = false) { + if (req.ctx.orgUUID) { + return findOrgByUUID(orgRepo, req.ctx.orgUUID, options, returnLegacyFormat) + } + + if (isAuthenticatedRequest(req)) { + return null + } + + if (req.ctx.org) { + return findOrgByShortName(orgRepo, req.ctx.org, options, returnLegacyFormat) + } + + return null +} + +async function getRequesterUser (req, userRepo, orgRepo, options = {}, isRegistryObject = true) { + if (req.ctx.userUUID) { + return findUserByUUID(userRepo, req.ctx.userUUID, options, isRegistryObject) + } + + if (isAuthenticatedRequest(req) && !req.ctx.userUUID) { + return null + } + + const orgUUID = await getRequesterOrgUUID(req, orgRepo, options, !isRegistryObject) + if (!req.ctx.user || !orgUUID) { + return null + } + + const user = await findUserByUsernameAndOrgUUID(userRepo, req.ctx.user, orgUUID, options, isRegistryObject) + if (user) { + return user + } + + if (typeof userRepo?.findOneByUsernameAndOrgShortname === 'function') { + return userRepo.findOneByUsernameAndOrgShortname(req.ctx.user, req.ctx.org, options, isRegistryObject) + } + + return null +} + +async function getRequesterUserUUID (req, userRepo, orgRepo, options = {}, isRegistryObject = true) { + if (req.ctx.userUUID) { + return req.ctx.userUUID + } + + if (isAuthenticatedRequest(req)) { + return null + } + + if (!req.ctx.orgUUID && typeof userRepo?.getUserUUID === 'function') { + if (isBaseUserRepository(userRepo)) { + return userRepo.getUserUUID(req.ctx.user, req.ctx.org, options, isRegistryObject) + } + + const orgUUID = await getRequesterOrgUUID(req, orgRepo, options, !isRegistryObject) + return userRepo.getUserUUID(req.ctx.user, orgUUID, options) + } + + const user = await getRequesterUser(req, userRepo, orgRepo, options, isRegistryObject) + if (user?.UUID) { + return user.UUID + } + + if (typeof userRepo?.getUserUUID === 'function') { + if (isBaseUserRepository(userRepo)) { + return userRepo.getUserUUID(req.ctx.user, req.ctx.org, options, isRegistryObject) + } + + const orgUUID = await getRequesterOrgUUID(req, orgRepo, options, !isRegistryObject) + return userRepo.getUserUUID(req.ctx.user, orgUUID, options) + } + + return null +} + +async function getTargetOrgUUID (orgRepo, orgShortName, options = {}, useLegacy = false) { + if (!orgShortName) { + return null + } + + if (typeof orgRepo?.getOrgUUID === 'function') { + return orgRepo.getOrgUUID(orgShortName, options, useLegacy) + } + + if (typeof orgRepo?.findOneByShortName === 'function') { + const org = await findOrgByShortName(orgRepo, orgShortName, options, useLegacy) + return org?.UUID || null + } + + return null +} + +async function isRequesterSameOrg (req, orgRepo, targetOrgOrShortName, options = {}, useLegacy = false) { + if (!isAuthenticatedRequest(req) && !req.ctx.orgUUID) { + return false + } + + const requesterOrgUUID = await getRequesterOrgUUID(req, orgRepo, options, useLegacy) + const targetOrgUUID = typeof targetOrgOrShortName === 'string' + ? await getTargetOrgUUID(orgRepo, targetOrgOrShortName, options, useLegacy) + : targetOrgOrShortName?.UUID + + return Boolean(requesterOrgUUID && targetOrgUUID && requesterOrgUUID === targetOrgUUID) +} + +async function isRequesterSecretariat (req, orgRepo, options = {}, returnLegacyFormat = false) { + if (isAuthenticatedRequest(req) || req.ctx.orgUUID) { + const orgUUID = await getRequesterOrgUUID(req, orgRepo, options, returnLegacyFormat) + return orgHasRoleByUUID(orgRepo, orgUUID, 'SECRETARIAT', options, returnLegacyFormat) + } + + if (hasOwnMethod(orgRepo, 'isSecretariatByShortName')) { + return orgRepo.isSecretariatByShortName(req.ctx.org, options, returnLegacyFormat) + } + + if (isBaseOrgRepository(orgRepo) && typeof orgRepo?.isSecretariat === 'function' && typeof orgRepo?.findOneByShortName === 'function') { + const org = await getRequesterOrg(req, orgRepo, options, returnLegacyFormat) + return orgRepo.isSecretariat(org, options, returnLegacyFormat) + } + + if (typeof orgRepo?.isSecretariatByShortName === 'function') { + return orgRepo.isSecretariatByShortName(req.ctx.org, options, returnLegacyFormat) + } + + if (typeof orgRepo?.isSecretariat === 'function') { + return orgRepo.isSecretariat(req.ctx.org, options) + } + + if (typeof orgRepo?.isSecretariatUUID === 'function') { + const orgUUID = await getRequesterOrgUUID(req, orgRepo, options, returnLegacyFormat) + return orgRepo.isSecretariatUUID(orgUUID) + } + + return false +} + +async function isRequesterBulkDownload (req, orgRepo, options = {}, returnLegacyFormat = false) { + if (isAuthenticatedRequest(req) || req.ctx.orgUUID) { + const orgUUID = await getRequesterOrgUUID(req, orgRepo, options, returnLegacyFormat) + return orgHasRoleByUUID(orgRepo, orgUUID, 'BULK_DOWNLOAD', options, returnLegacyFormat) + } + + if (typeof orgRepo?.isBulkDownloadByShortname === 'function') { + return orgRepo.isBulkDownloadByShortname(req.ctx.org, options, returnLegacyFormat) + } + + if (typeof orgRepo?.isBulkDownload === 'function') { + return orgRepo.isBulkDownload(req.ctx.org) + } + + return false +} + +async function isRequesterAdmin (req, userRepo, orgRepo, options = {}, isRegistryObject = true) { + if (isAuthenticatedRequest(req) || (req.ctx.orgUUID && req.ctx.userUUID)) { + if (!req.ctx.orgUUID || !req.ctx.userUUID) { + return false + } + + return isUserAdminOfOrgUUID(userRepo, orgRepo, req.ctx.userUUID, req.ctx.orgUUID, options, isRegistryObject) + } + + if (typeof userRepo?.isAdmin === 'function') { + return userRepo.isAdmin(req.ctx.user, req.ctx.org, options, isRegistryObject) + } + + return false +} + +async function isRequesterAdminOfOrg (req, userRepo, orgRepo, targetOrgOrShortName, options = {}, isRegistryObject = true) { + const fallbackTargetShortName = typeof targetOrgOrShortName === 'string' + ? targetOrgOrShortName + : targetOrgOrShortName?.short_name + + if (isAuthenticatedRequest(req)) { + if (!req.ctx.orgUUID || !req.ctx.userUUID) { + return false + } + + let targetOrg = typeof targetOrgOrShortName === 'string' ? null : targetOrgOrShortName + let targetOrgUUID = targetOrg?.UUID || null + + if (targetOrgUUID && !Array.isArray(targetOrg.admins)) { + const fullTargetOrg = await findOrgByUUID(orgRepo, targetOrgUUID, options, !isRegistryObject) + targetOrg = fullTargetOrg || targetOrg + } + + if (!targetOrg && fallbackTargetShortName) { + targetOrg = await findOrgByShortName(orgRepo, fallbackTargetShortName, options, !isRegistryObject) + } + + targetOrgUUID = targetOrg?.UUID || targetOrgUUID + if (!targetOrgUUID && fallbackTargetShortName) { + targetOrgUUID = await getTargetOrgUUID(orgRepo, fallbackTargetShortName, options, !isRegistryObject) + } + + if (await isUserAdminOfOrgUUID(userRepo, orgRepo, req.ctx.userUUID, targetOrgUUID, options, isRegistryObject)) { + return true + } + + const sameOrg = Boolean(req.ctx.orgUUID && targetOrgUUID && req.ctx.orgUUID === targetOrgUUID) + if (sameOrg) { + const user = await getRequesterUser(req, userRepo, orgRepo, options, isRegistryObject) + return userHasAdminRole(user) + } + + return false + } + + if (!req.ctx.orgUUID && !req.ctx.userUUID) { + if (hasOwnMethod(userRepo, 'isAdminOrSecretariat')) { + return userRepo.isAdminOrSecretariat(fallbackTargetShortName, req.ctx.user, req.ctx.org, options, isRegistryObject) + } + + if (hasOwnMethod(userRepo, 'isAdmin')) { + return userRepo.isAdmin(req.ctx.user, fallbackTargetShortName, options, isRegistryObject) + } + } + + let targetOrg = typeof targetOrgOrShortName === 'string' ? null : targetOrgOrShortName + + if (!targetOrg && fallbackTargetShortName) { + targetOrg = await findOrgByShortName(orgRepo, fallbackTargetShortName, options, !isRegistryObject) + } + + if (req.ctx.userUUID) { + const targetOrgUUID = targetOrg?.UUID || (fallbackTargetShortName ? await getTargetOrgUUID(orgRepo, fallbackTargetShortName, options, !isRegistryObject) : null) + if (await isUserAdminOfOrgUUID(userRepo, orgRepo, req.ctx.userUUID, targetOrgUUID, options, isRegistryObject)) { + return true + } + + const sameOrg = await isRequesterSameOrg(req, orgRepo, targetOrg || targetOrgOrShortName, options, !isRegistryObject) + if (sameOrg) { + const user = await getRequesterUser(req, userRepo, orgRepo, options, isRegistryObject) + return userHasAdminRole(user) + } + + return false + } + + if (typeof userRepo?.isAdmin === 'function') { + return userRepo.isAdmin(req.ctx.user, fallbackTargetShortName, options, isRegistryObject) + } + + return false +} + +async function getRequesterContext (req, repositories = {}, options = {}, isRegistryObject = true) { + const orgRepo = repositories.orgRepo + const userRepo = repositories.userRepo + const context = { + org: null, + orgUUID: null, + user: null, + userUUID: null, + isSecretariat: false, + isBulkDownload: false, + isAdmin: false + } + + if (orgRepo) { + context.orgUUID = await getRequesterOrgUUID(req, orgRepo, options, !isRegistryObject) + context.org = await getRequesterOrg(req, orgRepo, options, !isRegistryObject) + context.isSecretariat = await isRequesterSecretariat(req, orgRepo, options, !isRegistryObject) + context.isBulkDownload = await isRequesterBulkDownload(req, orgRepo, options, !isRegistryObject) + } + + if (userRepo && orgRepo) { + context.userUUID = await getRequesterUserUUID(req, userRepo, orgRepo, options, isRegistryObject) + context.user = await getRequesterUser(req, userRepo, orgRepo, options, isRegistryObject) + context.isAdmin = await isRequesterAdmin(req, userRepo, orgRepo, options, isRegistryObject) + } + + return context +} + +module.exports = { + getRequesterContext, + getRequesterOrg, + getRequesterOrgUUID, + getRequesterUser, + getRequesterUserUUID, + isRequesterAdmin, + isRequesterAdminOfOrg, + isRequesterBulkDownload, + isRequesterSameOrg, + isRequesterSecretariat, + orgHasRole +} diff --git a/test/integration-tests/audit/auditTest.js b/test/integration-tests/audit/auditTest.js index 5b23e2a9a..cd6967b1c 100644 --- a/test/integration-tests/audit/auditTest.js +++ b/test/integration-tests/audit/auditTest.js @@ -256,6 +256,32 @@ describe('Testing Audit Org endpoints', () => { }) }) + it('Should return an empty array when getting audit history for a non-existent org shortname', async () => { + const fakeShortname = uuid.v4().slice(0, 32) + + await chai.request(app) + .get(`/api/audit/org/${fakeShortname}`) + .set(constants.headers) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(200) + expect(res.body).to.deep.equal([]) + }) + }) + + it('Should return an empty array when getting audit history for a non-existent org UUID', async () => { + const fakeUUID = uuid.v4() + + await chai.request(app) + .get(`/api/audit/org/${fakeUUID}`) + .set(constants.headers) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(200) + expect(res.body).to.deep.equal([]) + }) + }) + it('Should fail to get last X changes with invalid number', async () => { const invalidNumber = -5 await chai.request(app) diff --git a/test/integration-tests/conversation/conversationTest.js b/test/integration-tests/conversation/conversationTest.js index dcc6986b7..bac7e0e47 100644 --- a/test/integration-tests/conversation/conversationTest.js +++ b/test/integration-tests/conversation/conversationTest.js @@ -7,9 +7,15 @@ chai.use(require('chai-http')) const constants = require('../constants.js') const app = require('../../../src/index.js') +const namedSecretariatHeaders = { + ...constants.headers, + 'CVE-API-USER': 'cps@mitre.org' +} + describe('Testing Conversation endpoints', () => { let orgUUID let secUserUUID + let namedSecUserUUID let rootConvoUUID before(async () => { @@ -32,6 +38,16 @@ describe('Testing Conversation endpoints', () => { expect(res).to.have.status(200) secUserUUID = res.body.UUID }) + + await chai + .request(app) + .get('/api/registry/org/mitre/user/cps@mitre.org') + .set(constants.headers) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(200) + namedSecUserUUID = res.body.UUID + }) }) context('Positive Tests', () => { @@ -58,7 +74,7 @@ describe('Testing Conversation endpoints', () => { expect(res.body.author_id).to.equal(secUserUUID) expect(res.body).to.haveOwnProperty('author_name') - expect(res.body.author_name).to.equal('Unknown User') + expect(res.body.author_name).to.equal('Secretariat') expect(res.body).to.haveOwnProperty('author_role') expect(res.body.author_role).to.equal('Secretariat') @@ -117,6 +133,11 @@ describe('Testing Conversation endpoints', () => { expect(res.body).to.haveOwnProperty('conversations') expect(res.body.conversations).to.be.an('array') expect(res.body.conversations).to.have.lengthOf(2) + res.body.conversations.forEach(convo => { + if (convo.author_role === 'Secretariat') { + expect(convo.author_name).to.equal('Secretariat') + } + }) }) }) it('Should get and see all conversations for target UUID as Secretariat', async () => { @@ -132,6 +153,9 @@ describe('Testing Conversation endpoints', () => { res.body.forEach(convo => { expect(convo).to.haveOwnProperty('target_uuid') expect(convo.target_uuid).to.equal(orgUUID) + if (convo.author_role === 'Secretariat') { + expect(convo.author_name).to.equal('Secretariat') + } }) }) }) @@ -157,6 +181,30 @@ describe('Testing Conversation endpoints', () => { expect(res.body.visibility).to.equal('private') }) }) + it('Should create a conversation as Secretariat without using the user personal name', async () => { + const conversation = { + visibility: 'public', + body: 'test named Secretariat author' + } + + await chai.request(app) + .post(`/api/conversation/target/${orgUUID}`) + .set(namedSecretariatHeaders) + .send(conversation) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(200) + + expect(res.body).to.haveOwnProperty('author_id') + expect(res.body.author_id).to.equal(namedSecUserUUID) + + expect(res.body).to.haveOwnProperty('author_name') + expect(res.body.author_name).to.equal('Secretariat') + + expect(res.body).to.haveOwnProperty('author_role') + expect(res.body.author_role).to.equal('Secretariat') + }) + }) }) context('Negative Tests', () => { diff --git a/test/integration-tests/middleware/authenticatedContextTest.js b/test/integration-tests/middleware/authenticatedContextTest.js new file mode 100644 index 000000000..eb4ec1779 --- /dev/null +++ b/test/integration-tests/middleware/authenticatedContextTest.js @@ -0,0 +1,351 @@ +const express = require('express') +const chai = require('chai') +const expect = chai.expect +chai.use(require('chai-http')) + +const middleware = require('../../../src/middleware/middleware') +const cveIdController = require('../../../src/controller/cve-id.controller/cve-id.controller') +const getConstants = require('../../../src/constants').getConstants +const mwFixtures = require('../../unit-tests/middleware/mockObjects.middleware') +const serviceApp = require('../../../src/index') +const BaseOrg = require('../../../src/model/baseorg') +const Org = require('../../../src/model/org') +const BaseUser = require('../../../src/model/baseuser') +const User = require('../../../src/model/user') +const CveId = require('../../../src/model/cve-id') + +const CONSTANTS = getConstants() + +describe('Authenticated request context middleware integration', () => { + it('uses the authenticated org UUID for Secretariat authorization after key validation', async () => { + const app = express() + const authenticatedOrg = { + UUID: '24ad129f-af00-4d8c-8f7b-e19b0587223f', + authority: [CONSTANTS.AUTH_ROLE_ENUM.CNA], + admins: [], + short_name: mwFixtures.existentOrg.short_name + } + const authenticatedUser = { + ...mwFixtures.existentUser, + UUID: 'a11186d5-ce3d-4fd9-aecd-8698c26897f2' + } + + class AmbiguousOrgRepo { + async getOrgUUID () { + return authenticatedOrg.UUID + } + + async findOneByUUID (orgUUID) { + expect(orgUUID).to.equal(authenticatedOrg.UUID) + return authenticatedOrg + } + + isSecretariat () { + return false + } + + async isSecretariatByShortName () { + return true + } + } + + class UserRepo { + async findOneByUserNameAndOrgUUID (username, orgUUID) { + expect(username).to.equal(authenticatedUser.username) + expect(orgUUID).to.equal(authenticatedOrg.UUID) + return authenticatedUser + } + } + + app.use(express.json()) + app.use(middleware.createCtxAndReqUUID) + app.post('/secretariat-only', + (req, res, next) => { + req.ctx.repositories = { + getBaseOrgRepository: () => new AmbiguousOrgRepo(), + getBaseUserRepository: () => new UserRepo() + } + next() + }, + middleware.validateUser, + middleware.onlySecretariat, + (req, res) => res.status(200).json({ message: 'Success' }) + ) + + const res = await chai.request(app) + .post('/secretariat-only') + .set(mwFixtures.secretariatHeaders) + + expect(res).to.have.status(403) + expect(res.body.error).to.equal('SECRETARIAT_ONLY') + }) + + it('uses the authenticated org UUID for controller-level CVE-ID filtering', async () => { + const app = express() + const authenticatedOrg = { + UUID: '24ad129f-af00-4d8c-8f7b-e19b0587223f', + authority: [CONSTANTS.AUTH_ROLE_ENUM.CNA], + admins: [], + short_name: mwFixtures.existentOrg.short_name + } + const legacyAuthenticatedOrg = { + UUID: authenticatedOrg.UUID, + authority: { + active_roles: [CONSTANTS.AUTH_ROLE_ENUM.CNA] + }, + short_name: authenticatedOrg.short_name + } + const authenticatedUser = { + ...mwFixtures.existentUser, + UUID: 'a11186d5-ce3d-4fd9-aecd-8698c26897f2', + org_UUID: authenticatedOrg.UUID + } + + class BaseOrgRepo { + async getOrgUUID () { + return authenticatedOrg.UUID + } + + async findOneByUUID (orgUUID) { + expect(orgUUID).to.equal(authenticatedOrg.UUID) + return authenticatedOrg + } + } + + class BaseUserRepo { + async findOneByUserNameAndOrgUUID (username, orgUUID) { + expect(username).to.equal(authenticatedUser.username) + expect(orgUUID).to.equal(authenticatedOrg.UUID) + return authenticatedUser + } + } + + class LegacyOrgRepo { + async getOrgUUID () { + return '11111111-1111-4111-8111-111111111111' + } + + async findOneByUUID (orgUUID) { + expect(orgUUID).to.equal(authenticatedOrg.UUID) + return legacyAuthenticatedOrg + } + + async isSecretariat () { + return true + } + + async isBulkDownload () { + return false + } + + async getAllOrgs () { + return [legacyAuthenticatedOrg] + } + } + + class LegacyUserRepo { + async getAllUsers () { + return [] + } + } + + class CveIdRepo { + async aggregatePaginate (aggregate) { + expect(aggregate[0].$match).to.have.property('owning_cna', authenticatedOrg.UUID) + return { + itemsList: [], + itemCount: 0, + itemsPerPage: 1000, + currentPage: 1, + pageCount: 1, + prevPage: null, + nextPage: null + } + } + } + + app.use(express.json()) + app.use(middleware.createCtxAndReqUUID) + app.get('/cve-id', + (req, res, next) => { + req.ctx.repositories = { + getBaseOrgRepository: () => new BaseOrgRepo(), + getBaseUserRepository: () => new BaseUserRepo(), + getOrgRepository: () => new LegacyOrgRepo(), + getUserRepository: () => new LegacyUserRepo(), + getCveIdRepository: () => new CveIdRepo() + } + next() + }, + middleware.validateUser, + (req, res, next) => { + req.ctx.query = req.query + return cveIdController.CVEID_GET_FILTER(req, res, next) + } + ) + + const res = await chai.request(app) + .get('/cve-id') + .set(mwFixtures.secretariatHeaders) + + expect(res).to.have.status(200) + expect(res.body.cve_ids).to.deep.equal([]) + }) + + context('DB-backed duplicate short-name regression', () => { + const duplicateShortName = 'authctx_dup' + const authenticatedOrgUUID = '11111111-1111-4111-8111-111111111111' + const legacySecretariatOrgUUID = '22222222-2222-4222-8222-222222222222' + const authenticatedUserUUID = '33333333-3333-4333-8333-333333333333' + const authenticatedUsername = 'authctx_dup_user@example.org' + const authenticatedCveId = 'CVE-2099-900001' + const legacySecretariatCveId = 'CVE-2099-900002' + const testSecret = 'S96E4QT-SMT4YE3-KX03X6K-4615CED' + + async function cleanupDuplicateShortNameFixtures () { + await BaseOrg.deleteMany({ + $or: [ + { UUID: authenticatedOrgUUID }, + { short_name: duplicateShortName } + ] + }) + await Org.deleteMany({ + $or: [ + { UUID: { $in: [authenticatedOrgUUID, legacySecretariatOrgUUID] } }, + { short_name: duplicateShortName } + ] + }) + await BaseUser.deleteMany({ + $or: [ + { UUID: authenticatedUserUUID }, + { username: authenticatedUsername } + ] + }) + await User.deleteMany({ + $or: [ + { UUID: authenticatedUserUUID }, + { username: authenticatedUsername } + ] + }) + await CveId.deleteMany({ cve_id: { $in: [authenticatedCveId, legacySecretariatCveId] } }) + } + + before(async function () { + this.timeout(10000) + await cleanupDuplicateShortNameFixtures() + + await BaseOrg.collection.insertOne({ + UUID: authenticatedOrgUUID, + long_name: 'Authenticated Duplicate Short Name CNA', + short_name: duplicateShortName, + authority: [CONSTANTS.AUTH_ROLE_ENUM.CNA], + users: [authenticatedUserUUID], + admins: [], + program_data: { + status: 'active' + }, + in_use: true + }) + + await BaseUser.create({ + UUID: authenticatedUserUUID, + username: authenticatedUsername, + secret: mwFixtures.existentUser.secret, + name: { + first: 'Authenticated', + last: 'Requester' + }, + status: 'active' + }) + + await Org.collection.insertOne({ + UUID: legacySecretariatOrgUUID, + name: 'Legacy Secretariat Duplicate Short Name', + short_name: duplicateShortName, + authority: { + active_roles: [CONSTANTS.AUTH_ROLE_ENUM.SECRETARIAT] + }, + policies: { + id_quota: 5 + }, + inUse: true + }) + + await Org.collection.insertOne({ + UUID: authenticatedOrgUUID, + name: 'Authenticated Duplicate Short Name CNA', + short_name: duplicateShortName, + authority: { + active_roles: [CONSTANTS.AUTH_ROLE_ENUM.CNA] + }, + policies: { + id_quota: 5 + }, + inUse: true + }) + + await User.collection.insertOne({ + UUID: authenticatedUserUUID, + username: authenticatedUsername, + org_UUID: authenticatedOrgUUID, + secret: mwFixtures.existentUser.secret, + active: true, + authority: { + active_roles: [] + }, + name: { + first: 'Authenticated', + last: 'Requester' + } + }) + + await CveId.collection.insertMany([ + { + cve_id: authenticatedCveId, + cve_year: '2099', + state: CONSTANTS.CVE_STATES.RESERVED, + owning_cna: authenticatedOrgUUID, + requested_by: { + cna: authenticatedOrgUUID, + user: authenticatedUserUUID + }, + reserved: new Date('2026-01-01T00:00:00.000Z') + }, + { + cve_id: legacySecretariatCveId, + cve_year: '2099', + state: CONSTANTS.CVE_STATES.RESERVED, + owning_cna: legacySecretariatOrgUUID, + requested_by: { + cna: legacySecretariatOrgUUID, + user: authenticatedUserUUID + }, + reserved: new Date('2026-01-01T00:00:00.000Z') + } + ]) + }) + + after(async function () { + this.timeout(10000) + await cleanupDuplicateShortNameFixtures() + }) + + it('scopes controller access to the authenticated org UUID when legacy short-name lookup would be Secretariat', async () => { + const headers = { + 'content-type': 'application/json', + [CONSTANTS.AUTH_HEADERS.ORG]: duplicateShortName, + [CONSTANTS.AUTH_HEADERS.USER]: authenticatedUsername, + [CONSTANTS.AUTH_HEADERS.KEY]: testSecret + } + + const res = await chai.request(serviceApp) + .get('/api/cve-id?state=RESERVED') + .set(headers) + + expect(res).to.have.status(200) + const cveIds = res.body.cve_ids.map(cveId => cveId.cve_id) + expect(cveIds).to.include(authenticatedCveId) + expect(cveIds).to.not.include(legacySecretariatCveId) + }) + }) +}) diff --git a/test/integration-tests/org/getOrgTest.js b/test/integration-tests/org/getOrgTest.js new file mode 100644 index 000000000..d739b481b --- /dev/null +++ b/test/integration-tests/org/getOrgTest.js @@ -0,0 +1,38 @@ +/* eslint-disable no-unused-expressions */ +const chai = require('chai') +chai.use(require('chai-http')) +const expect = chai.expect + +const constants = require('../constants.js') +const app = require('../../../src/index.js') + +describe('Testing legacy Org GET endpoint', () => { + context('Positive Tests', () => { + it('Does not return Mongo fields when retrieving an org by UUID', async () => { + let orgUUID + + await chai.request(app) + .get('/api/org/mitre') + .set(constants.headers) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(200) + orgUUID = res.body.UUID + }) + + await chai.request(app) + .get(`/api/org/${orgUUID}`) + .set(constants.headers) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(200) + expect(res.body).to.have.property('UUID', orgUUID) + + const mongoFields = ['_id', '__v', '__t', 'inUse', 'in_use'] + mongoFields.forEach(field => { + expect(res.body).to.not.haveOwnProperty(field) + }) + }) + }) + }) +}) diff --git a/test/integration-tests/org/postOrgUsersTest.js b/test/integration-tests/org/postOrgUsersTest.js index d952c7237..53dc843a5 100644 --- a/test/integration-tests/org/postOrgUsersTest.js +++ b/test/integration-tests/org/postOrgUsersTest.js @@ -8,6 +8,8 @@ const constants = require('../constants.js') const app = require('../../../src/index.js') const _ = require('lodash') const User = require('../../../src/model/user') +const BaseUser = require('../../../src/model/baseuser') +const BaseOrg = require('../../../src/model/baseorg') // const RegistryUser = require('../../../src/model/registry-user.js') const shortName = { shortname: 'win_5' } @@ -58,6 +60,74 @@ describe('Testing user post endpoint', () => { orgUuid = res.body.created.org_UUID }) }) + it('Adds registry-created users with authority admin roles to the org admins list', async () => { + const username = 'authorityadminuser999@win_5.com' + let userUUID + + await chai + .request(app) + .post('/api/registry/org/win_5/user') + .set({ ...constants.headers, ...shortName }) + .send({ + username, + name: { + first: 'Authority', + last: 'Admin' + }, + authority: { + active_roles: [ + 'Admin' + ] + } + }) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(200) + userUUID = res.body.created.UUID + }) + + const registryOrg = await BaseOrg.findOne({ short_name: 'win_5' }) + const legacyUser = await User.findOne({ username, UUID: userUUID }) + + expect(registryOrg.admins).to.include(userUUID) + expect(legacyUser.authority.active_roles).to.include('ADMIN') + }) + it('Syncs literal dotted legacy user fields into the registry collection', async () => { + const username = 'dotnotationuser999@win_5.com' + let userUUID + + await chai.request(app) + .post('/api/org/win_5/user') + .set({ ...constants.headers, ...shortName }) + .send({ + username, + 'name.first': 'AliceDot', + 'name.last': 'LegacyDot', + 'authority.active_roles': [ + 'ADMIN' + ] + }) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(200) + expect(res.body.created.name.first).to.equal('AliceDot') + expect(res.body.created.name.last).to.equal('LegacyDot') + userUUID = res.body.created.UUID + }) + + const legacyUser = await User.findOne({ username, UUID: userUUID }) + const registryUser = await BaseUser.findOne({ username, UUID: userUUID }) + const registryOrg = await BaseOrg.findOne({ short_name: 'win_5' }) + + expect(legacyUser).to.exist + expect(registryUser).to.exist + expect(legacyUser.name.first).to.equal('AliceDot') + expect(registryUser.name.first).to.equal('AliceDot') + expect(legacyUser.name.last).to.equal('LegacyDot') + expect(registryUser.name.last).to.equal('LegacyDot') + expect(legacyUser.authority.active_roles).to.include('ADMIN') + expect(registryOrg.admins).to.include(userUUID) + }) }) context('Negative Tests', () => { it('Fails creation of user for bad long first name', async () => { diff --git a/test/integration-tests/org/registryOrgAsOrgAdmin.js b/test/integration-tests/org/registryOrgAsOrgAdmin.js index 54b460aed..715898822 100644 --- a/test/integration-tests/org/registryOrgAsOrgAdmin.js +++ b/test/integration-tests/org/registryOrgAsOrgAdmin.js @@ -6,6 +6,7 @@ const expect = chai.expect const constants = require('../constants.js') const app = require('../../../src/index.js') const _ = require('lodash') +const BaseOrg = require('../../../src/model/baseorg') const shortName = 'beat_10' const userId = 'drocca@test.mitre.org' @@ -18,6 +19,7 @@ const adminHeaders = { describe('Testing Registry Org as org admin', () => { let secret + let adminUUID before(async () => { await chai.request(app) .post('/api/registry/org/beat_10/user') @@ -30,6 +32,7 @@ describe('Testing Registry Org as org admin', () => { ).then((res, err) => { expect(err).to.be.undefined secret = res.body.created.secret + adminUUID = res.body.created.UUID adminHeaders['CVE-API-KEY'] = secret }) @@ -194,6 +197,11 @@ describe('Testing Registry Org as org admin', () => { }) }) it('Registry: services api allows org admins to get their own org document', async () => { + await BaseOrg.collection.updateOne( + { short_name: shortName }, + { $set: { 'contact_info.additional_contacts': [adminUUID] } } + ) + await chai.request(app) .get(`/api/registry/org/${shortName}`) .set(adminHeaders) @@ -201,6 +209,9 @@ describe('Testing Registry Org as org admin', () => { expect(err).to.be.undefined expect(res).to.have.status(200) expect(res.body.short_name).to.equal(shortName) + expect(res.body.users).to.be.an('array').that.includes(adminUUID) + expect(res.body.admins).to.be.an('array').that.includes(adminUUID) + expect(res.body.contact_info.additional_contacts).to.deep.equal([adminUUID]) }) }) it('Registry: services api allows org admins to get their own user list', async () => { diff --git a/test/integration-tests/org/regularUsersTestRegistry.js b/test/integration-tests/org/regularUsersTestRegistry.js index b8cc596a5..2dda89f20 100644 --- a/test/integration-tests/org/regularUsersTestRegistry.js +++ b/test/integration-tests/org/regularUsersTestRegistry.js @@ -398,6 +398,19 @@ describe('Testing regular user permissions for /api/registry/org/ endpoints with describe('Testing ORG PUT endpoint with /api/registry/org', () => { /* Negative Tests */ context('Negative Test', () => { + it('regular user cannot update their own organization', async () => { + const org = constants.nonSecretariatUserHeaders['CVE-API-ORG'] + await chai.request(app) + .put(`/api/registry/org/${org}`) + .set(constants.nonSecretariatUserHeaders) + .send({ + }) + .then((res) => { + expect(res).to.have.status(403) + expect(res.body.error).to.contain('NOT_ORG_ADMIN_OR_SECRETARIAT_UPDATE') + expect(res.body.message).to.equal('Organizations can only be updated by the Secretariat or an Org Admin.') + }) + }) it('regular user cannot update an organization', async () => { const org = faker.datatype.uuid().slice(0, MAX_SHORTNAME_LENGTH) await chai.request(app) diff --git a/test/integration-tests/registry-org/registryOrgCRUDTest.js b/test/integration-tests/registry-org/registryOrgCRUDTest.js index 4673325ec..c28df9910 100644 --- a/test/integration-tests/registry-org/registryOrgCRUDTest.js +++ b/test/integration-tests/registry-org/registryOrgCRUDTest.js @@ -2,18 +2,20 @@ const chai = require('chai') const expect = chai.expect chai.use(require('chai-http')) +const { v4: uuidv4 } = require('uuid') const constants = require('../constants.js') const app = require('../../../src/index.js') const secretariatHeaders = { ...constants.headers, 'content-type': 'application/json' } +const namedSecretariatHeaders = { ...secretariatHeaders, 'CVE-API-USER': 'cps@mitre.org' } const testRegistryOrg = { short_name: 'registry_org_test', long_name: 'Registry Org Test', authority: ['CNA'], id_quota: 1000, - partner_role_type: 'Vendor', + partner_role_type: ['Vendor'], partner_number: 'Initial Partner Number', partner_country: 'US', advisory_locations: ['https://example.com/advisories'], @@ -21,6 +23,19 @@ const testRegistryOrg = { } let createdOrg +function expectResponseToExcludeValues (responseBody, values) { + const serializedResponse = JSON.stringify(responseBody) + Array.from(values).filter(Boolean).forEach(value => { + expect(serializedResponse).to.not.include(value) + }) +} + +function expectConversationWithoutAuthorId (conversations, body) { + const convo = conversations.find(c => c.body === body) + expect(convo).to.not.be.undefined + expect(convo).to.not.have.property('author_id') +} + describe('Testing /registryOrg endpoints', () => { context('Testing POST /registryOrg endpoint', () => { context('Positive Tests', () => { @@ -53,7 +68,7 @@ describe('Testing /registryOrg endpoints', () => { expect(res.body.created.id_quota).to.equal(testRegistryOrg.id_quota) expect(res.body.created).to.haveOwnProperty('partner_role_type') - expect(res.body.created.partner_role_type).to.equal(testRegistryOrg.partner_role_type) + expect(res.body.created.partner_role_type).to.deep.equal(testRegistryOrg.partner_role_type) expect(res.body.created).to.haveOwnProperty('partner_number') expect(res.body.created.partner_number).to.equal(testRegistryOrg.partner_number) @@ -232,6 +247,22 @@ describe('Testing /registryOrg endpoints', () => { expect(res.body.errors[0].params.additionalProperty).to.equal('soft_quota') }) }) + it('Fails to create a new registry organization with hard_quota provided', async () => { + await chai.request(app) + .post('/api/registry/org') + .set(secretariatHeaders) + .send({ + ...testRegistryOrg, + short_name: 'test_create_hard_quota', + hard_quota: 100 + }) + .then((res) => { + expect(res).to.have.status(400) + expect(res.body.message).to.equal('Parameters were invalid') + expect(res.body.errors[0].message).to.equal('must NOT have additional properties') + expect(res.body.errors[0].params.additionalProperty).to.equal('hard_quota') + }) + }) it('Fails to create a new registry organization with an erroneous key not found in the schema', async () => { await chai.request(app) .post('/api/registry/org') @@ -253,13 +284,13 @@ describe('Testing /registryOrg endpoints', () => { .send({ ...testRegistryOrg, short_name: 'test_create_invalid_enum', - partner_role_type: 'Invalid Enum Value' + partner_role_type: ['Invalid Enum Value'] }) .then((res) => { expect(res).to.have.status(400) expect(res.body.message).to.equal('Parameters were invalid') expect(res.body.errors[0].message).to.equal('must be equal to one of the allowed values') - expect(res.body.errors[0].instancePath).to.equal('/partner_role_type') + expect(res.body.errors[0].instancePath).to.equal('/partner_role_type/0') }) }) it('Fails to create a new registry organization with an alias that collides with an existing short_name', async () => { @@ -305,37 +336,146 @@ describe('Testing /registryOrg endpoints', () => { expect(res.body).to.have.property('long_name', createdOrg.long_name) expect(res.body).to.have.property('short_name', createdOrg.short_name) expect(res.body.authority).to.be.an('array').that.includes('CNA') - expect(res.body).to.have.property('partner_role_type', createdOrg.partner_role_type) + expect(res.body).to.haveOwnProperty('partner_role_type') + expect(res.body.partner_role_type).to.deep.equal(createdOrg.partner_role_type) expect(res.body).to.have.property('partner_number', createdOrg.partner_number) expect(res.body).to.have.property('partner_country', createdOrg.partner_country) expect(res.body).to.have.property('partner_country', createdOrg.partner_country) expect(res.body.advisory_locations).to.deep.equal(createdOrg.advisory_locations) }) }) - it('Strips author_id from conversations for non-secretariats', async () => { + it('Gets a registry organization with expanded user metadata', async () => { + const uniqueSuffix = uuidv4().replace(/-/g, '').slice(0, 20) + const orgShortName = `expand_${uniqueSuffix}` + const username = `${orgShortName}@example.com` + let orgUUID + let createdUserUUID + + await chai.request(app) + .post('/api/registry/org') + .set(secretariatHeaders) + .send({ + short_name: orgShortName, + long_name: 'Expanded User Map Test Org', + authority: ['CNA'], + id_quota: 1000 + }) + .then((res) => { + expect(res).to.have.status(200) + orgUUID = res.body.created.UUID + }) + + await chai.request(app) + .post(`/api/registry/org/${orgShortName}/user`) + .set(secretariatHeaders) + .send({ + username, + name: { + first: 'Expanded', + last: 'User' + } + }) + .then((res) => { + expect(res).to.have.status(200) + createdUserUUID = res.body.created.UUID + }) + + const conversationRes = await chai.request(app) + .post(`/api/conversation/target/${orgUUID}`) + .set(secretariatHeaders) + .send({ + visibility: 'public', + body: 'This is a test conversation for expanded user metadata' + }) + + expect(conversationRes).to.have.status(200) + const secretariatUserUUID = conversationRes.body.author_id + + await chai.request(app) + .get(`/api/registry/org/${orgShortName}?expand=users`) + .set(secretariatHeaders) + .then((res) => { + expect(res).to.have.status(200) + expect(res.body).to.have.property('_userMap') + + expect(res.body._userMap).to.have.property(createdUserUUID) + expect(res.body._userMap[createdUserUUID]).to.deep.equal({ + username, + name: { + first: 'Expanded', + last: 'User' + }, + org: { + short_name: orgShortName + } + }) + + expect(res.body._userMap).to.have.property(secretariatUserUUID) + expect(res.body._userMap[secretariatUserUUID]).to.include({ + username: 'test_secretariat_0@mitre.org' + }) + expect(res.body._userMap[secretariatUserUUID].org).to.deep.equal({ + short_name: 'mitre' + }) + }) + }) + it('Controls user UUID exposure for regular users and org admins when getting an org with public conversations', async () => { // 1. Get win_5 org UUID let win5UUID + const userUUIDsVisibleToSecretariat = new Set() + const orgUserUUIDsVisibleToSecretariat = new Set() + const outsideOrgUserUUIDsVisibleToSecretariat = new Set() await chai.request(app) .get('/api/registry/org/win_5') .set(secretariatHeaders) .then((res) => { expect(res).to.have.status(200) win5UUID = res.body.UUID + if (Array.isArray(res.body.users)) { + res.body.users.forEach(userUUID => { + userUUIDsVisibleToSecretariat.add(userUUID) + orgUserUUIDsVisibleToSecretariat.add(userUUID) + }) + } + if (Array.isArray(res.body.admins)) { + res.body.admins.forEach(userUUID => { + userUUIDsVisibleToSecretariat.add(userUUID) + orgUserUUIDsVisibleToSecretariat.add(userUUID) + }) + } }) - // 2. Post a conversation as Secretariat to win_5 - const conversationBody = { + // 2. Post public conversations to win_5 + const secretariatConversationBody = { visibility: 'public', - body: 'This is a test conversation for author_id stripping' + body: `This is a test conversation for user UUID stripping ${uuidv4()}` } - let postedConvoUUID + const partnerConversationBody = { + body: `This is a partner conversation for user UUID stripping ${uuidv4()}` + } + let postedSecretariatConvoUUID + let postedPartnerConvoUUID await chai.request(app) .post(`/api/conversation/target/${win5UUID}`) - .set(secretariatHeaders) - .send(conversationBody) + .set(namedSecretariatHeaders) + .send(secretariatConversationBody) + .then((res) => { + expect(res).to.have.status(200) + postedSecretariatConvoUUID = res.body.UUID + expect(res.body).to.have.property('author_name', 'Secretariat') + userUUIDsVisibleToSecretariat.add(res.body.author_id) + outsideOrgUserUUIDsVisibleToSecretariat.add(res.body.author_id) + }) + + await chai.request(app) + .post(`/api/conversation/target/${win5UUID}`) + .set(constants.nonSecretariatUserHeaders2) + .send(partnerConversationBody) .then((res) => { expect(res).to.have.status(200) - postedConvoUUID = res.body.UUID + postedPartnerConvoUUID = res.body.UUID + userUUIDsVisibleToSecretariat.add(res.body.author_id) + orgUserUUIDsVisibleToSecretariat.add(res.body.author_id) }) // 3. GET win_5 as Secretariat, verify author_id is present @@ -345,23 +485,134 @@ describe('Testing /registryOrg endpoints', () => { .then((res) => { expect(res).to.have.status(200) expect(res.body.conversation).to.be.an('array') - const convo = res.body.conversation.find(c => c.UUID === postedConvoUUID) || res.body.conversation[res.body.conversation.length - 1] - expect(convo).to.have.property('author_id') - expect(convo).to.have.property('body', 'This is a test conversation for author_id stripping') + const secretariatConvo = res.body.conversation.find(c => c.UUID === postedSecretariatConvoUUID) + const partnerConvo = res.body.conversation.find(c => c.UUID === postedPartnerConvoUUID) + expect(secretariatConvo).to.have.property('author_id') + expect(secretariatConvo).to.have.property('author_name', 'Secretariat') + expect(secretariatConvo).to.have.property('body', secretariatConversationBody.body) + expect(partnerConvo).to.have.property('author_id') + expect(partnerConvo).to.have.property('body', partnerConversationBody.body) + userUUIDsVisibleToSecretariat.add(secretariatConvo.author_id) + outsideOrgUserUUIDsVisibleToSecretariat.add(secretariatConvo.author_id) + userUUIDsVisibleToSecretariat.add(partnerConvo.author_id) + orgUserUUIDsVisibleToSecretariat.add(partnerConvo.author_id) }) - // 4. GET win_5 as Non-Secretariat (author of win_5), verify author_id is stripped + // 4. GET win_5 as a regular Non-Secretariat, verify user UUIDs are stripped await chai.request(app) .get('/api/registry/org/win_5') .set(constants.nonSecretariatUserHeaders) .then((res) => { expect(res).to.have.status(200) + expect(res.body).to.not.have.property('users') + expect(res.body).to.not.have.property('admins') + expect(res.body).to.not.have.property('_userMap') expect(res.body.conversation).to.be.an('array') // Remember non-secretariats don't see the UUID field returned in conversation so we can't search by UUID! - // We just check the latest one or search by body. - const convo = res.body.conversation.find(c => c.body === 'This is a test conversation for author_id stripping') - expect(convo).to.not.be.undefined - expect(convo).to.not.have.property('author_id') + // We search by body instead. + expectConversationWithoutAuthorId(res.body.conversation, secretariatConversationBody.body) + expectConversationWithoutAuthorId(res.body.conversation, partnerConversationBody.body) + expectResponseToExcludeValues(res.body, userUUIDsVisibleToSecretariat) + }) + + // 5. GET win_5 with expand=users as a regular Non-Secretariat, verify the expansion does not expose user UUIDs + await chai.request(app) + .get('/api/registry/org/win_5?expand=users') + .set(constants.nonSecretariatUserHeaders) + .then((res) => { + expect(res).to.have.status(200) + expect(res.body).to.not.have.property('users') + expect(res.body).to.not.have.property('admins') + expect(res.body).to.not.have.property('_userMap') + expect(res.body.conversation).to.be.an('array') + expectConversationWithoutAuthorId(res.body.conversation, secretariatConversationBody.body) + expectConversationWithoutAuthorId(res.body.conversation, partnerConversationBody.body) + expectResponseToExcludeValues(res.body, userUUIDsVisibleToSecretariat) + }) + + // 6. GET win_5 with expand=users as an org admin, verify only same-org user UUIDs are expanded + await chai.request(app) + .get('/api/registry/org/win_5?expand=users') + .set(constants.nonSecretariatUserHeaders2) + .then((res) => { + expect(res).to.have.status(200) + expect(res.body).to.have.property('_userMap') + Array.from(orgUserUUIDsVisibleToSecretariat).forEach(userUUID => { + expect(res.body._userMap).to.have.property(userUUID) + }) + Array.from(outsideOrgUserUUIDsVisibleToSecretariat).forEach(userUUID => { + expect(res.body._userMap).to.not.have.property(userUUID) + }) + expect(res.body.conversation).to.be.an('array') + expectConversationWithoutAuthorId(res.body.conversation, secretariatConversationBody.body) + expectConversationWithoutAuthorId(res.body.conversation, partnerConversationBody.body) + expectResponseToExcludeValues(res.body, outsideOrgUserUUIDsVisibleToSecretariat) + }) + }) + it('Masks stale schema fields from registry org GET responses', async () => { + const BaseOrg = require('../../../src/model/baseorg') + const staleOrg = { + ...testRegistryOrg, + short_name: 'registry_org_stale_mask', + long_name: 'Registry Org Stale Mask' + } + + await chai.request(app) + .post('/api/registry/org') + .set(secretariatHeaders) + .send(staleOrg) + .then((res) => { + expect(res).to.have.status(200) + }) + + await BaseOrg.collection.updateOne( + { short_name: staleOrg.short_name }, + { + $set: { + 'program_data.advisory_location_require_credentials': true, + 'program_data.vulnerability_advisory_location_for_web_scraping': ['https://example.com/stale'], + users: ['d41d8cd9-8f00-3204-a980-0998ecf8427e'], + admins: ['d41d8cd9-8f00-3204-a980-0998ecf8427e'] + } + } + ) + + let orgFromList + await chai.request(app) + .get('/api/registry/org') + .set(secretariatHeaders) + .then((res) => { + expect(res).to.have.status(200) + orgFromList = res.body.organizations.find(org => org.short_name === staleOrg.short_name) + expect(orgFromList).to.not.be.undefined + expect(orgFromList).to.have.property('created') + expect(orgFromList).to.have.property('last_updated') + expect(orgFromList.users).to.deep.equal(['d41d8cd9-8f00-3204-a980-0998ecf8427e']) + expect(orgFromList.admins).to.deep.equal(['d41d8cd9-8f00-3204-a980-0998ecf8427e']) + expect(orgFromList.program_data).to.not.have.property('advisory_location_require_credentials') + expect(orgFromList.program_data).to.not.have.property('vulnerability_advisory_location_for_web_scraping') + }) + + const putBody = { ...orgFromList } + delete putBody.created + delete putBody.last_updated + delete putBody.users + delete putBody.admins + delete putBody.conversation + delete putBody.reports_to + + await chai.request(app) + .put(`/api/registry/org/${staleOrg.short_name}`) + .set(secretariatHeaders) + .send({ + ...putBody, + long_name: 'Registry Org Stale Mask Updated' + }) + .then((res) => { + expect(res).to.have.status(200) + expect(res.body.updated.long_name).to.equal('Registry Org Stale Mask Updated') + expect(res.body.updated.program_data).to.not.have.property('advisory_location_require_credentials') + expect(res.body.updated.program_data).to.not.have.property('vulnerability_advisory_location_for_web_scraping') }) }) }) @@ -386,7 +637,7 @@ describe('Testing /registryOrg endpoints', () => { .send({ ...createdOrg, long_name: 'Registry Org Test Updated', - partner_role_type: 'Researcher', + partner_role_type: ['Researcher', 'Vendor'], partner_number: 'Updated Partner Number', partner_country: 'UK', advisory_locations: ['https://example.com/updated_advisories'] @@ -416,7 +667,7 @@ describe('Testing /registryOrg endpoints', () => { expect(res.body.updated.id_quota).to.equal(createdOrg.id_quota) expect(res.body.updated).to.haveOwnProperty('partner_role_type') - expect(res.body.updated.partner_role_type).to.equal('Researcher') + expect(res.body.updated.partner_role_type).to.deep.equal(['Researcher', 'Vendor']) expect(res.body.updated).to.haveOwnProperty('partner_number') expect(res.body.updated.partner_number).to.equal('Updated Partner Number') @@ -620,6 +871,10 @@ describe('Testing /registryOrg endpoints', () => { expect(res.body.updated.oversees).to.be.an('array').that.includes(createdSubOrgUUID) }) + const BaseOrg = require('../../../src/model/baseorg') + const registryOrgCheck = await BaseOrg.findOne({ short_name: createdOrg.short_name }) + expect(registryOrgCheck.oversees).to.be.an('array').that.includes(createdSubOrgUUID) + // Assert that the sub org dynamically returns reports_to matching the main org's UUID await chai.request(app) .get(`/api/registry/org/${subOrg.short_name}`) @@ -720,6 +975,19 @@ describe('Testing /registryOrg endpoints', () => { expect(res.body.message).to.equal("The organization cannot be renamed as 'mitre' because this shortname is used by another organization.") }) }) + it("Fails to update a registry organization's new_short_name to one that already exists", async () => { + await chai.request(app) + .put('/api/registry/org/registry_org_test') + .set(secretariatHeaders) + .send({ + ...createdOrg, + new_short_name: 'mitre' + }) + .then((res) => { + expect(res).to.have.status(400) + expect(res.body.message).to.equal("The organization cannot be renamed as 'mitre' because this shortname is used by another organization.") + }) + }) it('Fails to update a registry organization with an alias that collides with an existing short_name', async () => { await chai.request(app) .put('/api/registry/org/registry_org_test') @@ -747,19 +1015,36 @@ describe('Testing /registryOrg endpoints', () => { expect(res.body.message).to.equal('Parameters were invalid') }) }) + it('Fails to update a registry organization with lowercase authority and returns authority errors', async () => { + await chai.request(app) + .put('/api/registry/org/registry_org_test') + .set(secretariatHeaders) + .send({ + ...createdOrg, + authority: ['cna'] + }) + .then((res) => { + expect(res).to.have.status(400) + expect(res.body.message).to.equal('Parameters were invalid') + expect(res.body.errors).to.be.an('array') + expect(res.body.errors[0].instancePath).to.equal('/authority/0') + expect(res.body.errors[0].message).to.equal('must be equal to one of the allowed values') + expect(res.body.errors[0].params.allowedValues).to.include('CNA') + }) + }) it('Fails to update a registry organization providing an invalid partner_role_type enum value', async () => { await chai.request(app) .put('/api/registry/org/registry_org_test') .set(secretariatHeaders) .send({ ...createdOrg, - partner_role_type: 'Invalid Enum Value' + partner_role_type: ['Invalid Enum Value'] }) .then((res) => { expect(res).to.have.status(400) expect(res.body.message).to.equal('Parameters were invalid') expect(res.body.errors[0].message).to.equal('must be equal to one of the allowed values') - expect(res.body.errors[0].instancePath).to.equal('/partner_role_type') + expect(res.body.errors[0].instancePath).to.equal('/partner_role_type/0') }) }) it('Ignores protected fields such as users and admins during an update', async () => { diff --git a/test/integration-tests/registry-org/registryOrgWithJointReviewTest.js b/test/integration-tests/registry-org/registryOrgWithJointReviewTest.js index 7aa683ea9..1ae3fa977 100644 --- a/test/integration-tests/registry-org/registryOrgWithJointReviewTest.js +++ b/test/integration-tests/registry-org/registryOrgWithJointReviewTest.js @@ -28,6 +28,12 @@ const nonAdminHeadersAdvisory = { 'CVE-API-USER': 'drocca_admin_user_advisory' } +const nonAdminHeadersNewShortName = { + 'CVE-API-ORG': 'non_new_shortname_review', + 'content-type': 'application/json', + 'CVE-API-USER': 'drocca_admin_user_new_shortname' +} + const testRegistryOrgForReview = { short_name: 'non_secretariat_org', long_name: 'Non Secretariat Org', @@ -50,6 +56,13 @@ const testRegistryOrgForAdvisoryReview = { advisory_locations: ['https://example.com/advisories'] } +const testRegistryOrgForNewShortNameReview = { + short_name: 'non_new_shortname_review', + long_name: 'Non New Short Name Review Org', + authority: ['CNA'], + id_quota: 1000 +} + const testRegistryOrgAdminUser = { username: 'drocca_admin_user', active: 'true', @@ -92,6 +105,20 @@ const testRegistryOrgAdminUserAdvisory = { } } +const testRegistryOrgAdminUserNewShortName = { + username: 'drocca_admin_user_new_shortname', + active: 'true', + name: { + first: 'David', + last: 'Rocca', + middle: 'N', + suffix: 'I' + }, + authority: { + active_roles: ['Admin'] + } +} + describe('Testing Joint approval', () => { describe('Admin user attempts to edit a joint approval field', () => { let secret @@ -425,4 +452,81 @@ describe('Testing Joint approval', () => { }) }) }) + describe('Admin user attempts to rename with new_short_name', () => { + let secret + let orgUUID + const requestedShortName = 'non_new_shortname_requested' + + it('Create an org to use for new_short_name review testing', async () => { + await chai.request(app) + .post('/api/registry/org') + .set(secretariatHeaders) + .send(testRegistryOrgForNewShortNameReview) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(200) + expect(res.body.created.short_name).to.equal(testRegistryOrgForNewShortNameReview.short_name) + orgUUID = res.body.created.UUID + }) + }) + it('Create an admin user for the new_short_name review org', async () => { + await chai.request(app) + .post('/api/registry/org/non_new_shortname_review/user') + .set(constants.headers) + .send(testRegistryOrgAdminUserNewShortName) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(200) + expect(res.body.created.username).to.equal(testRegistryOrgAdminUserNewShortName.username) + secret = res.body.created.secret + nonAdminHeadersNewShortName['CVE-API-KEY'] = secret + }) + }) + it('Admin requests a new_short_name change for joint approval', async () => { + await chai.request(app) + .put('/api/registry/org/non_new_shortname_review') + .set(nonAdminHeadersNewShortName) + .send({ + ...testRegistryOrgForNewShortNameReview, + new_short_name: requestedShortName, + contact_info: { websites: ['https://www.example.com/new-short-name'] } + }) + .then((res) => { + expect(res).to.have.status(200) + expect(res.body.message).to.contain('organization was successfully updated, but joint approval is required for some fields.') + expect(res.body.updated.UUID).to.equal(orgUUID) + expect(res.body.updated.short_name).to.equal(testRegistryOrgForNewShortNameReview.short_name) + expect(res.body.updated.contact_info.websites[0]).to.equal('https://www.example.com/new-short-name') + }) + }) + it('Check to see if the new_short_name review was created', async () => { + await chai.request(app) + .get(`/api/review/org/${orgUUID}`) + .set(secretariatHeaders) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(200) + expect(res.body).to.have.property('status', 'pending') + expect(res.body.target_object_uuid).to.equal(orgUUID) + expect(res.body.new_review_data.short_name).to.equal(requestedShortName) + }) + }) + it('Check to see if the org short name was not directly updated', async () => { + await chai.request(app) + .get('/api/registry/org/non_new_shortname_review') + .set(secretariatHeaders) + .then((res, err) => { + expect(err).to.be.undefined + expect(res).to.have.status(200) + expect(res.body.short_name).to.equal(testRegistryOrgForNewShortNameReview.short_name) + }) + + await chai.request(app) + .get(`/api/registry/org/${requestedShortName}`) + .set(secretariatHeaders) + .then((res) => { + expect(res).to.have.status(404) + }) + }) + }) }) diff --git a/test/integration-tests/registry-user/registryUserCRUDTest.js b/test/integration-tests/registry-user/registryUserCRUDTest.js index 84a76d36d..01c089c98 100644 --- a/test/integration-tests/registry-user/registryUserCRUDTest.js +++ b/test/integration-tests/registry-user/registryUserCRUDTest.js @@ -1,15 +1,222 @@ +/* eslint-disable no-unused-expressions */ + const chai = require('chai') const expect = chai.expect chai.use(require('chai-http')) +const sinon = require('sinon') +const { v4: uuidv4 } = require('uuid') const constants = require('../constants.js') const app = require('../../../src/index.js') +const logger = require('../../../src/middleware/logger') const secretariatHeaders = { ...constants.headers, 'content-type': 'application/json' } +const getLoggedPayload = (loggerInfoStub, action) => { + return loggerInfoStub.getCalls() + .map(call => call.args[0]) + .filter(arg => typeof arg === 'string') + .map((arg) => { + try { + return JSON.parse(arg) + } catch { + return null + } + }) + .find(payload => payload?.action === action) +} + +const postNewOrg = async (shortName) => { + return chai.request(app) + .post('/api/registry/org') + .set(secretariatHeaders) + .send({ + short_name: shortName, + long_name: shortName, + authority: ['CNA'], + id_quota: 1000 + }) +} + +const postNewUser = async (orgShortName, username) => { + return chai.request(app) + .post(`/api/registryUser/${orgShortName}`) + .set(secretariatHeaders) + .send({ + username, + name: { + first: 'Registry', + last: 'User' + }, + status: 'active' + }) +} + +const createRegistryUser = async () => { + const orgShortName = `reguser${uuidv4().replace(/-/g, '').slice(0, 12)}` + const username = `user_${uuidv4().replace(/-/g, '').slice(0, 12)}@example.com` + + await postNewOrg(orgShortName) + .then((res) => { + expect(res).to.have.status(200) + }) + + let createdUser + await postNewUser(orgShortName, username) + .then((res) => { + expect(res).to.have.status(200) + createdUser = res.body.created + }) + + return { orgShortName, createdUser } +} + describe('Testing /registryUser endpoints', () => { context('Positive Tests', () => { - // TODO + it('Gets a list of all registry users', async () => { + await chai.request(app) + .get('/api/registryUser') + .set(secretariatHeaders) + .then((res) => { + expect(res).to.have.status(200) + expect(res.body).to.have.property('users') + expect(res.body.users).to.be.an('array').that.is.not.empty + }) + }) + + it('Gets a registry user by UUID', async () => { + let user + await chai.request(app) + .get('/api/registry/org/win_5/user/jasminesmith@win_5.com') + .set(secretariatHeaders) + .then((res) => { + expect(res).to.have.status(200) + user = res.body + }) + + await chai.request(app) + .get(`/api/registryUser/${user.UUID}`) + .set(secretariatHeaders) + .then((res) => { + expect(res).to.have.status(200) + expect(res.body).to.have.property('UUID', user.UUID) + expect(res.body).to.have.property('username', user.username) + expect(res.body).to.not.have.property('secret') + }) + }) + + it('Creates, updates, and deletes a registry user by UUID', async () => { + const username = `${uuidv4()}@registry-user.test` + let userUUID + + await chai.request(app) + .post('/api/registryUser/range_4') + .set(secretariatHeaders) + .send({ + username, + name: { + first: 'Registry', + last: 'User' + }, + status: 'active' + }) + .then((res) => { + expect(res).to.have.status(200) + expect(res.body).to.have.property('created') + expect(res.body.created).to.have.property('UUID') + expect(res.body.created).to.have.property('username', username) + userUUID = res.body.created.UUID + }) + + let user + await chai.request(app) + .get(`/api/registryUser/${userUUID}`) + .set(secretariatHeaders) + .then((res) => { + expect(res).to.have.status(200) + user = res.body + }) + + await chai.request(app) + .put(`/api/registryUser/${userUUID}`) + .set(secretariatHeaders) + .send({ + ...user, + name: { + ...user.name, + first: 'UpdatedRegistry' + } + }) + .then((res) => { + expect(res).to.have.status(200) + expect(res.body.updated.name.first).to.equal('UpdatedRegistry') + }) + + await chai.request(app) + .delete(`/api/registryUser/${userUUID}`) + .set(secretariatHeaders) + .then((res) => { + expect(res).to.have.status(200) + expect(res.body.message).to.contain(`${username} was successfully deleted`) + }) + + await chai.request(app) + .get(`/api/registryUser/${userUUID}`) + .set(secretariatHeaders) + .then((res) => { + expect(res).to.have.status(404) + }) + }) + + it('Logs the updated user UUID when updating a registry user by identifier', async () => { + const { createdUser } = await createRegistryUser() + const loggerInfoStub = sinon.stub(logger, 'info') + + try { + await chai.request(app) + .put(`/api/registryUser/${createdUser.UUID}`) + .set(secretariatHeaders) + .send({ + UUID: createdUser.UUID, + username: createdUser.username, + name: { + first: 'Updated', + last: 'User' + }, + status: 'active' + }) + .then((res) => { + expect(res).to.have.status(200) + expect(res.body.updated.UUID).to.equal(createdUser.UUID) + }) + + const payload = getLoggedPayload(loggerInfoStub, 'update_registry_user') + expect(payload).to.not.equal(undefined) + expect(payload.user_UUID).to.equal(createdUser.UUID) + } finally { + loggerInfoStub.restore() + } + }) + + it('Logs the deleted user UUID when deleting a registry user by identifier', async () => { + const { createdUser } = await createRegistryUser() + const loggerInfoStub = sinon.stub(logger, 'info') + + try { + await chai.request(app) + .delete(`/api/registryUser/${createdUser.UUID}`) + .set(secretariatHeaders) + .then((res) => { + expect(res).to.have.status(200) + }) + + const payload = getLoggedPayload(loggerInfoStub, 'delete_registry_user') + expect(payload).to.not.equal(undefined) + expect(payload.user_UUID).to.equal(createdUser.UUID) + } finally { + loggerInfoStub.restore() + } + }) }) context('Negative Tests', () => { it('Fails when page query parameter is not an integer', async () => { diff --git a/test/integration-tests/review-object/reviewObjectTest.js b/test/integration-tests/review-object/reviewObjectTest.js index bf91fcf7d..de3511dfb 100644 --- a/test/integration-tests/review-object/reviewObjectTest.js +++ b/test/integration-tests/review-object/reviewObjectTest.js @@ -192,14 +192,14 @@ describe('Review Object Controller Integration Tests', () => { .post(`/api/conversation/target/${orgUUID}`) .set({ ...constants.headers }) .send({ body: 'Test comment on org history', visibility: 'public' }) - expect(msgRes).to.have.status(200) + expect(msgRes, JSON.stringify({ targetUUID: orgUUID, body: msgRes.body })).to.have.status(200) // 2. Fetch the review history with conversations included const res = await chai .request(app) .get(`/api/review/org/${constants.testRegistryOrg2.short_name}/reviews?include_conversations=true`) .set({ ...constants.headers }) - expect(res).to.have.status(200) + expect(res, JSON.stringify(res.body)).to.have.status(200) expect(res.body).to.have.property('reviewObjects') expect(res.body.reviewObjects).to.be.an('array') expect(res.body.reviewObjects.length).to.be.greaterThan(0) diff --git a/test/integration-tests/user/getUsersTest.js b/test/integration-tests/user/getUsersTest.js new file mode 100644 index 000000000..10f4a5401 --- /dev/null +++ b/test/integration-tests/user/getUsersTest.js @@ -0,0 +1,67 @@ +/* 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 secretariatHeaders = { ...constants.headers, 'content-type': 'application/json' } + +describe('Testing global user list endpoints', () => { + context('Positive Tests', () => { + it('Should get all registry users from /registry/users as Secretariat', async () => { + await chai.request(app) + .get('/api/registry/users') + .set(secretariatHeaders) + .then((res) => { + expect(res).to.have.status(200) + expect(res.body).to.have.property('users') + expect(res.body.users).to.be.an('array').that.is.not.empty + }) + }) + + it('Should get all users from /users as Secretariat', async () => { + await chai.request(app) + .get('/api/users') + .set(secretariatHeaders) + .then((res) => { + expect(res).to.have.status(200) + expect(res.body).to.have.property('users') + expect(res.body.users).to.be.an('array').that.is.not.empty + }) + }) + + it('Should get all registry users from /users with registry query as Secretariat', async () => { + await chai.request(app) + .get('/api/users?registry=true') + .set(secretariatHeaders) + .then((res) => { + expect(res).to.have.status(200) + expect(res.body).to.have.property('users') + expect(res.body.users).to.be.an('array').that.is.not.empty + }) + }) + }) + + context('Negative Tests', () => { + it('Should reject non-Secretariat requests to /registry/users', async () => { + await chai.request(app) + .get('/api/registry/users') + .set(constants.nonSecretariatUserHeaders) + .then((res) => { + expect(res).to.have.status(403) + }) + }) + + it('Should reject non-Secretariat requests to /users', async () => { + await chai.request(app) + .get('/api/users') + .set(constants.nonSecretariatUserHeaders) + .then((res) => { + expect(res).to.have.status(403) + }) + }) + }) +}) diff --git a/test/unit-tests/conversation/conversationRepositoryTest.js b/test/unit-tests/conversation/conversationRepositoryTest.js new file mode 100644 index 000000000..5865edeed --- /dev/null +++ b/test/unit-tests/conversation/conversationRepositoryTest.js @@ -0,0 +1,130 @@ +/* eslint-disable no-unused-expressions */ +const sinon = require('sinon') +const chai = require('chai') +const expect = chai.expect + +const ConversationModel = require('../../../src/model/conversation') +const ConversationRepository = require('../../../src/repositories/conversationRepository') + +describe('Testing Conversation Repository', () => { + afterEach(() => { + sinon.restore() + }) + + it('stores Secretariat as the author name for Secretariat-authored conversations', async () => { + sinon.stub(ConversationModel, 'findOne').resolves(null) + sinon.stub(ConversationModel.prototype, 'save').callsFake(async function () { + return this + }) + + const repo = new ConversationRepository() + const result = await repo.createConversation( + 'target-uuid', + { body: 'Secretariat comment', visibility: 'public' }, + { + UUID: 'secretariat-user-uuid', + name: { + first: 'Named', + last: 'Secretariat' + } + }, + true + ) + + expect(result.author_name).to.equal('Secretariat') + expect(result.author_role).to.equal('Secretariat') + }) + + it('stores the user full name for partner-authored conversations', async () => { + sinon.stub(ConversationModel, 'findOne').resolves(null) + sinon.stub(ConversationModel.prototype, 'save').callsFake(async function () { + return this + }) + + const repo = new ConversationRepository() + const result = await repo.createConversation( + 'target-uuid', + { body: 'Partner comment' }, + { + UUID: 'partner-user-uuid', + name: { + first: 'Partner', + last: 'User' + } + }, + false + ) + + expect(result.author_name).to.equal('Partner User') + expect(result.author_role).to.equal('Partner') + }) + + it('normalizes stored Secretariat author names when conversations are returned to Secretariat', async () => { + sinon.stub(ConversationModel, 'find').resolves([ + { + toObject: () => ({ + UUID: 'conversation-uuid', + target_uuid: 'target-uuid', + author_id: 'secretariat-user-uuid', + author_name: 'Named Secretariat', + author_role: 'Secretariat', + visibility: 'public', + body: 'Existing Secretariat comment' + }) + } + ]) + + const repo = new ConversationRepository() + const result = await repo.getAllByTargetUUID('target-uuid', true) + + expect(result).to.have.lengthOf(1) + expect(result[0].author_name).to.equal('Secretariat') + expect(result[0].author_id).to.equal('secretariat-user-uuid') + }) + + it('normalizes stored Secretariat author names when all conversations are returned', async () => { + const repo = new ConversationRepository() + sinon.stub(repo, 'aggregatePaginate').resolves({ + itemsList: [ + { + UUID: 'conversation-uuid', + target_uuid: 'target-uuid', + author_id: 'secretariat-user-uuid', + author_name: 'Named Secretariat', + author_role: 'Secretariat', + visibility: 'public', + body: 'Existing Secretariat comment' + } + ], + itemCount: 1 + }) + + const result = await repo.getAll({ limit: 10 }) + + expect(result.conversations).to.have.lengthOf(1) + expect(result.conversations[0].author_name).to.equal('Secretariat') + }) + + it('continues stripping Secretariat author fields when conversations are returned to non-Secretariat', async () => { + sinon.stub(ConversationModel, 'find').resolves([ + { + toObject: () => ({ + UUID: 'conversation-uuid', + target_uuid: 'target-uuid', + author_id: 'secretariat-user-uuid', + author_name: 'Named Secretariat', + author_role: 'Secretariat', + visibility: 'public', + body: 'Existing Secretariat comment' + }) + } + ]) + + const repo = new ConversationRepository() + const result = await repo.getAllByTargetUUID('target-uuid', false) + + expect(result).to.have.lengthOf(1) + expect(result[0]).to.not.have.property('author_name') + expect(result[0]).to.not.have.property('author_id') + }) +}) diff --git a/test/unit-tests/cve-id/cveIdGetSingleTest.js b/test/unit-tests/cve-id/cveIdGetSingleTest.js index ea80b1ad1..3453bff7d 100644 --- a/test/unit-tests/cve-id/cveIdGetSingleTest.js +++ b/test/unit-tests/cve-id/cveIdGetSingleTest.js @@ -266,6 +266,8 @@ describe('Testing the GET /cve-id/:id endpoint in CveId Controller', () => { } req.ctx.repositories = factory req.ctx.authenticated = true + req.ctx.orgUUID = cveIdFixtures.owningOrg.UUID + req.ctx.userUUID = cveIdFixtures.owningOrgUser.UUID next() }, cveIdParams.parseGetParams, cveIdController.CVEID_GET_SINGLE) @@ -333,6 +335,8 @@ describe('Testing the GET /cve-id/:id endpoint in CveId Controller', () => { } req.ctx.repositories = factory req.ctx.authenticated = true + req.ctx.orgUUID = cveIdFixtures.secretariatOrg.UUID + req.ctx.userUUID = cveIdFixtures.secretariatUser.UUID next() }, cveIdParams.parseGetParams, cveIdController.CVEID_GET_SINGLE) diff --git a/test/unit-tests/cve-id/reserveCveId.non-sequential/reserveCveIdTest.non-sequential.js b/test/unit-tests/cve-id/reserveCveId.non-sequential/reserveCveIdTest.non-sequential.js index 7d3c45968..18a8a47e2 100644 --- a/test/unit-tests/cve-id/reserveCveId.non-sequential/reserveCveIdTest.non-sequential.js +++ b/test/unit-tests/cve-id/reserveCveId.non-sequential/reserveCveIdTest.non-sequential.js @@ -19,6 +19,10 @@ const cveIdNonSeqFixtures = require('./mockObjects.non-sequential') const cveIdController = require('../../../../src/controller/cve-id.controller/cve-id.controller') const cveIdParams = require('../../../../src/controller/cve-id.controller/cve-id.middleware') +function setOrgAUUIDContext (req) { + req.ctx.orgUUID = cveIdNonSeqFixtures.orgA.UUID +} + class CveIdReservePoolIncremented10Ids { constructor () { this.docs = cveIdNonSeqFixtures.availableCveIds @@ -167,6 +171,7 @@ describe('Testing the non sequential reservation (Base Case) of POST /cve-id end getOrgRepository: () => { return new OrgReserveNonSequentialYearDoesntExist() } } req.ctx.repositories = factory + setOrgAUUIDContext(req) next() }, cveIdParams.parsePostParams, cveIdController.CVEID_RESERVE) @@ -237,6 +242,7 @@ describe('Testing the non sequential reservation (Base Case) of POST /cve-id end getOrgRepository: () => { return orgRepo } } req.ctx.repositories = factory + setOrgAUUIDContext(req) next() }, cveIdParams.parsePostParams, cveIdController.CVEID_RESERVE) diff --git a/test/unit-tests/cve-id/reserveCveId.non-sequential/reserveCveIdTest.usersA_B.non-sequential.js b/test/unit-tests/cve-id/reserveCveId.non-sequential/reserveCveIdTest.usersA_B.non-sequential.js index fee9b72eb..f4db7ddeb 100644 --- a/test/unit-tests/cve-id/reserveCveId.non-sequential/reserveCveIdTest.usersA_B.non-sequential.js +++ b/test/unit-tests/cve-id/reserveCveId.non-sequential/reserveCveIdTest.usersA_B.non-sequential.js @@ -20,6 +20,14 @@ const cveIdNonSeqFixtures = require('./mockObjects.non-sequential') const cveIdController = require('../../../../src/controller/cve-id.controller/cve-id.controller') const cveIdParams = require('../../../../src/controller/cve-id.controller/cve-id.middleware') +function setOrgAUUIDContext (req) { + req.ctx.orgUUID = cveIdNonSeqFixtures.orgA.UUID +} + +function setOrgBUUIDContext (req) { + req.ctx.orgUUID = cveIdNonSeqFixtures.orgB.UUID +} + class CveIdReservePoolIncremented10IdsCaseAB1 { constructor () { this.docs = cveIdNonSeqFixtures.availableCveIdsAB @@ -277,6 +285,7 @@ describe('Testing the non sequential reservation (Case AB) of POST /cve-id endpo getOrgRepository: () => { return orgRepo } } req.ctx.repositories = factory + setOrgAUUIDContext(req) next() }, cveIdParams.parsePostParams, cveIdController.CVEID_RESERVE) @@ -376,6 +385,7 @@ describe('Testing the non sequential reservation (Case AB) of POST /cve-id endpo getOrgRepository: () => { return new OrgReserveNonSequentialSuccessCaseAB() } } req.ctx.repositories = factory + setOrgBUUIDContext(req) next() }, cveIdParams.parsePostParams, cveIdController.CVEID_RESERVE) @@ -581,6 +591,7 @@ describe('Testing the non sequential reservation (Case AB) of POST /cve-id endpo getOrgRepository: () => { return new OrgReserveNonSequentialSuccessCaseAB() } } req.ctx.repositories = factory + setOrgAUUIDContext(req) next() }, cveIdParams.parsePostParams, cveIdController.CVEID_RESERVE) diff --git a/test/unit-tests/cve-id/reserveCveId/cveIdReservePriorityTest.js b/test/unit-tests/cve-id/reserveCveId/cveIdReservePriorityTest.js index 3fa3727e1..8933d337e 100644 --- a/test/unit-tests/cve-id/reserveCveId/cveIdReservePriorityTest.js +++ b/test/unit-tests/cve-id/reserveCveId/cveIdReservePriorityTest.js @@ -20,6 +20,10 @@ const cveIdFixtures = require('../mockObjects.cve-id') const cveIdController = require('../../../../src/controller/cve-id.controller/cve-id.controller') const cveIdParams = require('../../../../src/controller/cve-id.controller/cve-id.middleware') +function setOwningOrgUUIDContext (req) { + req.ctx.orgUUID = cveIdFixtures.owningOrg.UUID +} + class NullUserRepo { async getUserUUID () { return null @@ -191,6 +195,7 @@ describe('Testing the priority reservation of POST /cve-id endpoint in CveId Con getOrgRepository: () => { return new OrgReserveSequentialIsFull() } } req.ctx.repositories = factory + setOwningOrgUUIDContext(req) next() }, cveIdParams.parsePostParams, cveIdController.CVEID_RESERVE) @@ -256,6 +261,7 @@ describe('Testing the priority reservation of POST /cve-id endpoint in CveId Con getOrgRepository: () => { return new OrgReserveSequentialPriorityIsFull() } } req.ctx.repositories = factory + setOwningOrgUUIDContext(req) next() }, cveIdParams.parsePostParams, cveIdController.CVEID_RESERVE) @@ -327,6 +333,7 @@ describe('Testing the priority reservation of POST /cve-id endpoint in CveId Con getOrgRepository: () => { return new OrgReserveSequentialPriorityIsFull() } } req.ctx.repositories = factory + setOwningOrgUUIDContext(req) next() }, cveIdParams.parsePostParams, cveIdController.CVEID_RESERVE) diff --git a/test/unit-tests/cve-id/reserveCveId/cveIdReserveSequentialTest.js b/test/unit-tests/cve-id/reserveCveId/cveIdReserveSequentialTest.js index fb5f3385c..4b1e94687 100644 --- a/test/unit-tests/cve-id/reserveCveId/cveIdReserveSequentialTest.js +++ b/test/unit-tests/cve-id/reserveCveId/cveIdReserveSequentialTest.js @@ -20,6 +20,10 @@ const cveIdFixtures = require('../mockObjects.cve-id') const cveIdController = require('../../../../src/controller/cve-id.controller/cve-id.controller') const cveIdParams = require('../../../../src/controller/cve-id.controller/cve-id.middleware') +function setOwningOrgUUIDContext (req) { + req.ctx.orgUUID = cveIdFixtures.owningOrg.UUID +} + class NullUserRepo { async getUserUUID () { return null @@ -137,6 +141,7 @@ describe('Testing the sequential reservation of POST /cve-id endpoint in CveId C getOrgRepository: () => { return new OrgReserveYear2025RangeDoesntExistSequential() } } req.ctx.repositories = factory + setOwningOrgUUIDContext(req) next() }, cveIdParams.parsePostParams, cveIdController.CVEID_RESERVE) @@ -241,6 +246,7 @@ describe('Testing the sequential reservation of POST /cve-id endpoint in CveId C getOrgRepository: () => { return new OrgReserveSequentialPriorityIsFull() } } req.ctx.repositories = factory + setOwningOrgUUIDContext(req) next() }, cveIdParams.parsePostParams, cveIdController.CVEID_RESERVE) diff --git a/test/unit-tests/middleware/onlySecretariatMiddlewareTest.js b/test/unit-tests/middleware/onlySecretariatMiddlewareTest.js index df0bf44dd..9b578b7d8 100644 --- a/test/unit-tests/middleware/onlySecretariatMiddlewareTest.js +++ b/test/unit-tests/middleware/onlySecretariatMiddlewareTest.js @@ -59,6 +59,90 @@ describe('Test only Secretariat middleware', () => { }) context('Negative Tests', function () { + it('User is not a secretariat when authenticated org UUID resolves to a non-secretariat org', function (done) { + class OrgOnlySecretariatRejectByUUID { + async findOneByUUID () { + return mwFixtures.notSecretariatOrg + } + + isSecretariat () { + return false + } + + async isSecretariatByShortName () { + return true + } + } + + app.route('/only-secretariat-reject-by-uuid') + .post((req, res, next) => { + const factory = { + getOrgRepository: () => { return new OrgOnlySecretariatRejectByUUID() }, + getBaseOrgRepository: () => { return new OrgOnlySecretariatRejectByUUID() } + } + req.ctx.repositories = factory + req.ctx.orgUUID = mwFixtures.notSecretariatOrg.UUID + next() + }, middleware.onlySecretariat, (req, res) => { + return res.status(200).json({ message: 'Success! You have reached the target endpoint.' }) + }) + + chai.request(app) + .post('/only-secretariat-reject-by-uuid') + .set(mwFixtures.secretariatHeaders) + .send() + .end((err, res) => { + if (err) { + done(err) + } + + expect(res).to.have.status(403) + expect(res).to.have.property('body').and.to.be.a('object') + const errObj = error.secretariatOnly() + expect(res.body.error).to.equal(errObj.error) + expect(res.body.message).to.equal(errObj.message) + done() + }) + }) + + it('Authenticated request without an org UUID does not fall back to a short-name Secretariat check', function (done) { + class OrgOnlySecretariatRejectMissingUUID { + async isSecretariatByShortName () { + return true + } + } + + app.route('/only-secretariat-reject-missing-authenticated-uuid') + .post((req, res, next) => { + const factory = { + getOrgRepository: () => { return new OrgOnlySecretariatRejectMissingUUID() }, + getBaseOrgRepository: () => { return new OrgOnlySecretariatRejectMissingUUID() } + } + req.ctx.repositories = factory + req.ctx.authenticated = true + next() + }, middleware.onlySecretariat, (req, res) => { + return res.status(200).json({ message: 'Success! You have reached the target endpoint.' }) + }) + + chai.request(app) + .post('/only-secretariat-reject-missing-authenticated-uuid') + .set(mwFixtures.secretariatHeaders) + .send() + .end((err, res) => { + if (err) { + done(err) + } + + expect(res).to.have.status(403) + expect(res).to.have.property('body').and.to.be.a('object') + const errObj = error.secretariatOnly() + expect(res.body.error).to.equal(errObj.error) + expect(res.body.message).to.equal(errObj.message) + done() + }) + }) + it('User is not a secretariat', function (done) { class OrgOnlySecretariatReject { async isSecretariat () { diff --git a/test/unit-tests/middleware/onlySecretariatOrAdminMiddlewareTest.js b/test/unit-tests/middleware/onlySecretariatOrAdminMiddlewareTest.js index 6935305b0..f963ad402 100644 --- a/test/unit-tests/middleware/onlySecretariatOrAdminMiddlewareTest.js +++ b/test/unit-tests/middleware/onlySecretariatOrAdminMiddlewareTest.js @@ -116,6 +116,118 @@ describe('Test only Secretariat or Org Admin user middleware', () => { }) context('Negative Tests', function () { + it('User is not a secretariat or admin when authenticated UUIDs resolve to a non-privileged org/user', function (done) { + class OrgOnlySecretariatOrAdminRejectByUUID { + async findOneByUUID () { + return { + ...mwSecretariatOrAdminFixtures.notSecretariatOrg, + admins: [] + } + } + + isSecretariat () { + return false + } + + async isSecretariatByShortName () { + return true + } + } + + class UserOnlySecretariatOrAdminRejectByUUID { + async isAdmin () { + return true + } + } + + app.route('/only-secretariat-or-admin-reject-by-uuid') + .post((req, res, next) => { + const factory = { + getOrgRepository: () => { return new OrgOnlySecretariatOrAdminRejectByUUID() }, + getBaseOrgRepository: () => { return new OrgOnlySecretariatOrAdminRejectByUUID() }, + getUserRepository: () => { return new UserOnlySecretariatOrAdminRejectByUUID() }, + getBaseUserRepository: () => { return new UserOnlySecretariatOrAdminRejectByUUID() } + } + req.ctx.repositories = factory + req.ctx.orgUUID = mwSecretariatOrAdminFixtures.notSecretariatOrg.UUID + req.ctx.userUUID = mwSecretariatOrAdminFixtures.regularUser.UUID + next() + }, middleware.onlySecretariatOrAdmin, (req, res) => { + return res.status(200).json({ message: 'Success! You have reached the target endpoint.' }) + }) + + chai.request(app) + .post('/only-secretariat-or-admin-reject-by-uuid') + .set(mwSecretariatOrAdminFixtures.secretariatHeaders) + .send() + .end((err, res) => { + if (err) { + done(err) + } + + expect(res).to.have.status(403) + expect(res).to.have.property('body').and.to.be.a('object') + const errObj = error.notOrgAdminOrSecretariat() + expect(res.body.error).to.equal(errObj.error) + expect(res.body.message).to.equal(errObj.message) + done() + }) + }) + + it('Authenticated request without a user UUID does not fall back to a short-name admin check', function (done) { + class OrgOnlySecretariatOrAdminRejectMissingUserUUID { + async findOneByUUID () { + return { + ...mwSecretariatOrAdminFixtures.notSecretariatOrg, + admins: [] + } + } + + async isSecretariatByShortName () { + return true + } + } + + class UserOnlySecretariatOrAdminRejectMissingUserUUID { + async isAdmin () { + return true + } + } + + app.route('/only-secretariat-or-admin-reject-missing-authenticated-user-uuid') + .post((req, res, next) => { + const factory = { + getOrgRepository: () => { return new OrgOnlySecretariatOrAdminRejectMissingUserUUID() }, + getBaseOrgRepository: () => { return new OrgOnlySecretariatOrAdminRejectMissingUserUUID() }, + getUserRepository: () => { return new UserOnlySecretariatOrAdminRejectMissingUserUUID() }, + getBaseUserRepository: () => { return new UserOnlySecretariatOrAdminRejectMissingUserUUID() } + } + req.ctx.repositories = factory + req.ctx.authenticated = true + req.ctx.orgUUID = mwSecretariatOrAdminFixtures.notSecretariatOrg.UUID + next() + }, middleware.onlySecretariatOrAdmin, (req, res) => { + return res.status(200).json({ message: 'Success! You have reached the target endpoint.' }) + }) + + chai.request(app) + .post('/only-secretariat-or-admin-reject-missing-authenticated-user-uuid') + .set(mwSecretariatOrAdminFixtures.secretariatHeaders) + .send() + .end((err, res) => { + if (err) { + done(err) + } + + expect(res).to.have.status(403) + expect(res).to.have.property('body').and.to.be.a('object') + const errObj = error.notOrgAdminOrSecretariat() + expect(res.body.error).to.equal(errObj.error) + expect(res.body.message).to.equal(errObj.message) + done() + }) + }) + it('User is not a secretariat or an admin user', function (done) { app.route('/only-secretariat-or-admin-reject') .post((req, res, next) => { diff --git a/test/unit-tests/middleware/onlySecretariatOrBulkDownloadMiddlewareTest.js b/test/unit-tests/middleware/onlySecretariatOrBulkDownloadMiddlewareTest.js new file mode 100644 index 000000000..bf14a842f --- /dev/null +++ b/test/unit-tests/middleware/onlySecretariatOrBulkDownloadMiddlewareTest.js @@ -0,0 +1,83 @@ +const express = require('express') +const app = express() +const chai = require('chai') +const expect = chai.expect +chai.use(require('chai-http')) + +app.use(express.json()) +app.use(express.urlencoded({ extended: false })) +const middleware = require('../../../src/middleware/middleware') +app.use(middleware.createCtxAndReqUUID) + +const getConstants = require('../../../src/constants').getConstants +const errors = require('../../../src/middleware/error') +const error = new errors.MiddlewareError() + +const CONSTANTS = getConstants() +const headers = { + 'content-type': 'application/json', + [CONSTANTS.AUTH_HEADERS.ORG]: 'mitre', + [CONSTANTS.AUTH_HEADERS.USER]: 'not_secretariat_user', + [CONSTANTS.AUTH_HEADERS.KEY]: 'S96E4QT-SMT4YE3-KX03X6K-4615CED' +} + +describe('Test only Secretariat or Bulk Download middleware', () => { + context('Negative Tests', function () { + it('User is not a secretariat or bulk download org when authenticated org UUID resolves to a non-privileged org', function (done) { + class OrgOnlySecretariatOrBulkDownloadRejectByUUID { + async findOneByUUID () { + return { + UUID: '24ad129f-af00-4d8c-8f7b-e19b0587223f', + authority: [CONSTANTS.AUTH_ROLE_ENUM.CNA], + short_name: 'mitre' + } + } + + isSecretariat () { + return false + } + + isBulkDownload () { + return false + } + + async isSecretariatByShortName () { + return true + } + + async isBulkDownloadByShortname () { + return true + } + } + + app.route('/only-secretariat-or-bulk-download-reject-by-uuid') + .post((req, res, next) => { + const factory = { + getBaseOrgRepository: () => { return new OrgOnlySecretariatOrBulkDownloadRejectByUUID() } + } + req.ctx.repositories = factory + req.ctx.orgUUID = '24ad129f-af00-4d8c-8f7b-e19b0587223f' + next() + }, middleware.onlySecretariatOrBulkDownload, (req, res) => { + return res.status(200).json({ message: 'Success! You have reached the target endpoint.' }) + }) + + chai.request(app) + .post('/only-secretariat-or-bulk-download-reject-by-uuid') + .set(headers) + .send() + .end((err, res) => { + if (err) { + done(err) + } + + expect(res).to.have.status(403) + expect(res).to.have.property('body').and.to.be.a('object') + const errObj = error.secretariatOnly() + expect(res.body.error).to.equal(errObj.error) + expect(res.body.message).to.equal(errObj.message) + done() + }) + }) + }) +}) diff --git a/test/unit-tests/middleware/validateUserTest.js b/test/unit-tests/middleware/validateUserTest.js index 5309da6ce..b470b633c 100644 --- a/test/unit-tests/middleware/validateUserTest.js +++ b/test/unit-tests/middleware/validateUserTest.js @@ -350,7 +350,11 @@ describe('Testing the user validation middleware', () => { req.ctx.repositories = factory next() }, middleware.validateUser, (req, res) => { - return res.status(200).json({ message: 'Success! You have reached the target endpoint.' }) + return res.status(200).json({ + message: 'Success! You have reached the target endpoint.', + orgUUID: req.ctx.orgUUID, + userUUID: req.ctx.userUUID + }) }) chai.request(app) @@ -366,6 +370,8 @@ describe('Testing the user validation middleware', () => { expect(res).to.have.property('body').and.to.be.a('object') expect(res.body).to.have.property('message').and.to.be.a('string') expect(res.body.message).to.equal('Success! You have reached the target endpoint.') + expect(res.body.orgUUID).to.equal(mwFixtures.existentOrg.UUID) + expect(res.body.userUUID).to.equal(mwFixtures.existentUser.UUID) done() }) }) diff --git a/test/unit-tests/org/baseOrgRepositoryHelpersTest.js b/test/unit-tests/org/baseOrgRepositoryHelpersTest.js new file mode 100644 index 000000000..293a2f725 --- /dev/null +++ b/test/unit-tests/org/baseOrgRepositoryHelpersTest.js @@ -0,0 +1,279 @@ +const { expect } = require('chai') +const sinon = require('sinon') + +const helpers = require('../../../src/repositories/baseOrgRepositoryHelpers') +const AuditRepository = require('../../../src/repositories/auditRepository') +const ReviewObjectRepository = require('../../../src/repositories/reviewObjectRepository') +const BaseOrgModel = require('../../../src/model/baseorg') +const ADPOrgModel = require('../../../src/model/adporg') + +describe('Testing BaseOrgRepository helper functions', () => { + afterEach(() => { + sinon.restore() + }) + + context('mergeAllowedFields', () => { + it('Should preserve protected fields and replace arrays from incoming data', () => { + const targetDoc = { + toObject: () => ({ + UUID: 'original-uuid', + aliases: ['old-alias'], + long_name: 'Old Name' + }), + overwrite: sinon.stub().callsFake(arg => arg) + } + + const result = helpers.mergeAllowedFields(targetDoc, { + UUID: 'malicious-uuid', + aliases: ['new-alias'], + long_name: 'New Name' + }, ['UUID']) + + expect(result).to.deep.equal({ + UUID: 'original-uuid', + aliases: ['new-alias'], + long_name: 'New Name' + }) + expect(targetDoc.overwrite.calledOnce).to.equal(true) + }) + }) + + context('manageReviewObject', () => { + it('Should create a review object when joint approval fields change and none exists', async () => { + const createReviewOrgObject = sinon.stub(ReviewObjectRepository.prototype, 'createReviewOrgObject').resolves({}) + const registryOrg = { + toObject: () => ({ + UUID: 'org-uuid', + short_name: 'old_short_name', + long_name: 'Old Name' + }) + } + + await helpers.manageReviewObject( + registryOrg, + { short_name: 'new_short_name' }, + ['short_name'], + null, + 'requester@example.org', + {} + ) + + expect(createReviewOrgObject.calledOnce).to.equal(true) + expect(createReviewOrgObject.firstCall.args[0]).to.include({ + UUID: 'org-uuid', + short_name: 'new_short_name' + }) + expect(createReviewOrgObject.firstCall.args[1]).to.equal('requester@example.org') + }) + + it('Should reject an existing review object when joint approval fields no longer change', async () => { + const rejectReviewOrgObject = sinon.stub(ReviewObjectRepository.prototype, 'rejectReviewOrgObject').resolves({}) + const registryOrg = { + toObject: () => ({ + UUID: 'org-uuid', + short_name: 'same_short_name' + }) + } + + await helpers.manageReviewObject( + registryOrg, + { short_name: 'same_short_name' }, + ['short_name'], + { uuid: 'review-uuid' }, + 'requester@example.org', + {} + ) + + expect(rejectReviewOrgObject.calledOnce).to.equal(true) + expect(rejectReviewOrgObject.firstCall.args[0]).to.equal('review-uuid') + expect(rejectReviewOrgObject.firstCall.args[1]).to.equal('requester@example.org') + expect(rejectReviewOrgObject.firstCall.args[2]).to.deep.equal({}) + }) + }) + + context('processJointApprovalAndMerge', () => { + it('Should merge all allowed fields immediately for Secretariat users', async () => { + const registryDoc = { + toObject: () => ({ + UUID: 'registry-uuid', + short_name: 'old_registry', + users: ['existing-user'] + }), + overwrite: sinon.stub().callsFake(arg => arg) + } + const legacyDoc = { + toObject: () => ({ + UUID: 'legacy-uuid', + short_name: 'old_legacy' + }), + overwrite: sinon.stub().callsFake(arg => arg) + } + + const result = await helpers.processJointApprovalAndMerge( + registryDoc, + legacyDoc, + { + UUID: 'malicious-registry-uuid', + short_name: 'new_registry', + users: ['malicious-user'] + }, + { + UUID: 'malicious-legacy-uuid', + short_name: 'new_legacy' + }, + null, + true, + {}, + 'secretariat@example.org', + [], + [] + ) + + expect(result.updatedRegistryOrg).to.deep.equal({ + UUID: 'registry-uuid', + short_name: 'new_registry', + users: ['existing-user'] + }) + expect(result.updatedLegacyOrg).to.deep.equal({ + UUID: 'legacy-uuid', + short_name: 'new_legacy' + }) + }) + + it('Should defer joint approval fields and merge non-joint fields for non-Secretariat users', async () => { + const createReviewOrgObject = sinon.stub(ReviewObjectRepository.prototype, 'createReviewOrgObject').resolves({}) + const registryDoc = { + toObject: () => ({ + UUID: 'registry-uuid', + short_name: 'old_registry', + long_name: 'Old Registry Name' + }), + overwrite: sinon.stub().callsFake(arg => arg) + } + const legacyDoc = { + toObject: () => ({ + UUID: 'legacy-uuid', + short_name: 'old_legacy', + name: 'Old Legacy Name' + }), + overwrite: sinon.stub().callsFake(arg => arg) + } + + const result = await helpers.processJointApprovalAndMerge( + registryDoc, + legacyDoc, + { + short_name: 'new_registry', + long_name: 'New Registry Name' + }, + { + short_name: 'new_legacy', + name: 'New Legacy Name' + }, + null, + false, + {}, + 'admin@example.org', + ['short_name'], + ['short_name'] + ) + + expect(createReviewOrgObject.calledOnce).to.equal(true) + expect(result.updatedRegistryOrg).to.deep.equal({ + UUID: 'registry-uuid', + short_name: 'old_registry', + long_name: 'New Registry Name' + }) + expect(result.updatedLegacyOrg).to.deep.equal({ + UUID: 'legacy-uuid', + short_name: 'old_legacy', + name: 'New Legacy Name' + }) + }) + }) + + context('createAuditLogEntry', () => { + it('Should seed audit history and skip append when the org did not change', async () => { + sinon.stub(console, 'log') + const seedAuditHistoryForOrg = sinon.stub(AuditRepository.prototype, 'seedAuditHistoryForOrg').resolves({}) + const appendToAuditHistoryForOrg = sinon.stub(AuditRepository.prototype, 'appendToAuditHistoryForOrg').resolves({}) + const originalOrg = { + UUID: 'org-uuid', + short_name: 'same_org' + } + const registryOrg = { + UUID: 'org-uuid', + toObject: () => ({ + UUID: 'org-uuid', + short_name: 'same_org' + }) + } + + await helpers.createAuditLogEntry(registryOrg, originalOrg, 'requester-uuid', {}) + + expect(seedAuditHistoryForOrg.calledOnce).to.equal(true) + expect(appendToAuditHistoryForOrg.notCalled).to.equal(true) + }) + + it('Should append audit history when the org changed', async () => { + sinon.stub(console, 'log') + sinon.stub(AuditRepository.prototype, 'seedAuditHistoryForOrg').resolves({}) + const appendToAuditHistoryForOrg = sinon.stub(AuditRepository.prototype, 'appendToAuditHistoryForOrg').resolves({}) + const registryOrg = { + UUID: 'org-uuid', + toObject: () => ({ + UUID: 'org-uuid', + short_name: 'new_org' + }) + } + + await helpers.createAuditLogEntry(registryOrg, { + UUID: 'org-uuid', + short_name: 'old_org' + }, 'requester-uuid', {}) + + expect(appendToAuditHistoryForOrg.calledOnce).to.equal(true) + expect(appendToAuditHistoryForOrg.firstCall.args[0]).to.equal('org-uuid') + expect(appendToAuditHistoryForOrg.firstCall.args[2]).to.equal('requester-uuid') + }) + }) + + context('handleAuthorityModelChange', () => { + it('Should not recast the org when authority has not changed', async () => { + const deleteOne = sinon.stub(BaseOrgModel, 'deleteOne').resolves({}) + const org = { + authority: ['CNA'], + toObject: () => ({ authority: ['CNA'] }) + } + + const result = await helpers.handleAuthorityModelChange(org, ['CNA'], {}) + + expect(result).to.equal(org) + expect(deleteOne.notCalled).to.equal(true) + }) + + it('Should recast the org document when authority changes', async () => { + const deleteOne = sinon.stub(BaseOrgModel, 'deleteOne').resolves({}) + const save = sinon.stub(ADPOrgModel.prototype, 'save').resolves({}) + const org = { + _id: 'mongo-id', + authority: ['ADP'], + toObject: () => ({ + _id: 'mongo-id', + UUID: 'org-uuid', + short_name: 'adp_org', + long_name: 'ADP Org', + authority: ['ADP'] + }) + } + + const result = await helpers.handleAuthorityModelChange(org, ['CNA'], {}) + + expect(deleteOne.calledOnce).to.equal(true) + expect(deleteOne.firstCall.args[0]).to.deep.equal({ _id: 'mongo-id' }) + expect(deleteOne.firstCall.args[1]).to.deep.equal({}) + expect(save.calledOnce).to.equal(true) + expect(result.toObject().authority).to.deep.equal(['ADP']) + }) + }) +}) diff --git a/test/unit-tests/org/orgGetIdQuotaTest.js b/test/unit-tests/org/orgGetIdQuotaTest.js index a2908baaf..18170e82f 100644 --- a/test/unit-tests/org/orgGetIdQuotaTest.js +++ b/test/unit-tests/org/orgGetIdQuotaTest.js @@ -21,6 +21,12 @@ const orgFixtures = require('./mockObjects.org') const orgController = require('../../../src/controller/org.controller/org.controller') const orgParams = require('../../../src/controller/org.controller/org.middleware') +function setOwningOrgAuthContext (req) { + req.ctx.authenticated = true + req.ctx.orgUUID = orgFixtures.owningOrg.UUID + req.ctx.userUUID = orgFixtures.existentUserDummy.UUID +} + describe('Testing the GET /org/:shortname/id_quota endpoint in Org Controller', () => { let mockSession beforeEach(() => { @@ -247,6 +253,7 @@ describe('Testing the GET /org/:shortname/id_quota endpoint in Org Controller', getCveIdRepository: () => { return new CveIdOwnerIdQuota() } } req.ctx.repositories = factory + setOwningOrgAuthContext(req) next() }, orgParams.parseGetParams, orgController.ORG_ID_QUOTA) diff --git a/test/unit-tests/org/orgGetSingleTest.js b/test/unit-tests/org/orgGetSingleTest.js index 34308c792..06821217f 100644 --- a/test/unit-tests/org/orgGetSingleTest.js +++ b/test/unit-tests/org/orgGetSingleTest.js @@ -139,9 +139,14 @@ describe('Testing the GET /org/:identifier endpoint in Org Controller', () => { it('Non-secretariat can access same org by shortname', async () => { // Org exists and requester is a user of the same org + req.ctx.authenticated = true req.ctx.org = orgFixtures.targetOrg.short_name + req.ctx.orgUUID = orgFixtures.targetOrg.UUID + req.ctx.userUUID = 'same-org-user-uuid' req.ctx.params.identifier = orgFixtures.targetOrg.short_name sinon.stub(baseOrgRepo, 'findOneByShortName').resolves(fakeTargetOrgDocument) + sinon.stub(baseOrgRepo, 'findOneByUUID').resolves(fakeTargetOrgDocument) + sinon.stub(baseOrgRepo, 'getOrgUUID').resolves(orgFixtures.targetOrg.UUID) sinon.stub(baseOrgRepo, 'isSecretariat').resolves(false) sinon.stub(baseOrgRepo, 'getOrg').resolves(orgFixtures.targetOrg) @@ -152,9 +157,13 @@ describe('Testing the GET /org/:identifier endpoint in Org Controller', () => { }) it('Non-secretariat can access same org by UUID', async () => { + req.ctx.authenticated = true req.ctx.org = orgFixtures.targetOrg.short_name + req.ctx.orgUUID = orgFixtures.targetOrg.UUID + req.ctx.userUUID = 'same-org-user-uuid' req.ctx.params.identifier = orgFixtures.targetOrg.UUID sinon.stub(baseOrgRepo, 'findOneByShortName').resolves(fakeTargetOrgDocument) + sinon.stub(baseOrgRepo, 'findOneByUUID').resolves(fakeTargetOrgDocument) sinon.stub(baseOrgRepo, 'isSecretariat').resolves(false) sinon.stub(baseOrgRepo, 'getOrg').resolves(orgFixtures.targetOrg) diff --git a/test/unit-tests/org/orgUpdateTest.js b/test/unit-tests/org/orgUpdateTest.js index f34bf7c85..c706d955d 100644 --- a/test/unit-tests/org/orgUpdateTest.js +++ b/test/unit-tests/org/orgUpdateTest.js @@ -19,6 +19,7 @@ const error = new errors.OrgControllerError() const orgFixtures = require('./mockObjects.org') const orgController = require('../../../src/controller/org.controller/org.controller') const orgParams = require('../../../src/controller/org.controller/org.middleware') +const logger = require('../../../src/middleware/logger') class NullUserRepo { async getUserUUID () { @@ -229,6 +230,57 @@ describe('Testing the PUT /org/:shortname endpoint in Org Controller', () => { }) }) + it('Org update logs the audit payload with the requesting user UUID', async () => { + const requesterUserUUID = 'b48b4f8e-b82a-45ff-9e3b-3bf1534ad663' + const logStub = sinon.stub(logger, 'info') + const getUserUUID = sinon.stub().callsFake((username, orgShortName) => { + if (orgShortName === orgFixtures.owningOrg.UUID) { + throw new Error('Requester user UUID should not be looked up by the updated org UUID') + } + return requesterUserUUID + }) + const userRepo = { + getUserUUID, + findOneByUsernameAndOrgShortname: sinon.stub().resolves(null), + isAdmin: sinon.stub().resolves(false) + } + + app.route('/org-updated-logs-payload/:shortname') + .put((req, res, next) => { + const factory = { + getBaseOrgRepository: () => { return new OrgUpdatedAddingRole() }, + getBaseUserRepository: () => { return userRepo } + } + req.ctx.repositories = factory + next() + }, orgParams.parsePostParams, orgController.ORG_UPDATE_SINGLE) + + const res = await chai.request(app) + .put(`/org-updated-logs-payload/${orgFixtures.owningOrg.short_name}?active_roles.add=ROOT`) + .set(orgFixtures.secretariatHeader) + + const updateLogCall = logStub.getCalls().find(call => { + try { + return JSON.parse(call.args[0]).action === 'update_org' + } catch (err) { + return false + } + }) + + expect(res).to.have.status(200) + expect(getUserUUID.calledOnce).to.equal(true) + expect(getUserUUID.firstCall.args[0]).to.equal(orgFixtures.secretariatHeader['CVE-API-USER']) + expect(getUserUUID.firstCall.args[1]).to.equal(orgFixtures.secretariatHeader['CVE-API-ORG']) + expect(updateLogCall).to.not.equal(undefined) + const payload = JSON.parse(updateLogCall.args[0]) + expect(payload.action).to.equal('update_org') + expect(payload.change).to.equal(`${orgFixtures.owningOrg.short_name} organization was successfully updated.`) + expect(payload.org_UUID).to.equal(orgFixtures.owningOrg.UUID) + expect(payload.user_UUID).to.equal(requesterUserUUID) + expect(payload.req_UUID).to.be.a('string') + expect(payload.org.short_name).to.equal(orgFixtures.owningOrg.short_name) + }) + it('Org is unchanged: Adding a role that the org already have', (done) => { const CONSTANTS = getConstants() diff --git a/test/unit-tests/org/registryOrgControllerTest.js b/test/unit-tests/org/registryOrgControllerTest.js new file mode 100644 index 000000000..7a0291062 --- /dev/null +++ b/test/unit-tests/org/registryOrgControllerTest.js @@ -0,0 +1,212 @@ +const { expect } = require('chai') +const sinon = require('sinon') +const mongoose = require('mongoose') + +const controller = require('../../../src/controller/registry-org.controller/registry-org.controller') + +function mockResponse () { + const res = {} + res.status = sinon.stub().returns(res) + res.json = sinon.stub().returns(res) + return res +} + +function mockSession () { + return { + startTransaction: sinon.stub(), + abortTransaction: sinon.stub().resolves(), + commitTransaction: sinon.stub().resolves(), + endSession: sinon.stub().resolves(), + inTransaction: sinon.stub().returns(false) + } +} + +describe('Testing Registry Org Controller', () => { + afterEach(() => { + sinon.restore() + }) + + context('SINGLE_ORG', () => { + it('Should strip internal conversation fields for non-Secretariat users', async () => { + const res = mockResponse() + const requesterOrg = { UUID: 'org-uuid', short_name: 'activity_6', authority: ['CNA'] } + const orgRepo = { + getOrgUUID: sinon.stub().resolves(requesterOrg.UUID), + findOneByShortName: sinon.stub().resolves(requesterOrg), + findOneByUUID: sinon.stub().resolves(requesterOrg), + isSecretariat: sinon.stub().resolves(false), + getOrg: sinon.stub().resolves({ UUID: 'org-uuid', short_name: 'activity_6' }) + } + const userRepo = { + isAdmin: sinon.stub().resolves(false) + } + const conversationRepo = { + getAllByTargetUUID: sinon.stub().resolves([{ + UUID: 'conversation-uuid', + body: 'Visible body', + visibility: 'private', + target_uuid: 'org-uuid', + previous_conversation_uuid: null, + next_conversation_uuid: null, + _id: 'mongo-id', + __v: 0 + }]) + } + const req = { + ctx: { + authenticated: true, + uuid: 'request-uuid', + org: 'activity_6', + orgUUID: requesterOrg.UUID, + user: 'user@activity_6.com', + userUUID: 'user-uuid', + params: { identifier: 'activity_6' }, + repositories: { + getBaseOrgRepository: () => orgRepo, + getBaseUserRepository: () => userRepo, + getConversationRepository: () => conversationRepo + } + } + } + + await controller.SINGLE_ORG(req, res, sinon.stub()) + + expect(res.status.calledOnceWith(200)).to.equal(true) + const payload = res.json.firstCall.args[0] + expect(payload.conversation).to.have.lengthOf(1) + expect(payload.conversation[0]).to.have.property('body', 'Visible body') + expect(payload.conversation[0]).to.not.have.property('UUID') + expect(payload.conversation[0]).to.not.have.property('visibility') + expect(payload.conversation[0]).to.not.have.property('target_uuid') + expect(payload.conversation[0]).to.not.have.property('_id') + expect(payload.conversation[0]).to.not.have.property('__v') + expect(userRepo.isAdmin.calledOnceWith('user@activity_6.com', 'activity_6', {})).to.equal(true) + }) + + it('Should reject non-Secretariat access to another organization', async () => { + const res = mockResponse() + const requesterOrg = { UUID: 'requester-org-uuid', short_name: 'win_5', authority: ['CNA'] } + const orgRepo = { + getOrgUUID: sinon.stub().callsFake((shortName) => { + if (shortName === requesterOrg.short_name) return requesterOrg.UUID + if (shortName === 'activity_6') return 'activity-org-uuid' + return null + }), + findOneByShortName: sinon.stub().resolves(requesterOrg), + findOneByUUID: sinon.stub().resolves(requesterOrg), + isSecretariat: sinon.stub().resolves(false), + getOrg: sinon.stub() + } + const req = { + ctx: { + authenticated: true, + uuid: 'request-uuid', + org: 'win_5', + orgUUID: requesterOrg.UUID, + user: 'user@win_5.com', + userUUID: 'requester-user-uuid', + params: { identifier: 'activity_6' }, + repositories: { + getBaseOrgRepository: () => orgRepo, + getConversationRepository: () => ({}) + } + } + } + + await controller.SINGLE_ORG(req, res, sinon.stub()) + + expect(res.status.calledOnceWith(403)).to.equal(true) + expect(res.json.firstCall.args[0].error).to.equal('NOT_SAME_ORG_OR_SECRETARIAT') + expect(orgRepo.getOrg.notCalled).to.equal(true) + }) + }) + + context('UPDATE_ORG', () => { + it('Should reject attempts to change an existing organization UUID', async () => { + const session = mockSession() + sinon.stub(mongoose, 'startSession').resolves(session) + const res = mockResponse() + const orgRepo = { + isSecretariatByShortName: sinon.stub().resolves(true), + findOneByShortName: sinon.stub().resolves({ + UUID: 'stored-org-uuid', + short_name: 'activity_6' + }) + } + const userRepo = { + isAdmin: sinon.stub().resolves(false), + findOneByUsernameAndOrgShortname: sinon.stub().resolves({ UUID: 'requester-user-uuid' }) + } + const req = { + ctx: { + uuid: 'request-uuid', + org: 'mitre', + user: 'test_secretariat_0@mitre.org', + params: { shortname: 'activity_6' }, + body: { + UUID: 'different-org-uuid', + short_name: 'activity_6' + }, + repositories: { + getBaseOrgRepository: () => orgRepo, + getBaseUserRepository: () => userRepo, + getConversationRepository: () => ({}) + } + } + } + + await controller.UPDATE_ORG(req, res, sinon.stub()) + + expect(res.status.calledOnceWith(400)).to.equal(true) + expect(res.json.firstCall.args[0].error).to.equal('UUID_PROVIDED') + expect(session.abortTransaction.calledOnce).to.equal(true) + expect(session.endSession.calledOnce).to.equal(true) + }) + + it('Should update an existing pending review object when the org is not yet approved', async () => { + const session = mockSession() + sinon.stub(mongoose, 'startSession').resolves(session) + const res = mockResponse() + const orgRepo = { + isSecretariatByShortName: sinon.stub().resolves(true), + findOneByShortName: sinon.stub().resolves(null) + } + const userRepo = { + isAdmin: sinon.stub().resolves(false), + findOneByUsernameAndOrgShortname: sinon.stub().resolves({ UUID: 'requester-user-uuid' }) + } + const reviewRepo = { + getOrgReviewObjectByOrgShortname: sinon.stub().resolves({ uuid: 'review-uuid' }), + updateReviewOrgObject: sinon.stub().resolves({ uuid: 'review-uuid' }) + } + const req = { + ctx: { + uuid: 'request-uuid', + org: 'mitre', + user: 'test_secretariat_0@mitre.org', + params: { shortname: 'pending_org' }, + body: { + UUID: 'review-uuid', + short_name: 'pending_org', + long_name: 'Pending Org' + }, + repositories: { + getBaseOrgRepository: () => orgRepo, + getBaseUserRepository: () => userRepo, + getConversationRepository: () => ({}), + getReviewObjectRepository: () => reviewRepo + } + } + } + + await controller.UPDATE_ORG(req, res, sinon.stub()) + + expect(reviewRepo.updateReviewOrgObject.calledOnce).to.equal(true) + expect(reviewRepo.updateReviewOrgObject.firstCall.args[1]).to.equal('review-uuid') + expect(session.commitTransaction.calledOnce).to.equal(true) + expect(session.endSession.calledOnce).to.equal(true) + expect(res.status.calledOnceWith(200)).to.equal(true) + expect(res.json.firstCall.args[0].message).to.equal('Review object updated successfully') + }) + }) +}) diff --git a/test/unit-tests/org/registryOrgGetSingleTest.js b/test/unit-tests/org/registryOrgGetSingleTest.js new file mode 100644 index 000000000..f863248b9 --- /dev/null +++ b/test/unit-tests/org/registryOrgGetSingleTest.js @@ -0,0 +1,314 @@ +/* eslint-disable no-unused-expressions */ +const sinon = require('sinon') +const chai = require('chai') +const expect = chai.expect + +const { SINGLE_ORG } = require('../../../src/controller/registry-org.controller/registry-org.controller') + +describe('Testing the GET /registry/org/:identifier endpoint in Registry Org Controller', () => { + let status, json, res, next, orgRepo, conversationRepo, userRepo, req, conversations + + const requesterOrg = { + UUID: 'requester-org-uuid', + short_name: 'mitre', + authority: ['SECRETARIAT'] + } + + const targetOrg = { + UUID: 'target-org-uuid', + short_name: 'activity_6', + long_name: 'Activity Six', + authority: ['CNA'], + users: ['user-uuid', 'admin-uuid'], + admins: ['admin-uuid'], + contact_info: { + additional_contacts: ['contact-uuid'] + } + } + + beforeEach(() => { + status = sinon.stub() + json = sinon.spy() + res = { status, json } + status.returns(res) + next = sinon.spy() + + conversations = [ + { + UUID: 'conversation-uuid', + target_uuid: targetOrg.UUID, + author_id: 'secretariat-user-uuid', + author_name: 'Test Secretariat', + author_role: 'Secretariat', + visibility: 'public', + body: 'A public conversation message' + } + ] + + orgRepo = { + getOrgUUID: sinon.stub().callsFake((shortName) => { + if (shortName === requesterOrg.short_name) return requesterOrg.UUID + if (shortName === targetOrg.short_name) return targetOrg.UUID + return null + }), + findOneByShortName: sinon.stub().callsFake((shortName) => { + if (shortName === requesterOrg.short_name) return requesterOrg + if (shortName === targetOrg.short_name) return targetOrg + return null + }), + findOneByUUID: sinon.stub().callsFake((orgUUID) => { + if (orgUUID === requesterOrg.UUID) return requesterOrg + if (orgUUID === targetOrg.UUID) return targetOrg + return null + }), + isSecretariat: sinon.stub().resolves(true), + getOrg: sinon.stub().resolves({ ...targetOrg, contact_info: { ...targetOrg.contact_info } }), + findOrgsByUserUUIDs: sinon.stub().resolves([ + { + short_name: targetOrg.short_name, + users: ['user-uuid', 'admin-uuid', 'contact-uuid'] + }, + { + short_name: 'mitre', + users: ['secretariat-user-uuid'] + } + ]) + } + + conversationRepo = { + getAllByTargetUUID: sinon.stub().resolves(conversations) + } + + userRepo = { + isAdmin: sinon.stub().resolves(false), + findUsersByUUIDs: sinon.stub().resolves([ + { + UUID: 'user-uuid', + username: 'user@activity_6.com', + name: { first: 'Regular', last: 'User' } + }, + { + UUID: 'admin-uuid', + username: 'admin@activity_6.com', + name: { first: 'Admin', last: 'User' } + }, + { + UUID: 'contact-uuid', + username: 'contact@activity_6.com', + name: { first: 'Contact', last: 'User' } + }, + { + UUID: 'secretariat-user-uuid', + username: 'secretariat@mitre.org', + name: { first: 'Secretariat', last: 'User' } + } + ]) + } + + req = { + ctx: { + authenticated: true, + org: requesterOrg.short_name, + orgUUID: requesterOrg.UUID, + user: 'secretariat@mitre.org', + userUUID: 'secretariat-user-uuid', + uuid: 'request-uuid', + params: { + identifier: targetOrg.short_name + }, + query: {}, + repositories: { + getBaseOrgRepository: sinon.stub().returns(orgRepo), + getConversationRepository: sinon.stub().returns(conversationRepo), + getBaseUserRepository: sinon.stub().returns(userRepo) + } + } + } + }) + + afterEach(() => { + sinon.restore() + }) + + it('does not include _userMap when expand=users is not requested', async () => { + req.ctx.repositories.getBaseUserRepository = sinon.stub().throws(new Error('getBaseUserRepository should not be called')) + + await SINGLE_ORG(req, res, next) + + expect(next.called).to.be.false + expect(status.args[0][0]).to.equal(200) + expect(json.args[0][0]).to.not.have.property('_userMap') + expect(req.ctx.repositories.getBaseUserRepository.called).to.be.false + }) + + it('does not include _userMap for regular non-secretariats when expand=users is requested', async () => { + const sameOrgRequester = { + UUID: targetOrg.UUID, + short_name: targetOrg.short_name + } + req.ctx.org = targetOrg.short_name + req.ctx.orgUUID = targetOrg.UUID + req.ctx.user = 'user@activity_6.com' + req.ctx.userUUID = 'user-uuid' + req.ctx.query.expand = 'users' + orgRepo.findOneByShortName.withArgs(targetOrg.short_name).resolves(sameOrgRequester) + orgRepo.findOneByUUID.withArgs(targetOrg.UUID).resolves(sameOrgRequester) + orgRepo.isSecretariat.resolves(false) + userRepo.isAdmin.resolves(false) + + await SINGLE_ORG(req, res, next) + + expect(next.called).to.be.false + expect(status.args[0][0]).to.equal(200) + const responseBody = json.args[0][0] + expect(responseBody).to.not.have.property('_userMap') + expect(responseBody).to.not.have.property('users') + expect(responseBody).to.not.have.property('admins') + expect(responseBody.conversation[0]).to.not.have.property('author_id') + expect(userRepo.isAdmin.calledOnce).to.be.true + expect(userRepo.isAdmin.args[0][0]).to.equal(req.ctx.user) + expect(userRepo.isAdmin.args[0][1]).to.equal(targetOrg.short_name) + expect(userRepo.findUsersByUUIDs.called).to.be.false + expect(orgRepo.findOrgsByUserUUIDs.called).to.be.false + expect(orgRepo.getOrg.args[0][4]).to.equal(false) + expect(conversationRepo.getAllByTargetUUID.args[0][1]).to.equal(false) + }) + + it('includes users and admins for org admins when expand=users is not requested', async () => { + const sameOrgRequester = { + ...targetOrg, + contact_info: { ...targetOrg.contact_info } + } + req.ctx.org = targetOrg.short_name + req.ctx.orgUUID = targetOrg.UUID + req.ctx.user = 'admin@activity_6.com' + req.ctx.userUUID = 'admin-uuid' + orgRepo.findOneByShortName.withArgs(targetOrg.short_name).resolves(sameOrgRequester) + orgRepo.findOneByUUID.withArgs(targetOrg.UUID).resolves(sameOrgRequester) + orgRepo.isSecretariat.resolves(false) + userRepo.isAdmin.resolves(true) + + await SINGLE_ORG(req, res, next) + + expect(next.called).to.be.false + expect(status.args[0][0]).to.equal(200) + + const responseBody = json.args[0][0] + expect(responseBody).to.not.have.property('_userMap') + expect(responseBody.users).to.deep.equal(targetOrg.users) + expect(responseBody.admins).to.deep.equal(targetOrg.admins) + expect(responseBody.contact_info.additional_contacts).to.deep.equal(targetOrg.contact_info.additional_contacts) + expect(responseBody.conversation[0]).to.not.have.property('author_id') + expect(userRepo.isAdmin.calledOnce).to.be.true + expect(userRepo.isAdmin.args[0][0]).to.equal(req.ctx.user) + expect(userRepo.isAdmin.args[0][1]).to.equal(targetOrg.short_name) + expect(userRepo.findUsersByUUIDs.called).to.be.false + expect(orgRepo.findOrgsByUserUUIDs.called).to.be.false + }) + + it('includes _userMap for org admins when expand=users is requested for their org', async () => { + const sameOrgRequester = { + ...targetOrg, + contact_info: { ...targetOrg.contact_info } + } + req.ctx.org = targetOrg.short_name + req.ctx.orgUUID = targetOrg.UUID + req.ctx.user = 'admin@activity_6.com' + req.ctx.userUUID = 'admin-uuid' + req.ctx.query.expand = 'users' + orgRepo.findOneByShortName.withArgs(targetOrg.short_name).resolves(sameOrgRequester) + orgRepo.findOneByUUID.withArgs(targetOrg.UUID).resolves(sameOrgRequester) + orgRepo.isSecretariat.resolves(false) + userRepo.isAdmin.resolves(true) + + await SINGLE_ORG(req, res, next) + + expect(next.called).to.be.false + expect(status.args[0][0]).to.equal(200) + + const responseBody = json.args[0][0] + expect(responseBody).to.have.property('_userMap') + expect(responseBody.users).to.deep.equal(targetOrg.users) + expect(responseBody.admins).to.deep.equal(targetOrg.admins) + expect(responseBody.contact_info.additional_contacts).to.deep.equal(targetOrg.contact_info.additional_contacts) + expect(responseBody._userMap).to.deep.equal({ + 'user-uuid': { + username: 'user@activity_6.com', + name: { first: 'Regular', last: 'User' }, + org: { short_name: 'activity_6' } + }, + 'admin-uuid': { + username: 'admin@activity_6.com', + name: { first: 'Admin', last: 'User' }, + org: { short_name: 'activity_6' } + }, + 'contact-uuid': { + username: 'contact@activity_6.com', + name: { first: 'Contact', last: 'User' }, + org: { short_name: 'activity_6' } + } + }) + expect(responseBody._userMap).to.not.have.property('secretariat-user-uuid') + expect(responseBody.conversation[0]).to.not.have.property('author_id') + expect(userRepo.isAdmin.calledOnce).to.be.true + expect(userRepo.isAdmin.args[0][0]).to.equal(req.ctx.user) + expect(userRepo.isAdmin.args[0][1]).to.equal(targetOrg.short_name) + expect(userRepo.findUsersByUUIDs.args[0][0]).to.have.members([ + 'user-uuid', + 'admin-uuid', + 'contact-uuid' + ]) + expect(orgRepo.findOrgsByUserUUIDs.args[0][0]).to.have.members([ + 'user-uuid', + 'admin-uuid', + 'contact-uuid' + ]) + }) + + it('includes _userMap for org users, admins, contacts, and conversation authors when expand=users is requested', async () => { + req.ctx.query.expand = 'users' + + await SINGLE_ORG(req, res, next) + + expect(next.called).to.be.false + expect(status.args[0][0]).to.equal(200) + + const responseBody = json.args[0][0] + expect(responseBody).to.have.property('_userMap') + expect(responseBody._userMap).to.deep.equal({ + 'user-uuid': { + username: 'user@activity_6.com', + name: { first: 'Regular', last: 'User' }, + org: { short_name: 'activity_6' } + }, + 'admin-uuid': { + username: 'admin@activity_6.com', + name: { first: 'Admin', last: 'User' }, + org: { short_name: 'activity_6' } + }, + 'contact-uuid': { + username: 'contact@activity_6.com', + name: { first: 'Contact', last: 'User' }, + org: { short_name: 'activity_6' } + }, + 'secretariat-user-uuid': { + username: 'secretariat@mitre.org', + name: { first: 'Secretariat', last: 'User' }, + org: { short_name: 'mitre' } + } + }) + + expect(userRepo.findUsersByUUIDs.args[0][0]).to.have.members([ + 'user-uuid', + 'admin-uuid', + 'contact-uuid', + 'secretariat-user-uuid' + ]) + expect(orgRepo.findOrgsByUserUUIDs.args[0][0]).to.have.members([ + 'user-uuid', + 'admin-uuid', + 'contact-uuid', + 'secretariat-user-uuid' + ]) + }) +}) diff --git a/test/unit-tests/registry-org/registryOrgPayloadTest.js b/test/unit-tests/registry-org/registryOrgPayloadTest.js new file mode 100644 index 000000000..2451e726a --- /dev/null +++ b/test/unit-tests/registry-org/registryOrgPayloadTest.js @@ -0,0 +1,167 @@ +const chai = require('chai') +const sinon = require('sinon') +const mongoose = require('mongoose') +const expect = chai.expect + +const registryOrgController = require('../../../src/controller/registry-org.controller/registry-org.controller') +const logger = require('../../../src/middleware/logger') + +describe('Registry org payload logging', () => { + let mockSession + + beforeEach(() => { + mockSession = { + startTransaction: sinon.stub(), + commitTransaction: sinon.stub().resolves(), + abortTransaction: sinon.stub().resolves(), + endSession: sinon.stub().resolves() + } + sinon.stub(mongoose, 'startSession').resolves(mockSession) + sinon.stub(logger, 'info') + }) + + afterEach(() => { + sinon.restore() + }) + + function createResponse () { + return { + status: sinon.stub().returnsThis(), + json: sinon.stub().returnsThis() + } + } + + function getLoggedPayload (action) { + const logCall = logger.info.getCalls().find(call => { + try { + return JSON.parse(call.args[0]).action === action + } catch (err) { + return false + } + }) + + expect(logCall).to.not.equal(undefined) + return JSON.parse(logCall.args[0]) + } + + async function updateRegistryOrg (updatedOrg) { + const callerOrgUUID = '54f5fa83-86f6-44fd-b45a-290087680ac0' + const org = { + UUID: updatedOrg.UUID, + short_name: updatedOrg.short_name, + toObject: () => ({ ...updatedOrg }) + } + const repo = { + isSecretariatByShortName: sinon.stub().resolves(true), + findOneByShortName: sinon.stub().resolves(org), + validateOrg: sinon.stub().returns({ isValid: true }), + checkAliasCollisions: sinon.stub().resolves(null), + getOrgUUID: sinon.stub().callsFake(shortName => { + if (shortName === updatedOrg.short_name) return updatedOrg.UUID + if (shortName === 'mitre') return callerOrgUUID + return null + }), + updateOrgFull: sinon.stub().resolves(updatedOrg) + } + const userRepo = { + isAdmin: sinon.stub().resolves(false), + findOneByUsernameAndOrgShortname: sinon.stub().resolves({ UUID: 'requesting-user-uuid' }) + } + const conversationRepo = { + getAllByTargetUUID: sinon.stub().resolves([]) + } + const reviewRepo = { + getOrgReviewObjectByOrgShortname: sinon.stub().resolves(null) + } + const req = { + ctx: { + uuid: 'request-uuid', + org: 'mitre', + user: 'test_secretariat_0@mitre.org', + params: { shortname: updatedOrg.short_name }, + body: { + short_name: updatedOrg.short_name, + long_name: 'Target Org', + authority: ['CNA'] + }, + repositories: { + getBaseOrgRepository: () => repo, + getBaseUserRepository: () => userRepo, + getConversationRepository: () => conversationRepo, + getReviewObjectRepository: () => reviewRepo + } + } + } + const res = createResponse() + const next = sinon.stub() + + await registryOrgController.UPDATE_ORG(req, res, next) + + return { payload: getLoggedPayload('update_registry_org'), res, next, callerOrgUUID } + } + + it('logs the updated registry org UUID for update_registry_org', async () => { + const updatedOrg = { + UUID: 'cc5f9414-acf2-4e43-8071-67e8ea810113', + short_name: 'target_org', + authority: ['CNA'], + joint_approval_required: false + } + + const { payload, res, next, callerOrgUUID } = await updateRegistryOrg(updatedOrg) + expect(res.status.calledWith(200)).to.equal(true) + expect(next.notCalled).to.equal(true) + expect(payload.org_UUID).to.equal(updatedOrg.UUID) + expect(payload.org_UUID).to.not.equal(callerOrgUUID) + expect(payload.org.UUID).to.equal(updatedOrg.UUID) + }) + + it('logs the updated registry org UUID for update_registry_org when joint approval is required', async () => { + const updatedOrg = { + UUID: '895cdd1b-9825-4f10-b031-bffad6650c26', + short_name: 'target_org', + authority: ['CNA'], + joint_approval_required: true + } + + const { payload, res, next, callerOrgUUID } = await updateRegistryOrg(updatedOrg) + expect(res.status.calledWith(200)).to.equal(true) + expect(next.notCalled).to.equal(true) + expect(payload.org_UUID).to.equal(updatedOrg.UUID) + expect(payload.org_UUID).to.not.equal(callerOrgUUID) + expect(payload.org.UUID).to.equal(updatedOrg.UUID) + }) + + it('logs the deleted registry org UUID for delete_registry_org', async () => { + const targetOrg = { + UUID: '8db8e7ed-a43f-40b8-9ebb-d68aa8dc2216', + short_name: 'target_org' + } + const repo = { + findOneByShortName: sinon.stub().resolves(targetOrg), + deleteOrg: sinon.stub().resolves(), + getOrgUUID: sinon.stub().throws(new Error('delete payload should not look up the caller org UUID')) + } + const req = { + ctx: { + uuid: 'request-uuid', + org: 'mitre', + params: { identifier: targetOrg.short_name }, + repositories: { + getBaseOrgRepository: () => repo + } + } + } + const res = createResponse() + const next = sinon.stub() + + await registryOrgController.DELETE_ORG(req, res, next) + + const payload = getLoggedPayload('delete_registry_org') + expect(res.status.calledWith(200)).to.equal(true) + expect(next.notCalled).to.equal(true) + expect(repo.deleteOrg.calledWith(targetOrg.short_name)).to.equal(true) + expect(repo.getOrgUUID.notCalled).to.equal(true) + expect(payload.org_UUID).to.equal(targetOrg.UUID) + }) +}) diff --git a/test/unit-tests/review-object/review-object.controller.test.js b/test/unit-tests/review-object/review-object.controller.test.js index 4724b01c6..c836de4c6 100644 --- a/test/unit-tests/review-object/review-object.controller.test.js +++ b/test/unit-tests/review-object/review-object.controller.test.js @@ -346,9 +346,12 @@ describe('Review Object Controller', function () { }) it('should pass isSecretariat flag to repository', async () => { + const orgUUID = 'org-uuid' orgRepoStub.orgExists = sinon.stub().resolves(true) orgRepoStub.isSecretariatByShortName = sinon.stub().resolves(false) + orgRepoStub.getOrgUUID = sinon.stub().resolves(orgUUID) req.ctx.org = orgShortName + req.ctx.orgUUID = orgUUID repoStub.getReviewHistoryByOrgShortNamePaginated = sinon.stub().resolves({ reviewObjects: [], totalDocs: 0 }) await controller.getReviewHistoryByOrgShortNamePaginated(req, res, next) const callArgs = repoStub.getReviewHistoryByOrgShortNamePaginated.getCall(0).args diff --git a/test/unit-tests/user/registryUserControllerTest.js b/test/unit-tests/user/registryUserControllerTest.js new file mode 100644 index 000000000..1ceeba8a9 --- /dev/null +++ b/test/unit-tests/user/registryUserControllerTest.js @@ -0,0 +1,310 @@ +const { expect } = require('chai') +const sinon = require('sinon') +const mongoose = require('mongoose') + +const controller = require('../../../src/controller/registry-user.controller/registry-user.controller') + +function mockResponse () { + const res = {} + res.status = sinon.stub().returns(res) + res.json = sinon.stub().returns(res) + return res +} + +function mockSession () { + return { + startTransaction: sinon.stub(), + abortTransaction: sinon.stub().resolves(), + commitTransaction: sinon.stub().resolves(), + endSession: sinon.stub().resolves() + } +} + +describe('Testing Registry User Controller', () => { + afterEach(() => { + sinon.restore() + }) + + context('ALL_USERS', () => { + it('Should hydrate ADMIN role from organization admins', async () => { + const res = mockResponse() + const userRepo = { + getAllUsers: sinon.stub().resolves({ + users: [ + { UUID: 'admin-user-uuid', org_UUID: 'org-uuid' }, + { UUID: 'regular-user-uuid', org_UUID: 'org-uuid' } + ] + }) + } + const orgRepo = { + findOneByUUID: sinon.stub().resolves({ admins: ['admin-user-uuid'] }) + } + const req = { + ctx: { + uuid: 'request-uuid', + query: {}, + repositories: { + getBaseUserRepository: () => userRepo, + getBaseOrgRepository: () => orgRepo + } + } + } + + await controller.ALL_USERS(req, res, sinon.stub()) + + expect(res.status.calledOnceWith(200)).to.equal(true) + const payload = res.json.firstCall.args[0] + expect(payload.users[0]).to.have.property('role', 'ADMIN') + expect(payload.users[1]).to.not.have.property('role') + }) + }) + + context('SINGLE_USER', () => { + it('Should reject non-UUID identifiers on the utility endpoint', async () => { + const res = mockResponse() + const req = { + ctx: { + params: { identifier: 'not-a-uuid' } + } + } + + await controller.SINGLE_USER(req, res, sinon.stub()) + + expect(res.status.calledOnceWith(400)).to.equal(true) + expect(res.json.firstCall.args[0].error).to.equal('This function expects a UUID when called this way') + }) + }) + + context('CREATE_USER', () => { + it('Should reject user UUIDs in the request body', async () => { + sinon.stub(mongoose, 'startSession').resolves(mockSession()) + const res = mockResponse() + const orgRepo = { + getOrgUUID: sinon.stub().resolves('org-uuid') + } + const req = { + ctx: { + uuid: 'request-uuid', + body: { + UUID: 'provided-user-uuid', + username: 'created_user@example.org' + }, + params: { shortname: 'range_4' }, + repositories: { + getBaseOrgRepository: () => orgRepo, + getBaseUserRepository: () => ({}) + } + } + } + + await controller.CREATE_USER(req, res, sinon.stub()) + + expect(res.status.calledOnceWith(400)).to.equal(true) + expect(res.json.firstCall.args[0].error).to.equal('UUID_PROVIDED') + }) + + it('Should reject org UUIDs in the request body', async () => { + sinon.stub(mongoose, 'startSession').resolves(mockSession()) + const res = mockResponse() + const orgRepo = { + getOrgUUID: sinon.stub().resolves('org-uuid') + } + const req = { + ctx: { + uuid: 'request-uuid', + body: { + org_UUID: 'provided-org-uuid', + username: 'created_user@example.org' + }, + params: { shortname: 'range_4' }, + repositories: { + getBaseOrgRepository: () => orgRepo, + getBaseUserRepository: () => ({}) + } + } + } + + await controller.CREATE_USER(req, res, sinon.stub()) + + expect(res.status.calledOnceWith(400)).to.equal(true) + expect(res.json.firstCall.args[0].error).to.equal('UUID_PROVIDED') + }) + + it('Should reject duplicate users in the organization', async () => { + const session = mockSession() + sinon.stub(mongoose, 'startSession').resolves(session) + const res = mockResponse() + const orgRepo = { + getOrgUUID: sinon.stub().resolves('org-uuid') + } + const userRepo = { + validateUser: sinon.stub().resolves({ isValid: true }), + orgHasUser: sinon.stub().resolves(true) + } + const req = { + ctx: { + uuid: 'request-uuid', + body: { + username: 'created_user@example.org' + }, + params: { shortname: 'range_4' }, + repositories: { + getBaseOrgRepository: () => orgRepo, + getBaseUserRepository: () => userRepo + } + } + } + + await controller.CREATE_USER(req, res, sinon.stub()) + + expect(res.status.calledOnceWith(400)).to.equal(true) + expect(res.json.firstCall.args[0].error).to.equal('USER_EXISTS') + expect(session.abortTransaction.calledOnce).to.equal(true) + expect(session.endSession.calledOnce).to.equal(true) + }) + + it('Should reject creating users after the organization reaches the user limit', async () => { + const session = mockSession() + sinon.stub(mongoose, 'startSession').resolves(session) + const res = mockResponse() + const orgRepo = { + getOrgUUID: sinon.stub().resolves('org-uuid') + } + const userRepo = { + validateUser: sinon.stub().resolves({ isValid: true }), + orgHasUser: sinon.stub().resolves(false), + findUsersByOrgShortname: sinon.stub().resolves(Array.from({ length: 100 }, (_, index) => ({ UUID: `existing-user-${index}` }))) + } + const req = { + ctx: { + uuid: 'request-uuid', + body: { + username: 'created_user@example.org' + }, + params: { shortname: 'range_4' }, + repositories: { + getBaseOrgRepository: () => orgRepo, + getBaseUserRepository: () => userRepo + } + } + } + + await controller.CREATE_USER(req, res, sinon.stub()) + + expect(res.status.calledOnceWith(400)).to.equal(true) + expect(res.json.firstCall.args[0].error).to.equal('NUMBER_OF_USERS_IN_ORG_LIMIT_REACHED') + expect(session.abortTransaction.calledOnce).to.equal(true) + expect(session.endSession.calledOnce).to.equal(true) + }) + }) + + context('UPDATE_USER', () => { + it('Should reject non-UUID identifiers on the utility endpoint', async () => { + sinon.stub(mongoose, 'startSession').resolves(mockSession()) + const res = mockResponse() + const req = { + ctx: { + params: { identifier: 'username@example.org' } + } + } + + await controller.UPDATE_USER(req, res, sinon.stub()) + + expect(res.status.calledOnceWith(400)).to.equal(true) + expect(res.json.firstCall.args[0].error).to.equal('This function expects a UUID when called this way') + }) + + it('Should ignore immutable timestamps and update registry membership fields', async () => { + const session = mockSession() + sinon.stub(mongoose, 'startSession').resolves(session) + const res = mockResponse() + const userUUID = 'd41d8cd9-8f00-4204-a980-0998ecf8427e' + const orgUUID = '405450a6-8f00-4204-a980-0998ecf8427e' + const userToEdit = { + UUID: userUUID, + username: 'created_user@example.org', + org_UUID: orgUUID + } + const org = { + UUID: orgUUID, + short_name: 'range_4', + admins: [] + } + const body = { + UUID: userUUID, + username: 'created_user@example.org', + name: { + first: 'Registry', + last: 'User' + }, + status: 'active', + created: '2026-01-01T00:00:00.000Z', + last_updated: '2026-01-02T00:00:00.000Z' + } + const updatedUser = { + UUID: userUUID, + username: 'created_user@example.org', + name: body.name, + status: 'active' + } + const userRepo = { + isAdmin: sinon.stub().resolves(false), + findUserByUUID: sinon.stub().resolves(userToEdit), + validateUser: sinon.stub().returns({ isValid: true }), + getUserUUID: sinon.stub().resolves('requesting-user-uuid'), + updateUserFull: sinon.stub().resolves(updatedUser) + } + const orgRepo = { + isSecretariatByShortName: sinon.stub().resolves(true), + getOrgUUIDByUserUUID: sinon.stub().resolves(orgUUID), + findOneByUUID: sinon.stub().resolves(org) + } + const req = { + ctx: { + uuid: 'request-uuid', + user: 'secretariat@example.org', + org: 'mitre', + body, + params: { identifier: userUUID }, + repositories: { + getBaseOrgRepository: () => orgRepo, + getBaseUserRepository: () => userRepo + } + } + } + + await controller.UPDATE_USER(req, res, sinon.stub()) + + expect(res.status.calledOnceWith(200)).to.equal(true) + const validatedBody = userRepo.validateUser.firstCall.args[0] + expect(validatedBody).to.not.have.property('created') + expect(validatedBody).to.not.have.property('last_updated') + expect(userRepo.updateUserFull.firstCall.args[1]).to.equal(validatedBody) + expect(res.json.firstCall.args[0].updated).to.deep.equal(updatedUser) + }) + }) + + context('DELETE_USER', () => { + it('Should return 404 when the user does not exist', async () => { + const res = mockResponse() + const userRepo = { + findUserByUUID: sinon.stub().resolves(null) + } + const req = { + ctx: { + uuid: 'request-uuid', + params: { identifier: 'missing-user-uuid' }, + repositories: { + getBaseUserRepository: () => userRepo, + getBaseOrgRepository: () => ({}) + } + } + } + + await controller.DELETE_USER(req, res, sinon.stub()) + + expect(res.status.calledOnceWith(404)).to.equal(true) + expect(res.json.firstCall.args[0].error).to.equal('USER_DNE') + }) + }) +}) diff --git a/test/unit-tests/user/userGetAllTest.js b/test/unit-tests/user/userGetAllTest.js index 7d3441212..6c29b16fb 100644 --- a/test/unit-tests/user/userGetAllTest.js +++ b/test/unit-tests/user/userGetAllTest.js @@ -17,6 +17,12 @@ const orgFixtures = require('./mockObjects.user') const orgController = require('../../../src/controller/org.controller/org.controller') const orgParams = require('../../../src/controller/org.controller/org.middleware') +function setOwningOrgAuthContext (req) { + req.ctx.authenticated = true + req.ctx.orgUUID = orgFixtures.owningOrg.UUID + req.ctx.userUUID = orgFixtures.existentUserDummy.UUID +} + describe('Testing the GET /org/:shortname/users endpoint in Org Controller', () => { context('Negative Tests', () => { it('should return 404 not found because org does not exist', (done) => { @@ -134,6 +140,7 @@ describe('Testing the GET /org/:shortname/users endpoint in Org Controller', () getBaseUserRepository: () => { return new GetAllUsersByOrgShortname() } } req.ctx.repositories = factory + setOwningOrgAuthContext(req) next() }, orgParams.parseGetParams, orgController.USER_ALL) @@ -223,6 +230,7 @@ describe('Testing the GET /org/:shortname/users endpoint in Org Controller', () getBaseUserRepository: () => { return new GetAllUsersByOrgShortname() } } req.ctx.repositories = factory + setOwningOrgAuthContext(req) next() }, orgParams.parseGetParams, orgController.USER_ALL) @@ -279,6 +287,7 @@ describe('Testing the GET /org/:shortname/users endpoint in Org Controller', () getBaseUserRepository: () => { return new GetAllUsersByOrgShortname() } } req.ctx.repositories = factory + setOwningOrgAuthContext(req) // temporary fix for #920: force pagnation req.TEST_PAGINATOR_LIMIT = itemsPerPage next() @@ -335,6 +344,7 @@ describe('Testing the GET /org/:shortname/users endpoint in Org Controller', () getBaseUserRepository: () => { return new GetAllUsersByOrgShortname() } } req.ctx.repositories = factory + setOwningOrgAuthContext(req) // temporary fix for #920: force pagnation req.TEST_PAGINATOR_LIMIT = 3 next() @@ -381,6 +391,7 @@ describe('Testing the GET /org/:shortname/users endpoint in Org Controller', () getBaseUserRepository: () => { return new GetAllUsersByOrgShortname() } } req.ctx.repositories = factory + setOwningOrgAuthContext(req) next() }, orgParams.parseGetParams, orgController.USER_ALL) diff --git a/test/unit-tests/user/userGetSingleTest.js b/test/unit-tests/user/userGetSingleTest.js index 216ed5ee6..88a289dc8 100644 --- a/test/unit-tests/user/userGetSingleTest.js +++ b/test/unit-tests/user/userGetSingleTest.js @@ -173,6 +173,7 @@ describe('Testing the GET /org/:shortname/user/:username endpoint in Org Control it('User exists and the requester belongs to the user\'s org', async () => { sinon.stub(baseOrgRepo, 'isSecretariatByShortName').resolves(false) sinon.stub(baseOrgRepo, 'getOrgUUID').resolves(userFixtures.owningOrg.UUID) + sinon.stub(baseOrgRepo, 'findOneByUUID').resolves(userFixtures.owningOrg) const mockUserDoc = { ...userFixtures.existentUserDummy, @@ -187,8 +188,11 @@ describe('Testing the GET /org/:shortname/user/:username endpoint in Org Control const req = { ctx: { + authenticated: true, uuid: faker.datatype.uuid(), org: userFixtures.owningOrg.short_name, + orgUUID: userFixtures.owningOrg.UUID, + userUUID: userFixtures.existentUserDummy.UUID, params: { shortname: userFixtures.owningOrg.short_name, username: userFixtures.existentUserDummy.username diff --git a/test/unit-tests/user/userResetSecretTest.js b/test/unit-tests/user/userResetSecretTest.js index 49d66b02b..590acd0b8 100644 --- a/test/unit-tests/user/userResetSecretTest.js +++ b/test/unit-tests/user/userResetSecretTest.js @@ -169,6 +169,8 @@ describe('Testing the PUT /org/:shortname/user/:username/reset_secret endpoint', isAdminStub.resolves(false) isRegAdminStub.resolves(false) findOneUserStub.resolves(userFixtures.userC) + sinon.stub(baseOrgRepo, 'findOneByUUID').resolves(userFixtures.existentOrgDummy) + sinon.stub(baseUserRepo, 'findOneByUserNameAndOrgUUID').resolves(userFixtures.userA) regUserUUIDStub.onFirstCall().resolves(userFixtures.userC.UUID) regUserUUIDStub.onSecondCall().resolves(userFixtures.userA.UUID) @@ -176,6 +178,7 @@ describe('Testing the PUT /org/:shortname/user/:username/reset_secret endpoint', ctx: { uuid: faker.datatype.uuid(), org: userFixtures.existentOrgDummy.short_name, + orgUUID: userFixtures.existentOrgDummy.UUID, user: userFixtures.userA.username, repositories: { getOrgRepository, getUserRepository, getBaseOrgRepository, getBaseUserRepository }, params: { diff --git a/test/unit-tests/user/userUpdateTest.js b/test/unit-tests/user/userUpdateTest.js index d74e26816..1f7ce75c8 100644 --- a/test/unit-tests/user/userUpdateTest.js +++ b/test/unit-tests/user/userUpdateTest.js @@ -22,6 +22,10 @@ const userFixtures = require('./mockObjects.user') const orgController = require('../../../src/controller/org.controller/org.controller') const orgParams = require('../../../src/controller/org.controller/org.middleware') +function setGoogleOrgUUIDContext (req) { + req.ctx.orgUUID = userFixtures.existentOrgDummy.UUID +} + class OrgUserNotUpdatedOrgQueryDoesntExist { async getOrgUUID (shortname) { if (shortname === userFixtures.existentOrg.short_name) { @@ -369,7 +373,7 @@ describe('Testing the PUT /org/:shortname/user/:username endpoint in Org Control it('User is not updated because Org Admin is trying to change organization', (done) => { class Org { async getOrgUUID () { - return userFixtures.existentOrg.UUID + return userFixtures.existentOrgDummy.UUID } async isSecretariat () { @@ -393,6 +397,8 @@ describe('Testing the PUT /org/:shortname/user/:username endpoint in Org Control return userFixtures.existentUserDummy2.UUID } else if (shortname === userFixtures.existentUserDummy.username) { return userFixtures.existentUserDummy.UUID + } else if (shortname === userFixtures.userA.username) { + return userFixtures.userA.UUID } return null } @@ -411,6 +417,7 @@ describe('Testing the PUT /org/:shortname/user/:username endpoint in Org Control getUserRepository: () => { return new User() } } req.ctx.repositories = factory + setGoogleOrgUUIDContext(req) next() }, orgParams.parsePutParams, orgController.USER_UPDATE_SINGLE) @@ -458,6 +465,8 @@ describe('Testing the PUT /org/:shortname/user/:username endpoint in Org Control return userFixtures.existentUserDummy2.UUID } else if (shortname === userFixtures.existentUserDummy.username) { return userFixtures.existentUserDummy.UUID + } else if (shortname === userFixtures.userA.username) { + return userFixtures.userA.UUID } return null } @@ -525,6 +534,8 @@ describe('Testing the PUT /org/:shortname/user/:username endpoint in Org Control return userFixtures.existentUserDummy2.UUID } else if (shortname === userFixtures.existentUserDummy.username) { return userFixtures.existentUserDummy.UUID + } else if (shortname === userFixtures.userA.username) { + return userFixtures.userA.UUID } return null } @@ -545,6 +556,7 @@ describe('Testing the PUT /org/:shortname/user/:username endpoint in Org Control getUserRepository: () => { return new User() } } req.ctx.repositories = factory + setGoogleOrgUUIDContext(req) next() }, orgParams.parsePutParams, orgController.USER_UPDATE_SINGLE) @@ -946,7 +958,10 @@ describe('Testing the PUT /org/:shortname/user/:username endpoint in Org Control this.testRes1.active = false } - async findOneByUserNameAndOrgUUID () { + async findOneByUserNameAndOrgUUID (username) { + if (username === userFixtures.userD.username) { + return userFixtures.userD + } return userFixtures.userA } @@ -1001,6 +1016,7 @@ describe('Testing the PUT /org/:shortname/user/:username endpoint in Org Control getUserRepository: () => { return new User() } } req.ctx.repositories = factory + setGoogleOrgUUIDContext(req) next() }, orgParams.parsePostParams, orgController.USER_UPDATE_SINGLE) @@ -1104,6 +1120,7 @@ describe('Testing the PUT /org/:shortname/user/:username endpoint in Org Control getUserRepository: () => { return new User() } } req.ctx.repositories = factory + setGoogleOrgUUIDContext(req) next() }, orgParams.parsePostParams, orgController.USER_UPDATE_SINGLE) diff --git a/test/unit-tests/utils/dateOnlyTest.js b/test/unit-tests/utils/dateOnlyTest.js new file mode 100644 index 000000000..bc47d1045 --- /dev/null +++ b/test/unit-tests/utils/dateOnlyTest.js @@ -0,0 +1,61 @@ +const { expect } = require('chai') + +const { + isValidDateOnlyString, + normalizeDateOnlyInput, + normalizeDateOnlyOutput, + normalizeOrgCveWebsiteUpdateDate +} = require('../../../src/utils/dateOnly') + +describe('Testing date-only utilities', () => { + context('isValidDateOnlyString', () => { + it('Should return true for a valid date-only string', () => { + expect(isValidDateOnlyString('2026-06-04')).to.equal(true) + }) + + it('Should return false for invalid dates and non-date-only strings', () => { + expect(isValidDateOnlyString('2026-02-29')).to.equal(false) + expect(isValidDateOnlyString('2026-06-04T00:00:00.000Z')).to.equal(false) + expect(isValidDateOnlyString(null)).to.equal(false) + }) + }) + + context('normalizeDateOnlyInput', () => { + it('Should normalize Date and ISO date-time values to YYYY-MM-DD', () => { + expect(normalizeDateOnlyInput(new Date('2026-06-04T12:30:00.000Z'))).to.equal('2026-06-04') + expect(normalizeDateOnlyInput('2026-06-04T12:30:00.000Z')).to.equal('2026-06-04') + }) + + it('Should leave invalid dates and non-string values unchanged', () => { + expect(normalizeDateOnlyInput('2026-02-29T12:30:00.000Z')).to.equal('2026-02-29T12:30:00.000Z') + expect(normalizeDateOnlyInput(123)).to.equal(123) + }) + }) + + context('normalizeDateOnlyOutput', () => { + it('Should normalize Mongoose date strings to YYYY-MM-DD', () => { + const mongooseDate = 'Thu Jun 04 2026 00:00:00 GMT+0000 (Coordinated Universal Time)' + expect(normalizeDateOnlyOutput(mongooseDate)).to.equal('2026-06-04') + }) + }) + + context('normalizeOrgCveWebsiteUpdateDate', () => { + it('Should normalize program_data.cve_website_update_date in place', () => { + const org = { + program_data: { + cve_website_update_date: '2026-06-04T12:30:00.000Z' + } + } + + const result = normalizeOrgCveWebsiteUpdateDate(org) + + expect(result).to.equal(org) + expect(org.program_data.cve_website_update_date).to.equal('2026-06-04') + }) + + it('Should leave objects without the field unchanged', () => { + const org = { program_data: {} } + expect(normalizeOrgCveWebsiteUpdateDate(org)).to.deep.equal({ program_data: {} }) + }) + }) +}) diff --git a/test/unit-tests/utils/utilsTest.js b/test/unit-tests/utils/utilsTest.js new file mode 100644 index 000000000..e24bb73e9 --- /dev/null +++ b/test/unit-tests/utils/utilsTest.js @@ -0,0 +1,90 @@ +const { expect } = require('chai') + +const { + booleanIsTrue, + deepRemoveEmpty, + getUserFullName, + isEnrichedContainer, + toDate +} = require('../../../src/utils/utils') + +describe('Testing shared utility helpers', () => { + context('booleanIsTrue', () => { + it('Should return true for accepted true-like values', () => { + expect(booleanIsTrue('1')).to.equal(true) + expect(booleanIsTrue('true')).to.equal(true) + expect(booleanIsTrue('TRUE')).to.equal(true) + expect(booleanIsTrue('yes')).to.equal(true) + }) + + it('Should return false for other values', () => { + expect(booleanIsTrue('0')).to.equal(false) + expect(booleanIsTrue('false')).to.equal(false) + expect(booleanIsTrue('no')).to.equal(false) + }) + }) + + context('toDate', () => { + it('Should convert valid ISO timestamp strings to Date objects', () => { + const result = toDate('2026-06-04T12:30:00Z') + + expect(result).to.be.instanceOf(Date) + expect(result.toISOString()).to.equal('2026-06-04T12:30:00.000Z') + }) + + it('Should return null for invalid timestamp strings', () => { + expect(toDate('2026-13-04T12:30:00Z')).to.equal(null) + }) + }) + + context('isEnrichedContainer', () => { + it('Should return true when a container has CVSS and CWE data', () => { + const container = { + metrics: [{ cvssV3_1: {} }], + problemTypes: [{ descriptions: [{ cweId: 'CWE-79' }] }] + } + + expect(isEnrichedContainer(container)).to.equal(true) + }) + + it('Should return false when CVSS or CWE data is missing', () => { + expect(isEnrichedContainer({ metrics: [{ cvssV3_1: {} }], problemTypes: [] })).to.equal(false) + expect(isEnrichedContainer({ metrics: [], problemTypes: [{ descriptions: [{ cweId: 'CWE-79' }] }] })).to.equal(false) + }) + }) + + context('deepRemoveEmpty', () => { + it('Should remove null and empty object values without mutating the input', () => { + const input = { + keep: 'value', + removeNull: null, + nested: { + removeObject: {}, + keepNested: 'nested value' + } + } + + const result = deepRemoveEmpty(input) + + expect(result).to.deep.equal({ + keep: 'value', + nested: { + keepNested: 'nested value' + } + }) + expect(input).to.have.property('removeNull', null) + }) + }) + + context('getUserFullName', () => { + it('Should build a full name from first and last name', () => { + expect(getUserFullName({ name: { first: 'Test', last: 'User' } })).to.equal('Test User') + }) + + it('Should fall back to Unknown when name parts are missing', () => { + expect(getUserFullName({})).to.equal('Unknown User') + expect(getUserFullName({ name: { last: 'User' } })).to.equal('Unknown User') + expect(getUserFullName({ name: { first: 'Test' } })).to.equal('Test Unknown') + }) + }) +})