diff --git a/Dockerfile b/Dockerfile index 3b11d2243..840d3983d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,14 @@ COPY . . ARG TARGETOS ARG TARGETARCH +ARG VERSION=dev +ARG BUILDTIME=unknown +ARG REVISION=unknown + +# Make the OCI build metadata available while Angular's static assets are built. +ENV APP_VERSION=${VERSION} \ + GIT_COMMIT=${REVISION} \ + BUILD_DATE=${BUILDTIME} # Install dependencies RUN npm install --cpu ${TARGETARCH} --os ${TARGETOS} @@ -51,4 +59,4 @@ COPY --from=build /workspace/dist/app/browser /usr/share/nginx/html EXPOSE 80 443 # Command to run NGINX in foreground -CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file +CMD ["nginx", "-g", "daemon off;"] diff --git a/angular.json b/angular.json index f1ddc95f5..b4225314a 100644 --- a/angular.json +++ b/angular.json @@ -73,7 +73,7 @@ { "type": "initial", "maximumWarning": "3mb", - "maximumError": "5mb" + "maximumError": "6mb" }, { "type": "anyComponentStyle", diff --git a/docs/local-dev.md b/docs/local-dev.md index 6fdac153a..76a427de7 100644 --- a/docs/local-dev.md +++ b/docs/local-dev.md @@ -78,6 +78,23 @@ ng serve Open your browser and navigate to `http://localhost:4200` +### Build information + +`ng serve` uses `src/assets/build-info.json`, which contains development +fallbacks. The repository's `npm run build` and `npm run build-prod` commands +generate `dist/app/browser/assets/build-info.json` after Angular finishes. +Provide release metadata through the same variables used by the Docker build: + +```bash +APP_VERSION=4.20.0-beta.23 \ +GIT_COMMIT=c2c017c146fae040caba559333b35536bfbd1189 \ +BUILD_DATE=2026-08-05T15:13:49.915Z \ +npm run build-prod +``` + +If variables are omitted, the generated asset uses the package version and +reports its commit and build date as `unknown`. + ## Note: Recommended setup using Visual Studio Code Workspaces @@ -140,4 +157,4 @@ Example VS Code Workspace configuration: ] } } -``` \ No newline at end of file +``` diff --git a/docs/usage.md b/docs/usage.md index f7a0dc7c0..7d92911fe 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -2,6 +2,14 @@ The ATT&CK Workbench is a tool intended to allow the ATT&CK community to *explore*, *create*, *annotate* and *share* extensions of ATT&CK. +## Build Information + +The bottom of the primary navigation shows the release versions of the +Workbench frontend and the connected REST API. Hover over either version to +see the source commit and build date. If the REST API cannot be reached, its +version is shown as `unknown` without preventing the rest of the application +from loading. + ## Exploring ATT&CK When first instantiated, the ATT&CK Workbench will not include any data. You can use the application to build a custom dataset, or import data from a data provider such as MITRE using the collections browser. @@ -18,6 +26,14 @@ Objects may exist in multiple collections simultaneously, and objects can exist You can read more about the technical specifications for a collection, such as the STIX representation of a collection object, in our [collections](/docs/collections.md) document. MITRE's ATT&CK collections and collection index can be found on our [attack-stix-data GitHub repository](https://github.com/mitre-attack/attack-stix-data). +#### Releasing a Release-Track Snapshot + +The release preview offers minor and major relative tags as well as an exact `MAJOR.MINOR` version. Relative tags are calculated from the tagged snapshot immediately before the selected draft. When releasing an older draft, the exact version must also remain below the next tagged snapshot; the dialog shows these exclusive bounds. Optional release notes are stored on that snapshot and become the `x-mitre-collection` description in exported STIX bundles. + +The release-track page can export the latest snapshot or a selected historical snapshot as a STIX 2.0 bundle, a STIX 2.1 bundle, or Workbench JSON. Historical snapshot exports can also copy a concise summary. Tagged snapshots with a bundle cache use that pinned member graph for deterministic member-only exports in either STIX version. + +Each cached snapshot card displays server-generated SHA-256 hashes for the exact UTF-8 JSON files produced by its STIX 2.0 and STIX 2.1 bundle downloads. The adjacent copy buttons copy a hash for external file-integrity verification. Snapshot notes are locked while the bundle is cached; delete the cache, edit the notes, and cache the bundle again to generate matching hashes. + #### Adding a Collection Index Collection indexes can be added from the collections page. To add a collection index, specify the URL at which the index is found. The application will then provide a preview of the index for you to review before you save. You can also choose from the provided "recommended collection indexes" to quickly connect your Workbench instance to a data provider without having to find the URL yourself. The ATT&CK Workbench is pre-configured to recommend the MITRE ATT&CK collection index in the "add a collection index" interface. @@ -226,6 +242,8 @@ When creating and/or editing an object, you can add multiple statements and sele Any object in the knowledge base, except for marking definitions, can be edited, even those imported from collections. Clicking the "edit" button in the toolbar, or the "edit" link in an object list, will bring you to the edit interface for the object. While editing an object, relationships cannot be viewed or created since they are saved independently of the objects they connect. +Domain-bearing ATT&CK objects expose a Domain field that reads and writes the STIX `x_mitre_domains` list. This includes techniques, campaigns, mitigations, groups, software, analytics, assets, data components, data sources, detection strategies, matrices, and tactics. When an existing object is revised, its domains are retained unless the editor explicitly changes them. + #### Editing Matrices Matrices share the typical fields on objects, including a description supporting markdown, LinkByIds, and citations. Unlike other object types, their IDs serve as identifier for their domain: @@ -425,6 +443,7 @@ The source and target objects can be changed after the relationship has been cre Relationships also have a description to provide additional context or to hold citations of relevant reporting. Like all descriptions, those on relationships support citations, LinkByIds, and markdown formatting. Relationships between sub-techniques and techniques however are purely structural and do not support descriptions. +Saving a relationship creates a new relationship revision and returns its connected source and target objects to the *work in progress* workflow state through new SDO revisions. Previously published or snapshot-pinned SDO revisions remain unchanged. ### Revoking and Deprecating Objects @@ -527,4 +546,4 @@ Within the page showing the list of teams, there is an option to create a new te ### Editing a team -When viewing a team, click the edit icon in the toolbar to edit it. You can edit the name, description, or user list of a team at any time. \ No newline at end of file +When viewing a team, click the edit icon in the toolbar to edit it. You can edit the name, description, or user list of a team at any time. diff --git a/package-lock.json b/package-lock.json index 6d2ca3410..cbd0160d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,15 +20,13 @@ "@angular/platform-browser": "^19.2.25", "@angular/platform-browser-dynamic": "^19.2.25", "@angular/router": "^19.2.25", - "@mitre-attack/attack-data-model": "^5.0.0-beta.1", + "@mitre-attack/attack-data-model": "^5.0.0-beta.3", "@ng-matero/extensions": "^19.5.1", "diff-match-patch-ts": "^0.8.0", - "jdenticon": "^3.2.0", "lodash": "^4.17.21", "marked": "^15.0.11", "moment": "^2.29.4", "ngx-autosize": "^2.0.4", - "ngx-jdenticon": "^2.0.0", "ngx-logger": "^5.0.12", "ngx-markdown": "^19.1.1", "rxjs": "^7.8.1", @@ -4057,9 +4055,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", - "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", "dev": true, "funding": [ { @@ -4081,9 +4079,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", - "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", "dev": true, "funding": [ { @@ -4098,7 +4096,7 @@ "license": "MIT", "dependencies": { "@csstools/color-helpers": "^6.1.0", - "@csstools/css-calc": "^3.2.1" + "@csstools/css-calc": "^3.3.0" }, "engines": { "node": ">=20.19.0" @@ -4132,9 +4130,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", - "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", "dev": true, "funding": [ { @@ -4638,9 +4636,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", "dev": true, "license": "MIT", "dependencies": { @@ -7038,9 +7036,9 @@ } }, "node_modules/@parcel/watcher": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", - "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", + "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -7049,7 +7047,7 @@ "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">= 10.0.0" @@ -7059,25 +7057,24 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "@parcel/watcher-android-arm64": "2.5.6", - "@parcel/watcher-darwin-arm64": "2.5.6", - "@parcel/watcher-darwin-x64": "2.5.6", - "@parcel/watcher-freebsd-x64": "2.5.6", - "@parcel/watcher-linux-arm-glibc": "2.5.6", - "@parcel/watcher-linux-arm-musl": "2.5.6", - "@parcel/watcher-linux-arm64-glibc": "2.5.6", - "@parcel/watcher-linux-arm64-musl": "2.5.6", - "@parcel/watcher-linux-x64-glibc": "2.5.6", - "@parcel/watcher-linux-x64-musl": "2.5.6", - "@parcel/watcher-win32-arm64": "2.5.6", - "@parcel/watcher-win32-ia32": "2.5.6", - "@parcel/watcher-win32-x64": "2.5.6" + "@parcel/watcher-android-arm64": "2.6.0", + "@parcel/watcher-darwin-arm64": "2.6.0", + "@parcel/watcher-darwin-x64": "2.6.0", + "@parcel/watcher-freebsd-x64": "2.6.0", + "@parcel/watcher-linux-arm-glibc": "2.6.0", + "@parcel/watcher-linux-arm-musl": "2.6.0", + "@parcel/watcher-linux-arm64-glibc": "2.6.0", + "@parcel/watcher-linux-arm64-musl": "2.6.0", + "@parcel/watcher-linux-x64-glibc": "2.6.0", + "@parcel/watcher-linux-x64-musl": "2.6.0", + "@parcel/watcher-win32-arm64": "2.6.0", + "@parcel/watcher-win32-x64": "2.6.0" } }, "node_modules/@parcel/watcher-android-arm64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", - "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz", + "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==", "cpu": [ "arm64" ], @@ -7096,9 +7093,9 @@ } }, "node_modules/@parcel/watcher-darwin-arm64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", - "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz", + "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==", "cpu": [ "arm64" ], @@ -7117,9 +7114,9 @@ } }, "node_modules/@parcel/watcher-darwin-x64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", - "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz", + "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==", "cpu": [ "x64" ], @@ -7138,9 +7135,9 @@ } }, "node_modules/@parcel/watcher-freebsd-x64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", - "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz", + "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==", "cpu": [ "x64" ], @@ -7159,9 +7156,9 @@ } }, "node_modules/@parcel/watcher-linux-arm-glibc": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", - "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz", + "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==", "cpu": [ "arm" ], @@ -7180,9 +7177,9 @@ } }, "node_modules/@parcel/watcher-linux-arm-musl": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", - "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz", + "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==", "cpu": [ "arm" ], @@ -7201,9 +7198,9 @@ } }, "node_modules/@parcel/watcher-linux-arm64-glibc": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", - "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz", + "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==", "cpu": [ "arm64" ], @@ -7222,9 +7219,9 @@ } }, "node_modules/@parcel/watcher-linux-arm64-musl": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", - "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz", + "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==", "cpu": [ "arm64" ], @@ -7243,9 +7240,9 @@ } }, "node_modules/@parcel/watcher-linux-x64-glibc": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", - "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", + "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", "cpu": [ "x64" ], @@ -7264,9 +7261,9 @@ } }, "node_modules/@parcel/watcher-linux-x64-musl": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", - "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", + "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", "cpu": [ "x64" ], @@ -7285,9 +7282,9 @@ } }, "node_modules/@parcel/watcher-win32-arm64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", - "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz", + "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==", "cpu": [ "arm64" ], @@ -7305,31 +7302,10 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/@parcel/watcher-win32-ia32": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", - "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/@parcel/watcher-win32-x64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", - "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz", + "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==", "cpu": [ "x64" ], @@ -7985,9 +7961,9 @@ } }, "node_modules/@semantic-release/github/node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "dev": true, "license": "MIT", "engines": { @@ -8197,9 +8173,9 @@ } }, "node_modules/@semantic-release/npm/node_modules/undici": { - "version": "6.27.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", - "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", "dev": true, "license": "MIT", "engines": { @@ -8456,9 +8432,9 @@ } }, "node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "dev": true, "license": "MIT" }, @@ -8509,9 +8485,9 @@ "license": "MIT" }, "node_modules/@ts-morph/common/node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.3.tgz", + "integrity": "sha512-DRdx5neNsG/QXbniLFWi2YmC/68oeOOmKz6zOjVk6ZS1ZLXgLIKqVEc6hWsmkjBbgii0SwaBTcJ5XKj5gzY/4A==", "dev": true, "license": "MIT", "dependencies": { @@ -8610,9 +8586,9 @@ "license": "MIT" }, "node_modules/@tufjs/models/node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.3.tgz", + "integrity": "sha512-DRdx5neNsG/QXbniLFWi2YmC/68oeOOmKz6zOjVk6ZS1ZLXgLIKqVEc6hWsmkjBbgii0SwaBTcJ5XKj5gzY/4A==", "dev": true, "license": "MIT", "dependencies": { @@ -9093,6 +9069,7 @@ "version": "20.19.43", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -9214,17 +9191,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz", - "integrity": "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", + "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.63.0", - "@typescript-eslint/type-utils": "8.63.0", - "@typescript-eslint/utils": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/type-utils": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -9237,22 +9214,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.63.0", + "@typescript-eslint/parser": "^8.65.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.63.0.tgz", - "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", + "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.63.0", - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", "debug": "^4.4.3" }, "engines": { @@ -9268,14 +9245,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz", - "integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", + "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.63.0", - "@typescript-eslint/types": "^8.63.0", + "@typescript-eslint/tsconfig-utils": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", "debug": "^4.4.3" }, "engines": { @@ -9290,14 +9267,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz", - "integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", + "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0" + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -9308,9 +9285,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz", - "integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", + "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", "dev": true, "license": "MIT", "engines": { @@ -9325,15 +9302,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.63.0.tgz", - "integrity": "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", + "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0", - "@typescript-eslint/utils": "8.63.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -9350,9 +9327,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz", - "integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", "dev": true, "license": "MIT", "engines": { @@ -9364,16 +9341,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz", - "integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", + "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.63.0", - "@typescript-eslint/tsconfig-utils": "8.63.0", - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0", + "@typescript-eslint/project-service": "8.65.0", + "@typescript-eslint/tsconfig-utils": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -9405,16 +9382,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz", - "integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", + "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.63.0", - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0" + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -9429,13 +9406,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz", - "integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", + "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/types": "8.65.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -9878,9 +9855,9 @@ } }, "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", "bin": { @@ -10519,9 +10496,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.43", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", - "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", + "version": "2.11.6", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.6.tgz", + "integrity": "sha512-69D/imtToCsIcAl8WBS2YaRwA4jO/j0HhU+hELqMEu9f54MoUtI6+XH5mrKU8rEFNEk/Ui1I2MK4/JkWacclGw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -10676,9 +10653,9 @@ "license": "MIT" }, "node_modules/bonjour-service": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.4.3.tgz", - "integrity": "sha512-2Kd5UYlFUVgAKMTyuBLl6w49wqfOnbxHqmuH0oCl/n7TfAikR0zoowNOP5BU4dfXmm+Vr9JyEN370auSMx+CNg==", + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.4.4.tgz", + "integrity": "sha512-jCZcVv7eoc4QesRscwEZtSROBen+6LpKAmBIsQYQrsAeVHLyMXWX/t6eIV5KiRZYNUBl8eVqImEEMQ8L5+c/Kw==", "dev": true, "license": "MIT", "dependencies": { @@ -10701,16 +10678,16 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/braces": { @@ -10727,9 +10704,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.6", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", - "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", "dev": true, "funding": [ { @@ -10747,9 +10724,9 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.42", - "caniuse-lite": "^1.0.30001803", - "electron-to-chromium": "^1.5.389", + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, @@ -10881,9 +10858,9 @@ "license": "ISC" }, "node_modules/cacache/node_modules/tar": { - "version": "7.5.20", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz", - "integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==", + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -10948,9 +10925,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001805", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001805.tgz", - "integrity": "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==", + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", "dev": true, "funding": [ { @@ -10968,15 +10945,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/canvas-renderer": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/canvas-renderer/-/canvas-renderer-2.2.1.tgz", - "integrity": "sha512-RrBgVL5qCEDIXpJ6NrzyRNoTnXxYarqm/cS/W6ERhUJts5UQtt/XPEosGN3rqUkZ4fjBArlnCbsISJ+KCFnIAg==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/cfb": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", @@ -13303,9 +13271,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.389", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", - "integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==", + "version": "1.5.397", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.397.tgz", + "integrity": "sha512-khGTy9U9x02KEtsKM8vx5A62BsRmcOsIgDpWr1ImE32Ax8GxHGPHZf+Eu9H8zOOyHJnB0jTbseyTHbq2XCT8yw==", "dev": true, "license": "ISC" }, @@ -13386,9 +13354,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.24.2", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz", - "integrity": "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==", + "version": "5.24.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.4.tgz", + "integrity": "sha512-GVoi+ICHocoOIU7qVVM48wOJziRsqrsyqlI0Ce0LdowRn6v3bcH2zUa9kp85ncx0nwIb9/HOCOLS3fdThDG/XQ==", "dev": true, "license": "MIT", "dependencies": { @@ -13664,14 +13632,15 @@ } }, "node_modules/es-toolkit": { - "version": "1.49.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", - "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz", + "integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==", "devOptional": true, "license": "MIT", "workspaces": [ "docs", - "benchmarks" + "benchmarks", + "tests/types" ] }, "node_modules/esbuild": { @@ -14385,9 +14354,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "dev": true, "funding": [ { @@ -14636,9 +14605,9 @@ } }, "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz", + "integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==", "dev": true, "license": "ISC" }, @@ -14739,9 +14708,9 @@ } }, "node_modules/fs-extra": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.6.tgz", - "integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==", + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", "dev": true, "license": "MIT", "dependencies": { @@ -14996,9 +14965,9 @@ "license": "MIT" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.3.tgz", + "integrity": "sha512-DRdx5neNsG/QXbniLFWi2YmC/68oeOOmKz6zOjVk6ZS1ZLXgLIKqVEc6hWsmkjBbgii0SwaBTcJ5XKj5gzY/4A==", "dev": true, "license": "MIT", "dependencies": { @@ -15598,9 +15567,9 @@ "license": "MIT" }, "node_modules/ignore-walk/node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.3.tgz", + "integrity": "sha512-DRdx5neNsG/QXbniLFWi2YmC/68oeOOmKz6zOjVk6ZS1ZLXgLIKqVEc6hWsmkjBbgii0SwaBTcJ5XKj5gzY/4A==", "dev": true, "license": "MIT", "dependencies": { @@ -15769,9 +15738,9 @@ } }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz", + "integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==", "dev": true, "license": "MIT", "engines": { @@ -16165,21 +16134,6 @@ "node": ">= 0.6.0" } }, - "node_modules/jdenticon": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/jdenticon/-/jdenticon-3.3.0.tgz", - "integrity": "sha512-DhuBRNRIybGPeAjMjdHbkIfiwZCCmf8ggu7C49jhp6aJ7DYsZfudnvnTY5/1vgUhrGA7JaDAx1WevnpjCPvaGg==", - "license": "MIT", - "dependencies": { - "canvas-renderer": "~2.2.0" - }, - "bin": { - "jdenticon": "bin/jdenticon.js" - }, - "engines": { - "node": ">=6.4.0" - } - }, "node_modules/jest-worker": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", @@ -17466,13 +17420,13 @@ "license": "ISC" }, "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -17847,20 +17801,6 @@ "@angular/core": ">12.0.0" } }, - "node_modules/ngx-jdenticon": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ngx-jdenticon/-/ngx-jdenticon-2.0.0.tgz", - "integrity": "sha512-Vs2xiEeYI25tRTAT0Rxcw8di+5vGAFPp+jNRcNnNem93OrgH5VHXh5dx8YKcfo+GeevCWhE5oU418MkgjQi4bw==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "peerDependencies": { - "@angular/common": ">=13", - "@angular/core": ">=13", - "jdenticon": "^3.2.0" - } - }, "node_modules/ngx-logger": { "version": "5.0.12", "resolved": "https://registry.npmjs.org/ngx-logger/-/ngx-logger-5.0.12.tgz", @@ -17994,9 +17934,9 @@ } }, "node_modules/node-gyp/node_modules/tar": { - "version": "7.5.20", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz", - "integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==", + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -20460,9 +20400,9 @@ } }, "node_modules/p-map": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.5.tgz", - "integrity": "sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.6.tgz", + "integrity": "sha512-I4Prw6ivkd6p8PiYR1tXASOAOBzIJwu0TB7fqaX0c/8c3QAehNYmX57EijyGGGBt3c/BIowGwV03RVBtXvHEVg==", "dev": true, "license": "MIT", "engines": { @@ -20544,9 +20484,9 @@ "license": "BlueOak-1.0.0" }, "node_modules/package-manager-detector": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.7.0.tgz", - "integrity": "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", + "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", "license": "MIT", "optional": true }, @@ -21256,9 +21196,9 @@ } }, "node_modules/prettier": { - "version": "3.9.5", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", - "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, "license": "MIT", "bin": { @@ -21511,9 +21451,9 @@ "license": "MIT" }, "node_modules/prettier-eslint/node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.3.tgz", + "integrity": "sha512-DRdx5neNsG/QXbniLFWi2YmC/68oeOOmKz6zOjVk6ZS1ZLXgLIKqVEc6hWsmkjBbgii0SwaBTcJ5XKj5gzY/4A==", "dev": true, "license": "MIT", "dependencies": { @@ -22694,9 +22634,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.101.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.101.0.tgz", - "integrity": "sha512-OL3GoQyoUdDt843DpVmDO6y2k1sc5IhUDSpu8XucEI+35neq5QivZ1iuegnpraEVTJXlQGK1gl27zKcTLEPbQw==", + "version": "1.102.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.102.0.tgz", + "integrity": "sha512-NSOyTnaQF7rTAEOtI2fwb386vL+akyiQLBZu8Na7hXCb+umJy0GAqlcMIaqACZ6Z1VgTBS4K9PG6B3IdjHGJsw==", "dev": true, "license": "MIT", "dependencies": { @@ -22786,9 +22726,9 @@ } }, "node_modules/sax": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", - "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", "dev": true, "license": "BlueOak-1.0.0", "optional": true, @@ -22876,9 +22816,9 @@ } }, "node_modules/semantic-release": { - "version": "25.0.7", - "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.7.tgz", - "integrity": "sha512-fFiUD7LNFI0TOGkc49WDHUX4GYKrOMDKct5ReSB1EJCZiPsPdbIPIJGys1xb2ILpK1v9JMsRkjJQgTRpbr7DgQ==", + "version": "25.0.8", + "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.8.tgz", + "integrity": "sha512-w/iZ0bur36rKffXZYmIUmy068eoBY3Ij1DCCddx2JwWEM5Tg+eU9ld/E9qSInVvPASyyR2Ln/XGfQ9OZrMlhtw==", "dev": true, "license": "MIT", "dependencies": { @@ -23134,16 +23074,16 @@ } }, "node_modules/semantic-release/node_modules/yargs": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", - "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", "dev": true, "license": "MIT", "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", - "string-width": "^7.2.0", + "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" }, @@ -23161,6 +23101,23 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, + "node_modules/semantic-release/node_modules/yargs/node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/semver": { "version": "7.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", @@ -24616,9 +24573,9 @@ } }, "node_modules/thingies": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.6.0.tgz", - "integrity": "sha512-rMHRjmlFLM1R96UYPvpmnc3LYtdFrT33JIB7L9hetGue1qAPfn1N2LJeEjxUSidu1Iku+haLZXDuEXUHNGO/lg==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.6.1.tgz", + "integrity": "sha512-cV/CMGTK3M4MlnJ/0At6ismOw/A0EEniDNScajjz/Br3c1sqE72YD01rGpPTKwd27wAxI5Pr+6+0w8yofzFRYw==", "dev": true, "license": "MIT", "engines": { @@ -24778,22 +24735,22 @@ } }, "node_modules/tldts": { - "version": "7.4.8", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.8.tgz", - "integrity": "sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A==", + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.4.8" + "tldts-core": "^7.4.9" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.4.8", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.8.tgz", - "integrity": "sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw==", + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", "dev": true, "license": "MIT" }, @@ -25341,16 +25298,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.63.0.tgz", - "integrity": "sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.65.0.tgz", + "integrity": "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.63.0", - "@typescript-eslint/parser": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0", - "@typescript-eslint/utils": "8.63.0" + "@typescript-eslint/eslint-plugin": "8.65.0", + "@typescript-eslint/parser": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -25395,6 +25352,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -27109,9 +27067,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", "dev": true, "license": "MIT", "engines": { @@ -27303,9 +27261,9 @@ } }, "node_modules/yoctocolors": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", - "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz", + "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index b31391462..9e1d0c33a 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,10 @@ "ng": "ng", "start": "ng serve", "build": "ng build", + "postbuild": "npm run generate:build-info", "build-prod": "ng build --configuration production", + "postbuild-prod": "npm run generate:build-info", + "generate:build-info": "node scripts/write-build-info.mjs", "test": "vitest run", "test:watch": "vitest watch", "test:ui": "vitest --ui", @@ -47,18 +50,16 @@ "@angular/forms": "^19.2.25", "@angular/material": "^19.2.16", "@angular/material-moment-adapter": "^19.2.16", - "@mitre-attack/attack-data-model": "^5.0.0-beta.1", + "@mitre-attack/attack-data-model": "^5.0.0-beta.3", "@angular/platform-browser": "^19.2.25", "@angular/platform-browser-dynamic": "^19.2.25", "@angular/router": "^19.2.25", "@ng-matero/extensions": "^19.5.1", "diff-match-patch-ts": "^0.8.0", - "jdenticon": "^3.2.0", "lodash": "^4.17.21", "marked": "^15.0.11", "moment": "^2.29.4", "ngx-autosize": "^2.0.4", - "ngx-jdenticon": "^2.0.0", "ngx-logger": "^5.0.12", "ngx-markdown": "^19.1.1", "rxjs": "^7.8.1", @@ -71,8 +72,6 @@ "@analogjs/vite-plugin-angular": "^1.22.1", "@analogjs/vitest-angular": "^1.22.1", "@eslint/js": "^9.39.1", - "@vitest/coverage-v8": "^3.2.4", - "@vitest/ui": "^3.2.4", "@angular-devkit/build-angular": "^19.2.27", "@angular/cli": "^19.2.27", "@angular/compiler-cli": "^19.2.25", @@ -81,6 +80,8 @@ "@types/jasmine": "^6.0.0", "@types/jasminewd2": "^2.0.10", "@types/node": "^20.19.43", + "@vitest/coverage-v8": "^3.2.4", + "@vitest/ui": "^3.2.4", "angular-eslint": "^19.4.0", "codelyzer": "^6.0.0", "commitlint": "^20.5.0", @@ -91,14 +92,14 @@ "eslint-plugin-unused-imports": "^4.1.4", "husky": "^9.1.7", "jsdom": "^27.0.1", - "sass": "^1.94.2", "prettier": "^3.8.1", "prettier-eslint": "^16.3.0", + "sass": "^1.94.2", "semantic-release": "^25.0.3", "ts-node": "^10.9.1", "typescript": "^5.8.3", + "typescript-eslint": "^8.58.0", "vite": "^6.4.1", - "vitest": "^3.2.4", - "typescript-eslint": "^8.58.0" + "vitest": "^3.2.4" } } diff --git a/scripts/write-build-info.mjs b/scripts/write-build-info.mjs new file mode 100644 index 000000000..cd9ac3b30 --- /dev/null +++ b/scripts/write-build-info.mjs @@ -0,0 +1,27 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const packageInfo = JSON.parse( + await readFile(resolve(repositoryRoot, 'package.json'), 'utf8') +); +const outputPath = process.env.BUILD_INFO_OUTPUT_PATH + ? resolve(process.env.BUILD_INFO_OUTPUT_PATH) + : resolve(repositoryRoot, 'dist/app/browser/assets/build-info.json'); + +function environmentValue(name, fallback) { + return process.env[name]?.trim() || fallback; +} + +const buildInfo = { + name: packageInfo.name, + version: environmentValue('APP_VERSION', packageInfo.version), + gitCommit: environmentValue('GIT_COMMIT', 'unknown'), + buildDate: environmentValue('BUILD_DATE', 'unknown'), +}; + +await mkdir(dirname(outputPath), { recursive: true }); +await writeFile(outputPath, `${JSON.stringify(buildInfo, null, 2)}\n`); + +console.log(`Wrote build information to ${outputPath}`); diff --git a/src/app/app-routing-stix.module.ts b/src/app/app-routing-stix.module.ts index 370e7a8bd..39c92f9b6 100644 --- a/src/app/app-routing-stix.module.ts +++ b/src/app/app-routing-stix.module.ts @@ -5,6 +5,7 @@ import { ReferenceManagerComponent } from './views/reference-manager/reference-m import { NotesPageComponent } from './views/notes-page/notes-page.component'; import { StixPageComponent } from './views/stix/stix-page/stix-page.component'; import { ContributorsPageComponent } from './views/contributors-page/contributors-page.component'; +import { AllObjectsPageComponent } from './views/stix/all-objects-page/all-objects-page.component'; import { RouterModule, Routes } from '@angular/router'; import { NgModule } from '@angular/core'; @@ -21,61 +22,64 @@ const stixRouteData = [ { attackType: 'matrix', editable: true, + group: 'core', }, { attackType: 'tactic', editable: true, + group: 'core', }, { attackType: 'technique', editable: true, + group: 'core', }, // cti { attackType: 'group', editable: true, - headerSection: 'cti', + group: 'cti', }, { attackType: 'software', editable: true, - headerSection: 'cti', + group: 'cti', }, { attackType: 'campaign', editable: true, - headerSection: 'cti', + group: 'cti', }, // defenses { attackType: 'mitigation', editable: true, - headerSection: 'defenses', + group: 'defenses', }, { attackType: 'asset', editable: true, - headerSection: 'defenses', + group: 'defenses', }, { attackType: 'detection-strategy', editable: true, - headerSection: 'defenses', + group: 'defenses', }, { attackType: 'analytic', editable: true, - headerSection: 'defenses', + group: 'defenses', }, { attackType: 'data-component', editable: true, - headerSection: 'defenses', + group: 'defenses', }, { attackType: 'data-source', editable: true, - headerSection: 'defenses', + group: 'defenses', deprecated: true, }, // more @@ -87,13 +91,24 @@ const stixRouteData = [ ]; const stixRoutes: Routes = []; +stixRoutes.push({ + path: 'objects', + canActivate: [AuthorizationGuard], + data: { + breadcrumb: 'all objects', + title: 'All Objects', + roles: viewRoles, + }, + component: AllObjectsPageComponent, +}); + stixRouteData.forEach(stixRoute => { stixRoutes.push({ path: stixRoute.attackType, canActivateChild: [AuthorizationGuard], data: { breadcrumb: AttackTypeToRoute[stixRoute.attackType].replace(/-/g, ' '), - headerSection: stixRoute.headerSection || undefined, + group: stixRoute.group, deprecated: stixRoute.deprecated ?? false, }, children: [ @@ -153,7 +168,7 @@ stixRoutes.push({ canActivateChild: [AuthorizationGuard], data: { breadcrumb: 'marking definitions', - headerSection: 'more', + group: 'more', }, children: [ { @@ -210,7 +225,7 @@ if (environment.integrations.collection_manager.enabled) { canActivateChild: [AuthorizationGuard], data: { breadcrumb: 'collections', - headerSection: 'more', + group: 'more', }, children: [ { @@ -307,7 +322,7 @@ stixRoutes.push( canActivateChild: [AuthorizationGuard], data: { breadcrumb: 'reference manager', - headerSection: 'more', + group: 'more', }, children: [ { @@ -327,7 +342,7 @@ stixRoutes.push( canActivateChild: [AuthorizationGuard], data: { breadcrumb: 'contributors', - headerSection: 'more', + group: 'more', }, children: [ { @@ -346,7 +361,7 @@ stixRoutes.push( canActivateChild: [AuthorizationGuard], data: { breadcrumb: 'notes', - headerSection: 'more', + group: 'more', }, children: [ { diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index ea3e93aa0..f426ceb08 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -7,15 +7,18 @@ import { DataQualityComponent } from './views/dashboard-page/data-quality/data-q import { OrgSettingsPageComponent } from './views/dashboard-page/org-settings-page/org-settings-page.component'; import { UserAccountsPageComponent } from './views/dashboard-page/user-accounts-page/user-accounts-page.component'; import { DefaultMarkingDefinitionsComponent } from './views/dashboard-page/default-marking-definitions/default-marking-definitions.component'; +import { ValidationBypassesComponent } from './views/dashboard-page/validation-bypasses/validation-bypasses.component'; import { ProfilePageComponent } from './views/profile-page/profile-page.component'; import { AuthorizationGuard } from './services/helpers/authorization.guard'; import { Role } from './classes/authn/role'; import { TeamsListPageComponent } from './views/dashboard-page/teams/teams-list-page/teams-list-page.component'; import { TeamsViewPageComponent } from './views/dashboard-page/teams/teams-view-page/teams-view-page.component'; +import { ReleaseManagementComponent } from './views/dashboard-page/release-management/release-management.component'; +import { ReleaseTrackPageComponent } from './views/dashboard-page/release-management/release-track-page/release-track-page.component'; const editRoles = [Role.EDITOR, Role.TEAM_LEAD, Role.ADMIN]; -const routes: Routes = [ +export const routes: Routes = [ { path: '', data: { @@ -48,12 +51,44 @@ const routes: Routes = [ children: [ { path: '', + pathMatch: 'full', + redirectTo: 'overview', + }, + { + path: 'overview', data: { - breadcrumb: 'dashboard', - title: 'Dashboard', + breadcrumb: 'overview', + title: 'Knowledge Base Overview', }, component: DashboardPageComponent, }, + { + path: 'release-management', + data: { + breadcrumb: 'release management', + title: 'Release Management', + roles: [Role.ADMIN, Role.TEAM_LEAD], + }, + children: [ + { + path: '', + data: { + breadcrumb: 'release management', + title: 'Release Management', + }, + component: ReleaseManagementComponent, + }, + { + path: ':id', + data: { + breadcrumb: 'view release track', + editable: false, + title: 'Release Track', + }, + component: ReleaseTrackPageComponent, + }, + ], + }, { path: 'teams', data: { @@ -117,6 +152,15 @@ const routes: Routes = [ }, component: DefaultMarkingDefinitionsComponent, }, + { + path: 'validation-bypasses', + data: { + breadcrumb: 'validation bypasses', + title: 'ADM Validation Bypasses', + roles: [Role.ADMIN], + }, + component: ValidationBypassesComponent, + }, ], }, { @@ -128,12 +172,8 @@ const routes: Routes = [ children: [ { path: '', - data: { - breadcrumb: 'documentation', - markdown: '/assets/docs/README.md', - title: 'Documentation', - }, - component: HelpPageComponent, + pathMatch: 'full', + redirectTo: 'usage', }, { path: 'usage', diff --git a/src/app/app.component.html b/src/app/app.component.html index 89982da93..d7e1de644 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,17 +1,22 @@
- + + + +
+ + (onScrollTop)="scrollToTop()"> -
- -
+ + +
+ +
+
+ +
+ +
+
+
- -
- -
-
diff --git a/src/app/app.component.scss b/src/app/app.component.scss index 756b3d9ed..190ebb3a2 100644 --- a/src/app/app.component.scss +++ b/src/app/app.component.scss @@ -5,6 +5,7 @@ flex-direction: column; .app-body { flex: 1; + min-height: 0; } .mat-drawer { overflow-y: hidden; @@ -18,15 +19,27 @@ height: 100%; // overflow: auto; } + .app-nav-drawer { + width: 286px; + border-right: none !important; + } .app-content { display: flex; flex-direction: column; - .router-padding { - flex: 1; - box-sizing: border-box; - padding: 50px 50px; - overflow: auto; - } + } + .page-body { + flex: 1; + min-height: 0; + } + .page-content { + display: flex; + min-height: 0; + } + .router-padding { + flex: 1; + box-sizing: border-box; + padding: 24px; + overflow: auto; } mat-drawer { border-left: none !important; diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 25656badd..fcc4c459e 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,10 +1,8 @@ import { - AfterViewInit, Component, ElementRef, ViewChild, ViewEncapsulation, - OnDestroy, } from '@angular/core'; import { MatDrawerContainer } from '@angular/material/sidenav'; import { OverlayContainer } from '@angular/cdk/overlay'; @@ -25,20 +23,16 @@ import { AppConfigService } from './services/config/app-config.service'; encapsulation: ViewEncapsulation.None, standalone: false, }) -export class AppComponent implements AfterViewInit, OnDestroy { +export class AppComponent { // Drawer container to resize when contents change size @ViewChild(MatDrawerContainer, { static: true }) private container: MatDrawerContainer; - // Elements for scroll behavior - @ViewChild('header', { static: false, read: ElementRef }) - private header: ElementRef; @ViewChild('scrollRef', { static: false, read: ElementRef }) private scrollRef: ElementRef; public theme; public alertStatus; - public hiddenHeaderPX = 0; // number of px of the header which is hidden public get sidebarOpened() { return this.sidebarService.opened && this.editorService.sidebarEnabled; @@ -77,7 +71,7 @@ export class AppComponent implements AfterViewInit, OnDestroy { const authSubscription = this.authenticationService .getSession() .subscribe({ - next: res => { + next: () => { this.checkStatus(); }, complete: () => { @@ -93,25 +87,6 @@ export class AppComponent implements AfterViewInit, OnDestroy { initLogger(logger); } - ngAfterViewInit(): void { - // header hiding with scroll - this.scrollRef.nativeElement.addEventListener( - 'scroll', - e => this.adjustHeaderPlacement(), - true - ); - // to fix rare cases that the page has resized without scroll events triggering, recompute the offset every 5 seconds - setInterval(() => this.adjustHeaderPlacement(), 5000); - } - - ngOnDestroy(): void { - this.scrollRef.nativeElement.removeEventListener( - 'scroll', - e => this.adjustHeaderPlacement(), - true - ); - } - /** * Check the account status of the logged in user. * If the user's account status is not active, an alert @@ -200,18 +175,6 @@ export class AppComponent implements AfterViewInit, OnDestroy { overlayContainerClasses.add(this.theme); } - // adjust the header placement - private adjustHeaderPlacement(): void { - const headerHeight = this.header.nativeElement.offsetHeight; - // constrain amount of hidden to bounds, round up because decimal scroll causes flicker - this.hiddenHeaderPX = Math.floor( - Math.min( - Math.max(0, this.scrollRef.nativeElement.scrollTop / 2), - headerHeight - ) - ); - } - // scroll to the top of the main content public scrollToTop(): void { this.scrollRef.nativeElement.scroll({ top: 0, behavior: 'smooth' }); diff --git a/src/app/app.module.ts b/src/app/app.module.ts index d90fd7223..4ba43f170 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -57,7 +57,6 @@ import { MatTooltipModule } from '@angular/material/tooltip'; // other library imports import { MtxPopoverModule } from '@ng-matero/extensions/popover'; import { AutosizeModule } from 'ngx-autosize'; -import { JDENTICON_CONFIG, NgxJdenticonModule } from 'ngx-jdenticon'; import { MarkdownModule } from 'ngx-markdown'; // custom components @@ -74,16 +73,22 @@ import { HeaderComponent } from './components/header/header.component'; import { LoadingOverlayComponent } from './components/loading-overlay/loading-overlay.component'; import { MarkdownViewDialogComponent } from './components/markdown-view-dialog/markdown-view-dialog.component'; import { MultipleChoiceDialogComponent } from './components/multiple-choice-dialog/multiple-choice-dialog.component'; +import { NavigationComponent } from './components/navigation/navigation.component'; import { ReferenceEditDialogComponent } from './components/reference-edit-dialog/reference-edit-dialog.component'; -import { HistoryTimelineComponent } from './components/resources-drawer/history-timeline/history-timeline.component'; +import { ReleasePreviewDialogComponent } from './components/release-preview-dialog/release-preview-dialog.component'; +import { SnapshotDescriptionDialogComponent } from './components/snapshot-description-dialog/snapshot-description-dialog.component'; +import { HistoryTimelineComponent } from './components/stix/stix-page-tabs/history-timeline/history-timeline.component'; +import { MembershipSectionComponent } from './components/stix/stix-page-tabs/membership-section/membership-section.component'; import { ReferenceSidebarComponent } from './components/resources-drawer/reference-sidebar/reference-sidebar.component'; import { ResourcesDrawerComponent } from './components/resources-drawer/resources-drawer.component'; import { SearchComponent } from './components/resources-drawer/search/search.component'; import { SaveDialogComponent } from './components/save-dialog/save-dialog.component'; import { SubheadingComponent } from './components/subheading/subheading.component'; import { ToolbarComponent } from './components/toolbar/toolbar.component'; +import { UserAvatarComponent } from './components/user-avatar/user-avatar.component'; import { ValidationResultsComponent } from './components/validation-results/validation-results.component'; import { VersionPopoverComponent } from './components/version-popover/version-popover.component'; +import { WorkflowStatusDialogComponent } from './components/workflow-status-dialog/workflow-status-dialog.component'; // STIX components import { StixListComponent } from './components/stix/stix-list/stix-list.component'; @@ -145,7 +150,7 @@ import { OrderedListViewComponent } from './components/stix/ordered-list-propert import { IconViewComponent } from './components/icon-view/icon-view.component'; import { ObjectStatusComponent } from './components/object-status/object-status.component'; import { RecentActivityComponent } from './components/recent-activity/recent-activity.component'; -import { NotesEditorComponent } from './components/resources-drawer/notes-editor/notes-editor.component'; +import { NotesEditorComponent } from './components/stix/stix-page-tabs/notes-editor/notes-editor.component'; import { IdentityPropertyComponent } from './components/stix/identity-property/identity-property.component'; import { CitationEditComponent } from './components/stix/citation-property/citation-edit/citation-edit.component'; @@ -160,9 +165,12 @@ import { DataQualityComponent } from './views/dashboard-page/data-quality/data-q import { OrgSettingsPageComponent } from './views/dashboard-page/org-settings-page/org-settings-page.component'; import { UserAccountsPageComponent } from './views/dashboard-page/user-accounts-page/user-accounts-page.component'; import { DefaultMarkingDefinitionsComponent } from './views/dashboard-page/default-marking-definitions/default-marking-definitions.component'; +import { ValidationBypassRuleDialogComponent } from './views/dashboard-page/validation-bypasses/validation-bypass-rule-dialog/validation-bypass-rule-dialog.component'; +import { ValidationBypassesComponent } from './views/dashboard-page/validation-bypasses/validation-bypasses.component'; import { ProfilePageComponent } from './views/profile-page/profile-page.component'; import { ReferenceManagerComponent } from './views/reference-manager/reference-manager.component'; +import { AllObjectsPageComponent } from './views/stix/all-objects-page/all-objects-page.component'; import { StixDialogComponent } from './views/stix/stix-dialog/stix-dialog.component'; import { StixPageComponent } from './views/stix/stix-page/stix-page.component'; @@ -229,6 +237,14 @@ import { DictionaryPropertyComponent } from './components/stix/dictionary-proper import { DictionaryEditComponent } from './components/stix/dictionary-property/dictionary-edit/dictionary-edit.component'; import { DictionaryViewComponent } from './components/stix/dictionary-property/dictionary-view/dictionary-view.component'; import { DictionaryDiffComponent } from './components/stix/dictionary-property/dictionary-diff/dictionary-diff.component'; +import { StixPageTabsComponent } from './components/stix/stix-page-tabs/stix-page-tabs.component'; +import { ReleaseManagementComponent } from './views/dashboard-page/release-management/release-management.component'; +import { ReleaseTrackCardComponent } from './components/release-track-card/release-track-card.component'; +import { ReleaseTrackObjectCardComponent } from './components/release-track-object-card/release-track-object-card.component'; +import { NewTrackDialogComponent } from './components/new-track-dialog/new-track-dialog.component'; +import { ReleaseTrackPageComponent } from './views/dashboard-page/release-management/release-track-page/release-track-page.component'; +import { StatusChipComponent } from './components/status-chip/status-chip.component'; +import { WorkbenchChipComponent } from './components/workbench-chip/workbench-chip.component'; export function initConfig(appConfigService: AppConfigService) { return () => appConfigService.loadAppConfig(); @@ -238,6 +254,7 @@ export function initConfig(appConfigService: AppConfigService) { declarations: [ AppComponent, HeaderComponent, + NavigationComponent, FooterComponent, LoadingOverlayComponent, ToolbarComponent, @@ -248,11 +265,15 @@ export function initConfig(appConfigService: AppConfigService) { MarkdownViewDialogComponent, CollectionImportSummaryComponent, SaveDialogComponent, + WorkflowStatusDialogComponent, AddDialogComponent, DeleteDialogComponent, HistoryTimelineComponent, + MembershipSectionComponent, ReferenceSidebarComponent, ReferenceEditDialogComponent, + ReleasePreviewDialogComponent, + SnapshotDescriptionDialogComponent, MultipleChoiceDialogComponent, ValidationResultsComponent, AddRelationshipButtonComponent, @@ -286,12 +307,16 @@ export function initConfig(appConfigService: AppConfigService) { DatepickerPropertyComponent, IconViewComponent, LandingPageComponent, + AllObjectsPageComponent, + StixPageTabsComponent, HelpPageComponent, DashboardPageComponent, DataQualityComponent, OrgSettingsPageComponent, UserAccountsPageComponent, DefaultMarkingDefinitionsComponent, + ValidationBypassesComponent, + ValidationBypassRuleDialogComponent, ProfilePageComponent, ReferenceManagerComponent, StixDialogComponent, @@ -375,11 +400,16 @@ export function initConfig(appConfigService: AppConfigService) { DictionaryEditComponent, DictionaryViewComponent, DictionaryDiffComponent, + ReleaseManagementComponent, + NewTrackDialogComponent, + ReleaseTrackPageComponent, + StatusChipComponent, ], exports: [ MatToolbarModule, MatButtonModule, MatSidenavModule, + MatSlideToggleModule, MatIconModule, MatTableModule, MatSortModule, @@ -417,7 +447,6 @@ export function initConfig(appConfigService: AppConfigService) { disableConsoleLogging: false, }), MtxPopoverModule, - NgxJdenticonModule, AutosizeModule, BrowserModule, AppRoutingModule, @@ -426,6 +455,7 @@ export function initConfig(appConfigService: AppConfigService) { MatToolbarModule, MatButtonModule, MatSidenavModule, + MatSlideToggleModule, MatIconModule, MatTableModule, MatSortModule, @@ -459,6 +489,10 @@ export function initConfig(appConfigService: AppConfigService) { ClipboardModule, OverlayModule, MatAutocompleteModule, + UserAvatarComponent, + WorkbenchChipComponent, + ReleaseTrackCardComponent, + ReleaseTrackObjectCardComponent, ], providers: [ AppConfigService, @@ -466,20 +500,6 @@ export function initConfig(appConfigService: AppConfigService) { const initializerFn = initConfig(inject(AppConfigService)); return initializerFn(); }), - { - provide: JDENTICON_CONFIG, - useValue: { - lightness: { - color: [0.35, 0.6], - grayscale: [0.35, 0.6], - }, - saturation: { - color: 0.5, - grayscale: 0.5, - }, - backColor: '#0000', - }, - }, { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, diff --git a/src/app/classes/release-tracks/api.ts b/src/app/classes/release-tracks/api.ts new file mode 100644 index 000000000..af8470ae4 --- /dev/null +++ b/src/app/classes/release-tracks/api.ts @@ -0,0 +1,207 @@ +import type { WorkflowStatusType } from 'src/app/utils/types'; +import type { Composition } from './composition'; +import type { ReleaseTrackConfig } from './config'; +import { + ReleaseTrackType, + type ExportFormatType, + type ReleasePreviewFormatType, +} from './enums'; +import type { SnapshotSchedule } from './release-track'; + +export type StixObjectRef = string | { id: string; modified?: string }; + +export interface CreateReleaseTrackPayload { + name: string; + description?: string; + snapshot_description?: string; + external_references?: any[]; + object_marking_refs?: string[]; + type?: ReleaseTrackType; + config?: ReleaseTrackConfig; + composition?: Composition; + snapshot_schedule?: SnapshotSchedule; +} + +export interface StixBundlePayload { + type: 'bundle'; + id?: string; + objects: any[]; +} + +export interface UpdateMetadataPayload { + name?: string; + description?: string; + external_references?: any[]; + object_marking_refs?: string[]; +} + +export interface UpdateContentsPayload { + x_mitre_contents: string[]; +} + +export type ReleasePayload = ( + | { increment: 'major' | 'minor'; version?: never } + | { increment?: never; version: string } + | { increment?: undefined; version?: undefined } +) & { description?: string }; + +export interface ClonePayload { + name?: string; +} + +export interface ReviewPayload { + from: WorkflowStatusType; + to: WorkflowStatusType; + object_refs?: StixObjectRef[]; +} + +export interface PromoteQuarantinePayload { + object_ref: string; + object_modified: string; +} + +export interface ReleaseTrackSnapshotOptions { + format?: ExportFormatType; + include?: 'members' | 'staged' | 'candidates' | 'quarantine' | 'all'; + state?: string | string[]; + stixVersion?: '2.0' | '2.1'; + includeToc?: boolean; +} + +export interface SnapshotHistoryOptions { + tagged?: boolean; + limit?: number; + offset?: number; +} + +export interface SnapshotGraphStatistics { + primary_count: number; + secondary_count: number; + relationship_count: number; + supporting_count: number; + link_target_count: number; + total_count: number; +} + +export type ReleasePreviewOptions = ReleasePayload & { + format?: ReleasePreviewFormatType; +}; + +export interface ReleasePreviewSummaryBase { + track_id: string; + type: ReleaseTrackType; + source_snapshot_modified: string; + version: string; + version_bounds: { + lower: { version: string; modified: string } | null; + upper: { version: string; modified: string } | null; + }; + releasable: boolean; + conflicts: any[]; +} + +export interface StandardReleasePreviewSummary extends ReleasePreviewSummaryBase { + type: ReleaseTrackType.Standard; + before: { + members_count: number; + staged_count: number; + candidates_count: number; + }; + after: { + members_count: number; + staged_count: number; + candidates_count: number; + }; + changes: { + promoted_count: number; + }; +} + +export interface VirtualReleasePreviewSummary extends ReleasePreviewSummaryBase { + type: ReleaseTrackType.Virtual; + previous_release: { + version: string; + modified: string; + } | null; + before: { + members_count: number; + quarantine_count: number; + }; + after: { + members_count: number; + quarantine_count: number; + }; + changes: { + new_count: number; + updated_count: number; + removed_count: number; + quarantined_count: number; + }; +} + +export type ReleasePreviewSummary = + StandardReleasePreviewSummary | VirtualReleasePreviewSummary; + +export interface SnapshotBundleHashes { + manifest_id: string; + stix_2_0: string; + stix_2_1: string; +} + +export interface ReleaseTrackSnapshotHistoryItem { + id?: string; + modified?: string | Date; + version?: string | null; + graph_manifest_id?: string; + bundle_hashes?: SnapshotBundleHashes; + graph_statistics?: SnapshotGraphStatistics; + snapshot_description?: string; + type?: ReleaseTrackType; + name?: string; + description?: string; + created?: string | Date; + tagged_at?: string | Date; + snapshot_id?: string | Date; + is_latest?: boolean; + members_count?: number; + staged_count?: number; + candidates_count?: number; + quarantine_count?: number; + added_count?: number; + modified_count?: number; + promoted_count?: number; + members?: any[]; + staged?: any[]; + candidates?: any[]; + contents?: { + members?: any[]; + staged?: any[]; + candidates?: any[]; + quarantine?: any[]; + [key: string]: any; + }; + summary?: { + members_count?: number; + staged_count?: number; + candidates_count?: number; + quarantine_count?: number; + added_count?: number; + modified_count?: number; + promoted_count?: number; + quarantined_count?: number; + [key: string]: any; + }; + statistics?: Record; + composition_resolution?: { + total_objects?: number; + [key: string]: any; + }; + stix?: { + id?: string; + modified?: string | Date; + x_mitre_version?: string | null; + x_mitre_contents?: any[]; + [key: string]: any; + }; + [key: string]: any; +} diff --git a/src/app/classes/release-tracks/component-track.ts b/src/app/classes/release-tracks/component-track.ts new file mode 100644 index 000000000..28d798b18 --- /dev/null +++ b/src/app/classes/release-tracks/component-track.ts @@ -0,0 +1,34 @@ +// ----------------------------------------------------------------------------- +// Component Tracks +// +// Release track (standard or virtual) referenced by a virtual release track +// ----------------------------------------------------------------------------- + +import { ResolutionStrategyType } from './enums'; + +export interface ComponentTrackFilters { + object_types?: string[]; + domains?: string[]; +} + +export interface ComponentTrack { + track_id: string; + resolution_strategy: ResolutionStrategyType; + priority: number; + version?: string | null; + snapshot?: Date; + filters?: ComponentTrackFilters; +} + +export interface ComponentSnapshotResolution { + track_id: string; + track_name: string; + track_type: string; + resolved_snapshot_id: Date; + resolved_version?: string | null; + strategy_used: string; + filters_applied?: ComponentTrackFilters; + total_objects_in_source: number; + objects_after_filter: number; + objects_contributed: number; +} diff --git a/src/app/classes/release-tracks/composition.ts b/src/app/classes/release-tracks/composition.ts new file mode 100644 index 000000000..c24592547 --- /dev/null +++ b/src/app/classes/release-tracks/composition.ts @@ -0,0 +1,35 @@ +// ----------------------------------------------------------------------------- +// Composition +// +// Rules and configuration that defines how a virtual release track +// aggregates content from component tracks +// ----------------------------------------------------------------------------- + +import { DeduplicationStrategyType, SnapshotTierType } from './enums'; +import { ComponentTrack, ComponentSnapshotResolution } from './component-track'; +import { WorkflowStatusType } from 'src/app/utils/types'; + +export interface Composition { + component_tracks?: ComponentTrack[]; + deduplication?: { + strategy?: DeduplicationStrategyType; + // When resolving conflicts between versions, which snapshot tier should be + // preferred (e.g., prefer from 'staged' over 'candidate' or vice-versa). + tier_resolution?: SnapshotTierType; + // When resolving conflicts, prefer the object with the highest workflow + // status (e.g., 'reviewed' over 'awaiting-review'). + status_resolution?: WorkflowStatusType; + }; +} + +export interface CompositionResolution { + resolved_at?: Date; + component_snapshots?: ComponentSnapshotResolution[]; + deduplication?: { + total_objects_before?: number; + total_objects_after?: number; + duplicates_found?: number; + conflicts_resolved?: any[]; + }; + summary?: any; +} diff --git a/src/app/classes/release-tracks/config.ts b/src/app/classes/release-tracks/config.ts new file mode 100644 index 000000000..4d8c6bbb4 --- /dev/null +++ b/src/app/classes/release-tracks/config.ts @@ -0,0 +1,31 @@ +// ----------------------------------------------------------------------------- +// Release Track Configuration +// ----------------------------------------------------------------------------- + +import { WorkflowStatusType } from 'src/app/utils/types'; +import type { + ConflictPolicyType, + MemberSyncBehaviorType, + MemberSyncPolicyType, + MemberSyncStrategyType, +} from './enums'; + +export interface ReleaseTrackConfig { + candidacy_threshold?: WorkflowStatusType; + auto_promote?: boolean; + include_secondary_objects?: { + enabled?: boolean; + status_threshold?: WorkflowStatusType; + }; + promotion_conflicts?: { + candidates_to_staged?: ConflictPolicyType; + staged_to_members?: ConflictPolicyType; + }; + member_sync?: { + strategy?: MemberSyncStrategyType; + supplant?: { + behavior?: MemberSyncBehaviorType; + status_policy?: MemberSyncPolicyType; + }; + }; +} diff --git a/src/app/classes/release-tracks/enums.ts b/src/app/classes/release-tracks/enums.ts new file mode 100644 index 000000000..694c488e7 --- /dev/null +++ b/src/app/classes/release-tracks/enums.ts @@ -0,0 +1,165 @@ +import { WorkflowStatus, WorkflowStatusType } from 'src/app/utils/types'; + +type EnumValue> = T[keyof T]; + +// ----------------------------------------------------------------------------- +// Release Track Type +// ----------------------------------------------------------------------------- + +export enum ReleaseTrackType { + Standard = 'standard', + Virtual = 'virtual', +} + +export const RELEASE_TRACK_TYPE_OPTIONS: ReleaseTrackType[] = Object.values( + ReleaseTrackType +) as ReleaseTrackType[]; + +// ----------------------------------------------------------------------------- +// Conflict Resolution +// ----------------------------------------------------------------------------- + +export enum ConflictPolicy { + AlwaysOverwrite = 'always_overwrite', // replace with incoming entry + AlwaysReject = 'always_reject', // always reject incoming + PreferLatest = 'prefer_latest', // keep whichever has newer object_modified + Abort = 'abort', // throw error on any conflict +} + +export type ConflictPolicyType = EnumValue; + +export const CONFLICT_POLICY_OPTIONS: ConflictPolicyType[] = Object.values( + ConflictPolicy +) as ConflictPolicyType[]; + +// ----------------------------------------------------------------------------- +// Deduplication Strategy +// ----------------------------------------------------------------------------- + +export enum DeduplicationStrategy { + PrioritizeLatestObject = 'prioritize_latest_object', // keep version with newest object_modified + PrioritizeLatestSnapshot = 'prioritize_latest_snapshot', // keep version from most recently modified snapshot + PrioritizeHigherPriority = 'prioritize_higher_priority', // keep version from the higher-priority component (lower number) + Quarantine = 'quarantine', // send all conflicting versions to quarantine for manual review +} + +export type DeduplicationStrategyType = EnumValue; + +export const DEDUPLICATION_STRATEGY_OPTIONS: DeduplicationStrategyType[] = + Object.values(DeduplicationStrategy) as DeduplicationStrategyType[]; + +// ----------------------------------------------------------------------------- +// Export Format +// ----------------------------------------------------------------------------- + +export enum ExportFormat { + Bundle = 'bundle', + Workbench = 'workbench', + FileSystemStore = 'filesystemstore', +} + +export type ExportFormatType = EnumValue; + +export const EXPORT_FORMAT_OPTIONS: ExportFormatType[] = Object.values( + ExportFormat +) as ExportFormatType[]; + +export enum ReleasePreviewFormat { + Summary = 'summary', + Bundle = 'bundle', + Workbench = 'workbench', + FileSystemStore = 'filesystemstore', +} + +export type ReleasePreviewFormatType = EnumValue; + +// ----------------------------------------------------------------------------- +// Release Track Snapshot Tiers +// ----------------------------------------------------------------------------- + +export enum SnapshotTier { + Member = 'released', + Staged = 'staged', + Candidate = 'candidate', + All = 'all', +} + +export type SnapshotTierType = EnumValue; + +export const SNAPSHOT_TIER_OPTIONS: SnapshotTierType[] = Object.values( + SnapshotTier +) as SnapshotTierType[]; + +// ----------------------------------------------------------------------------- +// Candidacy Thresholds +// ----------------------------------------------------------------------------- + +export const CANDIDACY_THRESHOLD_OPTIONS: WorkflowStatusType[] = Object.values( + WorkflowStatus +) as WorkflowStatusType[]; + +// ----------------------------------------------------------------------------- +// Resolution Strategy +// ----------------------------------------------------------------------------- + +export enum ResolutionStrategy { + LatestTagged = 'latest_tagged', + SpecificVersion = 'specific_version', + SpecificSnapshot = 'specific_snapshot', +} + +export type ResolutionStrategyType = EnumValue; + +export const RESOLUTION_STRATEGY_OPTIONS: ResolutionStrategyType[] = + Object.values(ResolutionStrategy) as ResolutionStrategyType[]; + +// ----------------------------------------------------------------------------- +// Snapshot Schedule Modes +// ----------------------------------------------------------------------------- + +export enum SnapshotScheduleMode { + Manual = 'manual', + Cron = 'cron', + Dates = 'dates', +} + +export type SnapshotScheduleModeType = EnumValue; + +export const SNAPSHOT_MODE_OPTIONS: SnapshotScheduleModeType[] = Object.values( + SnapshotScheduleMode +) as SnapshotScheduleModeType[]; + +// ----------------------------------------------------------------------------- +// Member Sync +// ----------------------------------------------------------------------------- + +export enum MemberSyncStrategy { + TrackLatest = 'track_latest', + Manual = 'manual', +} + +export type MemberSyncStrategyType = EnumValue; + +export const MEMBER_SYNC_STRATEGY_OPTIONS: MemberSyncStrategyType[] = + Object.values(MemberSyncStrategy) as MemberSyncStrategyType[]; + +export enum MemberSyncBehavior { + Replace = 'replace', + Queue = 'queue', + Ignore = 'ignore', +} + +export type MemberSyncBehaviorType = EnumValue; + +export const MEMBER_SYNC_BEHAVIOR_OPTIONS: MemberSyncBehaviorType[] = + Object.values(MemberSyncBehavior) as MemberSyncBehaviorType[]; + +export enum MemberSyncPolicy { + Reset = 'reset', + Preserve = 'preserve', +} + +export type MemberSyncPolicyType = EnumValue; + +export const MEMBER_SYNC_STATUS_POLICY_OPTIONS: MemberSyncPolicyType[] = + Object.values(MemberSyncPolicy) as MemberSyncPolicyType[]; diff --git a/src/app/classes/release-tracks/history.ts b/src/app/classes/release-tracks/history.ts new file mode 100644 index 000000000..0eb628809 --- /dev/null +++ b/src/app/classes/release-tracks/history.ts @@ -0,0 +1,13 @@ +export interface VersionHistoryEntry { + version: string; + tagged_at: Date; + tagged_by: string; + snapshot_id: Date; + summary?: { + members_count?: number; + promoted_count?: number; + staged_count?: number; + candidate_count?: number; + }; + component_versions?: any; // virtual tracks only +} diff --git a/src/app/classes/release-tracks/index.ts b/src/app/classes/release-tracks/index.ts new file mode 100644 index 000000000..cc65c34d4 --- /dev/null +++ b/src/app/classes/release-tracks/index.ts @@ -0,0 +1,9 @@ +export * from './api'; +export * from './component-track'; +export * from './composition'; +export * from './config'; +export * from './enums'; +export * from './history'; +export * from './release-track'; +export * from './snapshot'; +export * from './tiers'; diff --git a/src/app/classes/release-tracks/release-track.ts b/src/app/classes/release-tracks/release-track.ts new file mode 100644 index 000000000..0ee7b53ae --- /dev/null +++ b/src/app/classes/release-tracks/release-track.ts @@ -0,0 +1,24 @@ +import { ReleaseTrackType, SnapshotScheduleModeType } from './enums'; + +export interface ReleaseTrack { + track_id: string; + type: ReleaseTrackType; + name: string; + description?: string; + created_at: Date; + updated_at: Date; + + latest_snapshot_modified?: Date | null; + latest_tagged_version?: string | null; + snapshot_count?: number; + tagged_release_count?: number; + + // virtual tracks only + snapshot_schedule?: SnapshotSchedule; +} + +export interface SnapshotSchedule { + mode?: SnapshotScheduleModeType; + cron?: string | null; + dates?: Date[] | undefined; +} diff --git a/src/app/classes/release-tracks/snapshot.spec.ts b/src/app/classes/release-tracks/snapshot.spec.ts new file mode 100644 index 000000000..ffa548722 --- /dev/null +++ b/src/app/classes/release-tracks/snapshot.spec.ts @@ -0,0 +1,146 @@ +import { ReleaseTrackSnapshot } from './snapshot'; + +describe('ReleaseTrackSnapshot', () => { + it('should preserve snapshot-local notes separately from track metadata', () => { + const snapshot = new ReleaseTrackSnapshot({ + description: 'Long-lived track purpose', + snapshot_description: 'Context for this release', + }); + + expect(snapshot.description).toBe('Long-lived track purpose'); + expect(snapshot.snapshot_description).toBe('Context for this release'); + expect(snapshot.serialize()).toEqual( + expect.objectContaining({ + description: 'Long-lived track purpose', + snapshot_description: 'Context for this release', + }) + ); + }); + + it('should preserve candidate and staged display fields', () => { + const snapshot = new ReleaseTrackSnapshot({ + candidates: [ + { + object_ref: 'attack-pattern--candidate', + object_modified: '2026-01-01T00:00:00.000Z', + object_status: 'work-in-progress', + attack_id: 'T1234', + name: 'Candidate Technique', + description: 'Candidate description', + modified_by_user: { + id: 'user-account--candidate', + username: 'candidateuser', + name: 'Candidate Reviewer', + }, + }, + ], + staged: [ + { + object_ref: 'attack-pattern--staged', + object_modified: '2026-01-02T00:00:00.000Z', + object_status: 'reviewed', + attack_id: 'T5678', + name: 'Staged Technique', + description: 'Staged description', + modified_by_user: { + id: 'user-account--staged', + username: 'stageduser', + name: 'Staged Reviewer', + }, + }, + ], + members: [ + { + object_ref: 'attack-pattern--member', + object_modified: '2026-01-03T00:00:00.000Z', + attack_id: 'T9999', + name: 'Member Technique', + }, + ], + }); + + expect(snapshot.candidates?.[0]).toEqual( + expect.objectContaining({ + attack_id: 'T1234', + name: 'Candidate Technique', + description: 'Candidate description', + modified_by_user: expect.objectContaining({ + name: 'Candidate Reviewer', + }), + }) + ); + expect(snapshot.staged?.[0]).toEqual( + expect.objectContaining({ + attack_id: 'T5678', + name: 'Staged Technique', + description: 'Staged description', + modified_by_user: expect.objectContaining({ + name: 'Staged Reviewer', + }), + }) + ); + }); + + it('should preserve latest selectors for candidate and staged revisions', () => { + const snapshot = new ReleaseTrackSnapshot({ + candidates: [ + { + object_ref: 'attack-pattern--candidate', + object_modified: 'latest', + object_status: 'work-in-progress', + }, + ], + staged: [ + { + object_ref: 'attack-pattern--staged', + object_modified: 'latest', + object_status: 'reviewed', + }, + ], + }); + + expect(snapshot.candidates?.[0].object_modified).toBe('latest'); + expect(snapshot.staged?.[0].object_modified).toBe('latest'); + expect(snapshot.serialize()).toEqual( + expect.objectContaining({ + candidates: [ + expect.objectContaining({ + object_modified: 'latest', + }), + ], + staged: [ + expect.objectContaining({ + object_modified: 'latest', + }), + ], + }) + ); + }); + + it('should deserialize and serialize exact workflow revision timestamps', () => { + const modified = '2026-01-04T00:00:00.000Z'; + const snapshot = new ReleaseTrackSnapshot({ + candidates: [ + { + object_ref: 'attack-pattern--candidate', + object_modified: modified, + object_status: 'work-in-progress', + }, + ], + staged: [ + { + object_ref: 'attack-pattern--staged', + object_modified: modified, + object_status: 'reviewed', + }, + ], + }); + + expect(snapshot.candidates?.[0].object_modified).toEqual( + new Date(modified) + ); + expect(snapshot.staged?.[0].object_modified).toEqual(new Date(modified)); + expect(snapshot.serialize().candidates[0].object_modified).toBe(modified); + expect(snapshot.serialize().staged[0].object_modified).toBe(modified); + }); +}); diff --git a/src/app/classes/release-tracks/snapshot.ts b/src/app/classes/release-tracks/snapshot.ts new file mode 100644 index 000000000..8af8a6bb3 --- /dev/null +++ b/src/app/classes/release-tracks/snapshot.ts @@ -0,0 +1,317 @@ +import { Composition, CompositionResolution } from './composition'; +import { ReleaseTrackConfig } from './config'; +import { ReleaseTrackType } from './enums'; +import { VersionHistoryEntry } from './history'; +import { SnapshotSchedule } from './release-track'; +import { + CandidateEntry, + MemberEntry, + QuarantineEntry, + StagedEntry, + WorkflowRevisionSelector, +} from './tiers'; + +export class ReleaseTrackSnapshot { + public id = ''; + public type: ReleaseTrackType = ReleaseTrackType.Standard; + public modified: Date = new Date(); + public version?: string | null; + public name = ''; + public description?: string; + public snapshot_description?: string; + public created: Date = new Date(); + public created_by_ref?: string; + public object_marking_refs?: string[]; + + public config: ReleaseTrackConfig = {} as ReleaseTrackConfig; + public version_history: VersionHistoryEntry[] = []; + public summary?: Record; + + // standard track tiers + public members: MemberEntry[] = []; + public staged?: StagedEntry[]; + public candidates?: CandidateEntry[]; + + // virtual track tiers + public quarantine?: QuarantineEntry[]; + + // virtual track composition + public composition?: Composition; + public composition_resolution?: CompositionResolution; + public snapshot_schedule?: SnapshotSchedule; + + constructor(raw?: any) { + if (raw) this.deserialize(raw); + } + + public get isVirtual(): boolean { + return this.type === ReleaseTrackType.Virtual; + } + + public get isStandard(): boolean { + return this.type === ReleaseTrackType.Standard; + } + + public get memberCount(): number { + return this.members ? this.members.length : 0; + } + + public get stagedCount(): number { + return this.staged ? this.staged.length : 0; + } + + public get candidateCount(): number { + return this.candidates ? this.candidates.length : 0; + } + + public get quarantineCount(): number { + return this.quarantine ? this.quarantine.length : 0; + } + + public get isTagged(): boolean { + return !!this.version; + } + + public get latestTaggedVersion(): string | null { + if (!this.version_history || this.version_history.length === 0) return null; + const sorted = [...this.version_history].sort((a, b) => { + const ta = a.tagged_at ? new Date(a.tagged_at).getTime() : 0; + const tb = b.tagged_at ? new Date(b.tagged_at).getTime() : 0; + return tb - ta; + }); + return sorted[0].version || null; + } + + // Populate release track snapshot fields from a raw object + public deserialize(raw: any) { + if (!raw) return; + + if ('id' in raw) this.id = raw.id; + if ('type' in raw) this.type = raw.type; + if ('modified' in raw) this.modified = new Date(raw.modified); + if ('version' in raw) this.version = raw.version; + if ('name' in raw) this.name = raw.name; + if ('description' in raw) this.description = raw.description; + if ('snapshot_description' in raw) + this.snapshot_description = raw.snapshot_description; + if ('created' in raw) this.created = new Date(raw.created); + if ('created_by_ref' in raw) this.created_by_ref = raw.created_by_ref; + if ('object_marking_refs' in raw && Array.isArray(raw.object_marking_refs)) + this.object_marking_refs = raw.object_marking_refs.slice(); + + if ('config' in raw) this.config = raw.config; + if ('summary' in raw) this.summary = raw.summary; + + if ('version_history' in raw && Array.isArray(raw.version_history)) { + this.version_history = raw.version_history.map((v: any) => { + const entry: VersionHistoryEntry = { ...v } as any; + if (v.tagged_at) entry.tagged_at = new Date(v.tagged_at); + if (v.snapshot_id) entry.snapshot_id = new Date(v.snapshot_id); + return entry; + }); + } + + if ('members' in raw && Array.isArray(raw.members)) { + this.members = raw.members.map((m: any) => ({ + ...m, + object_ref: m.object_ref, + object_modified: m.object_modified + ? new Date(m.object_modified) + : undefined, + })); + } + + if ('staged' in raw && Array.isArray(raw.staged)) { + this.staged = raw.staged.map((s: any) => ({ + ...s, + object_ref: s.object_ref, + object_modified: this.deserializeWorkflowRevision(s.object_modified), + object_status: s.object_status, + object_staged_at: s.object_staged_at + ? new Date(s.object_staged_at) + : undefined, + object_staged_by: s.object_staged_by, + })); + } + + if ('candidates' in raw && Array.isArray(raw.candidates)) { + this.candidates = raw.candidates.map((c: any) => ({ + ...c, + object_ref: c.object_ref, + object_modified: this.deserializeWorkflowRevision(c.object_modified), + object_status: c.object_status, + object_added_at: c.object_added_at + ? new Date(c.object_added_at) + : undefined, + object_added_by: c.object_added_by, + })); + } + + if ('quarantine' in raw && Array.isArray(raw.quarantine)) { + this.quarantine = raw.quarantine.map((q: any) => ({ + ...q, + object_ref: q.object_ref, + object_modified: q.object_modified + ? new Date(q.object_modified) + : undefined, + source_track_id: q.source_track_id, + source_track_name: q.source_track_name, + source_snapshot_version: q.source_snapshot_version, + conflict_reason: q.conflict_reason, + })); + } + + if ('composition' in raw) this.composition = raw.composition; + + if ('snapshot_schedule' in raw) + this.snapshot_schedule = raw.snapshot_schedule; + + if ('composition_resolution' in raw && raw.composition_resolution) { + const cr = raw.composition_resolution; + const resolved: CompositionResolution = { ...cr } as any; + if (cr.resolved_at) resolved.resolved_at = new Date(cr.resolved_at); + if (cr.component_snapshots && Array.isArray(cr.component_snapshots)) { + resolved.component_snapshots = cr.component_snapshots.map( + (cs: any) => ({ + ...cs, + resolved_snapshot_id: cs.resolved_snapshot_id + ? new Date(cs.resolved_snapshot_id) + : undefined, + }) + ); + } + this.composition_resolution = resolved; + } + } + + // Generate object representation of the release track snapshot + public serialize(): any { + return { + id: this.id, + type: this.type, + modified: this.modified ? this.modified.toISOString() : undefined, + version: this.version, + name: this.name, + description: this.description, + snapshot_description: this.snapshot_description, + created: this.created ? this.created.toISOString() : undefined, + created_by_ref: this.created_by_ref, + object_marking_refs: this.object_marking_refs, + config: this.config, + summary: this.summary, + version_history: this.version_history?.map(v => ({ + ...v, + tagged_at: v.tagged_at + ? (v.tagged_at as any).toISOString() + : v.tagged_at, + snapshot_id: v.snapshot_id + ? (v.snapshot_id as any).toISOString() + : v.snapshot_id, + })), + members: this.members?.map(m => ({ + ...m, + object_modified: m.object_modified + ? (m.object_modified as any).toISOString() + : m.object_modified, + })), + staged: this.staged?.map(s => ({ + ...s, + object_modified: this.serializeWorkflowRevision(s.object_modified), + object_staged_at: s.object_staged_at + ? (s.object_staged_at as any).toISOString() + : s.object_staged_at, + })), + candidates: this.candidates?.map(c => ({ + ...c, + object_modified: this.serializeWorkflowRevision(c.object_modified), + object_added_at: c.object_added_at + ? (c.object_added_at as any).toISOString() + : c.object_added_at, + })), + quarantine: this.quarantine?.map(q => ({ + ...q, + object_modified: q.object_modified + ? (q.object_modified as any).toISOString() + : q.object_modified, + })), + composition: this.composition, + composition_resolution: this.composition_resolution, + snapshot_schedule: this.snapshot_schedule, + }; + } + + // Check if an object with the given STIX ID exists in members + public hasMember(objectRef: string): boolean { + return !!this.members.find(m => m.object_ref === objectRef); + } + + // Check if an object with the given STIX ID exists in candidates + public hasCandidate(objectRef: string): boolean { + return !!this.candidates?.find(c => c.object_ref === objectRef); + } + + // Check if an object with the given STIX ID exists in staged + public hasStaged(objectRef: string): boolean { + return !!this.staged?.find(c => c.object_ref === objectRef); + } + + // Get the member entry for the given STIX ID + public findMember(objectRef: string): MemberEntry | undefined { + return this.members.find(m => m.object_ref === objectRef); + } + + private deserializeWorkflowRevision( + value: string | Date | undefined + ): WorkflowRevisionSelector | undefined { + if (!value) return undefined; + return value === 'latest' ? 'latest' : new Date(value); + } + + private serializeWorkflowRevision( + value: WorkflowRevisionSelector | undefined + ): string | undefined { + if (value === undefined) return undefined; + if (value === 'latest') return value; + return value.toISOString(); + } + + // Get the candidate entry for the given STIX ID + public findCandidate(objectRef: string): CandidateEntry | undefined { + return this.candidates?.find(c => c.object_ref === objectRef); + } + + // Get the staged entry for the given STIX ID + public findStaged(objectRef: string): StagedEntry | undefined { + return this.staged?.find(c => c.object_ref === objectRef); + } + + // Add candidate entry + public addCandidate(entry: CandidateEntry): void { + if (!this.candidates) this.candidates = []; + if (!this.hasCandidate(entry.object_ref)) this.candidates.push(entry); + } + + // Remove candidate entry + public removeCandidate(objectRef: string): void { + if (!this.candidates) return; + this.candidates = this.candidates.filter(c => c.object_ref !== objectRef); + } + + // Get a summary of the snapshot object + public toSummary(): any { + return { + id: this.id, + name: this.name, + type: this.type, + version: this.version, + modified: this.modified, + snapshot_description: this.snapshot_description, + counts: { + members: this.memberCount, + staged: this.stagedCount, + candidates: this.candidateCount, + quarantine: this.quarantineCount, + }, + }; + } +} diff --git a/src/app/classes/release-tracks/tiers.ts b/src/app/classes/release-tracks/tiers.ts new file mode 100644 index 000000000..bae5f0ad9 --- /dev/null +++ b/src/app/classes/release-tracks/tiers.ts @@ -0,0 +1,51 @@ +import { WorkflowStatusType } from 'src/app/utils/types'; +import { SnapshotTier } from './enums'; + +export type ReleaseTrackObjectTier = + SnapshotTier.Candidate | SnapshotTier.Staged; + +export type WorkflowRevisionSelector = Date | 'latest'; + +export interface TierEntryModifiedByUser { + id?: string; + username?: string; + displayName?: string; + name?: string; +} + +export interface TierEntryDisplayFields { + attack_id?: string; + name?: string; + description?: string; + modified_by_user?: TierEntryModifiedByUser; +} + +export interface MemberEntry { + object_ref: string; + object_modified: Date; +} + +export interface StagedEntry extends TierEntryDisplayFields { + object_ref: string; + object_modified: WorkflowRevisionSelector; + object_status: WorkflowStatusType; + object_staged_at: Date; + object_staged_by: string; +} + +export interface CandidateEntry extends TierEntryDisplayFields { + object_ref: string; + object_modified: WorkflowRevisionSelector; + object_status: WorkflowStatusType; + object_added_at: Date; + object_added_by: string; +} + +export interface QuarantineEntry { + object_ref: string; + object_modified: Date; + source_track_id: string; + source_track_name: string; + source_snapshot_version?: string | null; + conflict_reason: string; +} diff --git a/src/app/classes/serializable.ts b/src/app/classes/serializable.ts index 286bf37c4..b4bdce1d8 100644 --- a/src/app/classes/serializable.ts +++ b/src/app/classes/serializable.ts @@ -1,6 +1,6 @@ import { Observable } from 'rxjs'; import { RestApiConnectorService } from '../services/connectors/rest-api/rest-api-connector.service'; -import { WorkflowState } from '../utils/types'; +import { WorkflowStatusType } from '../utils/types'; /** * Objects which are serializable to the REST API implement this class @@ -17,7 +17,7 @@ export abstract class Serializable { * @abstract * @param {*} raw the raw object to parse */ - public abstract deserialize(raw: any); + public abstract deserialize(raw: any): any; /** * Validate the current object state and return information on the result of the validation @@ -29,7 +29,7 @@ export abstract class Serializable { public abstract validate( restAPIService: RestApiConnectorService, options?: any, - tempWorkflowState?: WorkflowState + tempWorkflowState?: WorkflowStatusType ): Observable; } diff --git a/src/app/classes/stix/analytic.ts b/src/app/classes/stix/analytic.ts index 9ea63968c..b00d1a1c0 100644 --- a/src/app/classes/stix/analytic.ts +++ b/src/app/classes/stix/analytic.ts @@ -3,7 +3,7 @@ import { logger } from '../../utils/logger'; import { Observable, of, switchMap } from 'rxjs'; import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; import { ValidationData } from '../serializable'; -import { WorkflowState } from 'src/app/utils/types'; +import { WorkflowStatusType } from 'src/app/utils/types'; export class Analytic extends StixObject { public name = ''; @@ -179,7 +179,7 @@ export class Analytic extends StixObject { */ public validate( restAPIService: RestApiConnectorService, - tempWorkflowState?: WorkflowState + tempWorkflowState?: WorkflowStatusType ): Observable { return this.base_validate(restAPIService, tempWorkflowState).pipe( switchMap(result => { diff --git a/src/app/classes/stix/asset.ts b/src/app/classes/stix/asset.ts index c6a9add5c..42daeac38 100644 --- a/src/app/classes/stix/asset.ts +++ b/src/app/classes/stix/asset.ts @@ -3,7 +3,7 @@ import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/re import { logger } from '../../utils/logger'; import { ValidationData } from '../serializable'; import { StixObject } from './stix-object'; -import { WorkflowState } from 'src/app/utils/types'; +import { WorkflowStatusType } from 'src/app/utils/types'; export class Asset extends StixObject { public name = ''; @@ -139,7 +139,7 @@ export class Asset extends StixObject { */ public validate( restAPIService: RestApiConnectorService, - tempWorkflowState?: WorkflowState + tempWorkflowState?: WorkflowStatusType ): Observable { return this.base_validate(restAPIService, tempWorkflowState); } diff --git a/src/app/classes/stix/campaign-citations.spec.ts b/src/app/classes/stix/campaign-citations.spec.ts new file mode 100644 index 000000000..c4c4a72e7 --- /dev/null +++ b/src/app/classes/stix/campaign-citations.spec.ts @@ -0,0 +1,53 @@ +import { firstValueFrom, of } from 'rxjs'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { Campaign } from './campaign'; + +describe('Campaign citation validation', () => { + it('preserves references cited by the first and last seen fields', async () => { + const campaign = new Campaign({ + workspace: { + workflow: { + state: 'work-in-progress', + }, + }, + stix: { + type: 'campaign', + id: 'campaign--46421788-b6e1-4256-b351-f8beffd1afba', + created: '2023-09-27T13:11:52.340Z', + modified: '2026-07-31T20:48:14.920Z', + spec_version: '2.1', + name: '2015 Ukraine Electric Power Attack', + description: 'Campaign description without citations.', + aliases: ['2015 Ukraine Electric Power Attack'], + first_seen: '2015-12-01T05:00:00.000Z', + last_seen: '2016-01-01T05:00:00.000Z', + x_mitre_first_seen_citation: '(Citation: Booz Allen Hamilton)', + x_mitre_last_seen_citation: '(Citation: Booz Allen Hamilton)', + x_mitre_version: '1.0', + external_references: [ + { + source_name: 'mitre-attack', + external_id: 'C0028', + url: 'https://attack.mitre.org/campaigns/C0028', + }, + { + source_name: 'Booz Allen Hamilton', + description: 'When The Lights Went Out.', + url: 'https://example.com/when-the-lights-went-out.pdf', + }, + ], + }, + }); + const restApiService = { + validateStixObject: () => () => of({ errors: [], warnings: [] }), + getAllCampaigns: () => of({ data: [] }), + getReference: () => of([]), + } as unknown as RestApiConnectorService; + + await firstValueFrom(campaign.validate(restApiService)); + + expect(campaign.serialize().stix.external_references).toContainEqual( + expect.objectContaining({ source_name: 'Booz Allen Hamilton' }) + ); + }); +}); diff --git a/src/app/classes/stix/campaign.ts b/src/app/classes/stix/campaign.ts index 25d4c081e..e820334b2 100644 --- a/src/app/classes/stix/campaign.ts +++ b/src/app/classes/stix/campaign.ts @@ -3,7 +3,7 @@ import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/re import { logger } from '../../utils/logger'; import { ValidationData } from '../serializable'; import { StixObject } from './stix-object'; -import { WorkflowState } from 'src/app/utils/types'; +import { WorkflowStatusType } from 'src/app/utils/types'; export class Campaign extends StixObject { public name = ''; @@ -13,6 +13,7 @@ export class Campaign extends StixObject { public last_seen_citation = ''; public aliases: string[] = []; public contributors: string[] = []; + public domains: string[] = []; public readonly supportsAttackID = true; protected get attackIDValidator() { @@ -47,6 +48,7 @@ export class Campaign extends StixObject { rep.stix.x_mitre_last_seen_citation = this.last_seen_citation.trim(); rep.stix.aliases = this.aliases.map(x => x.trim()); rep.stix.x_mitre_contributors = this.contributors.map(x => x.trim()); + rep.stix.x_mitre_domains = this.domains; // Strip properties that are empty strs + lists rep.stix = this.filterObject(rep.stix); @@ -151,6 +153,12 @@ export class Campaign extends StixObject { ')' ); } else this.contributors = []; + + if ('x_mitre_domains' in sdo) { + if (this.isStringArray(sdo.x_mitre_domains)) + this.domains = sdo.x_mitre_domains; + else logger.error('TypeError: domains field is not a string array.'); + } else this.domains = []; } } @@ -161,7 +169,7 @@ export class Campaign extends StixObject { */ public validate( restAPIService: RestApiConnectorService, - tempWorkflowState?: WorkflowState + tempWorkflowState?: WorkflowStatusType ): Observable { return this.base_validate(restAPIService, tempWorkflowState); } diff --git a/src/app/classes/stix/canonical-domains.spec.ts b/src/app/classes/stix/canonical-domains.spec.ts new file mode 100644 index 000000000..834efd03d --- /dev/null +++ b/src/app/classes/stix/canonical-domains.spec.ts @@ -0,0 +1,77 @@ +import { + Analytic, + Asset, + Campaign, + DataComponent, + DataSource, + DetectionStrategy, + Group, + Matrix, + Mitigation, + Software, + StixObject, + Tactic, + Technique, +} from './index'; + +const domains = ['enterprise-attack', 'ics-attack']; + +function rawObject(type: string) { + return { + workspace: { + workflow: { + state: 'work-in-progress', + }, + }, + stix: { + type, + id: `${type}--00000000-0000-4000-8000-000000000000`, + created: '2026-08-03T00:00:00.000Z', + modified: '2026-08-03T00:00:00.000Z', + spec_version: '2.1', + name: 'Domain round-trip fixture', + x_mitre_version: '1.0', + x_mitre_domains: domains, + external_references: [], + }, + }; +} + +const targetTypes: { + type: string; + create: (raw: ReturnType) => StixObject; +}[] = [ + { type: 'attack-pattern', create: raw => new Technique(raw) }, + { type: 'campaign', create: raw => new Campaign(raw) }, + { type: 'course-of-action', create: raw => new Mitigation(raw) }, + { type: 'intrusion-set', create: raw => new Group(raw) }, + { type: 'malware', create: raw => new Software('malware', raw) }, + { type: 'tool', create: raw => new Software('tool', raw) }, + { type: 'x-mitre-analytic', create: raw => new Analytic(raw) }, + { type: 'x-mitre-asset', create: raw => new Asset(raw) }, + { + type: 'x-mitre-data-component', + create: raw => new DataComponent(raw), + }, + { type: 'x-mitre-data-source', create: raw => new DataSource(raw) }, + { + type: 'x-mitre-detection-strategy', + create: raw => new DetectionStrategy(raw), + }, + { type: 'x-mitre-matrix', create: raw => new Matrix(raw) }, + { type: 'x-mitre-tactic', create: raw => new Tactic(raw) }, +]; + +describe('canonical domain-bearing STIX objects', () => { + it.each(targetTypes)( + 'preserves x_mitre_domains when revising $type', + ({ type, create }) => { + const object = create(rawObject(type)); + + expect((object as StixObject & { domains: string[] }).domains).toEqual( + domains + ); + expect(object.serialize().stix.x_mitre_domains).toEqual(domains); + } + ); +}); diff --git a/src/app/classes/stix/collection.ts b/src/app/classes/stix/collection.ts index ed9d5ac39..ce6836624 100644 --- a/src/app/classes/stix/collection.ts +++ b/src/app/classes/stix/collection.ts @@ -19,7 +19,7 @@ import { Tactic, Technique, } from '../stix'; -import { WorkflowState } from 'src/app/utils/types'; +import { WorkflowStatusType } from 'src/app/utils/types'; /** * auto-generated changelog/report about an import @@ -498,7 +498,7 @@ export class Collection extends StixObject { */ public validate( restAPIService: RestApiConnectorService, - tempWorkflowState?: WorkflowState + tempWorkflowState?: WorkflowStatusType ): Observable { return this.base_validate(restAPIService, tempWorkflowState); } diff --git a/src/app/classes/stix/data-component.ts b/src/app/classes/stix/data-component.ts index f574be93b..03a3227ee 100644 --- a/src/app/classes/stix/data-component.ts +++ b/src/app/classes/stix/data-component.ts @@ -4,7 +4,7 @@ import { ValidationData } from '../serializable'; import { StixObject } from './stix-object'; import { logger } from '../../utils/logger'; import { DataSource } from './data-source'; -import { WorkflowState } from 'src/app/utils/types'; +import { WorkflowStatusType } from 'src/app/utils/types'; export class DataComponent extends StixObject { public name = ''; @@ -133,7 +133,7 @@ export class DataComponent extends StixObject { */ public validate( restAPIService: RestApiConnectorService, - tempWorkflowState?: WorkflowState + tempWorkflowState?: WorkflowStatusType ): Observable { return this.base_validate(restAPIService, tempWorkflowState); } diff --git a/src/app/classes/stix/data-source.ts b/src/app/classes/stix/data-source.ts index 26c154c2d..be7255988 100644 --- a/src/app/classes/stix/data-source.ts +++ b/src/app/classes/stix/data-source.ts @@ -4,7 +4,7 @@ import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/re import { Observable } from 'rxjs'; import { ValidationData } from '../serializable'; import { DataComponent } from './data-component'; -import { WorkflowState } from 'src/app/utils/types'; +import { WorkflowStatusType } from 'src/app/utils/types'; export class DataSource extends StixObject { public name = ''; @@ -136,7 +136,7 @@ export class DataSource extends StixObject { */ public validate( restAPIService: RestApiConnectorService, - tempWorkflowState?: WorkflowState + tempWorkflowState?: WorkflowStatusType ): Observable { return this.base_validate(restAPIService, tempWorkflowState); } diff --git a/src/app/classes/stix/detection-strategy.ts b/src/app/classes/stix/detection-strategy.ts index a1cfa794a..8f7a0eee6 100644 --- a/src/app/classes/stix/detection-strategy.ts +++ b/src/app/classes/stix/detection-strategy.ts @@ -3,12 +3,13 @@ import { logger } from '../../utils/logger'; import { Observable } from 'rxjs'; import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; import { ValidationData } from '../serializable'; -import { WorkflowState } from 'src/app/utils/types'; +import { WorkflowStatusType } from 'src/app/utils/types'; export class DetectionStrategy extends StixObject { public name = ''; public contributors: string[] = []; public analytics: string[] = []; // list of x-mitre-analytic uuids + public domains: string[] = []; public readonly supportsAttackID = true; protected get attackIDValidator() { @@ -37,6 +38,7 @@ export class DetectionStrategy extends StixObject { rep.stix.name = this.name.trim(); rep.stix.x_mitre_contributors = this.contributors.map(x => x.trim()); if (this.analytics) rep.stix.x_mitre_analytic_refs = this.analytics; + rep.stix.x_mitre_domains = this.domains; // Strip properties that are empty strs + lists rep.stix = this.filterObject(rep.stix); @@ -78,6 +80,12 @@ export class DetectionStrategy extends StixObject { `TypeError: x_mitre_analytic_refs field is not a string array: ${sdo.x_mitre_analytic_refs} (${typeof sdo.x_mitre_analytic_refs})` ); } else this.analytics = []; + + if ('x_mitre_domains' in sdo) { + if (this.isStringArray(sdo.x_mitre_domains)) + this.domains = sdo.x_mitre_domains; + else logger.error('TypeError: domains field is not a string array.'); + } else this.domains = []; } } @@ -88,7 +96,7 @@ export class DetectionStrategy extends StixObject { */ public validate( restAPIService: RestApiConnectorService, - tempWorkflowState?: WorkflowState + tempWorkflowState?: WorkflowStatusType ): Observable { return this.base_validate(restAPIService, tempWorkflowState); } diff --git a/src/app/classes/stix/group.ts b/src/app/classes/stix/group.ts index bd7e2b6ee..1850b27d5 100644 --- a/src/app/classes/stix/group.ts +++ b/src/app/classes/stix/group.ts @@ -3,12 +3,13 @@ import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/re import { Observable } from 'rxjs'; import { ValidationData } from '../serializable'; import { logger } from '../../utils/logger'; -import { WorkflowState } from 'src/app/utils/types'; +import { WorkflowStatusType } from 'src/app/utils/types'; export class Group extends StixObject { public name = ''; public aliases: string[] = []; public contributors: string[] = []; + public domains: string[] = []; public readonly supportsAttackID = true; protected get attackIDValidator() { @@ -38,6 +39,7 @@ export class Group extends StixObject { rep.stix.name = this.name.trim(); rep.stix.aliases = this.aliases.map(x => x.trim()); rep.stix.x_mitre_contributors = this.contributors.map(x => x.trim()); + rep.stix.x_mitre_domains = this.domains; // Strip properties that are empty strs + lists rep.stix = this.filterObject(rep.stix); @@ -90,6 +92,12 @@ export class Group extends StixObject { ')' ); } else this.contributors = []; + + if ('x_mitre_domains' in sdo) { + if (this.isStringArray(sdo.x_mitre_domains)) + this.domains = sdo.x_mitre_domains; + else logger.error('TypeError: domains field is not a string array.'); + } else this.domains = []; } } @@ -100,7 +108,7 @@ export class Group extends StixObject { */ public validate( restAPIService: RestApiConnectorService, - tempWorkflowState?: WorkflowState + tempWorkflowState?: WorkflowStatusType ): Observable { return this.base_validate(restAPIService, tempWorkflowState); } diff --git a/src/app/classes/stix/identity.ts b/src/app/classes/stix/identity.ts index 5a462ce61..7a3ce186b 100644 --- a/src/app/classes/stix/identity.ts +++ b/src/app/classes/stix/identity.ts @@ -3,7 +3,7 @@ import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/re import { logger } from '../../utils/logger'; import { ValidationData } from '../serializable'; import { StixObject } from './stix-object'; -import { WorkflowState } from 'src/app/utils/types'; +import { WorkflowStatusType } from 'src/app/utils/types'; export class Identity extends StixObject { public name: string; // identity name @@ -118,8 +118,9 @@ export class Identity extends StixObject { */ public validate( restAPIService: RestApiConnectorService, - _tempWorkflowState?: WorkflowState + _tempWorkflowState?: WorkflowStatusType ): Observable { + void _tempWorkflowState; return this.base_validate(restAPIService); } diff --git a/src/app/classes/stix/marking-definition.ts b/src/app/classes/stix/marking-definition.ts index 458e7e744..dc4cd1902 100644 --- a/src/app/classes/stix/marking-definition.ts +++ b/src/app/classes/stix/marking-definition.ts @@ -4,7 +4,7 @@ import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/re import { Observable, of } from 'rxjs'; import { ValidationData } from '../serializable'; import { logger } from '../../utils/logger'; -import { WorkflowState } from 'src/app/utils/types'; +import { WorkflowStatusType } from 'src/app/utils/types'; export class MarkingDefinition extends StixObject { public name = ''; @@ -104,7 +104,7 @@ export class MarkingDefinition extends StixObject { */ public validate( restAPIService: RestApiConnectorService, - tempWorkflowState?: WorkflowState + tempWorkflowState?: WorkflowStatusType ): Observable { return this.base_validate(restAPIService, tempWorkflowState).pipe( map(result => { diff --git a/src/app/classes/stix/matrix.ts b/src/app/classes/stix/matrix.ts index 1e62fdd78..f9264053f 100644 --- a/src/app/classes/stix/matrix.ts +++ b/src/app/classes/stix/matrix.ts @@ -4,11 +4,12 @@ import { ValidationData } from '../serializable'; import { StixObject } from './stix-object'; import { logger } from '../../utils/logger'; import { Tactic } from './tactic'; -import { WorkflowState } from 'src/app/utils/types'; +import { WorkflowStatusType } from 'src/app/utils/types'; export class Matrix extends StixObject { public name = ''; public tactic_refs: string[] = []; + public domains: string[] = []; // NOTE: this is only populated in the matrix view when calling getTechniquesInMatrix() NOT getMatrix() public tactic_objects: Tactic[] = []; @@ -40,6 +41,7 @@ export class Matrix extends StixObject { rep.stix.name = this.name.trim(); rep.stix.tactic_refs = this.tactic_refs; + rep.stix.x_mitre_domains = this.domains; // Strip properties that are empty strs + lists rep.stix = this.filterObject(rep.stix); @@ -80,6 +82,12 @@ export class Matrix extends StixObject { ')' ); } else this.tactic_refs = []; + + if ('x_mitre_domains' in sdo) { + if (this.isStringArray(sdo.x_mitre_domains)) + this.domains = sdo.x_mitre_domains; + else logger.error('TypeError: domains field is not a string array.'); + } else this.domains = []; } } @@ -90,7 +98,7 @@ export class Matrix extends StixObject { */ public validate( restAPIService: RestApiConnectorService, - tempWorkflowState?: WorkflowState + tempWorkflowState?: WorkflowStatusType ): Observable { // TODO verify all tactics exist return this.base_validate(restAPIService, tempWorkflowState); @@ -116,6 +124,7 @@ export class Matrix extends StixObject { public delete(_restAPIService: RestApiConnectorService): Observable { // deletion is not supported on Matrix objects + void _restAPIService; return of({}); } diff --git a/src/app/classes/stix/mitigation.ts b/src/app/classes/stix/mitigation.ts index 783582fd1..acf0a3580 100644 --- a/src/app/classes/stix/mitigation.ts +++ b/src/app/classes/stix/mitigation.ts @@ -3,7 +3,7 @@ import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/re import { ValidationData } from '../serializable'; import { StixObject } from './stix-object'; import { logger } from '../../utils/logger'; -import { WorkflowState } from 'src/app/utils/types'; +import { WorkflowStatusType } from 'src/app/utils/types'; export class Mitigation extends StixObject { public name = ''; @@ -94,7 +94,7 @@ export class Mitigation extends StixObject { */ public validate( restAPIService: RestApiConnectorService, - tempWorkflowState?: WorkflowState + tempWorkflowState?: WorkflowStatusType ): Observable { return this.base_validate(restAPIService, tempWorkflowState); } diff --git a/src/app/classes/stix/note.ts b/src/app/classes/stix/note.ts index bbf0e80e5..b9a44ca68 100644 --- a/src/app/classes/stix/note.ts +++ b/src/app/classes/stix/note.ts @@ -3,7 +3,7 @@ import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/re import { ValidationData } from '../serializable'; import { StixObject } from './stix-object'; import { logger } from '../../utils/logger'; -import { WorkflowState } from 'src/app/utils/types'; +import { WorkflowStatusType } from 'src/app/utils/types'; export class Note extends StixObject { public title = ''; @@ -88,7 +88,7 @@ export class Note extends StixObject { */ public validate( restAPIService: RestApiConnectorService, - tempWorkflowState?: WorkflowState + tempWorkflowState?: WorkflowStatusType ): Observable { return this.base_validate(restAPIService, tempWorkflowState); } diff --git a/src/app/classes/stix/relationship.spec.ts b/src/app/classes/stix/relationship.spec.ts new file mode 100644 index 000000000..9002419c0 --- /dev/null +++ b/src/app/classes/stix/relationship.spec.ts @@ -0,0 +1,89 @@ +import { firstValueFrom } from 'rxjs'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { createAsyncObservable } from 'src/app/testing/mocks/rest-api-connector.mock'; +import { Group } from './group'; +import { Mitigation } from './mitigation'; +import { Relationship } from './relationship'; + +function rawObject(type: string, id: string, state: string) { + return { + workspace: { workflow: { state } }, + stix: { + type, + id, + created: '2026-08-04T12:00:00.000Z', + modified: '2026-08-04T12:00:00.000Z', + spec_version: '2.1', + name: `${type} fixture`, + description: `${type} description`, + x_mitre_version: '1.0', + external_references: [], + }, + }; +} + +describe('Relationship revision workflow', () => { + it('creates WIP revisions of related SDOs after saving a relationship revision', async () => { + const source = rawObject( + 'intrusion-set', + 'intrusion-set--00000000-0000-4000-8000-000000000001', + 'reviewed' + ); + const target = rawObject( + 'course-of-action', + 'course-of-action--00000000-0000-4000-8000-000000000002', + 'awaiting-review' + ); + const relationship = new Relationship({ + workspace: { workflow: { state: 'reviewed' } }, + stix: { + type: 'relationship', + id: 'relationship--00000000-0000-4000-8000-000000000003', + created: '2026-08-04T12:00:00.000Z', + modified: '2026-08-04T12:00:00.000Z', + spec_version: '2.1', + relationship_type: 'mitigates', + source_ref: source.stix.id, + target_ref: target.stix.id, + description: 'Updated relationship description', + }, + source_object: source, + target_object: target, + }); + const calls: string[] = []; + const postRelationship = vi.fn((value: Relationship) => { + calls.push('relationship:post'); + return createAsyncObservable(value); + }); + const postGroup = vi.fn((value: Group) => { + calls.push('source:post'); + return createAsyncObservable(value); + }); + const postMitigation = vi.fn((value: Mitigation) => { + calls.push('target:post'); + return createAsyncObservable(value); + }); + const putGroup = vi.fn(); + const putMitigation = vi.fn(); + const restApiService = { + postRelationship, + postGroup, + postMitigation, + putGroup, + putMitigation, + } as unknown as RestApiConnectorService; + + await firstValueFrom(relationship.save(restApiService)); + + expect(calls).toEqual(['relationship:post', 'source:post', 'target:post']); + expect(postRelationship).toHaveBeenCalledWith(relationship); + expect(postGroup).toHaveBeenCalledOnce(); + expect(postMitigation).toHaveBeenCalledOnce(); + expect(postGroup.mock.calls[0][0].workflow?.state).toBe('work-in-progress'); + expect(postMitigation.mock.calls[0][0].workflow?.state).toBe( + 'work-in-progress' + ); + expect(putGroup).not.toHaveBeenCalled(); + expect(putMitigation).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/classes/stix/relationship.ts b/src/app/classes/stix/relationship.ts index c942fc39d..91ec7b110 100644 --- a/src/app/classes/stix/relationship.ts +++ b/src/app/classes/stix/relationship.ts @@ -1,5 +1,5 @@ -import { Observable, of } from 'rxjs'; -import { map, switchMap } from 'rxjs/operators'; +import { concat, defer, Observable, of } from 'rxjs'; +import { last, map, switchMap } from 'rxjs/operators'; import { Asset, Campaign, @@ -14,10 +14,11 @@ import { Technique, } from 'src/app/classes/stix'; import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { ReleaseTracksConnectorService } from 'src/app/services/connectors/rest-api/release-tracks.service'; import { logger } from '../../utils/logger'; import { ValidationData } from '../serializable'; import { StixObject } from './stix-object'; -import { WorkflowState } from 'src/app/utils/types'; +import { WorkflowStatus, WorkflowStatusType } from 'src/app/utils/types'; export class Relationship extends StixObject { public source_ref = ''; @@ -485,7 +486,8 @@ export class Relationship extends StixObject { * @returns {Observable} the validation warnings and errors once validation is complete. */ public validate( - restAPIService: RestApiConnectorService + restAPIService: RestApiConnectorService, + tempWorkflowState?: WorkflowStatusType ): Observable { return this.base_validate(restAPIService).pipe( map(result => { @@ -618,28 +620,38 @@ export class Relationship extends StixObject { * @returns {Observable} of the post */ public save( - restAPIService: RestApiConnectorService + restAPIService: RestApiConnectorService, + _releaseTracksService?: ReleaseTracksConnectorService ): Observable { - const postObservable = restAPIService.postRelationship(this); - const subscription = postObservable.subscribe({ - next: result => { + if (!this.workflow) { + // Initialize the workflow object if it doesn't exist + this.workflow = { state: WorkflowStatus.WorkInProgress }; + } + this.workflow.state = WorkflowStatus.WorkInProgress; + return restAPIService.postRelationship(this).pipe( + switchMap(result => { this.deserialize(result.serialize()); const source_object = this.getObject( this.source_object.stix.type, this.source_object ); - this.updateSourceTargetObject(restAPIService, source_object); const target_object = this.getObject( this.target_object.stix.type, this.target_object ); - this.updateSourceTargetObject(restAPIService, target_object); - }, - complete: () => { - subscription.unsubscribe(); - }, - }); - return postObservable; + return concat( + defer(() => + this.updateSourceTargetObject(restAPIService, source_object) + ), + defer(() => + this.updateSourceTargetObject(restAPIService, target_object) + ) + ).pipe( + last(), + map(() => result) + ); + }) + ); } /** @@ -677,7 +689,8 @@ export class Relationship extends StixObject { } /** - * Helper function to update the workflow status of the source object of the relationship, + * Creates a WIP revision of a related object. Existing revisions may be + * pinned by deterministic snapshot graphs and must remain immutable. * @param restAPIService the rest api service * @param object the relationship source object */ @@ -688,20 +701,9 @@ export class Relationship extends StixObject { // Check if the workflow object exists if (!object.workflow) { // Initialize the workflow object if it doesn't exist - object.workflow = { state: 'work-in-progress' }; + object.workflow = { state: WorkflowStatus.WorkInProgress }; } - object.workflow.state = 'work-in-progress'; - object.update(restAPIService).subscribe({ - next: response => { - console.log('Object updated successfully:', response); - window.location.reload(); - }, - error: error => { - console.error('Error updating object:', error); - }, - complete: () => { - console.log('Complete'); - }, - }); + object.workflow.state = WorkflowStatus.WorkInProgress; + return object.save(restAPIService); } } diff --git a/src/app/classes/stix/software.ts b/src/app/classes/stix/software.ts index d57b941c4..009998d4c 100644 --- a/src/app/classes/stix/software.ts +++ b/src/app/classes/stix/software.ts @@ -3,7 +3,7 @@ import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/re import { ValidationData } from '../serializable'; import { StixObject } from './stix-object'; import { logger } from '../../utils/logger'; -import { WorkflowState } from 'src/app/utils/types'; +import { WorkflowStatusType } from 'src/app/utils/types'; type type_software = 'malware' | 'tool'; export class Software extends StixObject { @@ -142,7 +142,7 @@ export class Software extends StixObject { */ public validate( restAPIService: RestApiConnectorService, - tempWorkflowState?: WorkflowState + tempWorkflowState?: WorkflowStatusType ): Observable { return this.base_validate(restAPIService, tempWorkflowState); } diff --git a/src/app/classes/stix/stix-object.ts b/src/app/classes/stix/stix-object.ts index fc0bbc87d..97dad7225 100644 --- a/src/app/classes/stix/stix-object.ts +++ b/src/app/classes/stix/stix-object.ts @@ -12,7 +12,7 @@ import { AttackTypeToRoute, StixTypeToAttackType, } from 'src/app/utils/type-mappings'; -import { StixType, WorkflowState } from 'src/app/utils/types'; +import { WorkflowStatus, WorkflowStatusType } from 'src/app/utils/types'; import { v4 as uuid } from 'uuid'; import { logger } from '../../utils/logger'; import { ExternalReferences } from '../external-references'; @@ -20,11 +20,6 @@ import { Serializable, ValidationData } from '../serializable'; import { UserAccount } from '../authn/user-account'; import { VersionNumber } from '../version-number'; -export type workflowStates = - | 'work-in-progress' - | 'awaiting-review' - | 'reviewed'; - export abstract class StixObject extends Serializable { public stixID: string; // STIX ID public type: string; // STIX type @@ -42,7 +37,7 @@ export abstract class StixObject extends Serializable { public object_marking_refs: string[] = []; //list of embedded relationships to marking_defs public abstract readonly supportsAttackID: boolean; // boolean to determine if object supports ATT&CK IDs - public tempWorkflowState: WorkflowState; + public tempWorkflowState: WorkflowStatusType; protected abstract get attackIDValidator(): { regex: string; // regex to validate the ID format: string; // format to display to user @@ -69,9 +64,10 @@ export abstract class StixObject extends Serializable { public version: VersionNumber; // version number of the object public external_references: ExternalReferences; public workflow: { - state: workflowStates; + state: WorkflowStatusType; created_by_user_account?: string; }; + public workspace?: any; public deprecated = false; //is object deprecated? public revoked = false; //is object revoked? @@ -94,7 +90,7 @@ export abstract class StixObject extends Serializable { this.external_references = new ExternalReferences(); if (this.type !== 'x-mitre-collection' && this.type !== 'relationship') { this.workflow = { - state: 'work-in-progress', + state: WorkflowStatus.WorkInProgress, }; } this.description = ''; @@ -368,6 +364,7 @@ export abstract class StixObject extends Serializable { if ('workspace' in raw) { // parse workspace fields const workspaceData = raw.workspace; + this.workspace = workspaceData; if ('workflow' in workspaceData && workspaceData.workflow !== undefined) { if (typeof workspaceData.workflow == 'object') { this.workflow = workspaceData.workflow; @@ -527,6 +524,8 @@ export abstract class StixObject extends Serializable { refs_fields.push('aliases'); if (this.attackType == 'asset') refs_fields.push('relatedAssets'); if (this.attackType == 'technique') refs_fields.push('detection'); + if (this.attackType == 'campaign') + refs_fields.push('first_seen_citation', 'last_seen_citation'); return this.external_references .validate(restAPIService, { object: this, fields: refs_fields }) diff --git a/src/app/classes/stix/tactic.ts b/src/app/classes/stix/tactic.ts index 32125bdc5..f1880e255 100644 --- a/src/app/classes/stix/tactic.ts +++ b/src/app/classes/stix/tactic.ts @@ -4,7 +4,7 @@ import { ValidationData } from '../serializable'; import { StixObject } from './stix-object'; import { logger } from '../../utils/logger'; import { Technique } from './technique'; -import { WorkflowState } from 'src/app/utils/types'; +import { WorkflowStatusType } from 'src/app/utils/types'; export class Tactic extends StixObject { public name = ''; @@ -105,7 +105,7 @@ export class Tactic extends StixObject { */ public validate( restAPIService: RestApiConnectorService, - tempWorkflowState?: WorkflowState + tempWorkflowState?: WorkflowStatusType ): Observable { return this.base_validate(restAPIService, tempWorkflowState); } diff --git a/src/app/classes/stix/technique.ts b/src/app/classes/stix/technique.ts index 5d867d20b..7ae6dbc45 100644 --- a/src/app/classes/stix/technique.ts +++ b/src/app/classes/stix/technique.ts @@ -5,7 +5,7 @@ import { logger } from '../../utils/logger'; import { ValidationData } from '../serializable'; import { Relationship } from './relationship'; import { StixObject } from './stix-object'; -import { WorkflowState } from 'src/app/utils/types'; +import { WorkflowStatusType } from 'src/app/utils/types'; export class Technique extends StixObject { public name = ''; @@ -453,7 +453,7 @@ export class Technique extends StixObject { */ public validate( restAPIService: RestApiConnectorService, - tempWorkflowState?: WorkflowState + tempWorkflowState?: WorkflowStatusType ): Observable { return this.base_validate(restAPIService, tempWorkflowState).pipe( map(result => { diff --git a/src/app/components/add-dialog/add-dialog.component.html b/src/app/components/add-dialog/add-dialog.component.html index 6c28c0882..874d0bc74 100644 --- a/src/app/components/add-dialog/add-dialog.component.html +++ b/src/app/components/add-dialog/add-dialog.component.html @@ -1,13 +1,7 @@

{{ config.title }}

-
+
{{ config.title }} + [config]=" + config.stixListConfig || { + select: config.selectionType + ? config.selectionType + : config.select + ? 'many' + : 'disabled', + selectionModel: config.select, + type: config.type, + clickBehavior: 'expand', + stixObjects: config.selectableObjects + ? config.selectableObjects + : undefined, + showFilters: false, + } + "> - + + + +
diff --git a/src/app/components/confirmation-dialog/confirmation-dialog.component.scss b/src/app/components/confirmation-dialog/confirmation-dialog.component.scss index 0e4bc39bc..e5a7ee608 100644 --- a/src/app/components/confirmation-dialog/confirmation-dialog.component.scss +++ b/src/app/components/confirmation-dialog/confirmation-dialog.component.scss @@ -1,10 +1,25 @@ .confirmation-dialog { margin: 0 24px; text-align: center; + + &.simple-confirmation-dialog { + text-align: left; + + markdown { + display: block; + font-size: 14px; + line-height: 20px; + } + } + .buttons { + display: flex; + justify-content: flex-end; + gap: 12px; margin: 18px 0; + button + button { - margin-left: 10px; + margin-left: 0; } } } diff --git a/src/app/components/confirmation-dialog/confirmation-dialog.component.ts b/src/app/components/confirmation-dialog/confirmation-dialog.component.ts index 7a7d29440..4e02dc030 100644 --- a/src/app/components/confirmation-dialog/confirmation-dialog.component.ts +++ b/src/app/components/confirmation-dialog/confirmation-dialog.component.ts @@ -13,15 +13,39 @@ export class ConfirmationDialogComponent implements OnInit { @Inject(MAT_DIALOG_DATA) public config: ConfirmationDialogConfig ) {} + public get cancelLabel(): string { + if (this.config.no_label) return this.config.no_label; + return this.config.no_suffix ? `no, ${this.config.no_suffix}` : 'no'; + } + + public get confirmLabel(): string { + if (this.config.yes_label) return this.config.yes_label; + return this.config.yes_suffix ? `Yes, ${this.config.yes_suffix}` : 'Yes'; + } + + public get confirmColor(): 'primary' | 'accent' | 'warn' { + return this.config.confirm_color || 'warn'; + } + + public get confirmAppearance(): 'raised' | 'stroked' { + return this.config.confirm_appearance || 'stroked'; + } + ngOnInit(): void { // intentionally left blank } } export interface ConfirmationDialogConfig { + title?: string; message: string; //prompt text yes_suffix?: string; //optional suffix to add to the yes button no_suffix?: string; //optional suffix to add to the no button + yes_label?: string; //optional full yes button label + no_label?: string; //optional full no button label + confirm_color?: 'primary' | 'accent' | 'warn'; + confirm_appearance?: 'raised' | 'stroked'; + layout?: 'default' | 'simple'; alternate_label?: string; //optional label for a third button alternate_value?: string; //optional value returned by the third button } diff --git a/src/app/components/delete-dialog/delete-dialog.component.html b/src/app/components/delete-dialog/delete-dialog.component.html index 5940ec7c0..699ee45db 100644 --- a/src/app/components/delete-dialog/delete-dialog.component.html +++ b/src/app/components/delete-dialog/delete-dialog.component.html @@ -1,12 +1,24 @@
-

Are you sure you want to delete this object?

+

{{ title }}

+

{{ warning }}

WARNING: This object will be permanently deleted.

WARNING: This will delete ALL objects associated with the collection. If an object is referenced in another collection, it will not be deleted.

- Please type DELETE to confirm. +

+ Please enter + + to confirm. +

diff --git a/src/app/components/delete-dialog/delete-dialog.component.scss b/src/app/components/delete-dialog/delete-dialog.component.scss index e11e4a178..04bd1162f 100644 --- a/src/app/components/delete-dialog/delete-dialog.component.scss +++ b/src/app/components/delete-dialog/delete-dialog.component.scss @@ -1,16 +1,58 @@ @use '../../../style/colors.scss'; .delete-dialog { margin: 0 24px; + .confirm-form { + font-size: 14px; + line-height: 20px; + .mat-mdc-form-field { display: block; + width: 100%; } } + + .confirm-instruction { + margin: 0 0 8px; + } + + .confirmation-token { + background: transparent; + border: 0; + color: colors.color(secondary); + cursor: pointer; + display: inline-flex; + font: inherit; + gap: 4px; + max-width: 100%; + padding: 0 2px; + text-align: left; + vertical-align: baseline; + white-space: normal; + + .confirmation-text { + overflow-wrap: anywhere; + user-select: all; + white-space: normal; + } + + mat-icon { + font-size: 16px; + height: 16px; + width: 16px; + } + } + .buttons { + display: flex; + justify-content: flex-end; + gap: 12px; margin: 18px 0; + button + button { - margin-left: 10px; + margin-left: 0; } + .warn { color: colors.color(error); } diff --git a/src/app/components/delete-dialog/delete-dialog.component.spec.ts b/src/app/components/delete-dialog/delete-dialog.component.spec.ts index 7bfb56929..5eb4522db 100644 --- a/src/app/components/delete-dialog/delete-dialog.component.spec.ts +++ b/src/app/components/delete-dialog/delete-dialog.component.spec.ts @@ -31,4 +31,19 @@ describe('DeleteDialogComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should default the confirmation text to DELETE', () => { + component.confirmInput = 'DELETE'; + + expect(component.confirmationText).toBe('DELETE'); + expect(component.invalid).toBe(false); + }); + + it('should use a STIX ID as the confirmation text when provided', () => { + component.config = { stixId: 'attack-pattern--123' }; + component.confirmInput = 'attack-pattern--123'; + + expect(component.confirmationText).toBe('attack-pattern--123'); + expect(component.invalid).toBe(false); + }); }); diff --git a/src/app/components/delete-dialog/delete-dialog.component.ts b/src/app/components/delete-dialog/delete-dialog.component.ts index 4490b48c2..e55f1afd1 100644 --- a/src/app/components/delete-dialog/delete-dialog.component.ts +++ b/src/app/components/delete-dialog/delete-dialog.component.ts @@ -9,13 +9,21 @@ import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; standalone: false, }) export class DeleteDialogComponent { - private deleteConfirmation = 'DELETE'; public confirmInput: string; + public get title(): string { + return this.config?.title || 'Are you sure you want to delete this object?'; + } + public get warning(): string { + return this.config?.warning || ''; + } + public get confirmationText(): string { + return this.config?.stixId || this.config?.stixID || 'DELETE'; + } public get hardDelete(): boolean { return this.config && this.config.hardDelete; } public get invalid(): boolean { - return this.confirmInput != this.deleteConfirmation; + return this.confirmInput != this.confirmationText; } public get collectionDelete(): boolean { return this.config && this.config.collectionDelete; diff --git a/src/app/components/footer/footer.component.html b/src/app/components/footer/footer.component.html index 0bf835531..c50303c7b 100644 --- a/src/app/components/footer/footer.component.html +++ b/src/app/components/footer/footer.component.html @@ -1,3 +1,12 @@ - -
{{ appName }} v{{ appVersion }}
-
+ diff --git a/src/app/components/footer/footer.component.scss b/src/app/components/footer/footer.component.scss index 57cfb842c..20fe98f38 100644 --- a/src/app/components/footer/footer.component.scss +++ b/src/app/components/footer/footer.component.scss @@ -1,8 +1,22 @@ @use '../../../style/globals'; +@use '../../../style/colors' as colors; + +:host { + display: block; + margin-top: auto; + padding-top: 16px; +} + .footer { - height: 48px; + padding: 12px 18px 0; + border-top: 1px solid rgba(colors.color(mitre-silver), 0.2); + .version-info { @extend .text-label; - opacity: 0.6; + + color: rgba(colors.color(mitre-silver), 0.62); + font-size: 11px; + line-height: 18px; + white-space: nowrap; } } diff --git a/src/app/components/footer/footer.component.spec.ts b/src/app/components/footer/footer.component.spec.ts index 482c0ef6e..f6e5b6497 100644 --- a/src/app/components/footer/footer.component.spec.ts +++ b/src/app/components/footer/footer.component.spec.ts @@ -1,15 +1,38 @@ import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { of } from 'rxjs'; import { FooterComponent } from './footer.component'; +import { BuildInfoService } from '../../services/build-info/build-info.service'; describe('FooterComponent', () => { let component: FooterComponent; let fixture: ComponentFixture; + const buildInfo = { + frontend: { + name: 'attack-workbench-frontend', + version: '4.20.0-beta.23', + gitCommit: 'frontend-commit', + buildDate: '2026-08-05T15:13:49.915Z', + }, + restApi: { + name: 'attack-workbench-rest-api', + version: '4.20.0-beta.22', + gitCommit: 'rest-api-commit', + buildDate: '2026-08-04T15:13:49.915Z', + attackSpecVersion: '3.3.0', + }, + }; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ declarations: [FooterComponent], + providers: [ + { + provide: BuildInfoService, + useValue: { getBuildInfo: vi.fn(() => of(buildInfo)) }, + }, + ], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); @@ -23,4 +46,23 @@ describe('FooterComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should display the frontend and REST API build versions', () => { + const versionElements: HTMLElement[] = Array.from( + fixture.nativeElement.querySelectorAll('.version-info') + ); + + expect(versionElements.map(element => element.textContent?.trim())).toEqual( + ['Frontend v4.20.0-beta.23', 'REST API v4.20.0-beta.22'] + ); + expect(versionElements[0].title).toContain('Commit: frontend-commit'); + expect(versionElements[1].title).toContain( + 'Built: 2026-08-04T15:13:49.915Z' + ); + }); + + it('should not duplicate an existing version prefix', () => { + expect(component.formatVersion('v4.20.0')).toBe('v4.20.0'); + expect(component.formatVersion('unknown')).toBe('unknown'); + }); }); diff --git a/src/app/components/footer/footer.component.ts b/src/app/components/footer/footer.component.ts index 765c617bb..514ac93f3 100644 --- a/src/app/components/footer/footer.component.ts +++ b/src/app/components/footer/footer.component.ts @@ -1,5 +1,9 @@ import { Component, OnInit } from '@angular/core'; import * as globals from '../../utils/globals'; +import { + BuildInfo, + BuildInfoService, +} from '../../services/build-info/build-info.service'; @Component({ selector: 'app-footer', @@ -8,14 +12,38 @@ import * as globals from '../../utils/globals'; standalone: false, }) export class FooterComponent implements OnInit { - public appVersion: string = globals.appVersion; - public appName: string = globals.appName; + public frontendBuildInfo: BuildInfo = { + name: globals.appName, + version: globals.appVersion, + gitCommit: 'unknown', + buildDate: 'unknown', + }; + public restApiBuildInfo: BuildInfo = { + name: 'attack-workbench-rest-api', + version: 'unknown', + gitCommit: 'unknown', + buildDate: 'unknown', + }; - constructor() { - // intentionally left blank + constructor(private buildInfoService: BuildInfoService) {} + + ngOnInit(): void { + this.buildInfoService.getBuildInfo().subscribe(buildInfo => { + this.frontendBuildInfo = buildInfo.frontend; + this.restApiBuildInfo = buildInfo.restApi; + }); + } + + public formatVersion(version: string): string { + if (!version || version === 'unknown') return 'unknown'; + return version.startsWith('v') ? version : `v${version}`; } - ngOnInit() { - // intentionally left blank + public buildDetails(label: string, buildInfo: BuildInfo): string { + return [ + `${label} ${this.formatVersion(buildInfo.version)}`, + `Commit: ${buildInfo.gitCommit}`, + `Built: ${buildInfo.buildDate}`, + ].join('\n'); } } diff --git a/src/app/components/header/header.component.html b/src/app/components/header/header.component.html index 2cf98756a..958189525 100644 --- a/src/app/components/header/header.component.html +++ b/src/app/components/header/header.component.html @@ -1,136 +1,42 @@ - - -
- - - - -
- - - - - - - - - - - - - - - - -
- - - + - + +
diff --git a/src/app/components/header/header.component.scss b/src/app/components/header/header.component.scss index 23feea97a..dcda145b3 100644 --- a/src/app/components/header/header.component.scss +++ b/src/app/components/header/header.component.scss @@ -1,82 +1,104 @@ -@use '../../../style/globals'; @use '../../../style/colors'; .header { + height: 74px; + padding: 0 30px 0 32px; + border-bottom: 1px solid rgba(colors.on-color(primary), 0.1); + .light & { background: colors.color(primary); - color: colors.on-color(primary); } + .dark & { - background: colors.color(light); + background: colors.color(primary-dark); + border-bottom-color: rgba(colors.on-color(primary-dark), 0.16); } + .mat-mdc-button:disabled { .light & { - color: colors.on-color-deemphasis(light); + color: colors.on-color-deemphasis(primary); } .dark & { - color: colors.on-color-deemphasis(dark); + color: colors.on-color-deemphasis(primary-dark); } } + .spacer { flex: 1 1 auto; } - .app-title { - a { - @extend .heading; - border-bottom-width: 0 !important; - .dark &, - .dark &:hover { - color: colors.color(primary-dark) !important; - } - .light &, - .light &:hover { - color: colors.color(light) !important; - } - } - .app-version { - text-transform: none; - @extend .text-label; - font-size: 10px; - } - } + button { font-family: Roboto, Arial, sans-serif; font-weight: 500; text-transform: uppercase; + .light & { - color: colors.on-color(dark); + --mat-text-button-ripple-color: #{rgba(colors.on-color(primary), 0.16)}; + --mat-text-button-state-layer-color: #{colors.on-color(primary)}; + --mdc-text-button-label-text-color: #{colors.on-color(primary)}; + + color: colors.on-color(primary); } + .dark & { - color: colors.on-color(light); - } - } + --mat-text-button-ripple-color: #{rgba( + colors.on-color(primary-dark), + 0.18 + )}; + --mat-text-button-state-layer-color: #{colors.on-color(primary-dark)}; + --mdc-text-button-label-text-color: #{colors.on-color(primary-dark)}; - .links { - overflow: hidden; - &.pad-right { - padding-right: 12px; + color: colors.on-color(primary-dark); } } - .account.pad-left { - margin-left: 12px; - } - .hamburger { - position: relative; - width: 0; - overflow-x: visible; - left: -40px; - } - .mat-mdc-icon-button.account { - scale: 0.85; + + .mat-mdc-button.account { + --account-avatar-size: 40px; + + align-items: center; + background: transparent; + border-radius: 999px; + display: inline-flex; + gap: 8px; + height: var(--account-avatar-size); + justify-content: center; + line-height: var(--account-avatar-size); + min-width: 0; + padding: 0 8px 0 0; + text-transform: none; vertical-align: middle; - .dark & { - background: colors.color-alternate(dark); + + .mdc-button__label { + align-items: center; + display: inline-flex; + gap: 8px; + height: var(--account-avatar-size); + min-width: 0; } - .light & { - background: colors.color(light); + + app-user-avatar { + display: inline-flex; + flex: 0 0 auto; + height: var(--account-avatar-size); + vertical-align: middle; + width: var(--account-avatar-size); } - svg { - scale: 1.3; + + app-user-avatar .user-avatar { + height: 100%; + width: 100%; + } + + .account-name { + align-self: center; + display: inline-block; + font-size: 16px; + font-weight: 700; + line-height: 22px; + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } } } diff --git a/src/app/components/header/header.component.spec.ts b/src/app/components/header/header.component.spec.ts index 72363ba34..9e8f69493 100644 --- a/src/app/components/header/header.component.spec.ts +++ b/src/app/components/header/header.component.spec.ts @@ -1,6 +1,5 @@ import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; import { provideHttpClient } from '@angular/common/http'; import { provideRouter } from '@angular/router'; import { MatToolbarModule } from '@angular/material/toolbar'; @@ -9,13 +8,17 @@ import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { of } from 'rxjs'; +import { By } from '@angular/platform-browser'; import { HeaderComponent } from './header.component'; +import { UserAvatarComponent } from '../user-avatar/user-avatar.component'; import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; import { AuthenticationService } from 'src/app/services/connectors/authentication/authentication.service'; import { createMockRestApiConnector } from 'src/app/testing/mocks/rest-api-connector.mock'; -import { createMockAuthenticationService } from 'src/app/testing/mocks/authentication-service.mock'; +import { + createMockAuthenticationService, + createMockUserAccount, +} from 'src/app/testing/mocks/authentication-service.mock'; describe('HeaderComponent', () => { let component: HeaderComponent; @@ -23,11 +26,15 @@ describe('HeaderComponent', () => { beforeEach(waitForAsync(() => { const mockRestApiConnector = createMockRestApiConnector({}); - const mockAuthService = createMockAuthenticationService({}); + const mockAuthService = createMockAuthenticationService({ + currentUser: createMockUserAccount(), + isLoggedIn: true, + }); TestBed.configureTestingModule({ declarations: [HeaderComponent], imports: [ + UserAvatarComponent, MatToolbarModule, MatMenuModule, MatButtonModule, @@ -39,13 +46,6 @@ describe('HeaderComponent', () => { { provide: RestApiConnectorService, useValue: mockRestApiConnector }, { provide: AuthenticationService, useValue: mockAuthService }, provideRouter([]), - { - provide: ActivatedRoute, - useValue: { - params: of({}), - queryParams: of({}), - }, - }, provideHttpClient(), ], schemas: [NO_ERRORS_SCHEMA], @@ -61,4 +61,10 @@ describe('HeaderComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should show the current user display name in the account trigger', () => { + const accountButton = fixture.debugElement.query(By.css('button.account')); + + expect(accountButton.nativeElement.textContent).toContain('Test User'); + }); }); diff --git a/src/app/components/header/header.component.ts b/src/app/components/header/header.component.ts index 636bf2a25..84f5b8e2f 100644 --- a/src/app/components/header/header.component.ts +++ b/src/app/components/header/header.component.ts @@ -1,19 +1,11 @@ import { - AfterViewInit, Component, - ElementRef, EventEmitter, - HostListener, Output, - ViewChild, ViewEncapsulation, } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; -import { stixRoutes } from '../../app-routing-stix.module'; import { AuthenticationService } from 'src/app/services/connectors/authentication/authentication.service'; -import { Subscription } from 'rxjs'; import { Role } from 'src/app/classes/authn/role'; -import * as globals from '../../utils/globals'; @Component({ selector: 'app-header', @@ -22,17 +14,10 @@ import * as globals from '../../utils/globals'; encapsulation: ViewEncapsulation.None, standalone: false, }) -export class HeaderComponent implements AfterViewInit { - public allRoutes: any[]; - public groupedRoutes: Record = {}; - public filteredRoutes: any[] = []; - public currentDropdown = null; - public appVersion = globals.appVersion; - +export class HeaderComponent { @Output() public onLogin = new EventEmitter(); @Output() public onLogout = new EventEmitter(); @Output() public onRegister = new EventEmitter(); - authnTypeSubscription: Subscription; public authnType: string; public get isAdmin(): boolean { @@ -44,48 +29,19 @@ export class HeaderComponent implements AfterViewInit { public get isLoggedIn(): boolean { return this.authenticationService.isLoggedIn; } - public get username() { - return this.authenticationService.currentUser.displayName - ? this.authenticationService.currentUser.displayName - : this.authenticationService.currentUser.username; - } - - @ViewChild('linkMenu', { static: false }) - private linkMenu: ElementRef; - - constructor( - private route: ActivatedRoute, - private authenticationService: AuthenticationService - ) { - this.allRoutes = stixRoutes; - this.groupedRoutes = this.groupRoutesBySection(stixRoutes); - this.filteredRoutes = this.groupedRoutes['none'] || []; - delete this.groupedRoutes['none']; - - this.authnTypeSubscription = this.authenticationService - .getAuthType() - .subscribe({ - next: v => { - this.authnType = v; - }, - complete: () => { - this.authnTypeSubscription.unsubscribe(); - }, - }); - } - - ngAfterViewInit() { - setTimeout(() => this.onResize(), 1000); //very hacky workaround: check menu size after 1 second to allow stuff to load + public get username(): string { + return ( + this.authenticationService.currentUser?.displayName || + this.authenticationService.currentUser?.username + ); } - public showHamburger = false; - - @HostListener('window:resize', ['$event']) - public onResize(event?: any) { - //if the element overflows, show hamburger instead - this.showHamburger = - this.linkMenu.nativeElement.offsetWidth < - this.linkMenu.nativeElement.scrollWidth; + constructor(private authenticationService: AuthenticationService) { + this.authenticationService.getAuthType().subscribe({ + next: v => { + this.authnType = v; + }, + }); } public login(): void { @@ -99,18 +55,4 @@ export class HeaderComponent implements AfterViewInit { public register(): void { this.onRegister.emit(); } - - public groupRoutesBySection(routes: any[]): Record { - const grouped = {}; - routes.forEach(route => { - const section = route.data.headerSection || 'none'; - if (!grouped[section]) grouped[section] = []; - grouped[section].push(route); - }); - return grouped; - } - - public setSectionDropdown(section) { - this.currentDropdown = section; - } } diff --git a/src/app/components/multiple-choice-dialog/multiple-choice-dialog.component.html b/src/app/components/multiple-choice-dialog/multiple-choice-dialog.component.html index 3434b8987..c8b7c1560 100644 --- a/src/app/components/multiple-choice-dialog/multiple-choice-dialog.component.html +++ b/src/app/components/multiple-choice-dialog/multiple-choice-dialog.component.html @@ -5,7 +5,7 @@

{{ config.title }}

+ (click)="dialogRef.close(choice.value || choice.label)">

{{ choice.label }}

{{ choice.description }}

diff --git a/src/app/components/multiple-choice-dialog/multiple-choice-dialog.component.scss b/src/app/components/multiple-choice-dialog/multiple-choice-dialog.component.scss index e3bf06225..b9005ed83 100644 --- a/src/app/components/multiple-choice-dialog/multiple-choice-dialog.component.scss +++ b/src/app/components/multiple-choice-dialog/multiple-choice-dialog.component.scss @@ -16,7 +16,7 @@ } } .choice:hover h3 { - color: colors.color(primary); + color: colors.color(secondary); } .choice h3 { @extend .subheading; diff --git a/src/app/components/multiple-choice-dialog/multiple-choice-dialog.component.ts b/src/app/components/multiple-choice-dialog/multiple-choice-dialog.component.ts index 5beaed46a..007bec550 100644 --- a/src/app/components/multiple-choice-dialog/multiple-choice-dialog.component.ts +++ b/src/app/components/multiple-choice-dialog/multiple-choice-dialog.component.ts @@ -1,4 +1,4 @@ -import { Component, Inject, OnInit, ViewEncapsulation } from '@angular/core'; +import { Component, Inject, ViewEncapsulation } from '@angular/core'; import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; @Component({ @@ -8,15 +8,11 @@ import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; encapsulation: ViewEncapsulation.None, standalone: false, }) -export class MultipleChoiceDialogComponent implements OnInit { +export class MultipleChoiceDialogComponent { constructor( public dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public config: MultipleChoiceDialogConfig ) {} - - ngOnInit(): void { - // intentionally left blank - } } export interface MultipleChoiceDialogConfig { @@ -24,6 +20,7 @@ export interface MultipleChoiceDialogConfig { description?: string; //additional explanation choices: { label: string; + value?: string; description?: string; }[]; } diff --git a/src/app/components/navigation/navigation.component.html b/src/app/components/navigation/navigation.component.html new file mode 100644 index 000000000..b4fe9b41b --- /dev/null +++ b/src/app/components/navigation/navigation.component.html @@ -0,0 +1,185 @@ + diff --git a/src/app/components/navigation/navigation.component.scss b/src/app/components/navigation/navigation.component.scss new file mode 100644 index 000000000..2920f1020 --- /dev/null +++ b/src/app/components/navigation/navigation.component.scss @@ -0,0 +1,263 @@ +@use 'sass:color'; +@use '../../../style/globals'; +@use '../../../style/colors' as colors; + +$nav-background: color.mix( + colors.color(mitre-navy), + colors.color(mitre-black), + 30% +); +$nav-parent-selected: colors.color(secondary); +$nav-stroke: color.mix( + colors.color(mitre-navy), + colors.color(mitre-silver), + 44% +); +$nav-text: color.mix(colors.color(mitre-navy), colors.color(mitre-silver), 8%); +$nav-text-muted: rgba($nav-text, 0.82); +$nav-child-text: rgba(colors.color(mitre-silver), 0.72); +$nav-child-text-hover: colors.color(mitre-silver); +$nav-hover: rgba(colors.color(mitre-silver), 0.1); +$nav-active: rgba(colors.color(mitre-silver), 0.16); + +.app-nav-drawer { + background: $nav-background; +} + +.app-navigation { + display: flex; + flex-direction: column; + box-sizing: border-box; + width: 286px; + height: 100%; + padding: 0 14px 20px; + overflow-y: auto; + background: $nav-background; + border-right: 1px solid rgba(colors.color(mitre-silver), 0.2); + color: $nav-text-muted; +} + +.app-title { + position: sticky; + top: 0; + z-index: 10; + margin: 0 -14px; + padding: 15px 0; + background: $nav-background; + border-bottom: 1px solid rgba(colors.color(mitre-silver), 0.14); + text-align: center; + + a { + @extend .heading; + + border-bottom-width: 0 !important; + color: $nav-text !important; + font-size: 22px; + letter-spacing: 0.8px; + + &:hover { + color: colors.color(mitre-silver) !important; + } + } +} + +.nav-area, +.nav-link { + display: flex; + align-items: center; + box-sizing: border-box; + min-width: 0; + text-decoration: none; +} + +.nav-area { + flex: 0 0 46px; + gap: 10px; + height: 46px; + margin-bottom: 7px; + padding: 0 18px; + border: 2px solid transparent !important; + border-radius: 8px; + background: transparent; + color: rgba(colors.color(mitre-silver), 0.86); + font-weight: 500; + + span { + color: inherit; + } + + .mat-icon { + width: 18px; + height: 18px; + color: rgba(colors.color(mitre-silver), 0.68); + font-size: 18px; + } + + &:hover { + border-color: rgba(colors.color(mitre-silver), 0.18) !important; + background: $nav-hover; + color: colors.color(mitre-silver) !important; + + .mat-icon { + color: colors.color(mitre-silver); + } + } + + &.active { + border-color: rgba($nav-parent-selected, 0.6) !important; + background: rgba($nav-parent-selected, 0.12); + color: colors.color(mitre-light-blue) !important; + // color: colors.color(mitre-silver) !important; + + .mat-icon { + // color: colors.color(mitre-silver); + color: colors.color(mitre-light-blue); + } + } + + &.disabled { + cursor: default; + opacity: 0.42; + } +} + +.nav-attention-dot, +.nav-attention-count { + flex: 0 0 auto; + background: colors.color(pending); +} + +.nav-attention-dot { + width: 9px; + height: 9px; + margin-left: auto; + border-radius: 50%; + box-shadow: 0 0 0 2px rgba(colors.color(pending), 0.18); +} + +.nav-attention-count { + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 999px; + color: colors.color(mitre-black) !important; + font-size: 10px; + font-weight: 800; + line-height: 18px; + text-align: center; +} + +.app-title + .nav-area { + margin-top: 14px; +} + +.nav-section { + flex: 0 0 auto; + margin-top: 8px; +} + +.nav-area-children { + position: relative; + display: flex; + flex-direction: column; + flex: 0 0 auto; + margin-bottom: 12px; + + &::before { + position: absolute; + top: -5px; + bottom: 8px; + left: 24px; + width: 1px; + background: rgba(colors.color(mitre-silver), 0.42); + content: ''; + } + + .nav-link { + margin-left: 40px; + padding-left: 16px; + } +} + +.nav-section-label { + display: flex; + align-items: center; + justify-content: space-between; + box-sizing: border-box; + width: 100%; + flex: 0 0 30px; + height: 30px; + padding: 0 18px 0 56px; + border: 0; + background: transparent; + color: $nav-child-text; + cursor: pointer; + font-family: inherit; + font-size: 11px; + font-weight: 650; + letter-spacing: 0.8px; + text-align: left; + text-transform: uppercase; + + span { + color: colors.color(mitre-light-blue); + } + + &:hover { + color: $nav-child-text-hover; + } + + .mat-icon { + width: 16px; + height: 16px; + font-size: 16px; + } +} + +.nav-section-items { + display: flex; + flex-direction: column; +} + +.nav-link { + flex: 0 0 auto; + justify-content: space-between; + gap: 8px; + min-height: 34px; + padding: 0 18px 0 56px; + border-bottom: 0 !important; + border-radius: 6px; + color: $nav-child-text; + font-size: 13.5px; + font-weight: 400; + line-height: 18px; + + span { + color: inherit; + } + + &:hover { + color: $nav-child-text-hover !important; + } + + &.active { + background: $nav-hover; + color: colors.color(mitre-silver) !important; + } + + &.disabled { + cursor: default; + opacity: 0.42; + } + + &.deprecated { + color: rgba(colors.color(mitre-silver), 0.5); + } + + .mat-icon { + width: 16px; + height: 16px; + color: colors.color(warn); + font-size: 16px; + } +} diff --git a/src/app/components/navigation/navigation.component.spec.ts b/src/app/components/navigation/navigation.component.spec.ts new file mode 100644 index 000000000..53e2e8d86 --- /dev/null +++ b/src/app/components/navigation/navigation.component.spec.ts @@ -0,0 +1,332 @@ +import { Component } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterModule } from '@angular/router'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatIconModule } from '@angular/material/icon'; +import { of } from 'rxjs'; + +import { NavigationComponent } from './navigation.component'; +import { routes } from 'src/app/app-routing.module'; +import { Role } from 'src/app/classes/authn/role'; +import { AuthenticationService } from 'src/app/services/connectors/authentication/authentication.service'; +import { createMockAuthenticationService } from 'src/app/testing/mocks/authentication-service.mock'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { createMockRestApiConnector } from 'src/app/testing/mocks/rest-api-connector.mock'; +import { UserAccountEventsService } from 'src/app/services/user-account-events/user-account-events.service'; + +@Component({ + selector: 'app-footer', + template: '', + standalone: false, +}) +class FooterStubComponent {} + +describe('NavigationComponent', () => { + let component: NavigationComponent; + let fixture: ComponentFixture; + let authenticationService: any; + let restApiConnector: any; + let userAccountEvents: UserAccountEventsService; + + beforeEach(async () => { + restApiConnector = createMockRestApiConnector({ + getAllUserAccounts: vi.fn(() => + of({ + data: [], + pagination: { total: 0, limit: 1, offset: 0 }, + }) + ), + }); + + await TestBed.configureTestingModule({ + declarations: [NavigationComponent, FooterStubComponent], + imports: [MatDividerModule, MatIconModule, RouterModule.forRoot([])], + providers: [ + { + provide: AuthenticationService, + useValue: createMockAuthenticationService({}), + }, + { + provide: RestApiConnectorService, + useValue: restApiConnector, + }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(NavigationComponent); + component = fixture.componentInstance; + authenticationService = TestBed.inject(AuthenticationService); + userAccountEvents = TestBed.inject(UserAccountEventsService); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should toggle navigation sections', () => { + expect(component.isSectionOpen('Core')).toBe(true); + + component.toggleSection('Core'); + expect(component.isSectionOpen('Core')).toBe(false); + + component.toggleSection('Core'); + expect(component.isSectionOpen('Core')).toBe(true); + }); + + it('should keep one parent navigation area expanded at a time', () => { + authenticationService.isLoggedIn = true; + + expect(component.isNavigationAreaExpanded('objectLibrary')).toBe(true); + + component.expandNavigationArea('dashboard'); + expect(component.isNavigationAreaExpanded('objectLibrary')).toBe(false); + expect(component.isNavigationAreaExpanded('dashboard')).toBe(true); + expect(component.isNavigationAreaExpanded('documentation')).toBe(false); + + component.expandNavigationArea('documentation'); + expect(component.isNavigationAreaExpanded('objectLibrary')).toBe(false); + expect(component.isNavigationAreaExpanded('dashboard')).toBe(false); + expect(component.isNavigationAreaExpanded('documentation')).toBe(true); + + component.collapseNavigationGroups(); + expect(component.isNavigationAreaExpanded('objectLibrary')).toBe(false); + expect(component.isNavigationAreaExpanded('dashboard')).toBe(false); + expect(component.isNavigationAreaExpanded('documentation')).toBe(false); + }); + + it('should collapse restricted parent navigation areas when logged out', () => { + expect(component.isNavigationAreaExpanded('objectLibrary')).toBe(false); + + component.expandNavigationArea('objectLibrary'); + expect(component.isNavigationAreaExpanded('objectLibrary')).toBe(false); + + component.expandNavigationArea('dashboard'); + expect(component.isNavigationAreaExpanded('dashboard')).toBe(false); + }); + + it('should allow documentation navigation to expand when logged out', () => { + component.expandNavigationArea('documentation'); + + expect(component.isNavigationAreaExpanded('documentation')).toBe(true); + }); + + it('should mark object library routes as active', () => { + expect(component.isObjectLibraryRoute('/objects')).toBe(true); + expect(component.isObjectLibraryRoute('/technique')).toBe(true); + expect( + component.isObjectLibraryRoute('/technique/attack-pattern--123') + ).toBe(true); + expect(component.isObjectLibraryRoute('/reference-manager')).toBe(false); + }); + + it('should mark dashboard routes as active', () => { + expect(component.isDashboardRoute('/dashboard')).toBe(true); + expect(component.isDashboardRoute('/dashboard/release-management')).toBe( + true + ); + expect( + component.isDashboardRoute('/dashboard/release-management/123') + ).toBe(true); + expect(component.isDashboardRoute('/objects')).toBe(false); + }); + + it('should mark documentation routes as active', () => { + expect(component.isDocumentationRoute('/docs')).toBe(true); + expect(component.isDocumentationRoute('/docs/usage')).toBe(true); + expect(component.isDocumentationRoute('/docs/collections')).toBe(true); + expect(component.isDocumentationRoute('/dashboard')).toBe(false); + }); + + it('should use title-cased route breadcrumbs for object labels', () => { + const ctiSection = component.sections.find( + section => section.label === 'CTI' + ); + const defenseSection = component.sections.find( + section => section.label === 'Defenses' + ); + + expect(ctiSection?.items.map(item => item.label)).toContain('Software'); + expect(defenseSection?.items.map(item => item.label)).toContain( + 'Data Components' + ); + }); + + it('should build section labels from route groups', () => { + expect(component.sections.map(section => section.label)).toEqual([ + 'Core', + 'CTI', + 'Defenses', + 'More', + ]); + }); + + it('should build dashboard links with admin links grouped separately', () => { + expect(component.dashboardItems.map(item => item.label)).toEqual([ + 'Overview', + 'Release Management', + 'Teams', + 'Data Quality', + ]); + expect(component.dashboardItems[0]).toEqual( + expect.objectContaining({ + label: 'Overview', + path: '/dashboard/overview', + exact: true, + }) + ); + expect(component.dashboardAdminItems.map(item => item.label)).toEqual([ + 'Organization Settings', + 'User Accounts', + 'Default Marking Definitions', + 'Validation Bypasses', + ]); + }); + + it('should show pending user account indicators for admins', () => { + Object.defineProperty(authenticationService, 'isLoggedIn', { + configurable: true, + get: () => true, + }); + authenticationService.isAuthorized = (roles: Role[]) => + roles.includes(Role.ADMIN); + restApiConnector.getAllUserAccounts.mockReturnValue( + of({ + data: [{}], + pagination: { total: 3, limit: 1, offset: 0 }, + }) + ); + + authenticationService.onLogin.emit(); + component.expandNavigationArea('dashboard'); + fixture.detectChanges(); + + expect(restApiConnector.getAllUserAccounts).toHaveBeenCalledWith({ + status: ['pending'], + limit: 1, + }); + expect(component.pendingUserAccounts).toBe(3); + expect( + fixture.nativeElement.querySelector('.nav-attention-dot') + ).toBeTruthy(); + expect( + fixture.nativeElement.querySelector('.nav-attention-count').textContent + ).toContain('3'); + }); + + it('should refresh pending user account indicators after session hydration', async () => { + Object.defineProperty(authenticationService, 'isLoggedIn', { + configurable: true, + get: () => true, + }); + authenticationService.isAuthorized = (roles: Role[]) => + roles.includes(Role.ADMIN); + restApiConnector.getAllUserAccounts.mockReturnValue( + of({ + data: [{}], + pagination: { total: 1, limit: 1, offset: 0 }, + }) + ); + + component.expandNavigationArea('dashboard'); + (component as any).schedulePendingUserAccountsRefresh(); + await new Promise(resolve => setTimeout(resolve, 550)); + fixture.detectChanges(); + + expect(component.pendingUserAccounts).toBe(1); + expect( + fixture.nativeElement.querySelector('.nav-attention-dot') + ).toBeTruthy(); + expect( + fixture.nativeElement.querySelector('.nav-attention-count').textContent + ).toContain('1'); + }); + + it('should refresh pending user account indicators when user accounts change', () => { + Object.defineProperty(authenticationService, 'isLoggedIn', { + configurable: true, + get: () => true, + }); + authenticationService.isAuthorized = (roles: Role[]) => + roles.includes(Role.ADMIN); + restApiConnector.getAllUserAccounts.mockReturnValue( + of({ + data: [{}], + pagination: { total: 2, limit: 1, offset: 0 }, + }) + ); + + authenticationService.onLogin.emit(); + component.expandNavigationArea('dashboard'); + fixture.detectChanges(); + + expect(component.pendingUserAccounts).toBe(2); + expect( + fixture.nativeElement.querySelector('.nav-attention-dot') + ).toBeTruthy(); + + restApiConnector.getAllUserAccounts.mockReturnValue( + of({ + data: [], + pagination: { total: 0, limit: 1, offset: 0 }, + }) + ); + + userAccountEvents.notifyUserAccountsChanged(); + fixture.detectChanges(); + + expect(component.pendingUserAccounts).toBe(0); + expect( + fixture.nativeElement.querySelector('.nav-attention-dot') + ).toBeFalsy(); + expect( + fixture.nativeElement.querySelector('.nav-attention-count') + ).toBeFalsy(); + }); + + it('should redirect the dashboard index to overview', () => { + const dashboardRoute = routes[0]?.children?.find( + route => route.path === 'dashboard' + ); + const indexRoute = dashboardRoute?.children?.find( + route => route.path === '' + ); + const overviewRoute = dashboardRoute?.children?.find( + route => route.path === 'overview' + ); + + expect(indexRoute?.redirectTo).toBe('overview'); + expect(indexRoute?.pathMatch).toBe('full'); + expect(overviewRoute?.data?.breadcrumb).toBe('overview'); + expect(overviewRoute?.data?.title).toBe('Knowledge Base Overview'); + }); + + it('should build documentation links', () => { + expect(component.documentationItems.map(item => item.label)).toEqual([ + 'Usage', + 'Collections', + 'Integrations', + 'Contributing', + ]); + expect(component.documentationItems.map(item => item.path)).toEqual([ + '/docs/usage', + '/docs/collections', + '/docs/integrations', + '/docs/contributing', + ]); + }); + + it('should redirect the documentation index to usage', () => { + const documentationRoute = routes[0]?.children?.find( + route => route.path === 'docs' + ); + const indexRoute = documentationRoute?.children?.find( + route => route.path === '' + ); + + expect(indexRoute?.redirectTo).toBe('usage'); + expect(indexRoute?.pathMatch).toBe('full'); + }); +}); diff --git a/src/app/components/navigation/navigation.component.ts b/src/app/components/navigation/navigation.component.ts new file mode 100644 index 000000000..660553faf --- /dev/null +++ b/src/app/components/navigation/navigation.component.ts @@ -0,0 +1,372 @@ +import { Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; +import { NavigationEnd, Route, Router } from '@angular/router'; +import { Subscription } from 'rxjs'; +import { routes as appRoutes } from 'src/app/app-routing.module'; +import { stixRoutes } from 'src/app/app-routing-stix.module'; +import { Role } from 'src/app/classes/authn/role'; +import { Status } from 'src/app/classes/authn/status'; +import { AuthenticationService } from 'src/app/services/connectors/authentication/authentication.service'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { UserAccountEventsService } from 'src/app/services/user-account-events/user-account-events.service'; + +interface NavigationItem { + label: string; + path: string; + deprecated?: boolean; + exact?: boolean; +} + +interface NavigationSection { + label: string; + items: NavigationItem[]; +} + +type NavigationArea = 'objectLibrary' | 'dashboard' | 'documentation'; + +@Component({ + selector: 'app-navigation', + templateUrl: './navigation.component.html', + styleUrls: ['./navigation.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false, +}) +export class NavigationComponent implements OnInit, OnDestroy { + public expandedNavigationArea: NavigationArea | null = null; + public pendingUserAccounts = 0; + + private readonly groupOrder = ['core', 'cti', 'defenses', 'more']; + private readonly documentationOrder = [ + 'usage', + 'collections', + 'integrations', + 'contributing', + ]; + private readonly objectLibraryExcludedPaths = new Set([ + 'objects', + 'reference-manager', + 'contributors', + ]); + private readonly uppercaseLabels = new Set(['cti']); + private readonly collapsedSections = new Set(); + private readonly routerEventsSubscription: Subscription; + private readonly loginSubscription: Subscription; + private readonly userAccountEventsSubscription: Subscription; + private pendingUserAccountsSubscription?: Subscription; + private pendingUserAccountsRefreshTimer?: ReturnType; + + public readonly sections: NavigationSection[] = this.buildSections(); + public readonly dashboardItems: NavigationItem[] = + this.buildDashboardItems(false); + public readonly dashboardAdminItems: NavigationItem[] = + this.buildDashboardItems(true); + public readonly documentationItems: NavigationItem[] = + this.buildDocumentationItems(); + + private readonly objectLibraryPaths = [ + '/objects', + ...this.sections.reduce( + (paths, section) => paths.concat(section.items.map(item => item.path)), + [] as string[] + ), + ]; + + public get isLoggedIn(): boolean { + return this.authenticationService.isLoggedIn; + } + + public get canAccessDashboard(): boolean { + return this.authenticationService.isAuthorized([ + Role.ADMIN, + Role.TEAM_LEAD, + ]); + } + + public get canAccessAdminDashboard(): boolean { + return this.authenticationService.isAuthorized([Role.ADMIN]); + } + + public get hasPendingUserAccounts(): boolean { + return this.canAccessAdminDashboard && this.pendingUserAccounts > 0; + } + + public get pendingUserAccountsDisplay(): string { + return this.pendingUserAccounts > 99 + ? '99+' + : this.pendingUserAccounts.toString(); + } + + public get pendingUserAccountsMessage(): string { + const accountLabel = + this.pendingUserAccounts === 1 ? 'account' : 'accounts'; + return `${this.pendingUserAccounts} pending user ${accountLabel}`; + } + + public get isObjectLibraryActive(): boolean { + return this.isObjectLibraryRoute(this.router.url); + } + + public get isDashboardActive(): boolean { + return this.isDashboardRoute(this.router.url); + } + + public get isDocumentationActive(): boolean { + return this.isDocumentationRoute(this.router.url); + } + + constructor( + private authenticationService: AuthenticationService, + private restApiConnector: RestApiConnectorService, + private userAccountEvents: UserAccountEventsService, + private router: Router + ) { + this.expandedNavigationArea = this.navigationAreaForUrl(this.router.url); + this.routerEventsSubscription = this.router.events.subscribe(event => { + if (event instanceof NavigationEnd) { + this.expandedNavigationArea = this.navigationAreaForUrl( + event.urlAfterRedirects + ); + this.schedulePendingUserAccountsRefresh(); + } + }); + this.loginSubscription = this.authenticationService.onLogin.subscribe(() => + this.refreshPendingUserAccounts() + ); + this.userAccountEventsSubscription = + this.userAccountEvents.userAccountsChanged$.subscribe(() => + this.refreshPendingUserAccounts() + ); + } + + public ngOnInit(): void { + this.refreshPendingUserAccounts(); + this.schedulePendingUserAccountsRefresh(); + } + + public ngOnDestroy(): void { + this.routerEventsSubscription.unsubscribe(); + this.loginSubscription.unsubscribe(); + this.userAccountEventsSubscription.unsubscribe(); + this.pendingUserAccountsSubscription?.unsubscribe(); + if (this.pendingUserAccountsRefreshTimer) { + clearTimeout(this.pendingUserAccountsRefreshTimer); + } + } + + public expandNavigationArea(area: NavigationArea): void { + if (!this.canDisplayNavigationArea(area)) { + this.expandedNavigationArea = null; + return; + } + + this.expandedNavigationArea = area; + } + + public collapseNavigationGroups(): void { + this.expandedNavigationArea = null; + } + + public isNavigationAreaExpanded(area: NavigationArea): boolean { + return ( + this.canDisplayNavigationArea(area) && + this.expandedNavigationArea === area + ); + } + + public isSectionOpen(section: string): boolean { + return !this.collapsedSections.has(section); + } + + public sectionId(section: string): string { + return `nav-section-${section.toLowerCase().replace(/\s+/g, '-')}`; + } + + public toggleSection(section: string): void { + if (this.collapsedSections.has(section)) { + this.collapsedSections.delete(section); + } else { + this.collapsedSections.add(section); + } + } + + public isObjectLibraryRoute(url: string): boolean { + const path = this.routePath(url); + return this.objectLibraryPaths.some( + objectPath => path === objectPath || path.startsWith(`${objectPath}/`) + ); + } + + public isDashboardRoute(url: string): boolean { + const path = this.routePath(url); + return path === '/dashboard' || path.startsWith('/dashboard/'); + } + + public isDocumentationRoute(url: string): boolean { + const path = this.routePath(url); + return path === '/docs' || path.startsWith('/docs/'); + } + + public isUserAccountsNavigationItem(item: NavigationItem): boolean { + return item.path === '/dashboard/user-accounts'; + } + + private refreshPendingUserAccounts(): void { + this.pendingUserAccountsSubscription?.unsubscribe(); + + if (!this.canAccessAdminDashboard) { + this.pendingUserAccounts = 0; + return; + } + + this.pendingUserAccountsSubscription = this.restApiConnector + .getAllUserAccounts({ status: [Status.PENDING], limit: 1 }) + .subscribe({ + next: response => { + this.pendingUserAccounts = + this.pendingUserAccountCountFromResponse(response); + }, + error: () => { + this.pendingUserAccounts = 0; + }, + }); + } + + private pendingUserAccountCountFromResponse(response: any): number { + if (Array.isArray(response)) return response.length; + if (Array.isArray(response?.data)) { + return response?.pagination?.total ?? response.data.length; + } + return 0; + } + + private schedulePendingUserAccountsRefresh(): void { + if (this.pendingUserAccountsRefreshTimer) { + clearTimeout(this.pendingUserAccountsRefreshTimer); + } + + this.pendingUserAccountsRefreshTimer = setTimeout(() => { + this.refreshPendingUserAccounts(); + }, 500); + } + + private navigationAreaForUrl(url: string): NavigationArea | null { + const path = this.routePath(url); + + if (path === '/' || this.isObjectLibraryRoute(path)) return 'objectLibrary'; + if (this.isDashboardRoute(path)) return 'dashboard'; + if (this.isDocumentationRoute(path)) return 'documentation'; + return null; + } + + private canDisplayNavigationArea(area: NavigationArea): boolean { + return this.isLoggedIn || area === 'documentation'; + } + + private routePath(url: string): string { + return url.split('?')[0].split('#')[0]; + } + + private buildSections(): NavigationSection[] { + const itemsByGroup = new Map(); + + stixRoutes + .filter( + route => + route.path && + route.data?.group && + !route.data?.deprecated && + !this.objectLibraryExcludedPaths.has(route.path) + ) + .forEach(route => { + const group = route.data?.group as string; + const items = itemsByGroup.get(group) || []; + items.push({ + label: this.titleCase( + route.data?.breadcrumb || (route.path as string) + ), + path: `/${route.path}`, + deprecated: !!route.data?.deprecated, + }); + itemsByGroup.set(group, items); + }); + + return Array.from(itemsByGroup.entries()) + .sort(([leftGroup], [rightGroup]) => { + const leftOrder = this.groupOrder.indexOf(leftGroup); + const rightOrder = this.groupOrder.indexOf(rightGroup); + return this.groupSortValue(leftOrder) - this.groupSortValue(rightOrder); + }) + .map(([group, items]) => ({ + label: this.titleCase(group), + items, + })); + } + + private groupSortValue(order: number): number { + return order === -1 ? Number.MAX_SAFE_INTEGER : order; + } + + private buildDashboardItems(adminOnly: boolean): NavigationItem[] { + const dashboardRoute = this.dashboardRoute(); + if (!dashboardRoute?.children) return []; + + return dashboardRoute.children + .filter(route => this.isDashboardNavigationRoute(route)) + .filter(route => this.isAdminDashboardRoute(route) === adminOnly) + .map(route => ({ + label: this.titleCase(route.data?.breadcrumb || (route.path as string)), + path: this.dashboardPath(route), + exact: route.path === '' || route.path === 'overview', + })); + } + + private dashboardRoute(): Route | undefined { + return appRoutes[0]?.children?.find(route => route.path === 'dashboard'); + } + + private buildDocumentationItems(): NavigationItem[] { + const documentationRoute = this.documentationRoute(); + if (!documentationRoute?.children) return []; + + return this.documentationOrder + .map(path => + documentationRoute.children?.find(route => route.path === path) + ) + .filter((route): route is Route => !!route) + .map(route => ({ + label: this.titleCase(route.data?.breadcrumb || (route.path as string)), + path: `/docs/${route.path}`, + exact: true, + })); + } + + private documentationRoute(): Route | undefined { + return appRoutes[0]?.children?.find(route => route.path === 'docs'); + } + + private dashboardPath(route: Route): string { + return route.path ? `/dashboard/${route.path}` : '/dashboard'; + } + + private isDashboardNavigationRoute(route: Route): boolean { + return route.path !== undefined && !!route.data?.breadcrumb; + } + + private isAdminDashboardRoute(route: Route): boolean { + const roles = route.data?.roles as Role[] | undefined; + return roles?.length === 1 && roles.includes(Role.ADMIN); + } + + private titleCase(value: string): string { + return value + .replace(/-/g, ' ') + .split(' ') + .filter(word => word.length > 0) + .map(word => { + const lowerCaseWord = word.toLowerCase(); + if (this.uppercaseLabels.has(lowerCaseWord)) + return lowerCaseWord.toUpperCase(); + return `${lowerCaseWord.charAt(0).toUpperCase()}${lowerCaseWord.slice(1)}`; + }) + .join(' '); + } +} diff --git a/src/app/components/new-track-dialog/new-track-dialog.component.html b/src/app/components/new-track-dialog/new-track-dialog.component.html new file mode 100644 index 000000000..dffa0b1ae --- /dev/null +++ b/src/app/components/new-track-dialog/new-track-dialog.component.html @@ -0,0 +1,283 @@ +
+

+ {{ + isVirtual + ? 'Create New Virtual Release Track' + : 'Create New Release Track' + }} +

+ +
+ + Track Name + + + + + Description + + + + + Initial snapshot notes + + + Shown only on the initial snapshot in release history + + + {{ form.get('snapshotDescription')?.value?.length || 0 }}/4000 + + + + + + +
+
+
+
Auto-promote Candidates
+
+ Automatically promote Candidates to "Staged" when they meet the + threshold. +
+
+ + Auto-promote + +
+ +
+ + Candidacy Threshold + + {{ + formatOption(opt) + }} + + +
+ + + + + Member Sync Strategy + + {{ + formatOption(opt) + }} + + + Determines if new revisions to existing member objects are + automatically added as candidates + + + + + Supplant Behavior + + {{ + formatOption(opt) + }} + + + How to handle a new version of an object that is already staged + + +
+ + +
+
+
Component Tracks
+
+ Standard tracks to include in this virtual release track. +
+
+ +
+
+ + Loading component tracks +
+ +
+ No standard release tracks found. +
+ +
+ + Add component track + + arrow_drop_down + + + + + {{ track.name }} + + + ({{ getComponentTrackSnapshotLabel(track) }}) + + + + + No matching standard tracks + + + + Select one or more standard tracks to include in this virtual + track. + + +
+ +
+
+
+
+
{{ track.name }}
+
+ {{ getComponentTrackSnapshotLabel(track) }} +
+
+ +
+ +

+ {{ track.description }} +

+ + + Object type filter + + + {{ opt.label }} + + + Leave empty to include all object types + + + + Domain filter + + + {{ domain }} + + + Leave empty to include all domains + +
+
+ + + +
+
Object Deduplication
+ + Strategy + + {{ formatOption(opt) }} + + +
+
+ + + +
+
Snapshot Schedule
+
+ Configure automatic snapshot scheduling for this virtual track +
+
+ +
+ + Mode + + {{ + formatOption(opt) + }} + + Select mode for snapshot scheduling + + + + Cron Expression + + Cron expression (UTC) used when mode is 'cron' + +
+
+
+ + + + + +
diff --git a/src/app/components/new-track-dialog/new-track-dialog.component.scss b/src/app/components/new-track-dialog/new-track-dialog.component.scss new file mode 100644 index 000000000..06a22e3ea --- /dev/null +++ b/src/app/components/new-track-dialog/new-track-dialog.component.scss @@ -0,0 +1,223 @@ +@use '../../../style/globals'; +@use '../../../style/colors'; + +.new-track-dialog { + margin: 0 24px; + height: 100%; + max-height: 80vh; + overflow: auto; + display: flex; + flex-direction: column; + + .divider { + margin: 12px 0; + } + + form { + display: flex; + flex-direction: column; + gap: 20px; + } + + .section-block { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + + &.gap-12 { + gap: 12px; + } + + &.stack { + align-items: stretch; + flex-direction: column; + gap: 4px; + } + } + + .section-title { + font-weight: 600; + + .light & { + color: colors.on-color-emphasis(light); + } + + .dark & { + color: colors.on-color-emphasis(dark); + } + } + + .section-sub { + font-size: 0.9em; + + .light & { + color: colors.on-color-deemphasis(light); + } + + .dark & { + color: colors.on-color-deemphasis(dark); + } + } + + .mat-mdc-form-field { + width: 100%; + } + + .no-hint .mat-mdc-form-field-subscript-wrapper { + display: none; // remove bottom padding used for hint + } + + mat-hint { + .light & { + color: colors.on-color-deemphasis(light); + } + + .dark & { + color: colors.on-color-deemphasis(dark); + } + } + + .loading-row, + .empty-state { + align-items: center; + display: flex; + gap: 12px; + min-height: 56px; + + .light & { + color: colors.on-color-deemphasis(light); + } + + .dark & { + color: colors.on-color-deemphasis(dark); + } + } + + .component-track-picker { + margin-bottom: 12px; + } + + .selected-component-track-list { + display: grid; + gap: 12px; + } + + .component-track-option { + border-radius: 6px; + border: 1px solid; + padding: 12px; + + .light & { + border-color: colors.border-color(light); + } + + .dark & { + border-color: colors.border-color(dark); + } + + &.selected { + .light & { + border-color: colors.color(secondary); + } + + .dark & { + border-color: colors.color(mitre-light-blue); + } + } + } + + .component-track-selected-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + + > div { + min-width: 0; + } + + button { + flex: 0 0 auto; + width: 32px; + height: 32px; + padding: 0; + } + } + + .component-track-name { + font-weight: 600; + margin-right: 8px; + } + + .component-track-meta { + font-size: 0.85em; + + .light & { + color: colors.on-color-deemphasis(light); + } + + .dark & { + color: colors.on-color-deemphasis(dark); + } + } + + .component-track-description { + font-size: 0.9em; + margin: 8px 0 12px; + + .light & { + color: colors.on-color-deemphasis(light); + } + + .dark & { + color: colors.on-color-deemphasis(dark); + } + } + + .object-type-filter { + margin-top: 8px; + } + + // actions pinned to bottom of dialog + mat-dialog-actions { + display: flex; + justify-content: flex-end; + margin: 16px 0px; + padding: 0px; + + button + button { + margin-left: 12px; + } + + .success { + color: colors.color(success); + } + } +} + +.component-track-option-label { + display: inline-flex; + align-items: baseline; + gap: 4px; + min-width: 0; +} + +.component-track-option-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.component-track-option-meta { + flex: 0 0 auto; + font-size: 0.82em; + + .light & { + color: colors.on-color-deemphasis(light); + } + + .dark & { + color: colors.on-color-deemphasis(dark); + } +} diff --git a/src/app/components/new-track-dialog/new-track-dialog.component.spec.ts b/src/app/components/new-track-dialog/new-track-dialog.component.spec.ts new file mode 100644 index 000000000..e1367cd46 --- /dev/null +++ b/src/app/components/new-track-dialog/new-track-dialog.component.spec.ts @@ -0,0 +1,294 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { of } from 'rxjs'; + +import { NewTrackDialogComponent } from './new-track-dialog.component'; +import { ReleaseTracksConnectorService } from 'src/app/services/connectors/rest-api/release-tracks.service'; +import { + DeduplicationStrategy, + ReleaseTrackType, + ResolutionStrategy, + SnapshotScheduleMode, +} from 'src/app/classes/release-tracks'; + +describe('NewTrackDialogComponent', () => { + let component: NewTrackDialogComponent; + let fixture: ComponentFixture; + let mockDialogRef: any; + let mockConnector: any; + + beforeEach(async () => { + mockDialogRef = { + close: vi.fn(), + }; + mockConnector = { + listReleaseTracks: vi.fn(() => + of({ + data: [ + { + track_id: 'release-track--standard-tagged', + type: 'standard', + name: 'Tagged Standard', + description: 'Can be used by a virtual track', + latest_tagged_version: '1.0', + tagged_release_count: 1, + }, + { + track_id: 'release-track--standard-draft', + type: 'standard', + name: 'Draft Only Standard', + tagged_release_count: 0, + }, + { + track_id: 'release-track--virtual-tagged', + type: 'virtual', + name: 'Virtual Track', + latest_tagged_version: '1.0', + tagged_release_count: 1, + }, + ], + }) + ), + createReleaseTrack: vi.fn(() => + of({ track_id: 'release-track--new-virtual' }) + ), + }; + + await TestBed.configureTestingModule({ + declarations: [NewTrackDialogComponent], + imports: [ReactiveFormsModule], + providers: [ + { provide: MatDialogRef, useValue: mockDialogRef }, + { + provide: MAT_DIALOG_DATA, + useValue: { type: ReleaseTrackType.Virtual }, + }, + { provide: ReleaseTracksConnectorService, useValue: mockConnector }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(NewTrackDialogComponent); + component = fixture.componentInstance; + component.ngOnInit(); + }); + + it('should list standard tracks even when they have no tagged snapshots', () => { + expect(mockConnector.listReleaseTracks).toHaveBeenCalledWith(); + expect(component.componentTrackOptions).toEqual([ + expect.objectContaining({ + trackId: 'release-track--standard-tagged', + name: 'Tagged Standard', + }), + expect.objectContaining({ + trackId: 'release-track--standard-draft', + name: 'Draft Only Standard', + }), + ]); + }); + + it('should filter and select component tracks from the autocomplete', () => { + component.form.get('composition.componentTrackSearch')?.setValue('draft'); + + expect(component.filteredComponentTrackOptions).toEqual([ + expect.objectContaining({ + trackId: 'release-track--standard-draft', + }), + ]); + + component.selectComponentTrack({ + option: { + value: component.componentTrackOptions[1], + }, + }); + + expect(component.selectedComponentTracks).toEqual([ + expect.objectContaining({ + trackId: 'release-track--standard-draft', + }), + ]); + expect(component.form.get('composition.componentTrackSearch')?.value).toBe( + '' + ); + expect(component.filteredComponentTrackOptions).not.toContain( + component.componentTrackOptions[1] + ); + }); + + it('should format component track snapshot labels', () => { + expect( + component.getComponentTrackSnapshotLabel( + component.componentTrackOptions[0] + ) + ).toBe('v1.0'); + expect( + component.getComponentTrackSnapshotLabel( + component.componentTrackOptions[1] + ) + ).toBe('no tagged snapshots'); + }); + + it('should remove selected component tracks and clear their filters', () => { + component.toggleComponentTrack(component.componentTrackOptions[0], true); + component.componentTrackOptions[0].objectTypes = ['attack-pattern']; + + component.removeComponentTrack(component.componentTrackOptions[0]); + + expect(component.componentTrackOptions[0].selected).toBe(false); + expect(component.componentTrackOptions[0].objectTypes).toEqual([]); + }); + + it('should create a virtual track with latest tagged components and priorities', () => { + component.form.patchValue({ + name: 'Combined Enterprise', + description: 'Aggregates released Enterprise content', + }); + component.toggleComponentTrack(component.componentTrackOptions[0], true); + component.componentTrackOptions[0].objectTypes = ['attack-pattern']; + + component.handleCreate(); + + expect(mockConnector.createReleaseTrack).toHaveBeenCalledWith({ + type: ReleaseTrackType.Virtual, + name: 'Combined Enterprise', + description: 'Aggregates released Enterprise content', + composition: { + component_tracks: [ + { + track_id: 'release-track--standard-tagged', + resolution_strategy: ResolutionStrategy.LatestTagged, + priority: 0, + filters: { + object_types: ['attack-pattern'], + }, + }, + ], + deduplication: { + strategy: DeduplicationStrategy.PrioritizeLatestObject, + }, + }, + snapshot_schedule: { + mode: SnapshotScheduleMode.Manual, + }, + }); + expect(mockDialogRef.close).toHaveBeenCalledWith({ + track_id: 'release-track--new-virtual', + }); + }); + + it('should create a virtual track with public domain filters', () => { + component.form.patchValue({ + name: 'Combined domain content', + }); + component.toggleComponentTrack(component.componentTrackOptions[0], true); + component.componentTrackOptions[0].objectTypes = ['malware']; + (component.componentTrackOptions[0] as any).domains = [ + 'enterprise', + 'mobile', + ]; + + component.handleCreate(); + + expect(mockConnector.createReleaseTrack).toHaveBeenCalledWith( + expect.objectContaining({ + composition: expect.objectContaining({ + component_tracks: [ + expect.objectContaining({ + filters: { + object_types: ['malware'], + domains: ['enterprise', 'mobile'], + }, + }), + ], + }), + }) + ); + }); + + it('should allow untagged standard tracks in virtual track composition', () => { + component.form.patchValue({ + name: 'Future Combined Track', + description: 'Will resolve once components are tagged', + }); + component.toggleComponentTrack(component.componentTrackOptions[1], true); + + component.handleCreate(); + + expect(mockConnector.createReleaseTrack).toHaveBeenCalledWith( + expect.objectContaining({ + composition: expect.objectContaining({ + component_tracks: [ + expect.objectContaining({ + track_id: 'release-track--standard-draft', + resolution_strategy: ResolutionStrategy.LatestTagged, + priority: 0, + }), + ], + }), + }) + ); + }); + + it('should create a standard track with the API member-sync supplant shape', () => { + component.mode = ReleaseTrackType.Standard; + component.form.patchValue({ + name: 'Enterprise Content', + description: 'Tracks Enterprise content', + snapshotDescription: 'Initial analyst context', + memberSync: 'track_latest', + supplantBehavior: 'replace', + }); + + component.handleCreate(); + + expect(mockConnector.createReleaseTrack).toHaveBeenCalledWith({ + type: ReleaseTrackType.Standard, + name: 'Enterprise Content', + description: 'Tracks Enterprise content', + snapshot_description: 'Initial analyst context', + config: { + auto_promote: false, + member_sync: { + strategy: 'track_latest', + supplant: { + behavior: 'replace', + status_policy: 'preserve', + }, + }, + }, + }); + }); + + it('should omit unsupported virtual deduplication resolution settings', () => { + component.form.patchValue({ + name: 'Scheduled Combined Track', + description: 'Includes optional virtual settings', + composition: { + deduplicationStrategy: DeduplicationStrategy.Quarantine, + }, + snapshotSchedule: { + mode: SnapshotScheduleMode.Cron, + cron: '0 0 1 1,7 *', + }, + }); + component.toggleComponentTrack(component.componentTrackOptions[0], true); + + component.handleCreate(); + + expect(mockConnector.createReleaseTrack).toHaveBeenCalledWith( + expect.objectContaining({ + composition: expect.objectContaining({ + deduplication: { + strategy: DeduplicationStrategy.Quarantine, + }, + }), + snapshot_schedule: { + mode: SnapshotScheduleMode.Cron, + cron: '0 0 1 1,7 *', + }, + }) + ); + }); +}); diff --git a/src/app/components/new-track-dialog/new-track-dialog.component.ts b/src/app/components/new-track-dialog/new-track-dialog.component.ts new file mode 100644 index 000000000..2ac160984 --- /dev/null +++ b/src/app/components/new-track-dialog/new-track-dialog.component.ts @@ -0,0 +1,391 @@ +import { Component, OnInit, ViewEncapsulation, Inject } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { + MemberSyncStrategy, + MemberSyncBehavior, + MemberSyncPolicy, + ReleaseTrackType, + DeduplicationStrategy, + ResolutionStrategy, + SnapshotScheduleMode, +} from 'src/app/classes/release-tracks/enums'; +import { ReleaseTracksConnectorService } from 'src/app/services/connectors/rest-api/release-tracks.service'; +import { WorkflowStatus, StixType } from 'src/app/utils/types'; +import { + AttackTypeToPlural, + StixTypeToAttackType, +} from 'src/app/utils/type-mappings'; +import { finalize, take } from 'rxjs/operators'; + +const OBJECT_FILTER_OPTIONS: StixType[] = [ + 'attack-pattern', + 'campaign', + 'course-of-action', + 'intrusion-set', + 'malware', + 'tool', + 'x-mitre-asset', + 'x-mitre-data-component', + 'x-mitre-detection-strategy', + 'x-mitre-analytic', + 'x-mitre-matrix', + 'x-mitre-tactic', +]; + +const DOMAIN_FILTER_OPTIONS = ['enterprise', 'ics', 'mobile']; + +@Component({ + standalone: false, + selector: 'app-new-track-dialog', + templateUrl: './new-track-dialog.component.html', + styleUrls: ['./new-track-dialog.component.scss'], + encapsulation: ViewEncapsulation.None, +}) +export class NewTrackDialogComponent implements OnInit { + public form: FormGroup; + public loading = false; + + public WorkflowStatus = WorkflowStatus; + public MemberSyncStrategy = MemberSyncStrategy; + public MemberSyncBehavior = MemberSyncBehavior; + public ReleaseTrack = ReleaseTrackType; + + public candidacyOptions = Object.values(WorkflowStatus); + public memberSyncOptions = Object.values(MemberSyncStrategy); + public supplantOptions = Object.values(MemberSyncBehavior); + + public deduplicationOptions = Object.values(DeduplicationStrategy); + public snapshotModeOptions = Object.values(SnapshotScheduleMode); + public componentTrackOptions: VirtualComponentTrackOption[] = []; + public isLoadingComponentTracks = false; + public objectTypeOptions = OBJECT_FILTER_OPTIONS.map(type => ({ + label: this.formatStixType(type), + value: type, + })); + public domainOptions = DOMAIN_FILTER_OPTIONS; + + public mode: 'standard' | 'virtual' = 'standard'; + + public get isVirtual() { + return this.mode === ReleaseTrackType.Virtual; + } + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: any, + private fb: FormBuilder, + private connector: ReleaseTracksConnectorService + ) { + this.form = this.fb.group({ + name: ['', [Validators.required]], + description: [''], + snapshotDescription: ['', [Validators.maxLength(4000)]], + autoPromote: [false], + candidacyThreshold: [{ value: WorkflowStatus.Reviewed, disabled: true }], + memberSync: [MemberSyncStrategy.TrackLatest], + supplantBehavior: [MemberSyncBehavior.Replace], + composition: this.fb.group({ + componentTrackSearch: [''], + deduplicationStrategy: [DeduplicationStrategy.PrioritizeLatestObject], + }), + snapshotSchedule: this.fb.group({ + mode: [SnapshotScheduleMode.Manual], + cron: [''], + }), + }); + + // Initialize mode from dialog data (if provided) + if (this.data && this.data.type) { + this.mode = this.data.type as ReleaseTrackType; + } + } + + ngOnInit(): void { + if (this.isVirtual) { + this.form.get('composition')?.setValidators([Validators.required]); + this.form + .get('composition') + ?.updateValueAndValidity({ emitEvent: false }); + this.loadComponentTrackOptions(); + } + + const autoCtrl = this.form.get('autoPromote'); + const candidacyCtrl = this.form.get('candidacyThreshold'); + if (autoCtrl && candidacyCtrl) { + // enable/disable candidacy threshold input based on auto promote value + autoCtrl.valueChanges.subscribe(value => { + if (value) candidacyCtrl.enable(); + else candidacyCtrl.disable(); + }); + } + } + + public closeDialog(): void { + this.dialogRef.close(); + } + + public isFormValid(): boolean { + const nameValid = !!this.form.get('name')?.value?.trim(); + if (this.isVirtual) { + return nameValid && this.selectedComponentTracks.length > 0; + } + return this.form.valid && nameValid; + } + + public formatOption(opt: any): string { + if (opt === null || opt === undefined) return ''; + // replace underscores and hyphens with spaces + const out = String(opt).replace(/[_-]+/g, ' '); + return out.toLowerCase(); + } + + public toggleComponentTrack( + track: VirtualComponentTrackOption, + selected: boolean + ): void { + track.selected = selected; + } + + public get selectedComponentTracks(): VirtualComponentTrackOption[] { + return this.componentTrackOptions.filter(track => track.selected); + } + + public get filteredComponentTrackOptions(): VirtualComponentTrackOption[] { + const search = this.getComponentTrackSearchText(); + return this.componentTrackOptions + .filter(track => !track.selected) + .filter(track => this.matchesComponentTrackSearch(track, search)); + } + + public displayComponentTrack(track: VirtualComponentTrackOption): string { + return track?.name || ''; + } + + public getComponentTrackSnapshotLabel( + track: VirtualComponentTrackOption + ): string { + return track.latestTaggedVersion + ? `v${track.latestTaggedVersion}` + : 'no tagged snapshots'; + } + + public selectComponentTrack(event: any): void { + const track = event?.option?.value as VirtualComponentTrackOption; + if (!track) return; + + track.selected = true; + this.form + .get('composition.componentTrackSearch') + ?.setValue('', { emitEvent: false }); + } + + public removeComponentTrack(track: VirtualComponentTrackOption): void { + track.selected = false; + track.objectTypes = []; + track.domains = []; + } + + public handleCreate(): void { + if (!this.isFormValid() || this.loading) return; + + let payload: any; + const snapshotDescription = String( + this.form.get('snapshotDescription')?.value || '' + ).trim(); + if (this.isVirtual) { + payload = { + name: this.form.get('name')?.value, + description: this.form.get('description')?.value, + composition: this.buildVirtualComposition(), + type: ReleaseTrackType.Virtual, + }; + const snapshotSchedule = this.buildVirtualSnapshotSchedule(); + if (snapshotSchedule) payload.snapshot_schedule = snapshotSchedule; + } else { + payload = { + name: this.form.get('name')?.value, + description: this.form.get('description')?.value, + config: { + auto_promote: !!this.form.get('autoPromote')?.value, + member_sync: { + strategy: this.form.get('memberSync')?.value, + supplant: { + behavior: this.form.get('supplantBehavior')?.value, + status_policy: MemberSyncPolicy.Preserve, + }, + }, + }, + type: ReleaseTrackType.Standard, + }; + if (payload.config.auto_promote) { + payload.config.candidacy_threshold = + this.form.get('candidacyThreshold')?.value; + } + } + + if (snapshotDescription) { + payload.snapshot_description = snapshotDescription; + } + + this.loading = true; + this.connector + .createReleaseTrack(payload) + .pipe(take(1)) + .subscribe({ + next: result => { + this.dialogRef.close(result); + }, + error: () => { + // leave dialog open and stop loading + this.loading = false; + }, + }); + } + + private loadComponentTrackOptions(): void { + this.isLoadingComponentTracks = true; + this.connector + .listReleaseTracks() + .pipe( + take(1), + finalize(() => { + this.isLoadingComponentTracks = false; + }) + ) + .subscribe({ + next: result => { + const tracks = this.getTrackList(result); + this.componentTrackOptions = tracks + .filter(track => this.isStandardTrack(track)) + .filter(track => !!this.getTrackId(track)) + .map(track => this.toComponentTrackOption(track)); + }, + error: () => { + this.componentTrackOptions = []; + }, + }); + } + + private buildVirtualComposition(): any { + const deduplication: any = {}; + const strategy = this.form.get('composition.deduplicationStrategy')?.value; + if (strategy) deduplication.strategy = strategy; + + return { + component_tracks: this.selectedComponentTracks.map((track, priority) => { + const componentTrack: any = { + track_id: track.trackId, + resolution_strategy: ResolutionStrategy.LatestTagged, + priority, + }; + + const filters: any = {}; + if (track.objectTypes.length) filters.object_types = track.objectTypes; + if (track.domains.length) filters.domains = track.domains; + if (Object.keys(filters).length) componentTrack.filters = filters; + + return componentTrack; + }), + deduplication, + }; + } + + private buildVirtualSnapshotSchedule(): any | undefined { + const snapshotSchedule = this.form.get('snapshotSchedule') as FormGroup; + if (!snapshotSchedule) return undefined; + + const mode = snapshotSchedule.get('mode')?.value; + const cron = snapshotSchedule.get('cron')?.value; + if (!mode) return undefined; + + const payload: any = { mode }; + if (mode === SnapshotScheduleMode.Cron && cron) payload.cron = cron; + return payload; + } + + private getTrackList(result: any): any[] { + if (Array.isArray(result?.data)) return result.data; + if (Array.isArray(result?.release_tracks)) return result.release_tracks; + if (Array.isArray(result)) return result; + return []; + } + + private isStandardTrack(track: any): boolean { + return String(track?.type).toLowerCase() === ReleaseTrackType.Standard; + } + + private toComponentTrackOption(track: any): VirtualComponentTrackOption { + const latestTaggedVersion = this.getLatestTaggedVersion(track); + const taggedReleaseCount = this.getTaggedReleaseCount(track); + + return { + trackId: this.getTrackId(track) as string, + name: track.name || 'Untitled release track', + description: track.description || '', + latestTaggedVersion, + taggedReleaseCount, + selected: false, + objectTypes: [], + domains: [], + }; + } + + private getTrackId(track: any): string | null { + return track?.track_id || track?.id || null; + } + + private getLatestTaggedVersion(track: any): string | null { + return ( + track?.latest_tagged_version || + track?.latestTaggedVersion || + track?.latest_version || + track?.latestVersion || + null + ); + } + + private getTaggedReleaseCount(track: any): number { + return Number( + track?.tagged_release_count || + track?.taggedReleaseCount || + track?.tagged_releases_count || + 0 + ); + } + + private formatStixType(type: StixType): string { + const attackType = StixTypeToAttackType[type]; + return AttackTypeToPlural[attackType]?.replace(/-/g, ' ') || type; + } + + private getComponentTrackSearchText(): string { + const value = this.form.get('composition.componentTrackSearch')?.value; + if (!value) return ''; + if (typeof value === 'string') return value.trim().toLowerCase(); + return String(value.name || value.trackId || '') + .trim() + .toLowerCase(); + } + + private matchesComponentTrackSearch( + track: VirtualComponentTrackOption, + search: string + ): boolean { + if (!search) return true; + return [track.name, track.trackId, track.description] + .filter(Boolean) + .some(value => value.toLowerCase().includes(search)); + } +} + +interface VirtualComponentTrackOption { + trackId: string; + name: string; + description: string; + latestTaggedVersion: string | null; + taggedReleaseCount: number; + selected: boolean; + objectTypes: StixType[]; + domains: string[]; +} diff --git a/src/app/components/object-status/object-status.component.ts b/src/app/components/object-status/object-status.component.ts index 4407310ab..eb5e03889 100644 --- a/src/app/components/object-status/object-status.component.ts +++ b/src/app/components/object-status/object-status.component.ts @@ -8,6 +8,7 @@ import { EditorService } from 'src/app/services/editor/editor.service'; import { AddDialogComponent } from '../add-dialog/add-dialog.component'; import { ConfirmationDialogComponent } from '../confirmation-dialog/confirmation-dialog.component'; import { forkJoin } from 'rxjs'; +import { WorkflowStatusMap } from 'src/app/utils/types'; @Component({ selector: 'app-object-status', @@ -18,6 +19,7 @@ import { forkJoin } from 'rxjs'; export class ObjectStatusComponent implements OnInit { public loaded = false; public select: SelectionModel; + public workflows = Object.entries(WorkflowStatusMap); public objects: StixObject[]; public object: StixObject; public relationships = []; diff --git a/src/app/components/recent-activity/recent-activity.component.ts b/src/app/components/recent-activity/recent-activity.component.ts index 1206bcb51..cbf974907 100644 --- a/src/app/components/recent-activity/recent-activity.component.ts +++ b/src/app/components/recent-activity/recent-activity.component.ts @@ -7,7 +7,6 @@ import { Note } from 'src/app/classes/stix/note'; import { Relationship } from 'src/app/classes/stix/relationship'; import { StixObject } from 'src/app/classes/stix/stix-object'; import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; -import { SidebarService } from 'src/app/services/sidebar/sidebar.service'; import { StixTypeToAttackType } from 'src/app/utils/type-mappings'; import { StixDialogComponent } from 'src/app/views/stix/stix-dialog/stix-dialog.component'; @@ -38,8 +37,7 @@ export class RecentActivityComponent implements OnInit { constructor( private restAPIService: RestApiConnectorService, private dialog: MatDialog, - private router: Router, - private sidebarService: SidebarService + private router: Router ) { // intentionally left blank } @@ -150,8 +148,6 @@ export class RecentActivityComponent implements OnInit { /** open the event in a dialog or redirect to the object page */ public open(event): void { if (event.sdo.attackType == 'note') { - this.sidebarService.opened = true; - this.sidebarService.currentTab = 'notes'; const objectRef = (event.sdo as Note).object_refs[0]; const type = StixTypeToAttackType[objectRef.split('--')[0]]; this.navigateTo(objectRef, type); diff --git a/src/app/components/release-preview-dialog/release-preview-dialog.component.html b/src/app/components/release-preview-dialog/release-preview-dialog.component.html new file mode 100644 index 000000000..0bff54c6a --- /dev/null +++ b/src/app/components/release-preview-dialog/release-preview-dialog.component.html @@ -0,0 +1,307 @@ +
+
+
+

+ Release Preview: {{ trackName }} +

+

+ Current: {{ currentVersion }} + Minor Bump: {{ minorVersion }} + Major Bump: {{ majorVersion }} +

+
+ + +
+ + + + + + Summary + + +
+
+ {{ totalIncludedCount }} + Total objects included +
+
+ {{ newObjectCount }} + New objects +
+
+ {{ updatedMemberCount }} + Updated members +
+
+ {{ unchangedObjectCount }} + Unchanged objects +
+
+ {{ excludedCandidates.length }} + Candidates not included +
+
+ {{ removedObjectCount }} + Removed objects +
+
+ {{ quarantinedObjectCount }} + Quarantined objects +
+
+ + + Release notes + + + Shown in history and exported as the collection description + + + {{ snapshotDescription.length }}/{{ snapshotDescriptionMaxLength }} + + +
+ + + + + Included ({{ includedObjects.length }}) + + +
+

+ No members or staged objects will be included. +

+
+ + + + + + + + + + + + + + + + + +
Name / IDTypeVersionSource
+ {{ getObjectName(item.object) }} + + {{ item.object.attack_id || item.object.object_ref }} + + {{ getObjectType(item.object) }}{{ getObjectVersion(item.object) }} + + {{ getIncludedSource(item) }} + +
+
+
+
+ + + + + Excluded ({{ excludedCandidates.length }}) + + +
+

+ No candidates are excluded from this snapshot. +

+
+ + + + + + + + + + + + + +
Name / IDWorkflow State
+ {{ getObjectName(item) }} + {{ item.attack_id || item.object_ref }} + + + {{ getWorkflowState(item) }} + +
+
+
+
+ + + + + Replacements ({{ replacements.length }}) + + +
+

+ No existing members will be replaced. +

+
+ + + + + + + + + + + + + + + +
Name / IDCurrent Member VersionIncoming Staged Version
+ {{ getObjectName(item.object) }} + + {{ item.object.attack_id || item.object.object_ref }} + + {{ getObjectVersion(item.currentMember) }} + + {{ getObjectVersion(item.incomingStaged) }} + + + warning_amber + +
+
+
+
+
+ +
+ + + +
+ + + +
+ + Exact version + + + +
+
+

+ {{ + exactVersion.length > 0 && exactVersionValidationMessage + ? exactVersionValidationMessage + : exactVersionBoundsHint + }} +

+
+
diff --git a/src/app/components/release-preview-dialog/release-preview-dialog.component.scss b/src/app/components/release-preview-dialog/release-preview-dialog.component.scss new file mode 100644 index 000000000..fde05e2ce --- /dev/null +++ b/src/app/components/release-preview-dialog/release-preview-dialog.component.scss @@ -0,0 +1,441 @@ +@use '../../../style/colors'; +@use '../../../style/typography'; + +.release-preview-dialog-backdrop { + background: rgba(colors.color(mitre-black), 0.76); +} + +.release-preview-dialog-panel .mat-mdc-dialog-surface { + border: 1px solid; + border-radius: 10px; + overflow: hidden; + @include colors.theme-border-color; +} + +.release-preview-dialog { + display: flex; + width: min(94vw, 1160px); + max-height: min(88vh, 820px); + flex-direction: column; + box-sizing: border-box; + + .release-preview-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + padding: 20px 24px 16px; + border-bottom: 1px solid; + @include colors.theme-border-color; + @include colors.theme-property( + background, + rgba(colors.on-color(dark), 0.045), + rgba(colors.color(mitre-black), 0.025) + ); + + h2 { + margin: 0; + font-size: 22px; + font-weight: 800; + line-height: 28px; + } + + p { + margin: 4px 0 0; + font-size: 13px; + line-height: 20px; + @include colors.theme-text-deemphasis; + } + } + + .release-version-summary { + display: flex; + flex-wrap: wrap; + gap: 6px 18px; + font-family: typography.$mono-font; + + strong { + @include colors.theme-property( + color, + colors.color(mitre-light-blue), + colors.color(primary-dark) + ); + } + } + + .release-preview-tabs { + min-height: 0; + flex: 1 1 auto; + + .mat-mdc-tab-header { + border-bottom: 1px solid; + @include colors.theme-border-color; + } + + .mat-mdc-tab-labels { + padding: 0 24px; + } + + .mdc-tab { + min-width: 150px; + } + + .mdc-tab__text-label { + display: inline-flex; + align-items: center; + gap: 12px; + } + + .mat-icon { + width: 19px; + height: 19px; + font-size: 19px; + line-height: 19px; + } + + .mat-mdc-tab-body-wrapper { + min-height: 0; + height: min(55vh, 520px); + } + + .mat-mdc-tab-body-content { + box-sizing: border-box; + overflow: auto; + } + } + + .release-summary { + display: grid; + width: min(760px, calc(100% - 48px)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 24px 28px; + margin: 28px auto; + } + + .release-snapshot-description-field { + display: block; + width: min(760px, calc(100% - 48px)); + margin: 0 auto 28px; + + textarea { + resize: vertical; + } + } + + .release-stat { + display: flex; + min-height: 132px; + align-items: center; + justify-content: center; + flex-direction: column; + box-sizing: border-box; + gap: 8px; + padding: 20px; + border: 1px solid; + border-radius: 8px; + text-align: center; + @include colors.theme-border-color; + @include colors.theme-property( + background, + rgba(colors.on-color(dark), 0.035), + rgba(colors.color(mitre-black), 0.025) + ); + + strong { + font-size: 52px; + font-weight: 800; + line-height: 56px; + } + + span { + font-size: 15px; + font-weight: 800; + letter-spacing: 0.04em; + line-height: 20px; + text-transform: uppercase; + @include colors.theme-text-emphasis; + } + } + + .release-stat--included strong { + color: colors.color(success); + } + + .release-stat--excluded strong { + color: colors.color(error); + } + + .release-stat--new strong { + @include colors.theme-property( + color, + colors.color(mitre-light-blue), + colors.color(primary-dark) + ); + } + + .release-stat--updated strong { + color: colors.color(warn); + } + + .release-stat--unchanged strong { + color: #c34ddd; + } + + .release-object-list { + display: grid; + gap: 10px; + width: calc(100% - 48px); + margin: 24px auto; + } + + .release-object-empty { + box-sizing: border-box; + border: 1px solid; + border-radius: 6px; + @include colors.theme-border-color; + } + + .release-table-wrapper { + overflow: auto; + border: 1px solid; + border-radius: 8px; + @include colors.theme-border-color; + } + + .release-preview-table { + width: 100%; + min-width: 760px; + border-collapse: collapse; + table-layout: fixed; + + th, + td { + box-sizing: border-box; + padding: 18px 26px; + border-bottom: 1px solid; + text-align: left; + vertical-align: middle; + @include colors.theme-border-color; + } + + th { + font-size: 13px; + font-weight: 800; + line-height: 18px; + @include colors.theme-property( + background, + rgba(colors.on-color(dark), 0.07), + rgba(colors.color(mitre-black), 0.055) + ); + } + + td { + height: 76px; + font-size: 13px; + line-height: 18px; + @include colors.theme-text-deemphasis; + + strong { + display: inline-block; + @include colors.theme-text-emphasis; + } + + small { + display: block; + margin-top: 2px; + font-family: typography.$mono-font; + @include colors.theme-text-deemphasis; + } + } + + tbody tr:last-child td { + border-bottom: 0; + } + + th:first-child, + td:first-child { + width: 43%; + } + } + + .release-preview-table--excluded { + th:first-child, + td:first-child { + width: 60%; + } + + th:nth-child(2), + td:nth-child(2) { + width: 40%; + } + } + + .release-preview-table--replacements { + th, + td { + width: 34%; + } + } + + .source-chip, + .workflow-chip { + display: inline-flex; + padding: 3px 8px; + border: 1px solid; + border-radius: 5px; + font-family: typography.$mono-font; + font-size: 11px; + font-weight: 700; + line-height: 16px; + } + + .source-chip { + border-color: rgba(#c34ddd, 0.45); + background: rgba(#c34ddd, 0.13); + color: #c34ddd; + } + + .source-chip--staged { + border-color: rgba(colors.color(success), 0.45); + background: rgba(colors.color(success), 0.13); + color: colors.color(success); + } + + .workflow-chip { + @include colors.theme-border-color; + @include colors.theme-property( + background, + rgba(colors.on-color(dark), 0.06), + rgba(colors.color(mitre-black), 0.045) + ); + @include colors.theme-text-emphasis; + } + + .release-table-row--invalid { + background: rgba(colors.color(error), 0.12); + } + + .incoming-version { + color: colors.color(success) !important; + font-family: typography.$mono-font; + } + + .incoming-version--invalid, + .invalid-version-icon { + color: colors.color(error) !important; + } + + .invalid-version-icon { + width: 16px; + height: 16px; + margin-left: 4px; + font-size: 16px; + line-height: 16px; + vertical-align: middle; + } + + .release-object-empty { + margin: 0; + padding: 28px; + text-align: center; + @include colors.theme-text-deemphasis; + } + + .release-preview-footer { + display: flex; + min-height: 72px; + align-items: center; + justify-content: space-between; + gap: 20px; + box-sizing: border-box; + padding: 14px 20px; + border-top: 1px solid; + @include colors.theme-border-color; + flex-wrap: wrap; + } + + .release-blocked-message { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; + margin: 0; + color: colors.color(error); + font-size: 13px; + line-height: 20px; + + .mat-icon { + flex: 0 0 auto; + } + } + + .release-preview-actions { + display: flex; + flex: 0 0 auto; + gap: 12px; + margin-left: auto; + } + + .release-preview-exact-version { + display: flex; + align-items: center; + gap: 8px; + + .mat-mdc-form-field { + width: 132px; + } + } + + .release-exact-version-guidance { + width: 100%; + margin: -6px 0 0; + text-align: right; + font-size: 12px; + @include colors.theme-text-deemphasis; + } + + .release-exact-version-guidance--error { + color: colors.color(error); + } +} + +@media (max-width: 720px) { + .release-preview-dialog { + width: 96vw; + max-height: 92vh; + + .release-preview-header { + padding: 16px; + } + + .release-preview-tabs .mat-mdc-tab-labels { + padding: 0; + } + + .release-summary { + width: calc(100% - 32px); + grid-template-columns: 1fr; + gap: 12px; + margin: 16px auto; + } + + .release-stat { + min-height: 108px; + } + + .release-preview-footer { + align-items: stretch; + flex-direction: column; + } + + .release-preview-actions { + width: 100%; + flex-wrap: wrap; + margin-left: 0; + + button { + flex: 1 1 auto; + } + } + } +} diff --git a/src/app/components/release-preview-dialog/release-preview-dialog.component.spec.ts b/src/app/components/release-preview-dialog/release-preview-dialog.component.spec.ts new file mode 100644 index 000000000..11e466309 --- /dev/null +++ b/src/app/components/release-preview-dialog/release-preview-dialog.component.spec.ts @@ -0,0 +1,285 @@ +import { CommonModule } from '@angular/common'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatTabsModule } from '@angular/material/tabs'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { vi } from 'vitest'; + +import { + ReleasePreviewDialogComponent, + ReleasePreviewDialogData, +} from './release-preview-dialog.component'; + +describe('ReleasePreviewDialogComponent', () => { + let component: ReleasePreviewDialogComponent; + let fixture: ComponentFixture; + let dialogRef: { close: ReturnType }; + let data: ReleasePreviewDialogData; + + beforeEach(async () => { + dialogRef = { close: vi.fn() }; + data = { + track: { + name: 'Core Objects', + members: [ + { + object_ref: 'attack-pattern--member', + attack_id: 'T0001', + name: 'Existing Member', + x_mitre_version: '1.0', + }, + ], + staged: [ + { + object_ref: 'attack-pattern--member', + attack_id: 'T0001', + name: 'Updated Member', + x_mitre_version: '1.1', + }, + { + object_ref: 'attack-pattern--new', + attack_id: 'T0002', + name: 'New Object', + x_mitre_version: '1.0', + }, + ], + candidates: [ + { + object_ref: 'attack-pattern--candidate', + attack_id: 'T0003', + name: 'Candidate', + object_status: 'work-in-progress', + }, + ], + }, + }; + + await TestBed.configureTestingModule({ + declarations: [ReleasePreviewDialogComponent], + imports: [ + CommonModule, + FormsModule, + MatButtonModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatTabsModule, + NoopAnimationsModule, + ], + providers: [ + { provide: MatDialogRef, useValue: dialogRef }, + { provide: MAT_DIALOG_DATA, useValue: data }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ReleasePreviewDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should calculate the draft snapshot summary from track contents', () => { + expect(component.includedObjects).toHaveLength(2); + expect(component.newObjectCount).toBe(1); + expect(component.updatedMemberCount).toBe(1); + expect(component.unchangedObjectCount).toBe(0); + expect(component.excludedCandidates).toHaveLength(1); + }); + + it('should default an unversioned track to version 0.1', () => { + data.track.version = null; + data.track.version_history = []; + + expect(component.currentVersion).toBe('v0.1'); + }); + + it('should use the backend diff when previewing a virtual release', () => { + data.previewSummary = { + type: 'virtual', + after: { members_count: 870, quarantine_count: 0 }, + changes: { + new_count: 30, + updated_count: 12, + removed_count: 10, + quarantined_count: 0, + }, + }; + + expect(component.isVirtualTrack).toBe(true); + expect(component.totalIncludedCount).toBe(870); + expect(component.newObjectCount).toBe(30); + expect(component.updatedMemberCount).toBe(12); + expect(component.unchangedObjectCount).toBe(828); + expect(component.removedObjectCount).toBe(10); + expect(component.quarantinedObjectCount).toBe(0); + }); + + it('should display the canonical ATT&CK object type', () => { + data.track.staged[0].attack_type = 'technique'; + + expect(component.getObjectType(data.track.staged[0])).toBe('Technique'); + }); + + it('should return the selected version bump when all bumps are valid', () => { + component.snapshotDescription = ' Analyst release context '; + component.tagVersion('minor'); + expect(dialogRef.close).toHaveBeenCalledWith({ + increment: 'minor', + description: 'Analyst release context', + }); + + component.tagVersion('major'); + expect(dialogRef.close).toHaveBeenCalledWith({ + increment: 'major', + description: 'Analyst release context', + }); + }); + + it('should return a normalized exact version within the backend bounds', () => { + data.previewSummary = { + version_bounds: { + lower: { version: '1.0', modified: '2026-07-01T00:00:00.000Z' }, + upper: { version: '3.0', modified: '2026-07-05T00:00:00.000Z' }, + }, + }; + component.snapshotDescription = ' Retroactive release '; + component.exactVersion = ' v2.7 '; + + expect(component.exactVersionValidationMessage).toBeNull(); + expect(component.exactVersionBoundsHint).toContain( + 'greater than v1.0 and less than v3.0' + ); + component.tagExactVersion(); + + expect(dialogRef.close).toHaveBeenCalledWith({ + version: '2.7', + description: 'Retroactive release', + }); + }); + + it('should reject malformed and out-of-bounds exact versions', () => { + data.previewSummary = { + version_bounds: { + lower: { version: '1.0', modified: '2026-07-01T00:00:00.000Z' }, + upper: { version: '3.0', modified: '2026-07-05T00:00:00.000Z' }, + }, + }; + + component.exactVersion = '2.0.1'; + expect(component.exactVersionValidationMessage).toContain('MAJOR.MINOR'); + component.tagExactVersion(); + + component.exactVersion = '3.0'; + expect(component.exactVersionValidationMessage).toBe( + 'Version must be less than v3.0.' + ); + component.tagExactVersion(); + + expect(dialogRef.close).not.toHaveBeenCalled(); + }); + + it('should close without selecting a release version', () => { + component.close(); + + expect(dialogRef.close).toHaveBeenCalledWith(); + }); + + it('should block tagging when the backend reports release conflicts', () => { + data.conflicts = [{ object_ref: 'attack-pattern--member' }]; + fixture.detectChanges(); + + expect(component.hasInvalidVersionBumps).toBe(false); + expect(component.hasPromotionConflicts).toBe(true); + expect(component.isReleaseBlocked).toBe(true); + component.tagVersion('minor'); + + expect(dialogRef.close).not.toHaveBeenCalled(); + expect(fixture.nativeElement.textContent).toContain( + 'Release is blocked by promotion conflicts.' + ); + expect(fixture.nativeElement.textContent).not.toContain( + 'Release is blocked by invalid incoming version bumps.' + ); + }); + + it('should provide fallbacks for incomplete object metadata', () => { + expect(component.getObjectName({})).toBe('ATT&CK object'); + expect(component.getObjectVersion(null)).toBe('Not available'); + expect(component.getObjectType({})).toBe('STIX Object'); + expect( + component.getObjectType({ object_ref: 'course-of-action--123' }) + ).toBe('Course Of Action'); + expect(component.getWorkflowState({})).toBe('work-in-progress'); + }); + + it('should allow tagging when an object has an invalid version string', () => { + data.track.staged[0].x_mitre_version = 'invalid'; + + expect(component.hasInvalidVersionBumps).toBe(false); + component.tagVersion('minor'); + + expect(dialogRef.close).toHaveBeenCalledWith({ + increment: 'minor', + description: '', + }); + }); + + it('should render the objects represented by the Included and Excluded counts', () => { + const element: HTMLElement = fixture.nativeElement; + const tabs = Array.from( + element.querySelectorAll('[role="tab"]') + ); + const includedTab = tabs.find(tab => tab.textContent?.includes('Included')); + const excludedTab = tabs.find(tab => tab.textContent?.includes('Excluded')); + + includedTab?.click(); + fixture.detectChanges(); + + expect(element.textContent).toContain('Updated Member'); + expect(element.textContent).toContain('New Object'); + + excludedTab?.click(); + fixture.detectChanges(); + + expect(element.textContent).toContain('Candidate'); + expect( + element.querySelectorAll('.release-preview-table--excluded thead th') + .length + ).toBe(2); + }); + + it('should highlight and block an invalid incoming version bump', () => { + data.track.staged[0].x_mitre_version = '1.3'; + fixture.detectChanges(); + + expect(component.hasInvalidVersionBumps).toBe(true); + expect(component.isReleaseBlocked).toBe(true); + component.tagVersion('minor'); + expect(dialogRef.close).not.toHaveBeenCalled(); + + const element: HTMLElement = fixture.nativeElement; + const replacementsTab = Array.from( + element.querySelectorAll('[role="tab"]') + ).find(tab => tab.textContent?.includes('Replacements')); + replacementsTab?.click(); + fixture.detectChanges(); + + const invalidRow = element.querySelector( + '.release-table-row--invalid' + ); + const minorButton = element.querySelector( + '.release-preview-tag-minor-button' + ); + const majorButton = element.querySelector( + '.release-preview-tag-major-button' + ); + + expect(invalidRow).toBeTruthy(); + expect(minorButton?.disabled).toBe(true); + expect(majorButton?.disabled).toBe(true); + }); +}); diff --git a/src/app/components/release-preview-dialog/release-preview-dialog.component.ts b/src/app/components/release-preview-dialog/release-preview-dialog.component.ts new file mode 100644 index 000000000..33f99038b --- /dev/null +++ b/src/app/components/release-preview-dialog/release-preview-dialog.component.ts @@ -0,0 +1,354 @@ +import { Component, Inject, ViewEncapsulation } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; + +import { VersionNumber } from 'src/app/classes/version-number'; + +export interface ReleasePreviewDialogData { + track: any; + conflicts?: any[]; + proposedMinorVersion?: string; + previewSummary?: any; +} + +export type ReleasePreviewSelection = ( + | { increment: 'minor' | 'major'; version?: never } + | { increment?: never; version: string } +) & { description: string }; + +interface ReleaseTrackObject { + object_ref?: string; + object_modified?: string; + attack_id?: string; + name?: string; + description?: string; + attack_type?: string; + version?: string; + x_mitre_version?: string; + stix?: { type?: string; x_mitre_version?: string }; + [key: string]: any; +} + +export interface IncludedReleaseObject { + object: ReleaseTrackObject; + currentMember: ReleaseTrackObject | null; + incomingStaged: ReleaseTrackObject | null; + invalidVersionBump: boolean; +} + +@Component({ + selector: 'app-release-preview-dialog', + templateUrl: './release-preview-dialog.component.html', + styleUrls: ['./release-preview-dialog.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false, +}) +export class ReleasePreviewDialogComponent { + public readonly snapshotDescriptionMaxLength = 4000; + public snapshotDescription: string; + public exactVersion = ''; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: ReleasePreviewDialogData + ) { + this.snapshotDescription = data.track?.snapshot_description || ''; + } + + public get trackName(): string { + return this.data.track?.name || 'Release Track'; + } + + public get currentVersion(): string { + const history = this.asArray(this.data.track?.version_history); + const version = + this.data.previewSummary?.version_bounds?.lower?.version ?? + this.data.track?.version ?? + history[history.length - 1]?.version ?? + '0.1'; + + return this.formatVersion(version); + } + + public get minorVersion(): string { + if (this.data.proposedMinorVersion) { + return this.formatVersion(this.data.proposedMinorVersion); + } + + return this.formatVersion( + new VersionNumber(this.currentVersion.replace(/^v/i, '')) + .nextMinorVersion() + .toString() + ); + } + + public get majorVersion(): string { + return this.formatVersion( + new VersionNumber(this.currentVersion.replace(/^v/i, '')) + .nextMajorVersion() + .toString() + ); + } + + public get members(): ReleaseTrackObject[] { + return this.asArray(this.data.track?.members); + } + + public get staged(): ReleaseTrackObject[] { + return this.asArray(this.data.track?.staged); + } + + public get excludedCandidates(): ReleaseTrackObject[] { + return this.asArray(this.data.track?.candidates); + } + + public get isVirtualTrack(): boolean { + return this.data.previewSummary?.type === 'virtual'; + } + + public get totalIncludedCount(): number { + return this.isVirtualTrack + ? (this.data.previewSummary?.after?.members_count ?? this.members.length) + : this.includedObjects.length; + } + + public get includedObjects(): IncludedReleaseObject[] { + const stagedByRef = new Map( + this.staged.map(item => [this.getObjectRef(item), item]) + ); + const memberRefs = new Set( + this.members.map(item => this.getObjectRef(item)) + ); + + const existingMembers = this.members.map(member => { + const incoming = stagedByRef.get(this.getObjectRef(member)) ?? null; + + return this.createIncludedObject(incoming ?? member, member, incoming); + }); + const newStaged = this.staged + .filter(item => !memberRefs.has(this.getObjectRef(item))) + .map(item => this.createIncludedObject(item, null, item)); + + return [...existingMembers, ...newStaged]; + } + + public get newObjectCount(): number { + if (this.isVirtualTrack) { + return this.data.previewSummary?.changes?.new_count ?? 0; + } + + const memberRefs = new Set( + this.members.map(item => this.getObjectRef(item)) + ); + return this.staged.filter(item => !memberRefs.has(this.getObjectRef(item))) + .length; + } + + public get updatedMemberCount(): number { + if (this.isVirtualTrack) { + return this.data.previewSummary?.changes?.updated_count ?? 0; + } + + const memberRefs = new Set( + this.members.map(item => this.getObjectRef(item)) + ); + return this.staged.filter(item => memberRefs.has(this.getObjectRef(item))) + .length; + } + + public get unchangedObjectCount(): number { + if (this.isVirtualTrack) { + return Math.max( + this.totalIncludedCount - this.newObjectCount - this.updatedMemberCount, + 0 + ); + } + + return Math.max(this.members.length - this.updatedMemberCount, 0); + } + + public get removedObjectCount(): number { + return this.data.previewSummary?.changes?.removed_count ?? 0; + } + + public get quarantinedObjectCount(): number { + return this.data.previewSummary?.changes?.quarantined_count ?? 0; + } + + public get replacements(): IncludedReleaseObject[] { + return this.includedObjects.filter( + item => !!item.currentMember && !!item.incomingStaged + ); + } + + public get hasInvalidVersionBumps(): boolean { + return this.includedObjects.some(item => item.invalidVersionBump); + } + + public get hasPromotionConflicts(): boolean { + return !!this.data.conflicts?.length; + } + + public get isReleaseBlocked(): boolean { + return this.hasInvalidVersionBumps || this.hasPromotionConflicts; + } + + public close(): void { + this.dialogRef.close(); + } + + public tagVersion(type: 'minor' | 'major'): void { + if (this.isReleaseBlocked) { + return; + } + + this.dialogRef.close({ + increment: type, + description: this.snapshotDescription.trim(), + } satisfies ReleasePreviewSelection); + } + + public get exactVersionValidationMessage(): string | null { + const version = this.normalizedExactVersion; + if (!version) return 'Enter a version in MAJOR.MINOR format.'; + if (!/^\d+\.\d+$/.test(version)) { + return 'Use MAJOR.MINOR format, for example 19.2.'; + } + + const selected = new VersionNumber(version); + const lower = this.data.previewSummary?.version_bounds?.lower?.version; + const upper = this.data.previewSummary?.version_bounds?.upper?.version; + if (lower && selected.compareTo(new VersionNumber(lower)) <= 0) { + return `Version must be greater than ${this.formatVersion(lower)}.`; + } + if (upper && selected.compareTo(new VersionNumber(upper)) >= 0) { + return `Version must be less than ${this.formatVersion(upper)}.`; + } + return null; + } + + public get exactVersionBoundsHint(): string { + const lower = this.data.previewSummary?.version_bounds?.lower?.version; + const upper = this.data.previewSummary?.version_bounds?.upper?.version; + if (lower && upper) { + return `Must be greater than ${this.formatVersion(lower)} and less than ${this.formatVersion(upper)}.`; + } + if (lower) return `Must be greater than ${this.formatVersion(lower)}.`; + if (upper) return `Must be less than ${this.formatVersion(upper)}.`; + return 'Use MAJOR.MINOR format.'; + } + + public tagExactVersion(): void { + if (this.isReleaseBlocked || this.exactVersionValidationMessage) return; + + this.dialogRef.close({ + version: this.normalizedExactVersion, + description: this.snapshotDescription.trim(), + } satisfies ReleasePreviewSelection); + } + + public getObjectName(item: ReleaseTrackObject): string { + return item?.name || item?.attack_id || item?.object_ref || 'ATT&CK object'; + } + + public getObjectVersion(item: ReleaseTrackObject | null): string { + const version = this.getRawVersion(item); + return version ? this.formatVersion(version) : 'Not available'; + } + + public getObjectType(item: ReleaseTrackObject): string { + const type = + item?.attack_type ?? + item?.type ?? + item?.stix?.type ?? + item?.object_ref?.split('--')[0] ?? + ''; + + return ( + String(type) + .replace(/[_-]+/g, ' ') + .replace(/\b\w/g, character => character.toUpperCase()) || 'STIX Object' + ); + } + + public getIncludedSource(item: IncludedReleaseObject): string { + return item.incomingStaged ? 'staged' : 'members'; + } + + public getWorkflowState(item: ReleaseTrackObject): string { + return item?.object_status ?? item?.status ?? 'work-in-progress'; + } + + public trackByIncludedObject( + index: number, + item: IncludedReleaseObject + ): string | number { + return item.object?.object_ref || index; + } + + public trackByObject( + index: number, + item: ReleaseTrackObject + ): string | number { + return item?.object_ref || index; + } + + private createIncludedObject( + object: ReleaseTrackObject, + currentMember: ReleaseTrackObject | null, + incomingStaged: ReleaseTrackObject | null + ): IncludedReleaseObject { + return { + object, + currentMember, + incomingStaged, + invalidVersionBump: this.isInvalidVersionBump( + currentMember, + incomingStaged + ), + }; + } + + private isInvalidVersionBump( + currentMember: ReleaseTrackObject | null, + incomingStaged: ReleaseTrackObject | null + ): boolean { + const currentVersion = this.getRawVersion(currentMember); + const incomingVersion = this.getRawVersion(incomingStaged); + + if (!currentVersion || !incomingVersion) { + return false; + } + + return new VersionNumber(incomingVersion).isDoubleIncrement( + new VersionNumber(currentVersion) + ); + } + + private getRawVersion(item: ReleaseTrackObject | null): string | null { + const value = + item?.version ?? item?.x_mitre_version ?? item?.stix?.x_mitre_version; + + if (value === null || value === undefined || value === '') { + return null; + } + + return String(value).replace(/^v/i, ''); + } + + private formatVersion(value: unknown): string { + const version = String(value); + return version.toLowerCase().startsWith('v') ? version : `v${version}`; + } + + private get normalizedExactVersion(): string { + return this.exactVersion.trim().replace(/^v/i, ''); + } + + private getObjectRef(item: ReleaseTrackObject): string { + return String(item?.object_ref ?? ''); + } + + private asArray(value: unknown): ReleaseTrackObject[] { + return Array.isArray(value) ? value : []; + } +} diff --git a/src/app/components/release-track-card/release-track-card.component.html b/src/app/components/release-track-card/release-track-card.component.html new file mode 100644 index 000000000..bdfd698ba --- /dev/null +++ b/src/app/components/release-track-card/release-track-card.component.html @@ -0,0 +1,82 @@ +
+
+ {{ track.name }} + +
+ +
+ + + {{ track.description }} + + + +
+
+ +
+
CANDIDATES
+
+ {{ track.stats?.candidates ?? 0 }} +
+
+
+
STAGED
+
+ {{ track.stats?.staged ?? 0 }} +
+
+
+
MEMBERS
+
+ {{ track.stats?.members ?? 0 }} +
+
+
+ + +
+
COMPONENTS
+
+ {{ track.composition?.component_tracks?.length || 0 }} +
+
+
+
QUARANTINED
+
{{ track.stats?.quarantined || 0 }}
+
+
+
MEMBERS
+
{{ track.stats?.members || 0 }}
+
+
+
+ + +
+
+ + +
diff --git a/src/app/components/release-track-card/release-track-card.component.scss b/src/app/components/release-track-card/release-track-card.component.scss new file mode 100644 index 000000000..cdb0b2f53 --- /dev/null +++ b/src/app/components/release-track-card/release-track-card.component.scss @@ -0,0 +1,190 @@ +@use 'sass:color'; +@use '../../../style/colors'; + +$candidate-dark: colors.color(mitre-light-blue); +$staged-dark: color.mix(white, colors.color(success), 28%); +$description-line-height: 1.25rem; +$description-lines: 4; + +.list-card { + cursor: pointer; + width: 22rem; + height: 19rem; + box-sizing: border-box; + color: colors.on-color-emphasis(light); +} + +.card-top { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + min-height: 3.5rem; + margin-bottom: 8px; +} + +.subheading { + display: -webkit-box; + flex: 1; + min-width: 0; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.release-track-type-chip { + flex: none; +} + +.card-body { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.card-desc { + display: -webkit-box; + flex: 0 0 ($description-line-height * $description-lines); + min-width: 0; + max-height: $description-line-height * $description-lines; + margin-bottom: 12px; + overflow: hidden; + line-height: $description-line-height; + -webkit-box-orient: vertical; + -webkit-line-clamp: $description-lines; +} + +.card-middle { + margin-top: auto; + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: stretch; + min-height: 4.5rem; +} + +.card-stats { + display: flex; + gap: 1rem; + width: 100%; + justify-content: flex-start; + align-items: center; + min-height: 3.25rem; + padding: 12px 16px; + box-sizing: border-box; + background: colors.color-alternate(light); + border-radius: 8px; +} + +.stat { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + flex: 1; + text-align: center; +} + +.card-standard, +.card-virtual { + border-radius: 8px; + padding: 24px; + min-height: 12rem; + display: flex; + flex-direction: column; + justify-content: space-between; + background: colors.color(light); + border: 1px solid colors.border-color(light); + color: colors.on-color-emphasis(light); + overflow: hidden; + transition: + background-color 120ms ease, + border-color 120ms ease, + color 120ms ease; + + &:hover { + border-color: colors.color(secondary); + + .subheading { + color: colors.color(secondary); + } + } + + .stat-label { + font-size: 11px; + color: colors.on-color-deemphasis(light); + line-height: 13px; + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 6px; + } + + .stat-value { + font-weight: 700; + font-size: 1.25rem; + } + + .candidates-stat-value { + color: colors.color(info); + } + + .staged-stat-value { + color: colors.color(success); + } + + .card-sep { + margin: 12px 0; + border-top-color: colors.border-color(light); + } + + .card-footer { + display: flex; + justify-content: space-between; + color: colors.on-color-deemphasis(light); + font-size: 13px; + } +} + +:host-context(.dark) { + .list-card { + color: colors.on-color-emphasis(dark); + } + + .card-stats { + background: colors.color-alternate(dark); + } + + .card-standard, + .card-virtual { + background: colors.color(dark); + border-color: colors.border-color(dark); + color: colors.on-color-emphasis(dark); + + &:hover { + border-color: colors.color(mitre-light-blue); + + .subheading { + color: colors.color(mitre-light-blue); + } + } + + .stat-label, + .card-footer { + color: colors.on-color-deemphasis(dark); + } + + .candidates-stat-value { + color: $candidate-dark; + } + + .staged-stat-value { + color: $staged-dark; + } + + .card-sep { + border-top-color: colors.border-color(dark); + } + } +} diff --git a/src/app/components/release-track-card/release-track-card.component.ts b/src/app/components/release-track-card/release-track-card.component.ts new file mode 100644 index 000000000..2f64b8a18 --- /dev/null +++ b/src/app/components/release-track-card/release-track-card.component.ts @@ -0,0 +1,34 @@ +import { Component, Input, Output, EventEmitter } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { WorkbenchChipComponent } from '../workbench-chip/workbench-chip.component'; + +@Component({ + selector: 'app-release-track-card', + standalone: true, + imports: [ + CommonModule, + MatDividerModule, + MatIconModule, + MatTooltipModule, + WorkbenchChipComponent, + ], + templateUrl: './release-track-card.component.html', + styleUrls: ['./release-track-card.component.scss'], +}) +export class ReleaseTrackCardComponent { + @Input() track: any = {}; + @Input() type: 'standard' | 'virtual' | null = null; + @Output() viewTrack = new EventEmitter(); + + public get chipVariant(): 'standard' | 'virtual' { + return this.type === 'virtual' ? 'virtual' : 'standard'; + } + + public onViewTrack(): void { + const id = this.track?.id || this.track?.track_id || null; + if (id) this.viewTrack.emit(id); + } +} diff --git a/src/app/components/release-track-object-card/release-track-object-card.component.html b/src/app/components/release-track-object-card/release-track-object-card.component.html new file mode 100644 index 000000000..c2985d30d --- /dev/null +++ b/src/app/components/release-track-object-card/release-track-object-card.component.html @@ -0,0 +1,78 @@ + + +
+
+
+ {{ title }} +
+
+ {{ subtitle }} +
+
+ + +
+
+ + + + + + +
+ + + + {{ modifiedHumanized }} + + + Unknown + + +
+ + +
+
diff --git a/src/app/components/release-track-object-card/release-track-object-card.component.scss b/src/app/components/release-track-object-card/release-track-object-card.component.scss new file mode 100644 index 000000000..30d81ea4d --- /dev/null +++ b/src/app/components/release-track-object-card/release-track-object-card.component.scss @@ -0,0 +1,210 @@ +@use 'sass:color'; +@use '../../../style/colors' as colors; + +.release-track-object-card { + display: flex; + flex-direction: column; + gap: 0; + overflow: hidden; + border-radius: 6px; + box-shadow: none; + transition: + border-color 120ms ease, + box-shadow 120ms ease, + transform 120ms ease; + + .light & { + background: color.mix(white, colors.color(light), 76%); + border-color: colors.border-color(light); + } + + .dark & { + background: colors.color-alternate(dark); + border-color: colors.border-color(dark); + } + + &:hover { + transform: translateY(-1px); + + .light & { + box-shadow: 0 2px 8px rgba(colors.color(mitre-black), 0.12); + } + + .dark & { + box-shadow: 0 2px 8px rgba(black, 0.3); + } + } + + .mat-mdc-card-header { + display: block; + padding: 16px 16px 0; + } + + .mat-mdc-card-content { + padding: 0px 16px 16px; + } + + .mat-mdc-card-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px; + min-height: 50px; + margin-top: auto; + padding: 10px 16px !important; + + .light & { + border-top: 1px solid colors.border-color(light); + background: rgba(colors.color(mitre-black), 0.03); + } + + .dark & { + border-top: 1px solid colors.border-color(dark); + background: rgba(colors.color(mitre-silver), 0.04); + } + } +} + +.object-card-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + min-width: 0; + margin-bottom: 12px; +} + +.object-card-title, +.object-card-subtitle, +.object-description { + overflow: hidden; + text-overflow: ellipsis; +} + +.object-card-title { + display: -webkit-box; + font-size: 16px; + font-weight: 800; + line-height: 20px; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + + .light & { + color: colors.on-color(light); + } + + .dark & { + color: colors.on-color(dark); + } +} + +.object-card-subtitle { + margin-top: 2px; + font-size: 13px; + line-height: 17px; + white-space: nowrap; + + .light & { + color: colors.on-color-deemphasis(light); + } + + .dark & { + color: colors.on-color-deemphasis(dark); + } +} + +.object-description { + display: block; + min-height: 30px; + margin: 0; + font-size: 14px; + font-weight: 500; + line-height: 22px; + + ::ng-deep p { + display: -webkit-box; + overflow: hidden; + margin: 0; + text-overflow: ellipsis; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + } + + ::ng-deep :last-child { + margin-bottom: 0; + } + + ::ng-deep a { + font-weight: 700; + } + + .light & { + color: colors.on-color(light); + } + + .dark & { + color: colors.on-color(dark); + } +} + +.object-meta, +.object-actions, +.object-footer-right { + display: flex; + align-items: center; + gap: 8px; +} + +.object-meta { + min-width: 0; + font-size: 12px; + line-height: 18px; + + .light & { + color: colors.on-color-deemphasis(light); + } + + .dark & { + color: colors.on-color-deemphasis(dark); + } +} + +.object-avatar { + flex: 0 0 auto; +} + +.object-modified { + min-width: 0; + white-space: nowrap; +} + +.object-footer-right { + justify-content: flex-end; + margin-left: auto; + min-width: 0; +} + +.object-actions { + .mdc-button { + height: 28px; + min-width: 0; + padding: 0 10px; + } + + .mat-mdc-button-touch-target { + height: 32px !important; + } + + .mat-icon { + width: 16px; + height: 16px; + margin-right: 4px; + font-size: 16px; + line-height: 16px; + } +} + +.view-button { + color: colors.color(info) !important; +} diff --git a/src/app/components/release-track-object-card/release-track-object-card.component.spec.ts b/src/app/components/release-track-object-card/release-track-object-card.component.spec.ts new file mode 100644 index 000000000..bca280f35 --- /dev/null +++ b/src/app/components/release-track-object-card/release-track-object-card.component.spec.ts @@ -0,0 +1,85 @@ +import { ReleaseTrackObjectCardComponent } from './release-track-object-card.component'; + +describe('ReleaseTrackObjectCardComponent', () => { + let component: ReleaseTrackObjectCardComponent; + + beforeEach(() => { + component = new ReleaseTrackObjectCardComponent(); + }); + + it('should show tier entry display fields when available', () => { + component.item = { + object_ref: 'attack-pattern--12345678-1234-1234-1234-123456789abc', + object_modified: '2026-01-01T00:00:00.000Z', + attack_id: 'T1234', + name: 'Technique Name', + description: 'Technique description\n\nAdditional details.', + modified_by_user: { + id: 'user-account--1234', + username: 'reviewer1', + displayName: 'Review User', + name: 'Review User', + }, + }; + + expect(component.title).toBe('Technique Name'); + expect(component.subtitle).toBe('T1234'); + expect(component.description).toBe('Technique description'); + expect(component.modifiedByName).toBe('Review User'); + }); + + it('should use username when modified by user has no display name', () => { + component.item = { + object_ref: 'attack-pattern--12345678-1234-1234-1234-123456789abc', + modified_by_user: { + username: 'reviewer1', + }, + }; + + expect(component.modifiedByName).toBe('reviewer1'); + }); + + it('should preserve markdown syntax in descriptions for rendering', () => { + component.item = { + object_ref: 'attack-pattern--12345678-1234-1234-1234-123456789abc', + description: '**Markdown** [description](https://example.com)', + }; + + expect(component.description).toBe( + '**Markdown** [description](https://example.com)' + ); + }); + + it('should prefer display name over username and name', () => { + component.item = { + object_ref: 'attack-pattern--12345678-1234-1234-1234-123456789abc', + modified_by_user: { + username: 'releaseuser', + displayName: 'Release Reviewer', + name: 'Fallback Name', + }, + }; + + expect(component.modifiedByName).toBe('Release Reviewer'); + }); + + it('should prefer modified by user info over staged identity ids', () => { + component.item = { + object_ref: 'attack-pattern--990e4d99-5c6f-4f34-b6f0-7221c55f39cb', + object_modified: '2026-04-22T17:57:26.218Z', + object_status: 'reviewed', + object_staged_at: '2026-06-24T19:50:19.859Z', + object_staged_by: 'identity--00000000-0000-4000-8000-000000000001', + attack_id: 'T1640.001', + name: '0 New Sub', + modified_by_user: { + id: 'identity--00000000-0000-4000-8000-000000000001', + username: 'releaseuser', + displayName: 'Release Reviewer', + name: 'Release Reviewer', + }, + }; + + expect(component.modifiedByName).toBe('Release Reviewer'); + }); +}); diff --git a/src/app/components/release-track-object-card/release-track-object-card.component.ts b/src/app/components/release-track-object-card/release-track-object-card.component.ts new file mode 100644 index 000000000..a806790e1 --- /dev/null +++ b/src/app/components/release-track-object-card/release-track-object-card.component.ts @@ -0,0 +1,158 @@ +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import moment from 'moment'; +import { MarkdownModule } from 'ngx-markdown'; +import { StixTypeToAttackType } from 'src/app/utils/type-mappings'; +import { StixType, WorkflowStatusType } from 'src/app/utils/types'; +import { UserAvatarComponent } from '../user-avatar/user-avatar.component'; +import type { TierEntryModifiedByUser } from 'src/app/classes/release-tracks'; + +export interface ReleaseTrackObjectItem { + object_ref: string; + object_modified?: Date | string; + object_status?: WorkflowStatusType; + object_added_at?: Date | string; + object_added_by?: string; + object_staged_at?: Date | string; + object_staged_by?: string; + attack_id?: string; + name?: string; + description?: string; + modified_by_user?: TierEntryModifiedByUser; + [key: string]: any; +} + +@Component({ + selector: 'app-release-track-object-card', + standalone: true, + imports: [ + CommonModule, + MatButtonModule, + MatCardModule, + MatIconModule, + MatTooltipModule, + MarkdownModule, + UserAvatarComponent, + ], + templateUrl: './release-track-object-card.component.html', + styleUrls: ['./release-track-object-card.component.scss'], +}) +export class ReleaseTrackObjectCardComponent { + @Input({ required: true }) item!: ReleaseTrackObjectItem; + @Input() cardType: string | null = null; + @Input() laneStatus: WorkflowStatusType | null = null; + @Input() showDescription = true; + @Input() showDiff = true; + @Input() diffDisabled = false; + @Input() diffDisabledMessage = ''; + @Input() showModifiedMeta = true; + + @Output() viewObject = new EventEmitter(); + @Output() diffObject = new EventEmitter(); + + public get title(): string { + return ( + this.item?.name || this.item?.object_name || this.fallbackObjectLabel + ); + } + + public get subtitle(): string { + return this.item?.attack_id || this.item?.attackId || '<>'; + } + + public get description(): string { + const description = + this.item?.description || + this.item?.object_description || + 'No description available.'; + return this.firstParagraph(description); + } + + public get modified(): Date | string | null { + return ( + this.item?.resolved_object_modified || this.item?.object_modified || null + ); + } + + public get modifiedHumanized(): string { + if (!this.modified) return 'Unknown'; + const now = moment(); + const then = moment(this.modified); + const difference = moment.duration(then.diff(now)); + return difference.asWeeks() > -1 + ? difference.humanize(true) + : then.format('D MMMM YYYY'); + } + + public get modifiedTimestamp(): string { + if (!this.modified) return ''; + return moment(this.modified).format('D MMMM YYYY, h:mm A'); + } + + public get modifiedByName(): string { + return ( + this.userDisplayName(this.item?.modified_by_user, false) || + this.userDisplayName( + this.item?.object_modified_by || + this.item?.object_added_by || + this.item?.object_staged_by || + this.item?.modified_by_ref + ) || + 'Unknown User' + ); + } + + public get cardClasses(): Record { + return { + [`status-${this.laneStatus}`]: !!this.laneStatus, + [`is-${this.cardType}`]: !!this.cardType, + 'has-description': this.showDescription, + }; + } + + public onView(): void { + this.viewObject.emit(this.item); + } + + public onDiff(): void { + this.diffObject.emit(this.item); + } + + private get fallbackObjectLabel(): string { + const [type, id] = (this.item?.object_ref || '').split('--'); + if (!type || !id) return this.item?.object_ref || 'Unknown object'; + return `${this.toDisplayLabel(type as StixType)} ${id.slice(0, 8)}`; + } + + private toDisplayLabel(value: StixType): string { + if (!value) return ''; + return StixTypeToAttackType[value] + .replace(/-/g, ' ') + .replace(/\b\w/g, char => char.toUpperCase()); + } + + private firstParagraph(value: string): string { + const [paragraph] = value + .replace(/\r\n/g, '\n') + .split(/\n\s*\n|\n/) + .map(part => part.trim()) + .filter(Boolean); + return paragraph || 'No description available.'; + } + + private userDisplayName(value: any, includeId = true): string | null { + if (!value) return null; + if (typeof value === 'string') return value; + + return ( + value.displayName || + value.username || + value.name || + (includeId ? value.id : null) + ); + } +} diff --git a/src/app/components/resources-drawer/resources-drawer.component.html b/src/app/components/resources-drawer/resources-drawer.component.html index 1167ccdb4..55eb9deaf 100644 --- a/src/app/components/resources-drawer/resources-drawer.component.html +++ b/src/app/components/resources-drawer/resources-drawer.component.html @@ -24,21 +24,17 @@

{{ currentTab }}

- - +
diff --git a/src/app/components/resources-drawer/resources-drawer.component.scss b/src/app/components/resources-drawer/resources-drawer.component.scss index 9982bac6c..cad1b17cc 100644 --- a/src/app/components/resources-drawer/resources-drawer.component.scss +++ b/src/app/components/resources-drawer/resources-drawer.component.scss @@ -7,6 +7,7 @@ width: 33vw; height: 100%; border-left: 1px solid; + border-top: 1px solid; .dark & { border-color: colors.border-color(dark); } diff --git a/src/app/components/resources-drawer/resources-drawer.component.spec.ts b/src/app/components/resources-drawer/resources-drawer.component.spec.ts index 384033eb4..7914c8e9d 100644 --- a/src/app/components/resources-drawer/resources-drawer.component.spec.ts +++ b/src/app/components/resources-drawer/resources-drawer.component.spec.ts @@ -32,4 +32,8 @@ describe('ResourcesDrawerComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should include notes as a sidebar tab', () => { + expect(component.tabs.some(tab => tab.name === 'notes')).toBe(true); + }); }); diff --git a/src/app/components/resources-drawer/resources-drawer.component.ts b/src/app/components/resources-drawer/resources-drawer.component.ts index 5b1158432..5a0c2c653 100644 --- a/src/app/components/resources-drawer/resources-drawer.component.ts +++ b/src/app/components/resources-drawer/resources-drawer.component.ts @@ -19,11 +19,12 @@ export class ResourcesDrawerComponent { @Output() onClose = new EventEmitter(); @Input() useService = true; //if true, control of this drawer is performed through the sidebar service. Otherwise, events and internal state are used. @Input() showCloseButton = true; - @Input() currentTabOverride = 'history'; + @Input() currentTabOverride = 'references'; public get tabs() { return this.sidebarService.tabs; } + public get currentTab(): string { if (this.useService) return this.sidebarService.currentTab; else return this.currentTabOverride; diff --git a/src/app/components/save-dialog/save-dialog.component.html b/src/app/components/save-dialog/save-dialog.component.html index 866ea23c0..69b8cb898 100644 --- a/src/app/components/save-dialog/save-dialog.component.html +++ b/src/app/components/save-dialog/save-dialog.component.html @@ -1,67 +1,155 @@ -
-
-
-

Validation

-
- - +
+
+
+
+
+

+ verified_user + Validation +

+ + {{ validationStatusLabel }} + +
+
+ Validating against + {{ validationReviewStatusLabel }} + requirements. +
+
+ + +
+ + + +
+ +
+

Version Increment

+ + + Keep + v{{ currentVersion }} + + + Minor + + v{{ currentVersion }} → v{{ nextMinorVersion }} + + + + Major + + v{{ currentVersion }} → v{{ nextMajorVersion }} + + + +
+
+ +
+
+

Object Review Status

+ + Loading tracks... + +
+ +
+
+ Release Track + Status Change +
+ +
+ {{ row.name }} + +
+ + + Not enrolled + + arrow_forward + +
+
- - + + +
+ {{ + loadingTracks + ? 'Loading release tracks.' + : 'This object is not enrolled in any release tracks.' + }} +
-
-
- - - mark as... - - - {{ workflow[1] }} + +
+
Enroll in New Release Track
+ + + + + {{ row.name }} + + + No matching standard tracks - + +
+ + +
+ + +
+
+

Determining required knowledge base patches...

+

Required Patches

@@ -104,6 +192,7 @@

Objects

+

Patching links...

diff --git a/src/app/components/save-dialog/save-dialog.component.scss b/src/app/components/save-dialog/save-dialog.component.scss index a642e018f..4cb374b9f 100644 --- a/src/app/components/save-dialog/save-dialog.component.scss +++ b/src/app/components/save-dialog/save-dialog.component.scss @@ -1,67 +1,374 @@ @use '../../../style/globals'; @use '../../../style/colors'; +@use '../../../style/typography'; .save-dialog { + width: min(92vw, 820px); + max-height: 86vh; + box-sizing: border-box; + overflow: auto; padding: 24px; - .version-buttons { - .mat-mdc-form-field-subscript-wrapper { - display: none; + + .save-dialog-content { + display: flex; + flex-direction: column; + min-height: 0; + } + + .save-section { + border: 1px solid; + border-radius: 6px; + @include colors.theme-border-color; + @include colors.theme-property( + background, + rgba(colors.color(mitre-silver), 0.025), + rgba(colors.color(mitre-black), 0.014) + ); + } + + .validation-section { + padding: 18px 20px 8px; + min-width: 0; + } + + .save-section-header, + .track-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 12px; + } + + h3, + h4 { + margin: 0; + font-weight: 800; + } + + h3 { + display: flex; + align-items: center; + gap: 8px; + font-size: 17px; + line-height: 24px; + + .mat-icon { + width: 20px; + height: 20px; + font-size: 20px; + line-height: 20px; } - .save-list-item { - @extend .subheading; - font-weight: 700 !important; + } + + h4 { + font-size: 13px; + letter-spacing: 0.08em; + line-height: 18px; + text-transform: uppercase; + @include colors.theme-text-emphasis; + } + + .validation-status { + border-radius: 4px; + padding: 3px 8px; + font-family: typography.$mono-font; + font-size: 12px; + font-weight: 800; + line-height: 18px; + } + + @each $status, $color in (success: success, warning: warn, error: error) { + .validation-status-#{$status} { + color: colors.color($color); + background: rgba(colors.color($color), 0.14); } - .mat-mdc-list-item { - padding: 16px !important; - height: fit-content; + } + + .validation-section { + .validation-results { + padding: 2px 0 0; } - .mat-mdc-list-item:hover:not(.mdc-list-item--disabled) { - .save-list-item { - color: colors.color(primary); + + .validation-context { + margin: -4px 0 8px; + font-size: 12px; + line-height: 18px; + @include colors.theme-text-deemphasis; + + strong { + font-weight: 700; + + .dark &, + .light & { + color: colors.color(primary); + } } } - .mat-mdc-list-item + .mat-mdc-list-item { - .dark & { - border-top: 1px solid colors.border-color(dark); - } - .light & { - border-top: 1px solid colors.border-color(light); - } + + .validation-item.mat-mdc-list-item { + min-height: 32px; + padding: 2px 0; + --mdc-list-list-item-one-line-container-height: 32px; + --mdc-list-list-item-two-line-container-height: 44px; + --mdc-list-list-item-three-line-container-height: 56px; } - .mat-mdc-list-item.mdc-list-item--disabled { - .light & { - color: colors.on-color-deemphasis(light); - } - .dark & { - color: colors.on-color-deemphasis(dark); - } + + .validation-item .mat-mdc-list-item-icon { + align-self: center; + margin-right: 8px; } - .mat-mdc-form-field { - width: 100%; + + .validation-item .mat-mdc-list-item-line { + line-height: 20px; + } + + .validation-item .mat-icon { + padding: 2px !important; } } - .stage.reviewing { - text-align: center; + + .save-top-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 250px; + gap: 16px; + } + + .version-section, + .track-section { + padding: 16px; + } + + .version-options { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 18px; + } + + .mat-mdc-radio-button { + --mdc-radio-state-layer-size: 28px; + } + + .version-option-label { + margin-right: 4px; + font-weight: 800; + } + + .version-option-value { + font-family: typography.$mono-font; + font-size: 13px; + font-weight: 700; + } + + .track-section { + min-width: 0; + margin-top: 16px; } - .validation { - h3 { - margin-bottom: 0px; + + .track-loading, + .track-enrollment, + .track-not-enrolled { + font-size: 12px; + line-height: 18px; + @include colors.theme-text-deemphasis; + } + + .track-empty { + font-size: 12px; + line-height: 18px; + @include colors.theme-property( + color, + colors.on-color(dark), + colors.on-color(light) + ); + } + + .track-table { + overflow-x: auto; + } + + .track-table-row { + display: grid; + grid-template-columns: minmax(180px, 1fr) minmax(300px, 1fr); + align-items: center; + column-gap: 24px; + min-width: 540px; + padding: 10px 0; + + & + .track-table-row { + border-top: 1px solid; + @include colors.theme-border-color; + } + } + + .track-table-head { + padding-top: 0; + font-size: 13px; + font-weight: 700; + line-height: 20px; + @include colors.theme-text-deemphasis; + } + + .track-table-head span:last-child, + .track-status-change { + justify-self: start; + } + + .track-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 16px; + font-weight: 400; + } + + .track-status-change { + display: grid; + grid-template-columns: 180px 18px max-content; + align-items: center; + column-gap: 12px; + min-width: 0; + + .mat-icon { + width: 17px; + height: 17px; + font-size: 17px; + line-height: 17px; + justify-self: center; + @include colors.theme-text-deemphasis; + } + } + + .track-enrollment { + padding-top: 14px; + border-top: 1px solid; + @include colors.theme-border-color; + + h5 { + margin: 0 0 8px; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.08em; + line-height: 16px; + text-transform: uppercase; + @include colors.theme-text-emphasis; } } - .column + .column { - padding-left: 16px; - .dark & { - border-left: 1px solid colors.border-color(dark); + + .enrollment-field { + display: block; + max-width: 260px; + + .mat-mdc-form-field-subscript-wrapper { + display: none; + } + + .mat-mdc-text-field-wrapper { + height: 36px; + padding: 0 12px; } - .light & { - border-left: 1px solid colors.border-color(light); + + .mat-mdc-form-field-flex { + height: 36px; + } + + .mat-mdc-form-field-infix { + min-height: 0; + padding: 7px 0; } + + input { + font-size: 13px; + } + } + + .save-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin: 18px -24px -24px; + padding: 14px 24px; + border-top: 1px solid; + @include colors.theme-border-color; + @include colors.theme-property( + background, + rgba(colors.color(dark), 0.42), + rgba(colors.color(mitre-black), 0.02) + ); } + + .save-footer-summary { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + font-size: 13px; + line-height: 20px; + @include colors.theme-text-deemphasis; + + .mat-icon { + width: 18px; + height: 18px; + font-size: 18px; + line-height: 18px; + } + + strong { + border-radius: 4px; + padding: 3px 6px; + font-family: typography.$mono-font; + @include colors.theme-property( + color, + colors.on-color(dark), + colors.on-color(light) + ); + @include colors.theme-property( + background, + rgba(colors.on-color(dark), 0.1), + rgba(colors.on-color(light), 0.08) + ); + } + } + + .save-footer-actions { + display: flex; + flex: 0 0 auto; + gap: 10px; + } + + .stage.reviewing { + padding: 24px; + text-align: center; + } + .buttons { + margin-top: 24px; + margin-bottom: 24px; + button + button { margin-left: 10px; } - margin-top: 24px; - margin-bottom: 24px; + } +} + +@media (max-width: 600px) { + .save-dialog { + width: 92vw; + + .save-top-grid { + grid-template-columns: 1fr; + } + + .save-footer { + flex-direction: column; + align-items: stretch; + } + + .save-footer-actions { + justify-content: flex-end; + } } } diff --git a/src/app/components/save-dialog/save-dialog.component.spec.ts b/src/app/components/save-dialog/save-dialog.component.spec.ts index 730c8bf9c..532664d18 100644 --- a/src/app/components/save-dialog/save-dialog.component.spec.ts +++ b/src/app/components/save-dialog/save-dialog.component.spec.ts @@ -1,32 +1,122 @@ +import { provideHttpClient } from '@angular/common/http'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; -import { provideHttpClient } from '@angular/common/http'; +import { FormsModule } from '@angular/forms'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatRadioModule } from '@angular/material/radio'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { vi } from 'vitest'; import { SaveDialogComponent } from './save-dialog.component'; import { VersionNumber } from 'src/app/classes/version-number'; -import { createAsyncObservable } from 'src/app/testing/mocks/rest-api-connector.mock'; +import { ReleaseTracksConnectorService } from 'src/app/services/connectors/rest-api/release-tracks.service'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { + createAsyncObservable, + createMockRestApiConnector, +} from 'src/app/testing/mocks/rest-api-connector.mock'; +import { WorkflowStatus } from 'src/app/utils/types'; describe('SaveDialogComponent', () => { let component: SaveDialogComponent; let fixture: ComponentFixture; + let mockObject; + let mockReleaseTracksService; beforeEach(async () => { - const mockObject = { + mockObject = { + stixID: 'attack-pattern--123', + attackType: 'technique', + modified: new Date('2026-01-01T00:00:00.000Z'), version: new VersionNumber('1.0'), - validate: vi.fn().mockReturnValue( + workflow: { + state: WorkflowStatus.WorkInProgress, + }, + workspace: { + release_tracks: [ + { + track_id: 'release-track--core', + name: 'Core Objects', + object_ref: 'attack-pattern--123', + object_modified: '2026-01-01T00:00:00.000Z', + object_status: WorkflowStatus.AwaitingReview, + }, + ], + }, + validate: vi.fn( + (_restApi, workflowState = WorkflowStatus.WorkInProgress) => + createAsyncObservable({ + successes: [], + errors: + workflowState === WorkflowStatus.Reviewed + ? [ + { + field: 'workflow', + result: 'error', + message: 'reviewed objects require stricter validation', + }, + ] + : [], + warnings: [], + info: [], + }) + ), + save: vi.fn().mockReturnValue(createAsyncObservable({})), + }; + mockReleaseTracksService = { + listReleaseTracks: vi.fn().mockReturnValue( createAsyncObservable({ - errors: [], - info: [], + data: [ + { + track_id: 'release-track--core', + name: 'Core Objects', + type: 'standard', + }, + { + track_id: 'release-track--groups', + name: 'Groups & Campaigns', + type: 'standard', + }, + ], }) ), + getLatestSnapshot: vi.fn((trackId: string) => + createAsyncObservable({ + name: trackId === 'release-track--core' ? 'Core Objects' : '', + candidates: + trackId === 'release-track--core' + ? [ + { + object_ref: 'attack-pattern--123', + object_modified: '2026-01-01T00:00:00.000Z', + object_status: WorkflowStatus.AwaitingReview, + }, + ] + : [], + staged: [], + }) + ), + addCandidates: vi.fn().mockReturnValue(createAsyncObservable({})), + reviewCandidates: vi.fn().mockReturnValue(createAsyncObservable({})), + promoteCandidates: vi.fn().mockReturnValue(createAsyncObservable({})), + demoteStaged: vi.fn().mockReturnValue(createAsyncObservable({})), }; await TestBed.configureTestingModule({ declarations: [SaveDialogComponent], + imports: [ + FormsModule, + MatAutocompleteModule, + MatFormFieldModule, + MatInputModule, + MatRadioModule, + NoopAnimationsModule, + ], providers: [ - { provide: MatDialogRef, useValue: {} }, + { provide: MatDialogRef, useValue: { close: vi.fn() } }, { provide: MAT_DIALOG_DATA, useValue: { @@ -34,6 +124,14 @@ describe('SaveDialogComponent', () => { versionAlreadyIncremented: false, }, }, + { + provide: RestApiConnectorService, + useValue: createMockRestApiConnector(), + }, + { + provide: ReleaseTracksConnectorService, + useValue: mockReleaseTracksService, + }, provideHttpClient(), ], schemas: [NO_ERRORS_SCHEMA], @@ -49,4 +147,119 @@ describe('SaveDialogComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should default to keeping the version and syncing tracks to WIP', async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(component.versionChoice).toBe('keep'); + expect(mockReleaseTracksService.getLatestSnapshot).toHaveBeenCalledWith( + 'release-track--core', + { format: 'workbench', include: 'all' } + ); + expect(component.trackRows).toEqual([ + expect.objectContaining({ + trackId: 'release-track--core', + name: 'Core Objects', + selected: false, + enrolled: true, + }), + expect.objectContaining({ + trackId: 'release-track--groups', + name: 'Groups & Campaigns', + selected: false, + enrolled: false, + }), + ]); + expect(component.statusRows).toEqual([ + expect.objectContaining({ + trackId: 'release-track--core', + }), + ]); + expect(component.enrollmentOptions).toEqual([ + expect.objectContaining({ + trackId: 'release-track--groups', + }), + ]); + }); + + it('should move newly enrolled tracks into the release track status table', async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + + const row = component.enrollmentOptions[0]; + component.selectEnrollmentTrack({ option: { value: row } }); + + expect(row.selected).toBe(true); + expect(component.statusRows).toEqual([ + expect.objectContaining({ + trackId: 'release-track--core', + }), + expect.objectContaining({ + trackId: 'release-track--groups', + }), + ]); + expect(component.enrollmentOptions).toEqual([]); + }); + + it('should always validate save updates against WIP', async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(component.validationReviewStatus).toBe( + WorkflowStatus.WorkInProgress + ); + expect(component.validationReviewStatusLabel).toBe('WIP'); + expect(mockObject.validate).toHaveBeenCalledWith( + expect.anything(), + WorkflowStatus.WorkInProgress + ); + expect(component.validationStatus).toBe('success'); + }); + + it('should add the saved object as a WIP candidate without reviewing tracks', async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + + const newTrack = component.enrollmentOptions[0]; + component.selectEnrollmentTrack({ option: { value: newTrack } }); + await new Promise(resolve => setTimeout(resolve, 10)); + + component.onConfirmSave(); + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockReleaseTracksService.reviewCandidates).not.toHaveBeenCalled(); + expect(mockReleaseTracksService.demoteStaged).not.toHaveBeenCalled(); + expect(mockReleaseTracksService.addCandidates).toHaveBeenCalledWith( + 'release-track--core', + ['attack-pattern--123'] + ); + expect(mockReleaseTracksService.addCandidates).toHaveBeenCalledWith( + 'release-track--groups', + ['attack-pattern--123'] + ); + }); + + it('should summarize validation status by severity', () => { + component.validation = { + successes: [], + errors: [], + warnings: [], + info: [], + }; + expect(component.validationStatus).toBe('success'); + expect(component.validationStatusLabel).toBe('Success'); + + component.validation.warnings.push({ + field: 'name', + result: 'warning', + message: 'name warning', + }); + expect(component.validationStatus).toBe('warning'); + expect(component.validationStatusLabel).toBe('Warning'); + + component.validation.errors.push({ + field: 'name', + result: 'error', + message: 'name error', + }); + expect(component.validationStatus).toBe('error'); + expect(component.validationStatusLabel).toBe('Error'); + }); }); diff --git a/src/app/components/save-dialog/save-dialog.component.ts b/src/app/components/save-dialog/save-dialog.component.ts index eeafd7d99..03a2bfd2a 100644 --- a/src/app/components/save-dialog/save-dialog.component.ts +++ b/src/app/components/save-dialog/save-dialog.component.ts @@ -1,12 +1,23 @@ import { Component, Inject, OnInit, ViewEncapsulation } from '@angular/core'; import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; -import { forkJoin } from 'rxjs'; +import { forkJoin, Observable, of } from 'rxjs'; +import { catchError, map, switchMap } from 'rxjs/operators'; +import { ExportFormat, SnapshotTier } from 'src/app/classes/release-tracks'; +import type { + ReleaseTrackObjectTier, + StixObjectRef, +} from 'src/app/classes/release-tracks'; import { ValidationData } from 'src/app/classes/serializable'; import { DetectionStrategy } from 'src/app/classes/stix'; import { StixObject } from 'src/app/classes/stix/stix-object'; import { VersionNumber } from 'src/app/classes/version-number'; +import { ReleaseTracksConnectorService } from 'src/app/services/connectors/rest-api/release-tracks.service'; import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; -import { WorkflowState, WorkflowStates } from 'src/app/utils/types'; +import { WORKFLOW_STATUS_LABELS, WorkflowStatus } from 'src/app/utils/types'; +import type { + ReleaseTrackStatus, + WorkflowStatusType, +} from 'src/app/utils/types'; import { logger } from '../../utils/logger'; @Component({ @@ -23,53 +34,78 @@ export class SaveDialogComponent implements OnInit { public nextMinorVersion: string; public patch_objects = []; public validation: ValidationData = null; - public newState: WorkflowState = 'work-in-progress'; - public workflows = Object.entries(WorkflowStates); + public validating = false; + public newState: WorkflowStatus | undefined = WorkflowStatus.WorkInProgress; public analyticsToPatch = new Set(); // list of stix ids of analytics that need patching + public versionChoice: SaveVersionChoice = 'keep'; + public trackRows: TrackRow[] = []; + public enrollmentSearch: string | TrackRow = ''; + public loadingTracks = false; + public readonly resetWorkflowStatus = WorkflowStatus.WorkInProgress; + private validationRequestId = 0; public get saveEnabled() { - return this.validation && this.validation.errors.length == 0; + return ( + !this.validating && this.validation && this.validation.errors.length == 0 + ); } public get workflowEnabled(): boolean { return this.config.showWorkflow !== false; } + public get validationStatus(): ValidationStatus { + if (!this.validation) return 'success'; + if (this.validation.errors.length) return 'error'; + if ( + this.validation.warnings.length || + this.config.patchId || + this.config.patchAnalytics + ) + return 'warning'; + return 'success'; + } + + public get validationStatusLabel(): string { + switch (this.validationStatus) { + case 'error': + return 'Error'; + case 'warning': + return 'Warning'; + default: + return 'Success'; + } + } + + public get validationReviewStatus(): WorkflowStatusType | undefined { + if ( + !this.workflowEnabled || + this.config.object.attackType === 'relationship' + ) + return undefined; + return this.resetWorkflowStatus; + } + + public get validationReviewStatusLabel(): string { + const status = this.validationReviewStatus; + if (!status) return ''; + return this.getWorkflowStatusLabel(status); + } + constructor( public dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public config: SaveDialogConfig, - public restApiService: RestApiConnectorService + public restApiService: RestApiConnectorService, + private releaseTracksService: ReleaseTracksConnectorService ) { this.currentVersion = config.object.version.toString(); this.nextMajorVersion = config.object.version.nextMajorVersion().toString(); this.nextMinorVersion = config.object.version.nextMinorVersion().toString(); - - const subscription = config.object.validate(this.restApiService).subscribe({ - next: result => { - // tell the user version has been incremented, but not if the version has an error - if ( - this.config.versionAlreadyIncremented && - !result.errors.some(x => x.field == 'version') - ) - result.info.push({ - field: 'version', - result: 'info', - message: 'version has already been changed', - }); - this.validation = result; - }, - error: err => { - logger.error(err); - }, - complete: () => { - subscription.unsubscribe(); - }, - }); } ngOnInit(): void { this.newState = this.workflowEnabled - ? this.config.initialWorkflowState || 'work-in-progress' + ? this.config.initialWorkflowState || WorkflowStatus.WorkInProgress : undefined; if (this.config.object.attackType === 'relationship') { this.newState = undefined; @@ -95,37 +131,64 @@ export class SaveDialogComponent implements OnInit { for (const a of newAnalytics) this.analyticsToPatch.add(a); } } - // Run validation for the initial state - const subscription = this.config.object - .validate( - this.restApiService, - this.workflowEnabled ? this.newState : undefined - ) - .subscribe({ - next: result => { - this.validation = result; - }, - error: err => { - logger.error(err); - }, - complete: () => subscription.unsubscribe(), - }); + this.validateObject(); + this.loadTrackRows(); } - onStatusChanged(event) { - if (!this.workflowEnabled) return; + public get selectedVersion(): string { + switch (this.versionChoice) { + case 'major': + return this.nextMajorVersion; + case 'minor': + return this.nextMinorVersion; + default: + return this.currentVersion; + } + } - const subscription = this.config.object - .validate(this.restApiService, event.value) - .subscribe({ - next: result => { - this.validation = result; - }, - error: err => { - logger.error(err); - }, - complete: () => subscription.unsubscribe(), - }); + public get selectedVersionLabel(): string { + return `v${this.selectedVersion}`; + } + + public get statusRows(): TrackRow[] { + return this.trackRows.filter(row => row.enrolled || row.selected); + } + + public get hasStatusRows(): boolean { + return this.statusRows.length > 0; + } + + public get enrollmentOptions(): TrackRow[] { + const search = this.getEnrollmentSearchText().toLowerCase().trim(); + return this.trackRows + .filter(row => !row.enrolled && !row.selected) + .filter(row => !search || row.name.toLowerCase().includes(search)); + } + + public onConfirmSave(): void { + switch (this.versionChoice) { + case 'major': + this.saveNextMajorVersion(); + return; + case 'minor': + this.saveNextMinorVersion(); + return; + default: + this.saveCurrentVersion(); + } + } + + public displayTrack(track: TrackRow | string): string { + if (typeof track === 'string') return track; + return track?.name || ''; + } + + public selectEnrollmentTrack(event): void { + const row = event?.option?.value as TrackRow; + if (!row) return; + + row.selected = true; + this.enrollmentSearch = ''; } /** @@ -261,6 +324,10 @@ export class SaveDialogComponent implements OnInit { else this.save(); } + private saveObject() { + return this.config.object.save(this.restApiService); // save this object + } + private applyWorkflowState(): void { this.config.object.workflow = this.workflowEnabled && this.newState @@ -268,10 +335,6 @@ export class SaveDialogComponent implements OnInit { : undefined; } - private saveObject() { - return this.config.object.save(this.restApiService); // save this object - } - /** * Save the object without patching other objects */ @@ -280,13 +343,348 @@ export class SaveDialogComponent implements OnInit { return; } this.applyWorkflowState(); - const sub = this.saveObject().subscribe({ - next: () => { - this.dialogRef.close(true); - }, - complete: () => sub.unsubscribe(), - }); + const sub = this.saveObject() + .pipe(switchMap(() => this.syncTracks())) + .subscribe({ + next: () => { + this.dialogRef.close(true); + }, + complete: () => sub.unsubscribe(), + }); } + + private loadTrackRows(): void { + if ( + !this.workflowEnabled || + !this.config.object?.stixID || + this.config.object.attackType === 'relationship' + ) { + this.trackRows = []; + return; + } + + this.loadingTracks = true; + this.releaseTracksService + .listReleaseTracks({ type: 'standard' }) + .pipe( + switchMap(tracksResult => { + const tracks = this.getTrackList(tracksResult); + if (!tracks.length) return of([]); + const workspaceTracksByTrack = this.getWorkspaceTracksById(); + + return forkJoin( + tracks.map(track => { + const trackId = this.getTrackId(track); + const workspaceTrack = trackId + ? workspaceTracksByTrack.get(trackId) || null + : null; + + if (!trackId || !workspaceTrack) { + return of(this.toTrackRow(track, null, workspaceTrack)); + } + + return this.releaseTracksService + .getLatestSnapshot(trackId, { + format: ExportFormat.Workbench, + include: 'all', + }) + .pipe( + map(snapshot => + this.toTrackRow(track, snapshot, workspaceTrack) + ), + catchError(err => { + logger.error( + 'Failed to load release track snapshot for save dialog', + err + ); + return of(this.toTrackRow(track, null, workspaceTrack)); + }) + ); + }) + ); + }), + catchError(err => { + logger.error('Failed to load release tracks for save dialog', err); + return of([]); + }) + ) + .subscribe({ + next: rows => { + this.trackRows = rows; + this.loadingTracks = false; + }, + error: err => { + logger.error(err); + this.trackRows = []; + this.loadingTracks = false; + }, + }); + } + + private toTrackRow( + track: any, + snapshot: any, + workspaceTrack?: ReleaseTrackStatus | null + ): TrackRow { + const trackId = this.getTrackId(track) || ''; + const entry = this.getTrackedObjectEntry(snapshot); + const tier = this.getEntryTier(entry) || workspaceTrack?.tier || null; + const status = + this.getEntryWorkflowStatus(entry) || + workspaceTrack?.status || + this.getFallbackWorkflowStatus(tier); + + return { + trackId, + name: + track?.name || + snapshot?.name || + workspaceTrack?.name || + trackId || + 'Release track', + tier, + status, + objectRef: entry + ? this.getObjectRef(entry) + : workspaceTrack?.objectRef || this.config.object.stixID, + enrolled: !!entry || !!workspaceTrack, + selected: false, + }; + } + + private getTrackedObjectEntry(snapshot: any): any | null { + if (!snapshot) return null; + return ( + this.getSnapshotWorkflowEntries(snapshot).find( + entry => this.getEntryObjectRef(entry) === this.config.object.stixID + ) || null + ); + } + + private getSnapshotWorkflowEntries(snapshot: any): any[] { + return [ + this.withTier(snapshot?.candidates, SnapshotTier.Candidate), + this.withTier(snapshot?.staged, SnapshotTier.Staged), + this.withTier(snapshot?.contents?.candidates, SnapshotTier.Candidate), + this.withTier(snapshot?.contents?.staged, SnapshotTier.Staged), + this.withTier(snapshot?.workspace?.candidates, SnapshotTier.Candidate), + this.withTier(snapshot?.workspace?.staged, SnapshotTier.Staged), + ] + .filter(Array.isArray) + .reduce((entries, tierEntries) => entries.concat(tierEntries), []); + } + + private withTier( + entries: any, + tier: ReleaseTrackObjectTier + ): any[] | undefined { + if (!Array.isArray(entries)) return undefined; + return entries.map(entry => ({ + ...entry, + tier: this.getEntryTier(entry) || tier, + })); + } + + private getEntryTier(entry: any): ReleaseTrackObjectTier | null { + if (!entry) return null; + const tier = String(entry.tier || entry.object_tier || '').toLowerCase(); + if (tier === SnapshotTier.Staged) return SnapshotTier.Staged; + if (tier === SnapshotTier.Candidate) return SnapshotTier.Candidate; + if (entry.object_staged_at || entry.staged_at) return SnapshotTier.Staged; + if (this.getEntryWorkflowStatus(entry)) return SnapshotTier.Candidate; + return null; + } + + private getEntryWorkflowStatus(entry: any): WorkflowStatusType | null { + if (!entry) return null; + const status = entry.object_status || entry.status; + if (Object.values(WorkflowStatus).includes(status)) return status; + return null; + } + + private getFallbackWorkflowStatus( + tier: ReleaseTrackObjectTier | null + ): WorkflowStatusType { + return tier === SnapshotTier.Staged + ? WorkflowStatus.Reviewed + : WorkflowStatus.WorkInProgress; + } + + private validateObject(): void { + const requestId = ++this.validationRequestId; + this.validation = null; + this.validating = true; + + this.config.object + .validate(this.restApiService, this.validationReviewStatus) + .subscribe({ + next: result => { + if (requestId !== this.validationRequestId) return; + this.applyValidationResult(result); + }, + error: err => { + if (requestId !== this.validationRequestId) return; + logger.error(err); + }, + complete: () => { + if (requestId === this.validationRequestId) { + this.validating = false; + } + }, + }); + } + + private applyValidationResult(result: ValidationData): void { + // tell the user version has been incremented, but not if the version has an error + if ( + this.config.versionAlreadyIncremented && + !result.errors.some(x => x.field == 'version') + ) { + result.info.push({ + field: 'version', + result: 'info', + message: 'version has already been changed', + }); + } + this.validation = result; + } + + private getWorkflowStatusLabel(status: WorkflowStatusType): string { + return WORKFLOW_STATUS_LABELS[status] || status; + } + + private syncTracks(): Observable { + const rows = this.trackRows.filter(row => row.enrolled || row.selected); + if (!rows.length) return of(null); + + return forkJoin( + rows.map(row => + this.syncTrack(row).pipe( + catchError(err => { + logger.error('Failed to update release track object status', err); + return of(null); + }) + ) + ) + ); + } + + private syncTrack(row: TrackRow): Observable { + if (!row.trackId) return of(null); + return this.addCandidateToTrack(row); + } + + private addCandidateToTrack(row: TrackRow): Observable { + return this.releaseTracksService.addCandidates(row.trackId, [ + this.config.object.stixID, + ]); + } + + private getTrackList(result: any): any[] { + if (Array.isArray(result?.data)) return result.data; + if (Array.isArray(result?.release_tracks)) return result.release_tracks; + if (Array.isArray(result)) return result; + return []; + } + + private getTrackId(track: any): string | null { + return ( + track?.trackId || + track?.track_id || + track?.release_track_id || + track?.releaseTrackId || + (track?.id?.startsWith('release-track--') ? track.id : null) + ); + } + + private getObjectRef(entry: any): StixObjectRef { + const modified = + this.toIsoString(entry.object_modified) || + this.toIsoString(entry.modified) || + this.config.object.modified?.toISOString(); + + return modified + ? { + id: this.getEntryObjectRef(entry) || this.config.object.stixID, + modified, + } + : this.getEntryObjectRef(entry) || this.config.object.stixID; + } + + private getWorkspaceTracksById(): Map { + const releaseTracks = this.config.object?.workspace?.release_tracks; + if (!Array.isArray(releaseTracks)) return new Map(); + + return releaseTracks.reduce((lookup, track) => { + const ref = this.toWorkspaceTrack(track); + if (ref && !lookup.has(ref.trackId)) lookup.set(ref.trackId, ref); + return lookup; + }, new Map()); + } + + private toWorkspaceTrack(track: any): ReleaseTrackStatus | null { + const trackId = typeof track === 'string' ? track : this.getTrackId(track); + if (!trackId) return null; + const tier = typeof track === 'string' ? null : this.getEntryTier(track); + const status = + typeof track === 'string' + ? WorkflowStatus.WorkInProgress + : this.getEntryWorkflowStatus(track) || + this.getFallbackWorkflowStatus(tier); + + return { + trackId, + name: typeof track === 'string' ? '' : track.name || track.track_name, + description: typeof track === 'string' ? '' : track.description || '', + tier, + status, + objectRef: + typeof track === 'string' + ? this.config.object.stixID + : this.getWorkspaceObjectRef(track), + }; + } + + private getWorkspaceObjectRef(track: any): StixObjectRef { + const modified = + this.toIsoString(track?.object_modified) || + this.toIsoString(track?.modified) || + this.config.object.modified?.toISOString(); + + return modified + ? { + id: track?.object_ref || track?.ref || this.config.object.stixID, + modified, + } + : track?.object_ref || track?.ref || this.config.object.stixID; + } + + private getEntryObjectRef(entry: any): string | null { + return entry?.object_ref || entry?.ref || entry?.id || null; + } + + private toIsoString(value: any): string | null { + if (!value) return null; + if (value instanceof Date) return value.toISOString(); + return String(value); + } + + private getEnrollmentSearchText(): string { + return this.displayTrack(this.enrollmentSearch); + } +} + +type SaveVersionChoice = 'keep' | 'minor' | 'major'; +type ValidationStatus = 'success' | 'warning' | 'error'; + +interface TrackRow { + trackId: string; + name: string; + tier: ReleaseTrackObjectTier | null; + status: WorkflowStatusType; + objectRef: StixObjectRef; + enrolled: boolean; + selected: boolean; } export interface SaveDialogConfig { @@ -294,6 +692,6 @@ export interface SaveDialogConfig { patchId?: string; // previous object ID to patch in LinkByID tags patchAnalytics?: Set; // previous list of analytics related to a detection strategy versionAlreadyIncremented: boolean; - initialWorkflowState?: WorkflowState; + initialWorkflowState?: WorkflowStatusType; showWorkflow?: boolean; } diff --git a/src/app/components/snapshot-description-dialog/snapshot-description-dialog.component.html b/src/app/components/snapshot-description-dialog/snapshot-description-dialog.component.html new file mode 100644 index 000000000..1bb806200 --- /dev/null +++ b/src/app/components/snapshot-description-dialog/snapshot-description-dialog.component.html @@ -0,0 +1,33 @@ +
+

{{ data.title }}

+ + +

+ {{ data.message }} +

+ + + Snapshot notes + + Shown on this snapshot in history + {{ description.length }}/{{ maxLength }} + +
+ + + + + +
diff --git a/src/app/components/snapshot-description-dialog/snapshot-description-dialog.component.scss b/src/app/components/snapshot-description-dialog/snapshot-description-dialog.component.scss new file mode 100644 index 000000000..9c6707db4 --- /dev/null +++ b/src/app/components/snapshot-description-dialog/snapshot-description-dialog.component.scss @@ -0,0 +1,16 @@ +.snapshot-description-dialog { + width: min(88vw, 560px); + + .snapshot-description-dialog-message { + margin: 0 0 18px; + line-height: 1.5; + } + + .mat-mdc-form-field { + width: 100%; + } + + textarea { + resize: vertical; + } +} diff --git a/src/app/components/snapshot-description-dialog/snapshot-description-dialog.component.spec.ts b/src/app/components/snapshot-description-dialog/snapshot-description-dialog.component.spec.ts new file mode 100644 index 000000000..3c1f0f6da --- /dev/null +++ b/src/app/components/snapshot-description-dialog/snapshot-description-dialog.component.spec.ts @@ -0,0 +1,61 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { vi } from 'vitest'; + +import { SnapshotDescriptionDialogComponent } from './snapshot-description-dialog.component'; + +describe('SnapshotDescriptionDialogComponent', () => { + let component: SnapshotDescriptionDialogComponent; + let fixture: ComponentFixture; + let dialogRef: { close: ReturnType }; + + beforeEach(async () => { + dialogRef = { close: vi.fn() }; + await TestBed.configureTestingModule({ + declarations: [SnapshotDescriptionDialogComponent], + imports: [FormsModule], + providers: [ + { provide: MatDialogRef, useValue: dialogRef }, + { + provide: MAT_DIALOG_DATA, + useValue: { + title: 'Edit snapshot notes', + description: 'Existing context', + }, + }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(SnapshotDescriptionDialogComponent); + component = fixture.componentInstance; + }); + + it('should initialize with existing notes and save a trimmed value', () => { + expect(component.description).toBe('Existing context'); + component.description = ' Updated context '; + + component.save(); + + expect(dialogRef.close).toHaveBeenCalledWith('Updated context'); + }); + + it('should allow empty notes to clear an annotation', () => { + component.description = ' '; + + component.save(); + + expect(dialogRef.close).toHaveBeenCalledWith(''); + }); + + it('should reject notes over the API limit', () => { + component.description = 'x'.repeat(4001); + + component.save(); + + expect(component.isInvalid).toBe(true); + expect(dialogRef.close).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/components/snapshot-description-dialog/snapshot-description-dialog.component.ts b/src/app/components/snapshot-description-dialog/snapshot-description-dialog.component.ts new file mode 100644 index 000000000..db46dab5e --- /dev/null +++ b/src/app/components/snapshot-description-dialog/snapshot-description-dialog.component.ts @@ -0,0 +1,40 @@ +import { Component, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; + +export interface SnapshotDescriptionDialogData { + title: string; + description?: string; + message?: string; + confirmLabel?: string; +} + +@Component({ + selector: 'app-snapshot-description-dialog', + templateUrl: './snapshot-description-dialog.component.html', + styleUrls: ['./snapshot-description-dialog.component.scss'], + standalone: false, +}) +export class SnapshotDescriptionDialogComponent { + public readonly maxLength = 4000; + public description: string; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: SnapshotDescriptionDialogData + ) { + this.description = data.description || ''; + } + + public get isInvalid(): boolean { + return this.description.length > this.maxLength; + } + + public cancel(): void { + this.dialogRef.close(); + } + + public save(): void { + if (this.isInvalid) return; + this.dialogRef.close(this.description.trim()); + } +} diff --git a/src/app/components/status-chip/status-chip.component.html b/src/app/components/status-chip/status-chip.component.html new file mode 100644 index 000000000..5770d2e63 --- /dev/null +++ b/src/app/components/status-chip/status-chip.component.html @@ -0,0 +1,4 @@ + diff --git a/src/app/components/status-chip/status-chip.component.scss b/src/app/components/status-chip/status-chip.component.scss new file mode 100644 index 000000000..feb4b28f5 --- /dev/null +++ b/src/app/components/status-chip/status-chip.component.scss @@ -0,0 +1,3 @@ +:host { + display: inline-flex; +} diff --git a/src/app/components/status-chip/status-chip.component.spec.ts b/src/app/components/status-chip/status-chip.component.spec.ts new file mode 100644 index 000000000..f7bb679d8 --- /dev/null +++ b/src/app/components/status-chip/status-chip.component.spec.ts @@ -0,0 +1,33 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { WorkbenchChipComponent } from '../workbench-chip/workbench-chip.component'; +import { StatusChipComponent } from './status-chip.component'; +import { WorkflowStatus } from 'src/app/utils/types'; + +describe('StatusChipComponent', () => { + let component: StatusChipComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [StatusChipComponent], + imports: [WorkbenchChipComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(StatusChipComponent); + component = fixture.componentInstance; + component.status = WorkflowStatus.WorkInProgress; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should map workflow statuses to the shared chip variants', () => { + component.status = WorkflowStatus.AwaitingReview; + + expect(component.chipVariant).toBe('awaiting-review'); + expect(component.label).toBe('Awaiting Review'); + }); +}); diff --git a/src/app/components/status-chip/status-chip.component.ts b/src/app/components/status-chip/status-chip.component.ts new file mode 100644 index 000000000..ec089a341 --- /dev/null +++ b/src/app/components/status-chip/status-chip.component.ts @@ -0,0 +1,27 @@ +import { Component, Input } from '@angular/core'; +import { WorkbenchChipVariant } from '../workbench-chip/workbench-chip.component'; +import { + WorkflowStatus, + WorkflowStatusMap, + WorkflowStatusType, +} from '../../utils/types'; + +@Component({ + selector: 'app-status-chip', + standalone: false, + templateUrl: './status-chip.component.html', + styleUrls: ['./status-chip.component.scss'], +}) +export class StatusChipComponent { + @Input() status!: WorkflowStatusType; + + public get label(): string { + return WorkflowStatusMap[this.status] ?? String(this.status); + } + + public get chipVariant(): WorkbenchChipVariant { + return Object.values(WorkflowStatus).includes(this.status) + ? this.status + : 'draft'; + } +} diff --git a/src/app/components/stix/identity-property/identity-property.component.html b/src/app/components/stix/identity-property/identity-property.component.html index 518c61199..fd6a4777e 100644 --- a/src/app/components/stix/identity-property/identity-property.component.html +++ b/src/app/components/stix/identity-property/identity-property.component.html @@ -3,12 +3,11 @@
- + matTooltipPosition="below"> {{ identity.name }} diff --git a/src/app/components/stix/identity-property/identity-property.component.scss b/src/app/components/stix/identity-property/identity-property.component.scss index 38e79446e..897326051 100644 --- a/src/app/components/stix/identity-property/identity-property.component.scss +++ b/src/app/components/stix/identity-property/identity-property.component.scss @@ -8,9 +8,6 @@ .identity { display: inline-block; padding: 5px 0; - svg { - vertical-align: middle; - } .v-align { vertical-align: middle; padding: 0 8px; diff --git a/src/app/components/stix/identity-property/identity-property.component.spec.ts b/src/app/components/stix/identity-property/identity-property.component.spec.ts index 837abf70d..758728c92 100644 --- a/src/app/components/stix/identity-property/identity-property.component.spec.ts +++ b/src/app/components/stix/identity-property/identity-property.component.spec.ts @@ -3,6 +3,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideHttpClient } from '@angular/common/http'; import { IdentityPropertyComponent } from './identity-property.component'; +import { UserAvatarComponent } from '../../user-avatar/user-avatar.component'; import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; import { createMockRestApiConnector, @@ -21,6 +22,7 @@ describe('IdentityPropertyComponent', () => { await TestBed.configureTestingModule({ declarations: [IdentityPropertyComponent], + imports: [UserAvatarComponent], providers: [ provideHttpClient(), { provide: RestApiConnectorService, useValue: mockRestApiConnector }, diff --git a/src/app/components/stix/list-property/list-view/list-view.component.spec.ts b/src/app/components/stix/list-property/list-view/list-view.component.spec.ts index cf6726cbb..b9ed0e9e5 100644 --- a/src/app/components/stix/list-property/list-view/list-view.component.spec.ts +++ b/src/app/components/stix/list-property/list-view/list-view.component.spec.ts @@ -30,4 +30,29 @@ describe('ListViewComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should return an empty list for missing list fields', () => { + component.config = { + mode: 'view', + object: {} as any, + field: 'domains', + label: 'Domain', + }; + + expect(component.values).toEqual([]); + expect(component.tooltip).toBe(''); + }); + + it('should not mutate list fields when sorting values', () => { + const object = { domains: ['mobile', 'enterprise'] }; + component.config = { + mode: 'view', + object: object as any, + field: 'domains', + label: 'Domain', + }; + + expect(component.values).toEqual(['enterprise', 'mobile']); + expect(object.domains).toEqual(['mobile', 'enterprise']); + }); }); diff --git a/src/app/components/stix/list-property/list-view/list-view.component.ts b/src/app/components/stix/list-property/list-view/list-view.component.ts index fbc6b2fb6..065ae9b8e 100644 --- a/src/app/components/stix/list-property/list-view/list-view.component.ts +++ b/src/app/components/stix/list-property/list-view/list-view.component.ts @@ -32,9 +32,10 @@ export class ListViewComponent { } public get values() { - if (this.config.field == 'aliases') - return this.config.object[this.config.field].slice(1); // filter out the first alias - const arr = this.config.object[this.config.field]; + const value = this.config.object[this.config.field]; + if (!Array.isArray(value)) return []; + if (this.config.field == 'aliases') return value.slice(1); // filter out the first alias + const arr = [...value]; arr.sort((a, b) => { const aVal = this.config.objectProperty && typeof a === 'object' diff --git a/src/app/components/stix/name-property/name-property.component.html b/src/app/components/stix/name-property/name-property.component.html index 4dbffbb7a..4eb827596 100644 --- a/src/app/components/stix/name-property/name-property.component.html +++ b/src/app/components/stix/name-property/name-property.component.html @@ -1,5 +1,6 @@
+

{{ @@ -8,41 +9,95 @@

>:  {{ config.object[field] }} - - - - - {{ workflowIcon(statusControl.value) }} - - - -
- - {{ workflowIcon(workflow[0]) }} - -
-
-
-

+ + +
+ + + + + + +
+

Object Release Tracks

+ +
+ Loading release tracks... +
+ +
+
+
+
+

{{ rt.name }}

+

+ {{ rt.description }} +

+
+ +
+ +
+ +
+
+
+ + +
+ No active release track status found. +
+
+
+
+
+

deprecated div { + min-width: 0; + } + + h4, + p { + margin: 0; + } + + h4 { + font-size: 16px; + font-weight: 800; + line-height: 21px; } - .mat-pseudo-checkbox { - display: none !important; + p { + margin-top: 2px; + font-size: 11px; + line-height: 16px; + @include colors.theme-text-deemphasis; + } + } + + .workflow-menu-state-chip { + flex: 0 0 auto; + } + + .workflow-menu-actions { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + + button { + border: 1px solid; + border-radius: 6px; + padding: 5px 7px; + cursor: pointer; + font-size: 12px; + font-weight: 700; + line-height: 16px; + text-align: center; + text-transform: uppercase; + + .dark & { + border-color: rgba(colors.on-color(dark), 0.09); + color: colors.on-color-deemphasis(dark); + background: rgba(colors.color(dark), 0.28); + } + + .light & { + border-color: rgba(colors.on-color(light), 0.12); + color: colors.on-color-deemphasis(light); + background: rgba(colors.on-color(light), 0.02); + } + + &.workflow-option-wip { + @include workflow-option-color($workflow-wip-color); + } + + &.workflow-option-awaiting-review { + @include workflow-option-color($workflow-awaiting-review-color); + } + + &.workflow-option-reviewed { + @include workflow-option-color($workflow-reviewed-color); + } + + &:disabled { + cursor: default; + opacity: 0.65; + + .dark & { + border-color: rgba(colors.on-color(dark), 0.08); + color: colors.on-color-deemphasis(dark); + background: rgba(colors.on-color(dark), 0.04); + } + + .light & { + border-color: rgba(colors.on-color(light), 0.12); + color: colors.on-color-deemphasis(light); + background: rgba(colors.on-color(light), 0.035); + } + } } } + + .workflow-menu-empty { + padding: 4px 0 2px; + font-size: 13px; + line-height: 19px; + @include colors.theme-text-deemphasis; + } } diff --git a/src/app/components/stix/name-property/name-property.component.spec.ts b/src/app/components/stix/name-property/name-property.component.spec.ts index bf8d0dd05..02e00b9b0 100644 --- a/src/app/components/stix/name-property/name-property.component.spec.ts +++ b/src/app/components/stix/name-property/name-property.component.spec.ts @@ -1,24 +1,78 @@ import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideHttpClient } from '@angular/common/http'; +import { MatDialog } from '@angular/material/dialog'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatSnackBar } from '@angular/material/snack-bar'; import { NamePropertyComponent } from './name-property.component'; +import { StixObject } from 'src/app/classes/stix/stix-object'; +import { ReleaseTracksConnectorService } from 'src/app/services/connectors/rest-api/release-tracks.service'; import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; -import { createMockRestApiConnector } from 'src/app/testing/mocks/rest-api-connector.mock'; +import { EditorService } from 'src/app/services/editor/editor.service'; +import { + createAsyncObservable, + createMockRestApiConnector, +} from 'src/app/testing/mocks/rest-api-connector.mock'; import { ActivatedRoute } from '@angular/router'; import { of } from 'rxjs'; +import { vi } from 'vitest'; +import { WorkflowStatus } from 'src/app/utils/types'; +import { WorkflowStatusDialogComponent } from 'src/app/components/workflow-status-dialog/workflow-status-dialog.component'; describe('NamePropertyComponent', () => { let component: NamePropertyComponent; let fixture: ComponentFixture; + let mockDialog; + let mockEditorService; + let mockReleaseTracksService; + let mockSnackbar; + let snapshotStatus; beforeEach(async () => { const mockRestApiConnector = createMockRestApiConnector({}); + mockDialog = { + open: vi.fn().mockReturnValue({ + afterClosed: () => of(true), + }), + }; + mockEditorService = { + onReload: { + emit: vi.fn(), + }, + }; + mockSnackbar = { + open: vi.fn(), + }; + snapshotStatus = WorkflowStatus.WorkInProgress; + mockReleaseTracksService = { + getLatestSnapshot: vi.fn(() => + createAsyncObservable({ + name: 'Core Objects', + description: 'Core workflow', + candidates: [ + { + object_ref: 'attack-pattern--123', + object_status: snapshotStatus, + }, + ], + staged: [], + }) + ), + }; await TestBed.configureTestingModule({ declarations: [NamePropertyComponent], + imports: [MatMenuModule], providers: [ { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + { provide: MatDialog, useValue: mockDialog }, + { provide: MatSnackBar, useValue: mockSnackbar }, + { provide: EditorService, useValue: mockEditorService }, + { + provide: ReleaseTracksConnectorService, + useValue: mockReleaseTracksService, + }, { provide: ActivatedRoute, useValue: { @@ -46,4 +100,196 @@ describe('NamePropertyComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should open workflow status dialog for a release track status action', async () => { + const object = Object.create(StixObject.prototype); + object.stixID = 'attack-pattern--123'; + object.attackType = 'technique'; + object.workflow = { state: WorkflowStatus.WorkInProgress }; + object.workspace = { + release_tracks: [ + { + track_id: 'release-track--core', + name: 'Core Objects', + object_ref: 'attack-pattern--123', + object_modified: '2026-01-01T00:00:00.000Z', + object_status: WorkflowStatus.WorkInProgress, + }, + ], + }; + component.config = { + mode: 'view', + object, + }; + component.ngOnInit(); + await new Promise(resolve => setTimeout(resolve, 10)); + + const track = component.trackStatuses[0]; + component.openStatusDialog(WorkflowStatus.Reviewed, undefined, track); + + expect(mockDialog.open).toHaveBeenCalledWith( + WorkflowStatusDialogComponent, + expect.objectContaining({ + data: expect.objectContaining({ + object, + targetStatus: WorkflowStatus.Reviewed, + track: expect.objectContaining({ + trackId: 'release-track--core', + }), + }), + }) + ); + expect(component.statusControl.value).toBe(WorkflowStatus.WorkInProgress); + expect(mockEditorService.onReload.emit).toHaveBeenCalled(); + }); + + it('should reset workflow status control when workflow status dialog is canceled', async () => { + mockDialog.open.mockReturnValue({ + afterClosed: () => of(false), + }); + const object = Object.create(StixObject.prototype); + object.stixID = 'attack-pattern--123'; + object.attackType = 'technique'; + object.workflow = { state: WorkflowStatus.WorkInProgress }; + object.workspace = { + release_tracks: [ + { + track_id: 'release-track--core', + name: 'Core Objects', + object_ref: 'attack-pattern--123', + object_modified: '2026-01-01T00:00:00.000Z', + object_status: WorkflowStatus.WorkInProgress, + }, + ], + }; + component.config = { + mode: 'view', + object, + }; + component.ngOnInit(); + await new Promise(resolve => setTimeout(resolve, 10)); + + component.openStatusDialog( + WorkflowStatus.Reviewed, + undefined, + component.trackStatuses[0] + ); + + expect(component.statusControl.value).toBe(WorkflowStatus.WorkInProgress); + }); + + it('should load release track workflow rows for the status menu', async () => { + const object = Object.create(StixObject.prototype); + object.stixID = 'attack-pattern--123'; + object.attackType = 'technique'; + object.workflow = { state: WorkflowStatus.WorkInProgress }; + object.workspace = { + release_tracks: [ + { + track_id: 'release-track--core', + name: 'Core Objects', + object_ref: 'attack-pattern--123', + object_modified: '2026-01-01T00:00:00.000Z', + object_status: WorkflowStatus.WorkInProgress, + }, + ], + }; + component.config = { + mode: 'view', + object, + }; + + component.ngOnInit(); + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockReleaseTracksService.getLatestSnapshot).toHaveBeenCalledWith( + 'release-track--core', + { format: 'workbench', include: 'all' } + ); + expect(component.trackStatuses).toEqual([ + expect.objectContaining({ + trackId: 'release-track--core', + name: 'Core Objects', + description: 'Core workflow', + status: WorkflowStatus.WorkInProgress, + }), + ]); + }); + + it('should refresh release track statuses when the saved object reloads', async () => { + const object = Object.create(StixObject.prototype); + object.stixID = 'attack-pattern--123'; + object.attackType = 'technique'; + object.modified = new Date('2026-01-01T00:00:00.000Z'); + object.workflow = { state: WorkflowStatus.WorkInProgress }; + object.workspace = { + release_tracks: [ + { + track_id: 'release-track--core', + name: 'Core Objects', + object_ref: 'attack-pattern--123', + object_modified: '2026-01-01T00:00:00.000Z', + object_status: WorkflowStatus.WorkInProgress, + }, + ], + }; + component.config = { + mode: 'view', + object, + }; + component.ngOnInit(); + await new Promise(resolve => setTimeout(resolve, 10)); + + snapshotStatus = WorkflowStatus.AwaitingReview; + const updatedObject = Object.create(StixObject.prototype); + updatedObject.stixID = 'attack-pattern--123'; + updatedObject.attackType = 'technique'; + updatedObject.modified = new Date('2026-01-02T00:00:00.000Z'); + updatedObject.workflow = { state: WorkflowStatus.WorkInProgress }; + updatedObject.workspace = { + release_tracks: [ + { + track_id: 'release-track--core', + name: 'Core Objects', + object_ref: 'attack-pattern--123', + object_modified: '2026-01-02T00:00:00.000Z', + object_status: WorkflowStatus.AwaitingReview, + }, + ], + }; + component.config = { + mode: 'view', + object: updatedObject, + }; + component.ngOnChanges({ + config: { + previousValue: { mode: 'view', object }, + currentValue: component.config, + firstChange: false, + isFirstChange: () => false, + }, + }); + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockReleaseTracksService.getLatestSnapshot).toHaveBeenCalledTimes(2); + expect(component.trackStatuses[0].status).toBe( + WorkflowStatus.AwaitingReview + ); + }); + + it('should only allow forward workflow status changes from the menu', () => { + const row = { + status: WorkflowStatus.AwaitingReview, + } as any; + + expect(component.isStatusDisabled(row, WorkflowStatus.WorkInProgress)).toBe( + true + ); + expect(component.isStatusDisabled(row, WorkflowStatus.AwaitingReview)).toBe( + true + ); + expect(component.isStatusDisabled(row, WorkflowStatus.Reviewed)).toBe( + false + ); + }); }); diff --git a/src/app/components/stix/name-property/name-property.component.ts b/src/app/components/stix/name-property/name-property.component.ts index ee7203331..1d54d5c5f 100644 --- a/src/app/components/stix/name-property/name-property.component.ts +++ b/src/app/components/stix/name-property/name-property.component.ts @@ -1,13 +1,41 @@ -import { Component, Input, OnInit, ViewEncapsulation } from '@angular/core'; +import { + Component, + ElementRef, + Input, + OnChanges, + OnInit, + SimpleChanges, + ViewChild, + ViewEncapsulation, +} from '@angular/core'; import { FormControl } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; +import { MatMenuTrigger } from '@angular/material/menu'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { forkJoin, of } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; +import { ExportFormat, SnapshotTier } from 'src/app/classes/release-tracks'; +import type { + ReleaseTrackObjectTier, + StixObjectRef, +} from 'src/app/classes/release-tracks'; import { Relationship } from 'src/app/classes/stix/relationship'; import { StixObject } from 'src/app/classes/stix/stix-object'; import { Technique } from 'src/app/classes/stix/technique'; +import { ReleaseTracksConnectorService } from 'src/app/services/connectors/rest-api/release-tracks.service'; import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; import { EditorService } from 'src/app/services/editor/editor.service'; -import { SaveDialogComponent } from '../../save-dialog/save-dialog.component'; -import { WorkflowState, WorkflowStates } from 'src/app/utils/types'; +import { WorkflowStatusDialogComponent } from 'src/app/components/workflow-status-dialog/workflow-status-dialog.component'; +import { + WORKFLOW_STATUS_OPTIONS, + WORKFLOW_STATUS_RANK, + WorkflowStatus, +} from 'src/app/utils/types'; +import type { + ReleaseTrackStatus, + WorkflowStatusType, +} from 'src/app/utils/types'; +import { logger } from 'src/app/utils/logger'; @Component({ selector: 'app-name-property', @@ -16,15 +44,23 @@ import { WorkflowState, WorkflowStates } from 'src/app/utils/types'; encapsulation: ViewEncapsulation.None, standalone: false, }) -export class NamePropertyComponent implements OnInit { +export class NamePropertyComponent implements OnChanges, OnInit { @Input() public config: NamePropertyConfig; + @ViewChild('workflowTriggerButton') + private workflowTriggerButton?: ElementRef; public currentTargetObj?: any; public loaded = false; - public statusControl: FormControl; - public workflows = Object.entries(WorkflowStates) as [ - WorkflowState, - string, - ][]; + public statusControl: FormControl; + public trackStatuses: ReleaseTrackStatus[] = []; + public loadingTracks = false; + private trackStatusLoadKey: string | null = null; + public statusActions: WorkflowStatusAction[] = WORKFLOW_STATUS_OPTIONS.map( + option => ({ + ...option, + status: option.value, + modifier: this.getWorkflowStatusModifier(option.value), + }) + ); public get field() { return this.config.field ? this.config.field : 'name'; @@ -44,6 +80,10 @@ export class NamePropertyComponent implements OnInit { ); } + public get hasTracks(): boolean { + return this.trackStatuses.length > 0; + } + /** * retrieve the internal link to the parent technique */ @@ -67,13 +107,21 @@ export class NamePropertyComponent implements OnInit { constructor( private dialog: MatDialog, private editorService: EditorService, - private restAPIService: RestApiConnectorService + private restAPIService: RestApiConnectorService, + private releaseTracksService: ReleaseTracksConnectorService, + public snackbar: MatSnackBar ) {} + ngOnChanges(changes: SimpleChanges): void { + if (!changes.config) return; + this.syncStatusControl(); + this.loadTrackStatuses(); + } + ngOnInit(): void { const object = this.object; - this.statusControl = new FormControl('work-in-progress'); - this.statusControl.setValue(object?.workflow?.state || 'work-in-progress'); + this.syncStatusControl(); + this.loadTrackStatuses(); if (this.config.mode !== 'diff' && object.revoked) { // retrieve revoking object const data$ = this.restAPIService.getRelatedTo({ @@ -99,56 +147,329 @@ export class NamePropertyComponent implements OnInit { return ''; } - public workflowChange(event): void { - if (!this.showWorkflowControl) return; - + public openStatusDialog( + targetStatus: WorkflowStatusType, + menuTrigger: MatMenuTrigger | undefined, + track: ReleaseTrackStatus + ): void { + menuTrigger?.closeMenu(); const previousWorkflowState = - this.object?.workflow?.state || 'work-in-progress'; - if (event.isUserInput) { - const dialogRef = this.dialog.open(SaveDialogComponent, { - maxWidth: '70em', - maxHeight: '70em', - data: { - object: this.object, - versionAlreadyIncremented: false, - initialWorkflowState: event.source.value, - showWorkflow: true, + this.object?.workflow?.state || WorkflowStatus.WorkInProgress; + const dialogRef = this.dialog.open(WorkflowStatusDialogComponent, { + maxWidth: '760px', + maxHeight: '86vh', + panelClass: 'workflow-status-dialog-panel', + backdropClass: 'workflow-status-dialog-backdrop', + data: { + object: this.object, + targetStatus, + track, + }, + autoFocus: false, + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + this.syncStatusControl(); + this.getTrackStatuses(); + this.editorService.onReload.emit(); + } else { + this.statusControl.setValue(previousWorkflowState); + } + }); + } + + public isStatusActive( + row: ReleaseTrackStatus, + status: WorkflowStatusType + ): boolean { + return row.status === status; + } + + public isStatusDisabled( + row: ReleaseTrackStatus, + status: WorkflowStatusType + ): boolean { + return WORKFLOW_STATUS_RANK[status] <= WORKFLOW_STATUS_RANK[row.status]; + } + + private syncStatusControl(): void { + const state = this.object?.workflow?.state || WorkflowStatus.WorkInProgress; + if (!this.statusControl) { + this.statusControl = new FormControl(state); + return; + } + this.statusControl.setValue(state, { emitEvent: false }); + } + + private loadTrackStatuses(): void { + if (!this.showWorkflowControl) { + this.trackStatuses = []; + this.trackStatusLoadKey = null; + return; + } + + const loadKey = this.getTrackStatusLoadKey(); + if (loadKey === this.trackStatusLoadKey) return; + this.trackStatusLoadKey = loadKey; + this.getTrackStatuses(); + } + + private getTrackStatusLoadKey(): string { + const object = this.object; + const releaseTracks = Array.isArray(object?.workspace?.release_tracks) + ? object.workspace.release_tracks + : []; + const trackKey = releaseTracks + .map(track => { + if (typeof track === 'string') return track; + return [ + this.getTrackId(track), + this.getEntryWorkflowStatus(track), + this.getEntryTier(track), + this.toIsoString(track?.object_modified || track?.modified), + ].join(':'); + }) + .join('|'); + + return [ + this.config?.mode || 'view', + object?.stixID || '', + this.toIsoString(object?.modified), + trackKey, + ].join('|'); + } + + private getTrackStatuses(): void { + if (!this.object?.stixID) { + this.trackStatuses = []; + return; + } + + this.loadingTracks = true; + const releaseTracks = this.getWorkspaceTracks(); + if (!releaseTracks.length) { + this.trackStatuses = []; + this.loadingTracks = false; + return; + } + + forkJoin( + releaseTracks.map(track => + this.releaseTracksService + .getLatestSnapshot(track.trackId, { + format: ExportFormat.Workbench, + include: 'all', + }) + .pipe( + map(snapshot => this.toTrackStatus(track, snapshot)), + catchError(err => { + logger.error( + 'Failed to load release track snapshot for workflow status menu', + err + ); + return of(this.toTrackStatus(track, null)); + }) + ) + ) + ) + .pipe( + map(rows => + rows.filter((row): row is ReleaseTrackStatus => row !== null) + ) + ) + .subscribe({ + next: rows => { + this.trackStatuses = rows; + this.loadingTracks = false; + }, + error: err => { + logger.error(err); + this.trackStatuses = []; + this.loadingTracks = false; }, - autoFocus: false, }); + } - dialogRef.afterClosed().subscribe(result => { - if (result) { - this.statusControl.setValue(event.source.value); - this.editorService.onReload.emit(); - } else { - this.statusControl.setValue(previousWorkflowState); - } + private toTrackStatus( + track: ReleaseTrackStatus, + snapshot: any + ): ReleaseTrackStatus | null { + const entry = this.getTrackedObjectEntry(snapshot); + if (!entry && !track.tier) return null; + + const tier = this.getEntryTier(entry) || track.tier || null; + const status = + this.getEntryWorkflowStatus(entry) || + track.status || + this.getFallbackWorkflowStatus(tier); + + return { + trackId: this.getTrackId(track) || '', + name: snapshot?.name || track.name || this.getTrackId(track) || '', + description: snapshot?.description || track.description || '', + tier, + status, + objectRef: entry ? this.getObjectRef(entry) : track.objectRef, + }; + } + + private getWorkspaceTracks(): ReleaseTrackStatus[] { + const releaseTracks = this.object?.workspace?.release_tracks; + if (!Array.isArray(releaseTracks)) return []; + + const seen = new Set(); + return releaseTracks + .map(track => this.toWorkspaceTrack(track)) + .filter((track): track is ReleaseTrackStatus => !!track) + .filter(track => { + if (seen.has(track.trackId)) return false; + seen.add(track.trackId); + return true; }); - } } - public workflowIcon(state: WorkflowState): string { - switch (state) { - case 'work-in-progress': - return 'assignment'; - case 'awaiting-review': - return 'assignment_ind'; - case 'reviewed': - return 'assignment_turned_in'; - } + private toWorkspaceTrack(track: any): ReleaseTrackStatus | null { + const trackId = typeof track === 'string' ? track : this.getTrackId(track); + if (!trackId) return null; + const tier = typeof track === 'string' ? null : this.getEntryTier(track); + const status = + typeof track === 'string' + ? WorkflowStatus.WorkInProgress + : this.getEntryWorkflowStatus(track) || + this.getFallbackWorkflowStatus(tier); + + return { + trackId, + name: typeof track === 'string' ? '' : track.name || track.track_name, + description: typeof track === 'string' ? '' : track.description || '', + tier, + status, + objectRef: + typeof track === 'string' + ? this.object.stixID + : this.getWorkspaceObjectRef(track), + }; + } + + private getTrackedObjectEntry(snapshot: any): any | null { + if (!snapshot) return null; + return ( + this.getSnapshotWorkflowEntries(snapshot).find( + entry => this.getEntryObjectRef(entry) === this.object.stixID + ) || null + ); + } + + private getSnapshotWorkflowEntries(snapshot: any): any[] { + return [ + this.withTier(snapshot?.candidates, SnapshotTier.Candidate), + this.withTier(snapshot?.staged, SnapshotTier.Staged), + this.withTier(snapshot?.contents?.candidates, SnapshotTier.Candidate), + this.withTier(snapshot?.contents?.staged, SnapshotTier.Staged), + this.withTier(snapshot?.workspace?.candidates, SnapshotTier.Candidate), + this.withTier(snapshot?.workspace?.staged, SnapshotTier.Staged), + ] + .filter(Array.isArray) + .reduce((entries, tierEntries) => entries.concat(tierEntries), []); } - public workflowColor(state: WorkflowState): string { - switch (state) { - case 'work-in-progress': - return 'error'; - case 'awaiting-review': - return 'warn'; - case 'reviewed': - return 'success'; + private withTier( + entries: any, + tier: ReleaseTrackObjectTier + ): any[] | undefined { + if (!Array.isArray(entries)) return undefined; + return entries.map(entry => ({ + ...entry, + tier: this.getEntryTier(entry) || tier, + })); + } + + private getEntryTier(entry: any): ReleaseTrackObjectTier | null { + if (!entry) return null; + const tier = String(entry.tier || entry.object_tier || '').toLowerCase(); + if (tier === SnapshotTier.Staged) return SnapshotTier.Staged; + if (tier === SnapshotTier.Candidate) return SnapshotTier.Candidate; + if (entry.object_staged_at || entry.staged_at) return SnapshotTier.Staged; + if (this.getEntryWorkflowStatus(entry)) return SnapshotTier.Candidate; + return null; + } + + private getEntryWorkflowStatus(entry: any): WorkflowStatusType | null { + if (!entry) return null; + const status = entry.object_status || entry.status; + if (Object.values(WorkflowStatus).includes(status)) return status; + return null; + } + + private getFallbackWorkflowStatus( + tier: ReleaseTrackObjectTier | null + ): WorkflowStatusType { + return tier === SnapshotTier.Staged + ? WorkflowStatus.Reviewed + : WorkflowStatus.WorkInProgress; + } + + private getWorkflowStatusModifier( + status: WorkflowStatusType + ): WorkflowStatusAction['modifier'] { + switch (status) { + case WorkflowStatus.AwaitingReview: + return 'awaiting-review'; + case WorkflowStatus.Reviewed: + return 'reviewed'; + default: + return 'wip'; } } + + private getEntryObjectRef(entry: any): string | null { + return entry?.object_ref || entry?.ref || entry?.id || null; + } + + private getObjectRef(entry: any): StixObjectRef { + const modified = + this.toIsoString(entry.object_modified) || + this.toIsoString(entry.modified) || + this.object.modified?.toISOString(); + + return modified + ? { + id: this.getEntryObjectRef(entry) || this.object.stixID, + modified, + } + : this.getEntryObjectRef(entry) || this.object.stixID; + } + + private getWorkspaceObjectRef(track: any): StixObjectRef { + const modified = + this.toIsoString(track?.object_modified) || + this.toIsoString(track?.modified) || + this.object.modified?.toISOString(); + + return modified + ? { + id: track?.object_ref || track?.ref || this.object.stixID, + modified, + } + : track?.object_ref || track?.ref || this.object.stixID; + } + + private toIsoString(value: any): string | null { + if (!value) return null; + if (value instanceof Date) return value.toISOString(); + return String(value); + } + + private getTrackId(track: any): string | null { + return ( + track?.trackId || + track?.track_id || + track?.release_track_id || + track?.releaseTrackId || + (track?.id?.startsWith('release-track--') ? track.id : null) + ); + } } export interface NamePropertyConfig { @@ -171,3 +492,9 @@ export interface NamePropertyConfig { */ field?: string; } + +interface WorkflowStatusAction { + label: string; + status: WorkflowStatusType; + modifier: 'wip' | 'awaiting-review' | 'reviewed'; +} diff --git a/src/app/components/stix/stix-list/stix-list.component.html b/src/app/components/stix/stix-list/stix-list.component.html index 575129780..46019f8d5 100644 --- a/src/app/components/stix/stix-list/stix-list.component.html +++ b/src/app/components/stix/stix-list/stix-list.component.html @@ -185,14 +185,14 @@ *ngIf="config.select && config.select === 'many'" color="primary" (click)="onSelect.emit(element); $event.stopPropagation()" - (change)="$event ? selection.toggle(element.stixID) : null" + (change)="$event ? toggleSelection(element) : null" [checked]="selection.isSelected(element.stixID)"> @@ -369,6 +369,8 @@ mat-row *matRowDef="let element; columns: tableColumns_controls" class="element-row" + [class.relationship-added]="isNewRelationship(element)" + [class.relationship-changed]="isChangedRelationship(element)" [class.expanded]=" isCollectionType() && expandedElement === element "> diff --git a/src/app/components/stix/stix-list/stix-list.component.scss b/src/app/components/stix/stix-list/stix-list.component.scss index d96c3e835..d866e9e03 100644 --- a/src/app/components/stix/stix-list/stix-list.component.scss +++ b/src/app/components/stix/stix-list/stix-list.component.scss @@ -128,6 +128,16 @@ } } + tr.element-row.relationship-added, + tr.element-row.relationship-changed { + .light & td { + background-color: #c0f8c0 !important; + } + .dark & td { + background-color: #3abc4c56 !important; + } + } + .mat-mdc-cell + .mat-cell, .mat-mdc-header-cell + .mat-header-cell { padding-left: 12px; diff --git a/src/app/components/stix/stix-list/stix-list.component.spec.ts b/src/app/components/stix/stix-list/stix-list.component.spec.ts index cc58ab167..ba43bf689 100644 --- a/src/app/components/stix/stix-list/stix-list.component.spec.ts +++ b/src/app/components/stix/stix-list/stix-list.component.spec.ts @@ -1,8 +1,13 @@ import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { provideHttpClient } from '@angular/common/http'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { of } from 'rxjs'; +import { MatDialog } from '@angular/material/dialog'; +import { Router } from '@angular/router'; import { StixListComponent, StixListConfig } from './stix-list.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { AuthenticationService } from 'src/app/services/connectors/authentication/authentication.service'; describe('StixListComponent', () => { let component: StixListComponent; @@ -15,7 +20,39 @@ describe('StixListComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ declarations: [StixListComponent], - providers: [provideHttpClient()], + imports: [NoopAnimationsModule], + providers: [ + { + provide: RestApiConnectorService, + useValue: { + getAllAllowedValues: vi.fn(() => of([])), + getAllObjects: vi.fn(() => + of({ data: [], pagination: { total: 0, limit: 0, offset: 0 } }) + ), + getAllTechniques: vi.fn(() => + of({ data: [], pagination: { total: 0, limit: 0, offset: 0 } }) + ), + }, + }, + { + provide: AuthenticationService, + useValue: { + canEdit: vi.fn(() => true), + }, + }, + { + provide: MatDialog, + useValue: { + open: vi.fn(), + }, + }, + { + provide: Router, + useValue: { + navigate: vi.fn(), + }, + }, + ], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); @@ -30,4 +67,93 @@ describe('StixListComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should order all objects columns as ID, Name, Type, Domain, Modified', () => { + component.config = { columnsPreset: 'all-objects' }; + component.tableColumns = []; + component.tableColumns_settings = new Map(); + + (component as any).buildTable(); + + expect(component.tableColumns).toEqual([ + 'attackID', + 'name', + 'attackType', + 'domains', + 'modified', + ]); + }); + + it('should not show the workflow status column for STIX object lists', () => { + const types: StixListConfig['type'][] = [ + 'asset', + 'campaign', + 'data-component', + 'data-source', + 'detection-strategy', + 'group', + 'matrix', + 'mitigation', + 'software', + 'tactic', + 'technique', + ]; + + types.forEach(type => { + component.config = { type }; + component.tableColumns = []; + component.tableColumns_settings = new Map(); + + (component as any).buildTable(); + + expect(component.tableColumns).not.toContain('workflow'); + expect(component.tableColumns).toContain('state'); + }); + }); + it('should ignore objects without domains when filtering local objects by domain', () => { + const domainlessObject = { + stixID: 'campaign--1', + name: 'Operation Triangulation', + }; + const domainObject = { + stixID: 'attack-pattern--1', + name: 'Technique', + domains: ['enterprise-attack'], + }; + + const result = (component as any).filterLocalObjects( + [domainlessObject, domainObject], + { + deprecated: false, + revoked: false, + state: undefined, + platforms: [], + domains: ['enterprise-attack'], + exclusiveDeprecated: false, + exclusiveRevoked: false, + } + ); + + expect(result).toEqual([domainObject]); + }); + + it('identifies an existing relationship modified after the candidate baseline', () => { + component.config = { + type: 'relationship', + relationshipAddedAfter: '2026-07-02T00:00:00.000Z', + } as any; + + expect( + (component as any).isChangedRelationship({ + created: new Date('2026-07-01T00:00:00.000Z'), + modified: new Date('2026-07-03T00:00:00.000Z'), + }) + ).toBe(true); + expect( + (component as any).isChangedRelationship({ + created: new Date('2026-07-03T00:00:00.000Z'), + modified: new Date('2026-07-03T00:00:00.000Z'), + }) + ).toBe(false); + }); }); diff --git a/src/app/components/stix/stix-list/stix-list.component.ts b/src/app/components/stix/stix-list/stix-list.component.ts index bf453ea9e..a0d6ce541 100644 --- a/src/app/components/stix/stix-list/stix-list.component.ts +++ b/src/app/components/stix/stix-list/stix-list.component.ts @@ -32,7 +32,6 @@ import { RestApiConnectorService, } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; import { AuthenticationService } from 'src/app/services/connectors/authentication/authentication.service'; -import { SidebarService } from 'src/app/services/sidebar/sidebar.service'; import { AddDialogComponent } from '../../add-dialog/add-dialog.component'; import { Collection } from 'src/app/classes/stix/collection'; import { logger } from 'src/app/utils/logger'; @@ -161,8 +160,7 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { public dialog: MatDialog, private restAPIConnectorService: RestApiConnectorService, private router: Router, - private authenticationService: AuthenticationService, - private sidebarService: SidebarService + private authenticationService: AuthenticationService ) {} ngOnInit(): void { @@ -201,7 +199,7 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { ngAfterViewInit() { // set up listener to search input - if (this.config.type && this.config.type != 'relationship') { + if (this.config.type != 'relationship') { this.searchSubscription = fromEvent(this.search.nativeElement, 'keyup') .pipe( filter(Boolean), @@ -235,6 +233,14 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { this.tableDetail = []; return; } + if (this.config.columnsPreset === 'all-objects') { + this.addIdAndNameColumns(sticky_allowed); + this.addColumn('type', 'attackType', 'plain'); + this.addDomainColumn(); + this.addColumn('modified', 'modified', 'timestamp'); + this.tableDetail = []; + return; + } if ('type' in this.config) { // set columns according to type switch (this.config.type.replace(/_/g, '-')) { @@ -276,24 +282,24 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { case 'mitigation': case 'tactic': case 'data-component': - this.addWorkflowAndStateColumns(); + this.addStateColumnOnly(); this.addIdAndNameColumns(sticky_allowed); this.addDomainColumn(); this.addColumn('modified', 'modified', 'timestamp'); break; case 'matrix': - this.addWorkflowAndStateColumns(); + this.addStateColumnOnly(); this.addNameColumn(sticky_allowed); this.addColumn('modified', 'modified', 'timestamp'); break; case 'detection-strategy': case 'campaign': - this.addWorkflowAndStateColumns(); + this.addStateColumnOnly(); this.addIdAndNameColumns(sticky_allowed); this.addColumn('modified', 'modified', 'timestamp'); break; case 'analytic': - this.addWorkflowAndStateColumns(); + this.addStateColumnOnly(); this.addColumn('ID', 'attackID', 'plain', false); this.addColumn( 'related detection strategy', @@ -308,34 +314,34 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { this.addColumn('modified', 'modified', 'timestamp'); break; case 'group': - this.addWorkflowAndStateColumns(); + this.addStateColumnOnly(); this.addIdAndNameColumns(sticky_allowed); this.addColumn('associated groups', 'aliases', 'list'); this.addColumn('modified', 'modified', 'timestamp'); break; case 'software': - this.addWorkflowAndStateColumns(); + this.addStateColumnOnly(); this.addIdAndNameColumns(sticky_allowed); this.addColumn('type', 'type', 'plain'); this.addDomainColumn(); this.addColumn('modified', 'modified', 'timestamp'); break; case 'identity': - this.addWorkflowAndStateColumns(); + this.addStateColumnOnly(); this.addNameColumn(sticky_allowed); this.addColumn('identity class', 'identity_class', 'plain'); this.addColumn('modified', 'modified', 'timestamp'); break; case 'data-source': case 'technique': - this.addWorkflowAndStateColumns(); + this.addStateColumnOnly(); this.addIdAndNameColumns(sticky_allowed); this.addDomainColumn(); this.addPlatformsColumn(); this.addColumn('modified', 'modified', 'timestamp'); break; case 'asset': - this.addWorkflowAndStateColumns(); + this.addStateColumnOnly(); this.addIdAndNameColumns(sticky_allowed); this.addPlatformsColumn(); this.addColumn('sectors', 'sectors', 'list'); @@ -387,12 +393,10 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { this.config.sourceRef ? sticky_allowed : false, ['relationship-name'] ); - if ( - !( - this.config.relationshipType && - this.config.relationshipType == 'subtechnique-of' - ) - ) + if (!( + this.config.relationshipType && + this.config.relationshipType == 'subtechnique-of' + )) this.addColumn( 'description', 'description', @@ -423,11 +427,6 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { } } - private addWorkflowAndStateColumns(): void { - this.addColumn('', 'workflow', 'icon'); - this.addColumn('', 'state', 'icon'); - } - private addStateColumnOnly(): void { this.addColumn('', 'state', 'icon'); } @@ -682,9 +681,6 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { // Get type to navigate from target_ref const type = StixTypeToAttackType[object_ref.split('--')[0]]; - this.sidebarService.opened = true; - this.sidebarService.currentTab = 'notes'; - // collection objs have a different URL structure let url = `/${type}/${object_ref}`; if (type === 'collection') { @@ -723,6 +719,21 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { } } + public toggleSelection(element: StixObject): void { + this.selection.toggle(element.stixID); + const selectedObjectRefs = this.config.selectedObjectRefs; + if (!selectedObjectRefs) return; + + if (this.selection.isSelected(element.stixID)) { + selectedObjectRefs.set(element.stixID, { + id: element.stixID, + modified: element.modified.toISOString(), + }); + } else { + selectedObjectRefs.delete(element.stixID); + } + } + public isCollectionType(): boolean { return ['collection', 'collection-created', 'collection-imported'].includes( this.config.type @@ -892,7 +903,7 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { return; } // filter on STIX objects specified in the config - let filtered = this.config.stixObjects; + let filtered = this.filterExcludedAttackTypes(this.config.stixObjects); filtered = this.filterLocalObjects(filtered, filterStates); filtered = this.sortObjects(filtered); @@ -945,7 +956,7 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { this.data$ = this.restAPIConnectorService.getAllTactics(options); else if (this.config.type == 'technique') this.data$ = this.restAPIConnectorService.getAllTechniques(options); - else if (this.config.type.includes('collection')) + else if (this.config.type?.includes('collection')) this.data$ = this.restAPIConnectorService.getAllCollections({ search: this.searchQuery, versions: 'all', @@ -984,8 +995,35 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { this.data$ = this.restAPIConnectorService.getAllIdentities(options); else if (this.config.type == 'note') this.data$ = this.restAPIConnectorService.getAllNotes(options); - const subscription = this.data$.subscribe({ + else + this.data$ = this.restAPIConnectorService.getAllObjects({ + limit, + offset, + state: filterStates.state, + revoked: filterStates.revoked, + deprecated: filterStates.deprecated, + deserialize: true, + lastUpdatedBy: this.userIdsUsedInSearch, + search: this.searchQuery, + }); + let subscription: Subscription | undefined; + subscription = this.data$.subscribe({ next: data => { + if ( + this.config.type === 'relationship' && + this.config.relationshipCreatedBefore + ) { + // For a staged diff, hide relationships created after that timestamp so they do not + // appear in the staged view. Keep the pagination count in sync with + // the rows hidden client-side. + const relationshipCount = data.data.length; + data.data = this.filterRelationshipsCreatedBefore( + data.data, + this.config.relationshipCreatedBefore + ); + data.pagination.total -= relationshipCount - data.data.length; + } + data.data = this.filterExcludedAttackTypes(data.data); this.totalObjectCount = data.pagination.total; this.emitDetectsHasData(data.data.length > 0); }, @@ -995,6 +1033,75 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { }); } + /** + * Relationships created after `createdBefore` did not exist at + * the staged object's timestamp, so they are hidden. An invalid cutoff, or + * an unreadable relationship creation date, leaves that relationship visible + * to avoid treating missing date data as proof that it is new. + */ + private filterRelationshipsCreatedBefore( + relationships: StixObject[], + createdBefore: Date | string + ): StixObject[] { + const cutoff = new Date(createdBefore).getTime(); + if (!Number.isFinite(cutoff)) return relationships; + + return relationships.filter(relationship => { + const created = new Date(relationship.created).getTime(); + return !Number.isFinite(created) || created <= cutoff; + }); + } + + /** + * Determines whether a relationship should receive the candidate-diff + * styling. The candidate table remains the current relationship list; + * a row is new only when its creation time is later than the staged object's + * `relationshipAddedAfter` baseline. Non-relationship tables and invalid or + * missing timestamps are never marked as new. + */ + public isNewRelationship(relationship: StixObject): boolean { + if ( + this.config.type !== 'relationship' || + !this.config.relationshipAddedAfter + ) { + return false; + } + + const baseline = new Date(this.config.relationshipAddedAfter).getTime(); + const created = new Date(relationship.created).getTime(); + return ( + Number.isFinite(baseline) && + Number.isFinite(created) && + created > baseline + ); + } + + /** + * Determines whether an existing relationship changed after the staged + * baseline. Candidate relationships are fetched at their current version, + * so this highlights changes that cannot be represented accurately in the + * staged table without relationship version history. + */ + public isChangedRelationship(relationship: StixObject): boolean { + if ( + this.config.type !== 'relationship' || + !this.config.relationshipAddedAfter + ) { + return false; + } + + const baseline = new Date(this.config.relationshipAddedAfter).getTime(); + const created = new Date(relationship.created).getTime(); + const modified = new Date(relationship.modified).getTime(); + return ( + Number.isFinite(baseline) && + Number.isFinite(created) && + Number.isFinite(modified) && + created <= baseline && + modified > baseline + ); + } + private filterLocalObjects( objects: StixObject[], { @@ -1011,7 +1118,7 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { //filter by domains if (Array.isArray(domains) && domains.length > 0) { filtered = filtered.filter((obj: any) => - obj.domains.some((object_domain: any) => + this.getArrayField(obj, 'domains').some((object_domain: any) => domains.includes(object_domain) ) ); @@ -1020,7 +1127,7 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { //filter by platforms if (Array.isArray(platforms) && platforms.length > 0) { filtered = filtered.filter((obj: any) => - obj.platforms.some((object_platform: any) => + this.getArrayField(obj, 'platforms').some((object_platform: any) => platforms.includes(object_platform) ) ); @@ -1066,6 +1173,11 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { return filtered; } + private getArrayField(object: any, field: string): any[] { + const value = object?.[field]; + return Array.isArray(value) ? value : []; + } + private sortObjects(objects: StixObject[]): StixObject[] { return [...objects].sort((a, b) => { const x = a as any; @@ -1098,6 +1210,17 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { } } + private filterExcludedAttackTypes(objects: StixObject[]): StixObject[] { + if (!this.config.excludeAttackTypes?.length) return objects; + + return objects.filter( + object => + !this.config.excludeAttackTypes.includes( + object.attackType as AttackType + ) + ); + } + public showDeprecated(event) { if (event.checked) { this.filter.push('state.deprecated'); @@ -1221,9 +1344,15 @@ export interface StixListConfig { targetType?: AttackType; /** relationship type to get, use with type=='relationship' */ relationshipType?: string; + /** Hide relationships created after this timestamp. */ + relationshipCreatedBefore?: Date | string; + /** Mark relationships created after this timestamp as new. */ + relationshipAddedAfter?: Date | string; /** force the list to show only this type */ type?: AttackType | 'collection-created' | 'collection-imported'; + /** exclude rows matching these ATT&CK object types */ + excludeAttackTypes?: AttackType[]; /** can the user select in this list? allowed options: * "one": user can select a single element at a time @@ -1236,6 +1365,7 @@ export interface StixListConfig { * Only relevant if 'select' is also enabled. Also, will cause problems if multiple constructor pram is set according to 'select'. */ selectionModel?: SelectionModel; + selectedObjectRefs?: Map; /** show links to view/edit pages for relevant objects? */ showLinks?: boolean; /** default true, if false hides the filter dropdown menu */ @@ -1243,7 +1373,7 @@ export interface StixListConfig { /** default true, if false hides all search/filter/control options */ showControls?: boolean; /** Optional preset to override default columns */ - columnsPreset?: 'id-name'; + columnsPreset?: 'id-name' | 'all-objects'; /** display the 'show deprecated' filter, default false * this may be relevant when displaying a list of embedded relationships, where * the list of STIX objects is provided in the 'stixObjects' configuration diff --git a/src/app/components/resources-drawer/history-timeline/history-timeline.component.html b/src/app/components/stix/stix-page-tabs/history-timeline/history-timeline.component.html similarity index 100% rename from src/app/components/resources-drawer/history-timeline/history-timeline.component.html rename to src/app/components/stix/stix-page-tabs/history-timeline/history-timeline.component.html diff --git a/src/app/components/resources-drawer/history-timeline/history-timeline.component.scss b/src/app/components/stix/stix-page-tabs/history-timeline/history-timeline.component.scss similarity index 96% rename from src/app/components/resources-drawer/history-timeline/history-timeline.component.scss rename to src/app/components/stix/stix-page-tabs/history-timeline/history-timeline.component.scss index a9f78dd4d..c8e99bbfb 100644 --- a/src/app/components/resources-drawer/history-timeline/history-timeline.component.scss +++ b/src/app/components/stix/stix-page-tabs/history-timeline/history-timeline.component.scss @@ -1,5 +1,5 @@ -@use '../../../../style/globals'; -@use '../../../../style/colors'; +@use '../../../../../style/globals'; +@use '../../../../../style/colors'; .history-timeline { h3 { diff --git a/src/app/components/resources-drawer/history-timeline/history-timeline.component.spec.ts b/src/app/components/stix/stix-page-tabs/history-timeline/history-timeline.component.spec.ts similarity index 100% rename from src/app/components/resources-drawer/history-timeline/history-timeline.component.spec.ts rename to src/app/components/stix/stix-page-tabs/history-timeline/history-timeline.component.spec.ts diff --git a/src/app/components/resources-drawer/history-timeline/history-timeline.component.ts b/src/app/components/stix/stix-page-tabs/history-timeline/history-timeline.component.ts similarity index 100% rename from src/app/components/resources-drawer/history-timeline/history-timeline.component.ts rename to src/app/components/stix/stix-page-tabs/history-timeline/history-timeline.component.ts diff --git a/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.html b/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.html new file mode 100644 index 000000000..f0901aa9a --- /dev/null +++ b/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.html @@ -0,0 +1,203 @@ +
+
+

Release Track Distribution

+ +
+ + + +
+
+ +
+ Loading release track membership... +
+ +
+

This object is not in any release tracks yet.

+

Add it to a standard release track to begin managing its release.

+ + + + +
+ + + +
+
+
+
+

{{ getTrackName(membership) }}

+

{{ getTrackDescription(membership) }}

+
+ +
+ + + +
+
+ +
+
+

Current Draft Status

+ +

+ Not in current draft. +

+ +
+ + +
+ + WIP + + + + Review + + + + Staged + +
+
+
+ +
+

Production Releases

+ +

+ No production releases found. +

+ +
+ +
+
+
+
+
+
diff --git a/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.scss b/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.scss new file mode 100644 index 000000000..0606f95f5 --- /dev/null +++ b/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.scss @@ -0,0 +1,726 @@ +@use 'sass:color'; +@use '../../../../../style/globals'; +@use '../../../../../style/colors' as colors; + +$membership-accent: colors.color(mitre-light-blue); +$membership-accent-dark: #62c9ff; +$membership-success: colors.color(secondary); +$membership-release: colors.color(active); + +:host { + display: block; + width: 100%; +} + +.membership-section { + box-sizing: border-box; + width: 100%; + padding-top: 32px; +} + +.membership-header { + display: flex; + align-items: center; + justify-content: space-between; + box-sizing: border-box; + width: 100%; + gap: 24px; + margin-bottom: 22px; + + h2 { + margin: 0; + color: $membership-accent; + font-size: 18px; + font-weight: 700; + letter-spacing: 0.2px; + line-height: 24px; + text-align: left; + } +} + +.membership-header-actions { + display: flex; + align-items: center; + flex: 0 0 auto; + gap: 10px; + margin-left: auto; +} + +button.membership-action-button { + display: inline-flex; + box-sizing: border-box; + min-width: 68px; + height: 34px; + align-items: center; + justify-content: center; + padding: 0 14px; + border: 1px solid; + border-radius: 4px; + cursor: pointer; + font-family: inherit; + font-size: 12px; + font-weight: 650; + line-height: 32px; + visibility: visible; + opacity: 1; + transition: + background-color 120ms ease, + border-color 120ms ease, + color 120ms ease; + + &:disabled, + &[disabled] { + display: inline-flex; + cursor: default; + visibility: visible; + opacity: 1; + } +} + +.loading-state { + display: flex; + box-sizing: border-box; + min-height: 150px; + align-items: center; + justify-content: center; + padding: 28px; + border: 1px solid; + border-radius: 4px; + text-align: center; +} + +.empty-membership-state { + padding: 28px; + border: 1px solid; + border-radius: 4px; + text-align: center; + + h3 { + margin: 0 0 6px; + font-size: 16px; + } + + p { + margin: 0; + font-size: 13px; + } +} + +.add-track-button { + min-height: 34px; + margin-top: 18px; + padding: 0 14px; + border: 1px solid; + border-radius: 4px; + cursor: pointer; + font-family: inherit; + font-size: 12px; + font-weight: 650; + + &:disabled { + cursor: default; + opacity: 0.65; + } +} + +.membership-error { + margin: 12px 0 0; + color: colors.color(error); + font-size: 13px; + text-align: center; +} + +.release-track-list { + display: flex; + flex-direction: column; + gap: 24px; +} + +.release-track-card { + overflow: hidden; + border: 1px solid; + border-radius: 4px; + transition: + border-color 120ms ease, + box-shadow 120ms ease, + background-color 120ms ease; +} + +.release-track-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + padding: 16px; + border-bottom: 1px solid; +} + +.release-track-heading { + min-width: 0; + + h3 { + margin: 0 0 4px; + font-size: 17px; + font-weight: 700; + line-height: 23px; + text-align: left; + } + + p { + margin: 0; + font-size: 13px; + line-height: 18px; + } +} + +.release-track-actions { + display: flex; + align-items: center; + flex: 0 0 auto; + gap: 10px; +} + +.view-track-button { + display: inline-flex; + box-sizing: border-box; + min-height: 30px; + align-items: center; + justify-content: center; + gap: 6px; + padding: 0 12px; + border: 1px solid; + border-radius: 4px; + background: transparent; + cursor: pointer; + font-family: inherit; + font-size: 11px; + font-weight: 650; + transition: + background-color 120ms ease, + border-color 120ms ease, + color 120ms ease; + + &:disabled { + cursor: default; + } +} + +.external-link-icon { + color: inherit; + font-size: 13px; + line-height: 1; +} + +.release-track-content { + display: grid; + grid-template-columns: minmax(280px, 1fr) minmax(420px, 2fr); + gap: 20px; + padding: 18px 16px; +} + +.draft-column, +.releases-column { + min-width: 0; + + h4 { + margin: 0 0 10px; + color: $membership-accent; + font-size: 13px; + font-weight: 700; + line-height: 18px; + text-align: left; + } +} + +.no-current-draft, +.no-production-releases { + margin: 0; + font-size: 13px; + font-style: italic; + line-height: 18px; +} + +.draft-card, +.production-release { + border: 1px solid; + border-radius: 4px; +} + +.draft-card { + padding: 12px; +} + +.release-selection, +.production-release { + display: flex; + align-items: flex-start; + gap: 11px; +} + +.release-selection { + cursor: pointer; +} + +.production-release-list { + display: flex; + flex-direction: column; + gap: 7px; +} + +.production-release { + box-sizing: border-box; + min-height: 52px; + padding: 10px 12px; + cursor: pointer; + transition: + background-color 120ms ease, + border-color 120ms ease; + + .release-information strong { + color: $membership-release; + } + + .release-tag-icon { + border-color: $membership-release; + + &::after { + background: $membership-release; + } + } +} + +.release-information { + display: flex; + min-width: 0; + flex-direction: column; + gap: 3px; + + strong { + color: $membership-success; + font-size: 14px; + font-weight: 700; + line-height: 18px; + } + + small { + overflow: hidden; + font-size: 11px; + line-height: 15px; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.draft-status { + text-transform: capitalize; +} + +.draft-status--in-review { + color: colors.color(pending) !important; +} + +.draft-status--staged { + color: colors.color(active) !important; +} + +.release-tag-icon { + position: relative; + width: 13px; + height: 9px; + margin-top: 4px; + border: 1.5px solid $membership-success; + border-radius: 2px 4px 4px 2px; + flex: 0 0 auto; + transform: rotate(45deg); + + &::after { + position: absolute; + top: 2px; + left: 1px; + width: 2px; + height: 2px; + border-radius: 50%; + background: $membership-success; + content: ''; + } +} + +input[type='checkbox'] { + width: 14px; + height: 14px; + margin: 3px 0 0; + accent-color: $membership-success; + cursor: pointer; + + &:disabled { + cursor: default; + } +} + +.status-progress { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; + margin-top: 16px; +} + +.status-step { + display: flex; + min-height: 22px; + align-items: center; + justify-content: center; + border: 1px solid; + border-radius: 4px; + font-size: 9px; + font-weight: 750; + line-height: 20px; + text-transform: uppercase; +} + +.status-step--active { + border-color: rgba($membership-success, 0.65) !important; + background: rgba($membership-success, 0.18) !important; + color: $membership-success !important; +} + +.status-step--review-active { + border-color: rgba(colors.color(pending), 0.8) !important; + background: rgba(colors.color(pending), 0.16) !important; + color: colors.color(pending) !important; +} + +.status-step--staged-active { + border-color: rgba(colors.color(active), 0.8) !important; + background: rgba(colors.color(active), 0.16) !important; + color: colors.color(active) !important; +} + +/* + * Light mode + * + * Mirrors the dashboard's white cards, subtle inner surfaces, + * standard borders and dark text. + */ +:host-context(.light) { + .membership-section { + color: colors.color(dark); + } + + .membership-header { + h2 { + color: colors.color(primary); + } + } + + button.membership-action-button { + border-color: colors.border-color(light); + background: #ffffff; + color: colors.color(dark); + + &:hover:not(:disabled) { + border-color: rgba(colors.color(primary), 0.45); + background: color.mix(colors.color(mitre-light-blue), #ffffff, 18%); + } + + &:disabled, + &[disabled] { + border-color: colors.border-color(light); + background: #ffffff; + color: colors.on-color-deemphasis(light); + } + } + + button.membership-action-button--primary { + border-color: rgba(colors.color(dark), 0.3); + background: rgba(colors.color(dark), 0.62); + color: colors.color(light); + + &:hover:not(:disabled) { + border-color: rgba(colors.color(dark), 0.75); + background: rgba(colors.color(dark), 0.75); + } + + &:disabled, + &[disabled] { + border-color: rgba(colors.color(dark), 0.18); + background: rgba(colors.color(dark), 0.28); + color: rgba(colors.color(light), 0.9); + } + } + + .loading-state, + .empty-membership-state, + .release-track-card { + border-color: colors.border-color(light); + background: #ffffff; + } + + .loading-state { + color: colors.on-color-deemphasis(light); + } + + .draft-column, + .releases-column { + h4 { + color: colors.color(primary-dark); + } + } + + .empty-membership-state { + h3 { + color: colors.color(dark); + } + + p { + color: colors.on-color-deemphasis(light); + } + } + + .add-track-button { + border-color: colors.color(primary); + background: colors.color(primary); + color: colors.on-color(primary); + } + + .release-track-header { + border-bottom-color: colors.border-color(light); + } + + .release-track-heading { + h3 { + color: colors.color(dark); + } + + p { + color: colors.on-color-deemphasis(light); + } + } + + .view-track-button { + border-color: rgba(colors.color(primary), 0.45); + color: colors.color(primary); + + &:hover:not(:disabled) { + border-color: colors.color(primary); + background: color.mix(colors.color(mitre-light-blue), #ffffff, 20%); + } + + &:disabled { + border-color: colors.border-color(light); + background: transparent; + color: colors.on-color-deemphasis(light); + opacity: 0.65; + } + } + + .no-current-draft, + .no-production-releases, + .release-information small { + color: colors.on-color-deemphasis(light); + } + + .draft-card, + .production-release { + border-color: colors.border-color(light); + background: rgba(colors.color(mitre-black), 0.025); + } + + .production-release { + &:hover { + border-color: rgba(colors.color(primary), 0.3); + background: color.mix(colors.color(mitre-light-blue), #ffffff, 14%); + } + } + + .status-step { + border-color: colors.border-color(light); + background: #ffffff; + color: colors.on-color-deemphasis(light); + } +} + +/* + * Dark mode + */ +:host-context(.dark) { + .membership-section { + color: colors.color(light); + } + + .membership-header { + h2 { + color: colors.color(mitre-light-blue); + } + } + + button.membership-action-button { + border-color: colors.border-color(dark); + background: transparent; + color: colors.color(light); + + &:hover:not(:disabled) { + border-color: rgba(colors.color(mitre-silver), 0.5); + background: rgba(colors.color(mitre-silver), 0.08); + } + + &:disabled, + &[disabled] { + border-color: colors.border-color(dark); + background: transparent; + color: colors.on-color-deemphasis(dark); + } + } + + button.membership-action-button--primary { + border-color: rgba(colors.color(mitre-silver), 0.28); + background: rgba(colors.color(mitre-silver), 0.2); + color: colors.color(mitre-silver); + + &:hover:not(:disabled) { + background: rgba(colors.color(mitre-silver), 0.28); + } + + &:disabled, + &[disabled] { + border-color: rgba(colors.color(mitre-silver), 0.2); + background: rgba(colors.color(mitre-silver), 0.1); + color: rgba(colors.color(mitre-silver), 0.45); + } + } + + .loading-state, + .empty-membership-state, + .release-track-card { + border-color: colors.border-color(dark); + background: color.mix(colors.color(dark), #ffffff, 94%); + } + + .loading-state { + color: colors.on-color-deemphasis(dark); + } + + .empty-membership-state { + h3 { + color: colors.color(light); + } + + p { + color: colors.on-color-deemphasis(dark); + } + } + + .add-track-button { + border-color: $membership-accent; + background: transparent; + color: $membership-accent; + } + + .release-track-header { + border-bottom-color: colors.border-color(dark); + } + + .release-track-heading { + h3 { + color: colors.color(light); + } + + p { + color: colors.on-color-deemphasis(dark); + } + } + + .view-track-button { + border-color: rgba($membership-accent, 0.45); + color: $membership-accent; + + &:hover:not(:disabled) { + border-color: $membership-accent; + background: rgba($membership-accent, 0.08); + } + + &:disabled { + border-color: colors.border-color(dark); + background: transparent; + color: colors.on-color-deemphasis(dark); + opacity: 0.65; + } + } + + .no-current-draft, + .no-production-releases, + .release-information small { + color: colors.on-color-deemphasis(dark); + } + + .draft-status { + color: $membership-accent-dark; + } + + .draft-card, + .production-release { + border-color: colors.border-color(dark); + background: rgba(colors.color(mitre-silver), 0.06); + } + + .production-release { + &:hover { + border-color: rgba(colors.color(mitre-silver), 0.36); + background: rgba(colors.color(mitre-silver), 0.1); + } + } + + .status-step { + border-color: colors.border-color(dark); + background: rgba(colors.color(mitre-silver), 0.04); + color: colors.on-color-deemphasis(dark); + } + + .status-step--active { + border-color: rgba($membership-accent-dark, 0.9) !important; + background: rgba($membership-accent-dark, 0.2) !important; + color: $membership-accent-dark !important; + } + + .status-step--review-active { + border-color: rgba(colors.color(pending), 0.9) !important; + background: rgba(colors.color(pending), 0.2) !important; + color: colors.color(pending) !important; + } + + .status-step--staged-active { + border-color: rgba(colors.color(active), 0.9) !important; + background: rgba(colors.color(active), 0.2) !important; + color: colors.color(active) !important; + } +} + +@media (max-width: 900px) { + .release-track-content { + grid-template-columns: 1fr; + } +} + +@media (max-width: 650px) { + .membership-section { + padding-top: 24px; + } + + .membership-header, + .release-track-header { + align-items: stretch; + flex-direction: column; + } + + .membership-header-actions, + .release-track-actions { + flex-wrap: wrap; + } + + .membership-header-actions { + margin-left: 0; + } + + .membership-action-button { + flex: 1; + } +} diff --git a/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.spec.ts b/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.spec.ts new file mode 100644 index 000000000..5d853583e --- /dev/null +++ b/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.spec.ts @@ -0,0 +1,323 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { Router } from '@angular/router'; +import { MatDialog } from '@angular/material/dialog'; +import { vi } from 'vitest'; + +import { AuthenticationService } from 'src/app/services/connectors/authentication/authentication.service'; +import { MembershipSectionComponent } from './membership-section.component'; +import { WorkbenchChipComponent } from 'src/app/components/workbench-chip/workbench-chip.component'; +import { + MembershipSectionDataService, + MembershipTrack, +} from 'src/app/services/connectors/rest-api/membership-section-data.service'; +import { ReleaseTracksConnectorService } from 'src/app/services/connectors/rest-api/release-tracks.service'; + +describe('MembershipSectionComponent', () => { + let component: MembershipSectionComponent; + let fixture: ComponentFixture; + let membershipDataService: any; + let authenticationService: any; + let releaseTracksConnector: any; + let dialog: any; + let router: any; + + const membershipTracks: MembershipTrack[] = [ + { + id: 'release-track--standard', + name: 'Core Release Track', + description: 'Standard release track', + type: 'standard', + current_draft: null, + production_releases: [], + releases: [], + versions: [], + }, + { + id: 'release-track--virtual', + name: 'Enterprise Release Track', + description: 'Virtual release track', + type: 'virtual', + current_draft: null, + production_releases: [], + releases: [], + versions: [], + }, + ]; + + beforeEach(async () => { + membershipDataService = { + loadMemberships: vi.fn(), + }; + + membershipDataService.loadMemberships.mockReturnValue( + of(membershipTracks.map(track => ({ ...track }))) + ); + + authenticationService = { + isAuthorized: vi.fn().mockReturnValue(true), + }; + + router = { navigate: vi.fn() }; + dialog = { open: vi.fn() }; + releaseTracksConnector = { + listReleaseTracks: vi.fn(), + addCandidates: vi.fn(), + }; + + await TestBed.configureTestingModule({ + declarations: [MembershipSectionComponent], + imports: [WorkbenchChipComponent], + providers: [ + { + provide: MembershipSectionDataService, + useValue: membershipDataService, + }, + { + provide: AuthenticationService, + useValue: authenticationService, + }, + { + provide: ReleaseTracksConnectorService, + useValue: releaseTracksConnector, + }, + { provide: MatDialog, useValue: dialog }, + { provide: Router, useValue: router }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(MembershipSectionComponent); + + component = fixture.componentInstance; + + component.config = { + mode: 'view', + object: { + stixID: 'attack-pattern--063b5b92-5361-481a-9c3f-95492ed9a2d8', + name: 'Service Stop', + type: 'attack-pattern', + }, + }; + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should expose the object reference', () => { + expect(component.objectRef).toBe( + 'attack-pattern--063b5b92-5361-481a-9c3f-95492ed9a2d8' + ); + }); + + it('should load memberships through the data service', () => { + expect(membershipDataService.loadMemberships).toHaveBeenCalledWith( + 'attack-pattern--063b5b92-5361-481a-9c3f-95492ed9a2d8', + component.membershipObject, + component.config + ); + }); + + it('should display the object release-track memberships', () => { + expect(component.memberships.length).toBe(2); + expect(component.memberships[0].name).toBe('Core Release Track'); + expect(component.memberships[0].type).toBe('standard'); + expect(component.memberships[1].name).toBe('Enterprise Release Track'); + expect(component.memberships[1].type).toBe('virtual'); + }); + + it('should mark a virtual release track as virtual', () => { + const enterpriseTrack = component.memberships[1]; + + expect(component.getTrackType(enterpriseTrack)).toBe('VIRTUAL'); + + expect(component.isVirtualTrack(enterpriseTrack)).toBe(true); + }); + + it('should label a work-in-progress draft with a placeholder date', () => { + const membership = { + ...component.memberships[0], + current_draft: { + status: 'work-in-progress', + in_current_draft: true, + }, + }; + + expect(component.getDraftStatusLabel(membership)).toBe('Work in Progress'); + expect(component.getDraftDateLabel(membership)).toBe('As of TBD'); + }); + + it('should identify a draft that is awaiting review', () => { + const membership = { + ...component.memberships[0], + current_draft: { + status: 'awaiting-review', + in_current_draft: true, + }, + }; + + expect(component.getDraftStatusLabel(membership)).toBe('In Review'); + expect(component.isDraftInReview(membership)).toBe(true); + }); + + it('should identify a staged draft', () => { + const membership = { + ...component.memberships[0], + tier: 'staged', + status: 'reviewed', + current_draft: { + tier: 'staged', + status: 'reviewed', + in_current_draft: true, + }, + }; + + expect(component.getDraftStatusLabel(membership)).toBe('Staged'); + expect(component.isDraftStaged(membership)).toBe(true); + expect(component.isDraftInReview(membership)).toBe(false); + }); + + it('should render Clear and Compare buttons', () => { + const element: HTMLElement = fixture.nativeElement; + + expect(element.textContent).toContain('Clear'); + expect(element.textContent).toContain('Compare (0/2)'); + }); + + it('should navigate authorized users to the release track dashboard', () => { + const track = component.memberships[0]; + + component.toggleReleaseSelection( + { id: 'release--1', version: '1.0' }, + track, + true + ); + + component.viewTrack(track); + + expect(router.navigate).toHaveBeenCalledWith([ + '/dashboard/release-management', + track.id, + ]); + }); + + it('should enable View Track only for a track with a selected release', () => { + const selectedTrack = component.memberships[0]; + const otherTrack = component.memberships[1]; + + component.toggleReleaseSelection( + { id: 'release--1', version: '1.0' }, + selectedTrack, + true + ); + + expect(component.hasSelectedReleaseForTrack(selectedTrack)).toBe(true); + expect(component.hasSelectedReleaseForTrack(otherTrack)).toBe(false); + }); + + it('should allow no more than two selected releases', () => { + const membership = component.memberships[0]; + + component.toggleReleaseSelection( + { id: 'release--1', version: '1.0' }, + membership, + true + ); + + component.toggleReleaseSelection( + { id: 'release--2', version: '2.0' }, + membership, + true + ); + + component.toggleReleaseSelection( + { id: 'release--3', version: '3.0' }, + membership, + true + ); + + expect(component.selectedReleaseCount).toBe(2); + }); + + it('should clear selected releases', () => { + const membership = component.memberships[0]; + + component.toggleReleaseSelection( + { id: 'release--1', version: '1.0' }, + membership, + true + ); + + component.clearSelection(); + + expect(component.selectedReleaseCount).toBe(0); + }); + + it('should disable View Track after clearing the selected release', () => { + const membership = component.memberships[0]; + + component.toggleReleaseSelection( + { id: 'release--1', version: '1.0' }, + membership, + true + ); + fixture.detectChanges(); + + const element: HTMLElement = fixture.nativeElement; + const viewTrackButton = + element.querySelector('.view-track-button'); + const clearButton = element.querySelector( + '.membership-action-button' + ); + + expect(viewTrackButton?.disabled).toBe(false); + + clearButton?.click(); + fixture.detectChanges(); + + expect(component.selectedReleaseCount).toBe(0); + expect(viewTrackButton?.disabled).toBe(true); + }); + + it('should show an empty state when the object has no memberships', () => { + component.memberships = []; + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain( + 'This object is not in any release tracks yet.' + ); + expect(fixture.nativeElement.textContent).toContain('Add to Release Track'); + }); + + it('should add the object to the selected standard release track', () => { + component.memberships = []; + releaseTracksConnector.listReleaseTracks.mockReturnValue( + of({ + data: [ + { + id: 'release-track--available', + name: 'Available Track', + type: 'standard', + }, + ], + }) + ); + releaseTracksConnector.addCandidates.mockReturnValue(of({})); + dialog.open.mockReturnValue({ + afterClosed: () => of('release-track--available'), + }); + const reload = vi.spyOn(component, 'loadMembershipData'); + + component.addToReleaseTrack(); + + expect(releaseTracksConnector.addCandidates).toHaveBeenCalledWith( + 'release-track--available', + ['attack-pattern--063b5b92-5361-481a-9c3f-95492ed9a2d8'] + ); + expect(reload).toHaveBeenCalled(); + }); +}); diff --git a/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.ts b/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.ts new file mode 100644 index 000000000..a96ad69f6 --- /dev/null +++ b/src/app/components/stix/stix-page-tabs/membership-section/membership-section.component.ts @@ -0,0 +1,712 @@ +import { + ChangeDetectorRef, + Component, + EventEmitter, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, + SimpleChanges, +} from '@angular/core'; +import { Subject } from 'rxjs'; +import { finalize, takeUntil } from 'rxjs/operators'; +import { Router } from '@angular/router'; +import { MatDialog } from '@angular/material/dialog'; + +import { Role } from 'src/app/classes/authn/role'; +import { ReleaseTrackType } from 'src/app/classes/release-tracks/enums'; +import { MultipleChoiceDialogComponent } from 'src/app/components/multiple-choice-dialog/multiple-choice-dialog.component'; +import { AuthenticationService } from 'src/app/services/connectors/authentication/authentication.service'; +import { + MembershipSectionDataService, + MembershipTrack, +} from 'src/app/services/connectors/rest-api/membership-section-data.service'; +import { ReleaseTracksConnectorService } from 'src/app/services/connectors/rest-api/release-tracks.service'; + +interface SelectedRelease { + key: string; + release: any; + membership: MembershipTrack; +} + +@Component({ + selector: 'app-membership-section', + standalone: false, + templateUrl: './membership-section.component.html', + styleUrls: ['./membership-section.component.scss'], +}) +export class MembershipSectionComponent + implements OnInit, OnChanges, OnDestroy +{ + @Input() config: any; + @Input() object: any; + + @Output() releasesCompared = new EventEmitter(); + + memberships: MembershipTrack[] = []; + + loading = false; + addingToTrack = false; + loadError: string | null = null; + addError: string | null = null; + private loadedObjectRef: string | null = null; + + private selectedReleases = new Map(); + + private readonly destroy$ = new Subject(); + + constructor( + private readonly membershipData: MembershipSectionDataService, + private readonly releaseTracksConnector: ReleaseTracksConnectorService, + private readonly authenticationService: AuthenticationService, + private readonly dialog: MatDialog, + private readonly router: Router, + private readonly changeDetectorRef: ChangeDetectorRef + ) {} + + ngOnInit(): void { + this.loadMembershipData(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (!changes.config && !changes.object) { + return; + } + + const currentObjectRef = this.objectRef; + + if (currentObjectRef && currentObjectRef !== this.loadedObjectRef) { + this.loadMembershipData(); + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + get membershipObject(): any { + return this.object ?? this.config?.object ?? null; + } + + get objectRef(): string | null { + return ( + this.membershipObject?.stixID ?? + this.membershipObject?.id ?? + this.membershipObject?.stix?.id ?? + null + ); + } + + get selectedReleaseCount(): number { + return this.selectedReleases.size; + } + + get hasSelection(): boolean { + return this.selectedReleaseCount > 0; + } + + get canViewReleaseTrackDashboard(): boolean { + return this.authenticationService.isAuthorized([ + Role.TEAM_LEAD, + Role.ADMIN, + ]); + } + + loadMembershipData(): void { + const objectRef = this.objectRef; + + if (!objectRef) { + this.memberships = []; + + this.loadError = + 'Unable to load release-track membership because the object ID is missing.'; + + return; + } + + if (this.loading && this.loadedObjectRef === objectRef) { + return; + } + + this.loadedObjectRef = objectRef; + this.loading = true; + this.loadError = null; + this.addError = null; + this.selectedReleases = new Map(); + + this.membershipData + .loadMemberships(objectRef, this.membershipObject, this.config) + .pipe( + takeUntil(this.destroy$), + finalize(() => { + this.loading = false; + }) + ) + .subscribe({ + next: tracks => { + this.memberships = tracks; + }, + error: error => { + console.error('Unable to load release-track membership', error); + + this.memberships = []; + + this.loadError = 'Release-track membership could not be loaded.'; + }, + }); + } + + addToReleaseTrack(): void { + const objectRef = this.objectRef; + + if (!objectRef || !this.canViewReleaseTrackDashboard) { + return; + } + + this.addError = null; + this.releaseTracksConnector + .listReleaseTracks({ type: ReleaseTrackType.Standard }) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: response => { + const membershipIds = new Set( + this.memberships + .map(track => this.getTrackId(track)) + .filter((id): id is string => !!id) + ); + const tracks = this.normalizeTrackList(response).filter( + track => + String(track?.type ?? track?.track_type).toLowerCase() === + ReleaseTrackType.Standard && + !membershipIds.has(String(track?.track_id ?? track?.id)) + ); + + if (!tracks.length) { + this.addError = 'No available standard release tracks found.'; + return; + } + + this.dialog + .open(MultipleChoiceDialogComponent, { + maxWidth: '35em', + data: { + title: 'Add to a release track', + description: + 'Choose the standard release track to add this object to.', + choices: tracks.map(track => ({ + label: track.name ?? track.title ?? 'Release Track', + value: String(track.track_id ?? track.id), + description: track.description, + })), + }, + }) + .afterClosed() + .pipe(takeUntil(this.destroy$)) + .subscribe(trackId => { + if (trackId) { + this.enrollInTrack(String(trackId), objectRef); + } + }); + }, + error: error => { + console.error('Unable to load available release tracks', error); + this.addError = 'Available release tracks could not be loaded.'; + }, + }); + } + + private enrollInTrack(trackId: string, objectRef: string): void { + this.addingToTrack = true; + this.addError = null; + + this.releaseTracksConnector + .addCandidates(trackId, [objectRef]) + .pipe( + takeUntil(this.destroy$), + finalize(() => { + this.addingToTrack = false; + }) + ) + .subscribe({ + next: () => this.loadMembershipData(), + error: error => { + console.error('Unable to add object to release track', error); + this.addError = 'The object could not be added to the release track.'; + }, + }); + } + + private normalizeTrackList(response: any): any[] { + if (Array.isArray(response)) { + return response; + } + + const tracks = + response?.data ?? + response?.release_tracks ?? + response?.releaseTracks ?? + response?.items ?? + response?.results ?? + []; + + return Array.isArray(tracks) ? tracks : []; + } + + getTrackName(membership: MembershipTrack): string { + return ( + membership?.name ?? + membership?.title ?? + membership?.track?.name ?? + membership?.release_track?.name ?? + this.formatIdentifier(membership?.id) ?? + 'Release Track' + ); + } + + getTrackDescription(membership: MembershipTrack): string { + return ( + membership?.description ?? + membership?.track?.description ?? + membership?.release_track?.description ?? + 'Release track containing this ATT&CK object.' + ); + } + + getTrackType(membership: MembershipTrack): string { + const type = + membership?.type ?? + membership?.track_type ?? + membership?.track?.type ?? + membership?.release_track?.type ?? + 'STANDARD'; + + return String(type).replace(/[_-]+/g, ' ').toUpperCase(); + } + + isVirtualTrack(membership: MembershipTrack): boolean { + return String( + membership?.type ?? + membership?.track_type ?? + membership?.track?.type ?? + membership?.release_track?.type ?? + '' + ) + .trim() + .toLowerCase() + .includes('virtual'); + } + + viewTrack(membership: MembershipTrack, event?: Event): void { + event?.stopPropagation(); + + if ( + !this.canViewReleaseTrackDashboard || + !this.hasSelectedReleaseForTrack(membership) + ) { + return; + } + + const trackId = this.getTrackId(membership); + + this.router.navigate(['/dashboard/release-management', trackId]); + } + + getCurrentDraft(membership: MembershipTrack): any { + return ( + membership?.current_draft ?? + membership?.currentDraft ?? + membership?.draft ?? + null + ); + } + + hasCurrentDraft(membership: MembershipTrack): boolean { + const draft = this.getCurrentDraft(membership); + + if (!draft) { + return false; + } + + return draft?.in_current_draft !== false && draft?.inCurrentDraft !== false; + } + + getDraftStatusLabel(membership: MembershipTrack): string { + const draft = this.getCurrentDraft(membership); + + const status = + draft?.status ?? + draft?.state ?? + draft?.tier ?? + membership?.status ?? + membership?.tier ?? + 'work-in-progress'; + + if (this.isDraftStaged(membership)) { + return 'Staged'; + } + + if ( + ['work-in-progress', 'wip', 'candidate', 'candidates', 'draft'].includes( + this.normalizeStatus(status) + ) + ) { + return 'Work in Progress'; + } + + if (this.isDraftInReview(membership)) { + return 'In Review'; + } + + return this.formatStatus(status); + } + + getDraftDateLabel(membership: MembershipTrack): string { + const draft = this.getCurrentDraft(membership); + + const date = + draft?.updated_at ?? + draft?.updatedAt ?? + draft?.modified ?? + draft?.date ?? + draft?.created; + + return date ? `As of ${this.formatDate(date, true)}` : 'As of TBD'; + } + + isDraftInReview(membership: MembershipTrack): boolean { + const draft = this.getCurrentDraft(membership); + + const status = this.normalizeStatus( + draft?.status ?? draft?.state ?? membership?.status + ); + + return ( + !this.isDraftStaged(membership) && + ['awaiting-review', 'review', 'reviewed'].includes(status) + ); + } + + isDraftStaged(membership: MembershipTrack): boolean { + const draft = this.getCurrentDraft(membership); + + return [ + draft?.tier, + draft?.state, + draft?.status, + membership?.tier, + membership?.status, + ].some(value => this.normalizeStatus(value) === 'staged'); + } + + isDraftStepActive(membership: MembershipTrack, step: string): boolean { + const draft = this.getCurrentDraft(membership); + + const status = this.normalizeStatus( + draft?.status ?? + draft?.state ?? + draft?.tier ?? + membership?.status ?? + membership?.tier + ); + + const normalizedStep = this.normalizeStatus(step); + + if (normalizedStep === 'work-in-progress') { + return [ + 'work-in-progress', + 'wip', + 'candidate', + 'candidates', + 'draft', + ].includes(status); + } + + if (normalizedStep === 'review') { + return this.isDraftInReview(membership); + } + + if (normalizedStep === 'staged') { + return this.isDraftStaged(membership); + } + + return status === normalizedStep; + } + + getProductionReleases(membership: MembershipTrack): any[] { + const releases = + membership?.production_releases ?? + membership?.productionReleases ?? + membership?.releases ?? + []; + + if (!Array.isArray(releases)) { + return []; + } + + return [...releases].sort( + (first, second) => + this.getReleaseTimestamp(second) - this.getReleaseTimestamp(first) + ); + } + + getReleaseVersion(release: any): string { + const version = + this.normalizeVersionValue(release?.version) ?? + release?.name ?? + release?.title ?? + release?.release_version ?? + release?.releaseVersion ?? + release?.tag; + + if (!version) { + return 'Release'; + } + + const value = String(version); + + return value.toLowerCase().startsWith('v') ? value : `v${value}`; + } + + getReleaseDateLabel(release: any): string { + const date = + release?.released_at ?? + release?.releasedAt ?? + release?.release_date ?? + release?.releaseDate ?? + release?.tagged_at ?? + release?.taggedAt ?? + release?.modified ?? + release?.created_at ?? + release?.createdAt ?? + release?.created ?? + release?.date; + + return date ? `Released on ${this.formatDate(date)}` : 'Production release'; + } + + toggleReleaseSelection( + release: any, + membership: MembershipTrack, + checked: boolean + ): void { + if (!release) { + return; + } + + const key = this.getReleaseSelectionKey(release, membership); + + if (!checked) { + const nextSelection = new Map(this.selectedReleases); + nextSelection.delete(key); + this.selectedReleases = nextSelection; + return; + } + + if (this.selectedReleases.size >= 2) { + return; + } + + this.selectedReleases = new Map(this.selectedReleases).set(key, { + key, + release, + membership, + }); + } + + isReleaseSelected(release: any, membership: MembershipTrack): boolean { + if (!release) { + return false; + } + + return this.selectedReleases.has( + this.getReleaseSelectionKey(release, membership) + ); + } + + hasSelectedReleaseForTrack(membership: MembershipTrack): boolean { + const trackId = this.getTrackId(membership); + + return [...this.selectedReleases.values()].some( + selection => this.getTrackId(selection.membership) === trackId + ); + } + + clearSelection(): void { + this.selectedReleases = new Map(); + this.changeDetectorRef.markForCheck(); + } + + compareSelectedReleases(): void { + if (this.selectedReleaseCount !== 2) { + return; + } + + this.releasesCompared.emit([...this.selectedReleases.values()]); + } + + trackByMembership( + index: number, + membership: MembershipTrack + ): string | number { + return ( + membership?.id ?? + membership?.apiId ?? + membership?.track_id ?? + membership?.name ?? + index + ); + } + + trackByRelease(index: number, release: any): string | number { + const version = + release?.version?._version ?? + release?.version ?? + release?.release_version; + + return ( + release?.id ?? + release?.snapshot_id ?? + release?.modified ?? + release?.tag ?? + release?.name ?? + (Array.isArray(version) ? version.join('.') : version) ?? + index + ); + } + + private getTrackId(membership: MembershipTrack): string { + return String( + membership?.id ?? + membership?.apiId ?? + membership?.track_id ?? + membership?.track?.id ?? + membership?.release_track?.id ?? + membership?.name ?? + '' + ); + } + + private getReleaseSelectionKey( + release: any, + membership: MembershipTrack + ): string { + const membershipId = + membership?.apiId ?? + membership?.track_id ?? + membership?.id ?? + membership?.name ?? + 'release-track'; + + const releaseId = + release?.id ?? + release?.snapshot_id ?? + release?.modified ?? + this.normalizeVersionValue(release?.version) ?? + release?.tag ?? + release?.name ?? + release?.status ?? + JSON.stringify(release); + + return `${membershipId}:${releaseId}`; + } + + private getReleaseTimestamp(release: any): number { + const date = + release?.released_at ?? + release?.releasedAt ?? + release?.release_date ?? + release?.releaseDate ?? + release?.modified ?? + release?.created_at ?? + release?.createdAt ?? + release?.created ?? + release?.date; + + if (!date) { + return 0; + } + + const timestamp = new Date(date).getTime(); + + return Number.isNaN(timestamp) ? 0 : timestamp; + } + + private normalizeVersionValue(version: any): string | null { + if (version === null || version === undefined) { + return null; + } + + if (Array.isArray(version)) { + return version.join('.'); + } + + if (typeof version === 'object') { + const nestedVersion = + version._version ?? version.version ?? version.value; + + if (Array.isArray(nestedVersion)) { + return nestedVersion.join('.'); + } + + return nestedVersion === null || nestedVersion === undefined + ? null + : String(nestedVersion); + } + + return String(version); + } + + private normalizeStatus(status: unknown): string { + return String(status ?? '') + .trim() + .toLowerCase() + .replace(/[\s_]+/g, '-'); + } + + private formatStatus(status: unknown): string { + if (!status) { + return 'Work In Progress'; + } + + return String(status) + .replace(/[_-]+/g, ' ') + .replace(/\b\w/g, character => character.toUpperCase()); + } + + private formatDate(value: string | Date, includeTime = false): string { + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return String(value); + } + + const options: Intl.DateTimeFormatOptions = includeTime + ? { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + } + : { + year: 'numeric', + month: 'short', + day: 'numeric', + }; + + return new Intl.DateTimeFormat('en-US', options).format(date); + } + + private formatIdentifier(identifier: unknown): string | null { + if (!identifier) { + return null; + } + + const value = String(identifier); + const identifierName = value.includes('--') ? value.split('--')[0] : value; + + return this.formatStatus(identifierName); + } +} diff --git a/src/app/components/resources-drawer/notes-editor/notes-editor.component.html b/src/app/components/stix/stix-page-tabs/notes-editor/notes-editor.component.html similarity index 100% rename from src/app/components/resources-drawer/notes-editor/notes-editor.component.html rename to src/app/components/stix/stix-page-tabs/notes-editor/notes-editor.component.html diff --git a/src/app/components/resources-drawer/notes-editor/notes-editor.component.scss b/src/app/components/stix/stix-page-tabs/notes-editor/notes-editor.component.scss similarity index 97% rename from src/app/components/resources-drawer/notes-editor/notes-editor.component.scss rename to src/app/components/stix/stix-page-tabs/notes-editor/notes-editor.component.scss index 16ae5efe2..6ea7ccac9 100644 --- a/src/app/components/resources-drawer/notes-editor/notes-editor.component.scss +++ b/src/app/components/stix/stix-page-tabs/notes-editor/notes-editor.component.scss @@ -1,5 +1,5 @@ -@use '../../../../style/globals'; -@use '../../../../style/colors'; +@use '../../../../../style/globals'; +@use '../../../../../style/colors'; .notes-editor { .notes-toolbar { diff --git a/src/app/components/resources-drawer/notes-editor/notes-editor.component.spec.ts b/src/app/components/stix/stix-page-tabs/notes-editor/notes-editor.component.spec.ts similarity index 61% rename from src/app/components/resources-drawer/notes-editor/notes-editor.component.spec.ts rename to src/app/components/stix/stix-page-tabs/notes-editor/notes-editor.component.spec.ts index d74217131..4db95f7d6 100644 --- a/src/app/components/resources-drawer/notes-editor/notes-editor.component.spec.ts +++ b/src/app/components/stix/stix-page-tabs/notes-editor/notes-editor.component.spec.ts @@ -14,11 +14,18 @@ import { describe('NotesEditorComponent', () => { let component: NotesEditorComponent; let fixture: ComponentFixture; + let mockRestApiConnector: any; + let mockRouter: any; beforeEach(async () => { - const mockRestApiConnector = createMockRestApiConnector({ - getAllNotes: () => createAsyncObservable(createPaginatedResponse()), + mockRestApiConnector = createMockRestApiConnector({ + getAllNotes: vi.fn(() => + createAsyncObservable(createPaginatedResponse()) + ), }); + mockRouter = { + url: '/test/mock-id?param=value', + }; await TestBed.configureTestingModule({ declarations: [NotesEditorComponent], @@ -26,9 +33,7 @@ describe('NotesEditorComponent', () => { provideHttpClient(), { provide: Router, - useValue: { - url: '/test/mock-id?param=value', - }, + useValue: mockRouter, }, { provide: RestApiConnectorService, useValue: mockRestApiConnector }, ], @@ -45,4 +50,16 @@ describe('NotesEditorComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should not load notes when there is no object STIX ID in the URL', () => { + mockRouter.url = '/dashboard/release-management'; + mockRestApiConnector.getAllNotes.mockClear(); + + const dashboardFixture = TestBed.createComponent(NotesEditorComponent); + const dashboardComponent = dashboardFixture.componentInstance; + dashboardFixture.detectChanges(); + + expect(dashboardComponent.objectStixID).toBe(''); + expect(mockRestApiConnector.getAllNotes).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/components/resources-drawer/notes-editor/notes-editor.component.ts b/src/app/components/stix/stix-page-tabs/notes-editor/notes-editor.component.ts similarity index 91% rename from src/app/components/resources-drawer/notes-editor/notes-editor.component.ts rename to src/app/components/stix/stix-page-tabs/notes-editor/notes-editor.component.ts index 2d528aadd..fa231d831 100644 --- a/src/app/components/resources-drawer/notes-editor/notes-editor.component.ts +++ b/src/app/components/stix/stix-page-tabs/notes-editor/notes-editor.component.ts @@ -18,8 +18,8 @@ import { tap, } from 'rxjs/operators'; import { Note } from 'src/app/classes/stix/note'; +import { ConfirmationDialogComponent } from 'src/app/components/confirmation-dialog/confirmation-dialog.component'; import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; -import { ConfirmationDialogComponent } from '../../confirmation-dialog/confirmation-dialog.component'; @Component({ selector: 'app-notes-editor', @@ -44,7 +44,7 @@ export class NotesEditorComponent implements OnInit, AfterViewInit { ) {} ngOnInit(): void { - this.objectStixID = this.router.url.split('/')[2].split('?')[0]; + this.objectStixID = this.getObjectStixID(); this.selected = new FormControl('date-descending'); this.parseNotes(); } @@ -66,6 +66,12 @@ export class NotesEditorComponent implements OnInit, AfterViewInit { /** Retrieve objects from backend */ private parseNotes(): void { this.loading = true; + if (!this.objectStixID) { + this.notes = []; + this.loading = false; + return; + } + const query = this.search ? this.search.nativeElement.value.toLowerCase() : ''; @@ -103,6 +109,13 @@ export class NotesEditorComponent implements OnInit, AfterViewInit { }); } + private getObjectStixID(): string { + const [path] = this.router.url.split('?'); + const segments = path.split('/').filter(Boolean); + if (segments[0] === 'dashboard') return ''; + return segments[1] || ''; + } + /** Limit editing to one note at a time */ public startEditing(note: Note): void { if (!this.isEditing() || note.editing) note.editing = true; diff --git a/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.html b/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.html new file mode 100644 index 000000000..5b74be55b --- /dev/null +++ b/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.html @@ -0,0 +1,41 @@ + + + + DETAILS + + +
+ +
+
+
+ + + + + {{ tab.label | uppercase }} + + + + + + + + + + MEMBERSHIP + + + + + + + + + HISTORY + + + + + +
diff --git a/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.scss b/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.scss new file mode 100644 index 000000000..ef7ee4e08 --- /dev/null +++ b/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.scss @@ -0,0 +1,24 @@ +@use '../../../../style/colors.scss'; +@use '../../../../style/globals.scss'; + +.stix-page-tabs { + .mat-mdc-tab-header { + .dark & { + border-bottom: 1px solid colors.border-color(dark) !important; + } + .light & { + border-bottom: 1px solid colors.border-color(light) !important; + } + } + .nav-tab-label { + font-family: Roboto, Arial, sans-serif; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.4px; + display: inline-block; + } + + .details-tpl-wrapper { + margin-top: 20px; + } +} diff --git a/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.ts b/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.ts new file mode 100644 index 000000000..fc4f67dfb --- /dev/null +++ b/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.ts @@ -0,0 +1,24 @@ +import { ViewEncapsulation } from '@angular/core'; +import { Component, Input, TemplateRef } from '@angular/core'; +import { StixViewConfig } from 'src/app/views/stix/stix-view-page'; + +interface CustomTab { + label: string; + template: TemplateRef; +} + +@Component({ + selector: 'app-stix-page-tabs', + templateUrl: './stix-page-tabs.component.html', + styleUrls: ['./stix-page-tabs.component.scss'], + standalone: false, + encapsulation: ViewEncapsulation.None, +}) +export class StixPageTabsComponent { + @Input() config!: StixViewConfig; + @Input() detailsTemplate!: TemplateRef; + @Input() customTabs: CustomTab[] = []; + @Input() showHistory = true; + @Input() showNotes = true; + @Input() showMembership = true; +} diff --git a/src/app/components/subheading/subheading.component.html b/src/app/components/subheading/subheading.component.html index ad4665e56..209fd18d8 100644 --- a/src/app/components/subheading/subheading.component.html +++ b/src/app/components/subheading/subheading.component.html @@ -2,7 +2,7 @@ @@ -16,7 +16,6 @@ *ngIf="object.hasOwnProperty('modified')" style="border-radius: 16px" [disabled]="config.sidebarControl === 'disable'" - (click)="openHistory()" cdkDrag #popoverTrigger2="mtxPopoverTrigger" [mtxPopoverTriggerFor]="historyPopover" diff --git a/src/app/components/subheading/subheading.component.spec.ts b/src/app/components/subheading/subheading.component.spec.ts index c4532435e..f8d9d67b2 100644 --- a/src/app/components/subheading/subheading.component.spec.ts +++ b/src/app/components/subheading/subheading.component.spec.ts @@ -6,6 +6,7 @@ import { MtxPopoverModule } from '@ng-matero/extensions/popover'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { SubheadingComponent } from './subheading.component'; +import { StixObject } from 'src/app/classes/stix/stix-object'; describe('SubheadingComponent', () => { let component: SubheadingComponent; @@ -39,4 +40,28 @@ describe('SubheadingComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should hide the STIX ID property when the name header shows workflow actions', () => { + const object = Object.create(StixObject.prototype); + object.stixID = 'attack-pattern--123'; + object.attackType = 'technique'; + component.config = { + mode: 'view', + object, + } as any; + + expect(component.showStixIdProperty).toBe(false); + }); + + it('should keep the STIX ID property for collections', () => { + const object = Object.create(StixObject.prototype); + object.stixID = 'x-mitre-collection--123'; + object.attackType = 'collection'; + component.config = { + mode: 'view', + object, + } as any; + + expect(component.showStixIdProperty).toBe(true); + }); }); diff --git a/src/app/components/subheading/subheading.component.ts b/src/app/components/subheading/subheading.component.ts index f167d2509..4e6c0ac01 100644 --- a/src/app/components/subheading/subheading.component.ts +++ b/src/app/components/subheading/subheading.component.ts @@ -1,13 +1,7 @@ -import { - Component, - EventEmitter, - Input, - Output, - ViewEncapsulation, -} from '@angular/core'; -import { SidebarService } from 'src/app/services/sidebar/sidebar.service'; +import { Component, Input, ViewEncapsulation } from '@angular/core'; import { StixViewConfig } from 'src/app/views/stix/stix-view-page'; import { EditorService } from 'src/app/services/editor/editor.service'; +import { StixObject } from 'src/app/classes/stix/stix-object'; @Component({ selector: 'app-subheading', @@ -17,9 +11,7 @@ import { EditorService } from 'src/app/services/editor/editor.service'; standalone: false, }) export class SubheadingComponent { - @Input() public config: StixViewConfig; - @Output() public onOpenHistory = new EventEmitter(); - @Output() public onOpenNotes = new EventEmitter(); + @Input() public config!: StixViewConfig; public get object() { return Array.isArray(this.config.object) @@ -30,33 +22,18 @@ export class SubheadingComponent { return this.editorService.editing; } - public openHistory() { - if ( - this.config.sidebarControl == 'service' || - !this.config.hasOwnProperty('sidebarControl') - ) { - this.sidebarService.opened = true; - this.sidebarService.currentTab = 'history'; - } else if (this.config.sidebarControl == 'events') { - this.onOpenHistory.emit(); - } - } - public openNotes() { - if ( - this.config.sidebarControl == 'service' || - !this.config.hasOwnProperty('sidebarControl') - ) { - this.sidebarService.opened = true; - this.sidebarService.currentTab = 'notes'; - } else if (this.config.sidebarControl == 'events') { - this.onOpenNotes.emit(); - } + public get showStixIdProperty(): boolean { + return ( + !!this.object?.stixID && + !( + this.config.mode === 'view' && + this.object instanceof StixObject && + this.object.attackType !== 'collection' + ) + ); } - constructor( - private sidebarService: SidebarService, - private editorService: EditorService - ) { + constructor(private editorService: EditorService) { // intentionally left blank } } diff --git a/src/app/components/toolbar/toolbar.component.scss b/src/app/components/toolbar/toolbar.component.scss index a14ce557d..e395fad74 100644 --- a/src/app/components/toolbar/toolbar.component.scss +++ b/src/app/components/toolbar/toolbar.component.scss @@ -1,5 +1,8 @@ @use '../../../style/globals'; .object-toolbar { + position: relative; + z-index: 2; + mat-toolbar { @extend %toolbar; } diff --git a/src/app/components/user-avatar/user-avatar.component.html b/src/app/components/user-avatar/user-avatar.component.html new file mode 100644 index 000000000..25d5b565d --- /dev/null +++ b/src/app/components/user-avatar/user-avatar.component.html @@ -0,0 +1,21 @@ + + + + {{ initials }} + + diff --git a/src/app/components/user-avatar/user-avatar.component.scss b/src/app/components/user-avatar/user-avatar.component.scss new file mode 100644 index 000000000..3489a215d --- /dev/null +++ b/src/app/components/user-avatar/user-avatar.component.scss @@ -0,0 +1,22 @@ +:host { + display: inline-flex; + flex: 0 0 auto; + line-height: 1; + vertical-align: middle; +} + +.user-avatar { + border-radius: 50%; + display: block; + flex: 0 0 auto; + overflow: hidden; + user-select: none; +} + +.user-avatar text { + dominant-baseline: central; + font-family: Roboto, Arial, sans-serif; + font-weight: 700; + letter-spacing: 0; + text-transform: uppercase; +} diff --git a/src/app/components/user-avatar/user-avatar.component.spec.ts b/src/app/components/user-avatar/user-avatar.component.spec.ts new file mode 100644 index 000000000..5d5daad95 --- /dev/null +++ b/src/app/components/user-avatar/user-avatar.component.spec.ts @@ -0,0 +1,80 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { UserAvatarComponent } from './user-avatar.component'; + +describe('UserAvatarComponent', () => { + let component: UserAvatarComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [UserAvatarComponent], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(UserAvatarComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show first and last initials for a display name with middle names', () => { + component.name = 'Example Middle User'; + + expect(component.initials).toBe('EU'); + }); + + it('should show first and last initials for a two-word display name', () => { + component.name = 'Release Analyst'; + + expect(component.initials).toBe('RA'); + }); + + it('should show first and last initials for the modified by user display name', () => { + component.name = 'Review User'; + + expect(component.initials).toBe('RU'); + }); + + it('should show the first two letters for usernames', () => { + component.name = 'exampleuser'; + + expect(component.initials).toBe('EX'); + }); + + it('should show the first two letters for usernames that match display initials', () => { + component.name = 'releaseuser'; + + expect(component.initials).toBe('RE'); + }); + + it('should ignore prefixes and suffixes when building display initials', () => { + component.name = 'Dr. Example Middle User Jr.'; + + expect(component.initials).toBe('EU'); + }); + + it('should ignore professional suffixes when building display initials', () => { + component.name = 'Release Analyst, PhD'; + + expect(component.initials).toBe('RA'); + }); + + it('should build initials from the remaining name when only a prefix is ignored', () => { + component.name = 'Dr. Example'; + + expect(component.initials).toBe('EX'); + }); + + it('should assign the same background to the same name', () => { + component.name = 'Stable User'; + const background = component.background; + + component.name = 'Stable User'; + + expect(component.background).toBe(background); + }); +}); diff --git a/src/app/components/user-avatar/user-avatar.component.ts b/src/app/components/user-avatar/user-avatar.component.ts new file mode 100644 index 000000000..d7a8263d8 --- /dev/null +++ b/src/app/components/user-avatar/user-avatar.component.ts @@ -0,0 +1,93 @@ +import { Component, Input } from '@angular/core'; + +const USER_AVATAR_BACKGROUNDS = [ + '#3f5f7f', + '#4a7078', + '#55735f', + '#6d704d', + '#85634b', + '#8a5264', + '#765a83', + '#5b638c', + '#4f746f', + '#6f5f78', +]; + +const IGNORED = new Set(['ii', 'iii', 'iv', 'v', 'jr', 'sr', 'dr', 'phd']); + +@Component({ + selector: 'app-user-avatar', + templateUrl: './user-avatar.component.html', + styleUrls: ['./user-avatar.component.scss'], + standalone: true, +}) +export class UserAvatarComponent { + @Input() public name = 'Unknown User'; + + private defaultSize = 24; + private _size = this.defaultSize; + + @Input() + public set size(value: number | string) { + const parsed = Number(value); + this._size = + Number.isFinite(parsed) && parsed > 0 ? parsed : this.defaultSize; + } + + public get size(): number { + return this._size; + } + + public get initials(): string { + const value = `${this.name || 'Unknown User'}`.trim(); + if (!value) return '?'; + + const words = this.initialWords(value); + if (words.length > 1) { + return `${this.firstCharacters(words[0], 1)}${this.firstCharacters( + words[words.length - 1], + 1 + )}`.toLocaleUpperCase(); + } + + return this.firstCharacters(words[0], 2).toLocaleUpperCase(); + } + + public get background(): string { + const key = `${this.name || this.initials}`.trim().toLocaleLowerCase(); + return USER_AVATAR_BACKGROUNDS[ + this.hash(key) % USER_AVATAR_BACKGROUNDS.length + ]; + } + + public get fontSize(): number { + return Math.max(10, Math.round(this.size * 0.5)); + } + + public get ariaLabel(): string { + return this.name ? `${this.name} avatar` : 'User avatar'; + } + + private firstCharacters(value: string, count: number): string { + return (value.match(/[\p{L}\p{N}]/gu) || []).slice(0, count).join(''); + } + + private initialWords(value: string): string[] { + const words = value.split(/\s+/).filter(Boolean); + const filteredWords = words.filter( + word => !IGNORED.has(this.normalizeNamePart(word)) + ); + + return filteredWords.length ? filteredWords : words; + } + + private normalizeNamePart(value: string): string { + return (value.match(/[\p{L}\p{N}]/gu) || []).join('').toLocaleLowerCase(); + } + + private hash(value: string): number { + return Array.from(value).reduce((hash, character) => { + return (hash * 31 + (character.codePointAt(0) || 0)) >>> 0; + }, 0); + } +} diff --git a/src/app/components/users-list/users-list.component.spec.ts b/src/app/components/users-list/users-list.component.spec.ts index ed7c3231c..bcf7e8295 100644 --- a/src/app/components/users-list/users-list.component.spec.ts +++ b/src/app/components/users-list/users-list.component.spec.ts @@ -3,6 +3,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideHttpClient } from '@angular/common/http'; import { UsersListComponent } from './users-list.component'; +import { Role } from 'src/app/classes/authn/role'; +import { Status } from 'src/app/classes/authn/status'; import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; import { AuthenticationService } from 'src/app/services/connectors/authentication/authentication.service'; import { @@ -10,17 +12,24 @@ import { createAsyncObservable, createPaginatedResponse, } from 'src/app/testing/mocks/rest-api-connector.mock'; -import { createMockAuthenticationService } from 'src/app/testing/mocks/authentication-service.mock'; +import { + createMockAuthenticationService, + createMockUserAccount, +} from 'src/app/testing/mocks/authentication-service.mock'; +import { UserAccountEventsService } from 'src/app/services/user-account-events/user-account-events.service'; describe('UsersListComponent', () => { let component: UsersListComponent; let fixture: ComponentFixture; + let mockRestApiConnector: any; + let userAccountEvents: UserAccountEventsService; beforeEach(async () => { - const mockRestApiConnector = createMockRestApiConnector({ + mockRestApiConnector = createMockRestApiConnector({ getAllUserAccounts: () => createAsyncObservable(createPaginatedResponse()), getTeamsByUserId: () => createAsyncObservable([]), + putUserAccount: vi.fn(user => createAsyncObservable(user)), }); const mockAuthService = createMockAuthenticationService({}); @@ -38,6 +47,7 @@ describe('UsersListComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(UsersListComponent); component = fixture.componentInstance; + userAccountEvents = TestBed.inject(UserAccountEventsService); component.config = { mode: 'view', columnsToDisplay: [], @@ -52,4 +62,18 @@ describe('UsersListComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should notify listeners after user role updates complete', async () => { + const notifySpy = vi.spyOn(userAccountEvents, 'notifyUserAccountsChanged'); + const pendingUser = createMockUserAccount({ + status: Status.PENDING, + role: Role.NONE, + }).serialize(); + + component.updateUserRole(pendingUser, Role.ADMIN); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockRestApiConnector.putUserAccount).toHaveBeenCalled(); + expect(notifySpy).toHaveBeenCalled(); + }); }); diff --git a/src/app/components/users-list/users-list.component.ts b/src/app/components/users-list/users-list.component.ts index cac9512a5..b199af2b5 100644 --- a/src/app/components/users-list/users-list.component.ts +++ b/src/app/components/users-list/users-list.component.ts @@ -19,6 +19,7 @@ import { AuthenticationService } from '../../services/connectors/authentication/ import { Status } from 'src/app/classes/authn/status'; import { Team } from 'src/app/classes/authn/team'; import { SelectionModel } from '@angular/cdk/collections'; +import { UserAccountEventsService } from 'src/app/services/user-account-events/user-account-events.service'; @Component({ selector: 'app-users-list', @@ -72,7 +73,8 @@ export class UsersListComponent implements OnInit { constructor( private restAPIConnector: RestApiConnectorService, - private authenticationService: AuthenticationService + private authenticationService: AuthenticationService, + private userAccountEvents: UserAccountEventsService ) { this.filterOptions = [ { @@ -204,6 +206,7 @@ export class UsersListComponent implements OnInit { : Status.INACTIVE; const subscription = user.save(this.restAPIConnector).subscribe({ complete: () => { + this.userAccountEvents.notifyUserAccountsChanged(); this.applyControls(); // refresh list subscription.unsubscribe(); }, @@ -228,6 +231,7 @@ export class UsersListComponent implements OnInit { const subscription = user.save(this.restAPIConnector).subscribe({ complete: () => { + this.userAccountEvents.notifyUserAccountsChanged(); this.applyControls(); // refresh list subscription.unsubscribe(); }, diff --git a/src/app/components/workbench-chip/workbench-chip.component.html b/src/app/components/workbench-chip/workbench-chip.component.html new file mode 100644 index 000000000..2b8f5c126 --- /dev/null +++ b/src/app/components/workbench-chip/workbench-chip.component.html @@ -0,0 +1 @@ +{{ chipLabel }} diff --git a/src/app/components/workbench-chip/workbench-chip.component.scss b/src/app/components/workbench-chip/workbench-chip.component.scss new file mode 100644 index 000000000..ce3c5a95f --- /dev/null +++ b/src/app/components/workbench-chip/workbench-chip.component.scss @@ -0,0 +1,138 @@ +@use 'sass:color'; +@use '../../../style/colors' as colors; + +$standard-light: colors.color(primary-dark); +$standard-dark: colors.color(mitre-light-blue); +$virtual-base: #8c5bd6; +$virtual-light: #6f3db8; +$virtual-dark: #c3a0f4; +$tagged-light: colors.color(success); +$tagged-dark: color.mix(white, colors.color(success), 30%); +$draft-light: colors.on-color-deemphasis(light); +$draft-dark: colors.on-color-deemphasis(dark); +$latest-light: colors.color(primary-dark); +$latest-dark: colors.color(mitre-light-blue); +$awaiting-review: color.mix( + colors.color(warn), + colors.color(spark-yellow), + 70% +); + +:host { + display: inline-flex; +} + +.workbench-chip { + display: inline-flex; + box-sizing: border-box; + min-height: 22px; + align-items: center; + justify-content: center; + padding: 0 10px; + border: 1px solid; + border-radius: 999px; + font-size: 10px; + font-weight: 700; + line-height: 20px; + letter-spacing: 0; + text-transform: uppercase; + white-space: nowrap; +} + +.workbench-chip--standard { + border-color: rgba($standard-light, 0.48); + background: rgba($standard-light, 0.1); + color: $standard-light; +} + +.workbench-chip--virtual { + border-color: rgba($virtual-light, 0.8); + background: rgba($virtual-base, 0.14); + color: $virtual-light; +} + +.workbench-chip--tagged { + border-color: rgba($tagged-light, 0.55); + background: rgba($tagged-light, 0.14); + color: color.mix($tagged-light, colors.on-color(light), 82%); +} + +.workbench-chip--draft { + border-color: rgba($draft-light, 0.45); + background: rgba($draft-light, 0.12); + color: $draft-light; +} + +.workbench-chip--latest { + border-color: rgba($latest-light, 0.52); + background: rgba($latest-light, 0.12); + color: $latest-light; +} + +.workbench-chip--work-in-progress { + border-color: rgba($draft-light, 0.45); + background: rgba($draft-light, 0.12); + color: $draft-light; +} + +.workbench-chip--awaiting-review { + border-color: rgba($awaiting-review, 0.6); + background: rgba($awaiting-review, 0.14); + color: color.mix($awaiting-review, colors.on-color(light), 82%); +} + +.workbench-chip--reviewed { + border-color: rgba($tagged-light, 0.55); + background: rgba($tagged-light, 0.14); + color: color.mix($tagged-light, colors.on-color(light), 82%); +} + +:host-context(.dark) { + .workbench-chip--standard { + border-color: rgba($standard-dark, 0.65); + background: rgba($standard-dark, 0.12); + color: $standard-dark; + } + + .workbench-chip--virtual { + border-color: rgba($virtual-dark, 0.9); + background: rgba($virtual-base, 0.26); + color: $virtual-dark; + } + + .workbench-chip--tagged { + border-color: rgba($tagged-dark, 0.65); + background: rgba($tagged-dark, 0.12); + color: $tagged-dark; + } + + .workbench-chip--draft { + border-color: rgba($draft-dark, 0.42); + background: rgba($draft-dark, 0.1); + color: $draft-dark; + } + + .workbench-chip--latest { + border-color: rgba($latest-dark, 0.65); + background: rgba($latest-dark, 0.14); + color: $latest-dark; + } + + .workbench-chip--work-in-progress { + border-color: rgba($draft-dark, 0.42); + background: rgba($draft-dark, 0.1); + color: $draft-dark; + } + + .workbench-chip--awaiting-review { + border-color: rgba($awaiting-review, 0.68); + background: rgba($awaiting-review, 0.14); + color: $awaiting-review; + } + + .workbench-chip--reviewed { + border-color: rgba($tagged-dark, 0.65); + background: rgba($tagged-dark, 0.12); + color: $tagged-dark; + } +} diff --git a/src/app/components/workbench-chip/workbench-chip.component.spec.ts b/src/app/components/workbench-chip/workbench-chip.component.spec.ts new file mode 100644 index 000000000..62b9644f5 --- /dev/null +++ b/src/app/components/workbench-chip/workbench-chip.component.spec.ts @@ -0,0 +1,34 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { WorkbenchChipComponent } from './workbench-chip.component'; + +describe('WorkbenchChipComponent', () => { + let component: WorkbenchChipComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [WorkbenchChipComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(WorkbenchChipComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should use the default label for the selected variant', () => { + component.variant = 'virtual'; + + expect(component.chipLabel).toBe('Virtual'); + }); + + it('should allow a custom label', () => { + component.label = 'Preview'; + + expect(component.chipLabel).toBe('Preview'); + }); +}); diff --git a/src/app/components/workbench-chip/workbench-chip.component.ts b/src/app/components/workbench-chip/workbench-chip.component.ts new file mode 100644 index 000000000..9a1d47a3d --- /dev/null +++ b/src/app/components/workbench-chip/workbench-chip.component.ts @@ -0,0 +1,42 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { + WORKFLOW_STATUS_LABELS, + WorkflowStatus, + WorkflowStatusType, +} from 'src/app/utils/types'; + +export type WorkbenchChipVariant = + 'standard' | 'virtual' | 'tagged' | 'draft' | 'latest' | WorkflowStatusType; + +const WORKBENCH_CHIP_LABELS: Record = { + standard: 'Standard', + virtual: 'Virtual', + tagged: 'Tagged Release', + draft: 'Draft Release', + latest: 'Latest', + [WorkflowStatus.WorkInProgress]: + WORKFLOW_STATUS_LABELS[WorkflowStatus.WorkInProgress], + [WorkflowStatus.AwaitingReview]: + WORKFLOW_STATUS_LABELS[WorkflowStatus.AwaitingReview], + [WorkflowStatus.Reviewed]: WORKFLOW_STATUS_LABELS[WorkflowStatus.Reviewed], +}; + +@Component({ + selector: 'app-workbench-chip', + standalone: true, + templateUrl: './workbench-chip.component.html', + styleUrls: ['./workbench-chip.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class WorkbenchChipComponent { + @Input() variant: WorkbenchChipVariant = 'standard'; + @Input() label?: string; + + public get chipClass(): string { + return `workbench-chip workbench-chip--${this.variant}`; + } + + public get chipLabel(): string { + return this.label || WORKBENCH_CHIP_LABELS[this.variant]; + } +} diff --git a/src/app/components/workflow-status-dialog/workflow-status-dialog.component.html b/src/app/components/workflow-status-dialog/workflow-status-dialog.component.html new file mode 100644 index 000000000..fe3f444b3 --- /dev/null +++ b/src/app/components/workflow-status-dialog/workflow-status-dialog.component.html @@ -0,0 +1,127 @@ +
+
+
+ +
+

{{ dialogTitle }}

+

{{ config.object.stixID }}

+
+
+ + +
+ +
+ Object + {{ objectDisplayName }} +
+ +
+
+

+ verified_user + Validation +

+ + {{ validationStatusLabel }} + +
+ +
+ Validating against + {{ targetStatusLabel }} + requirements. +
+ +
+ +
+ + + +
+ +
+
+

Release Track to Update

+ + Loading tracks... + +
+ +
+
+
+ + {{ row.name }} + + {{ row.description }} + + + No description provided. + + + +
+
+
+ + +
+ + {{ + loadingTracks + ? 'Loading release tracks' + : 'No release tracks to update' + }} + + + {{ + loadingTracks + ? 'Checking standard release tracks for this object.' + : 'This object is not currently enrolled in a standard release track that needs this status change.' + }} + +
+
+
+ +
+ + +
+
diff --git a/src/app/components/workflow-status-dialog/workflow-status-dialog.component.scss b/src/app/components/workflow-status-dialog/workflow-status-dialog.component.scss new file mode 100644 index 000000000..b0042b460 --- /dev/null +++ b/src/app/components/workflow-status-dialog/workflow-status-dialog.component.scss @@ -0,0 +1,412 @@ +@use '../../../style/colors'; +@use '../../../style/typography'; + +.workflow-status-dialog-backdrop { + background: rgba(colors.color(mitre-black), 0.72); +} + +.workflow-status-dialog-panel .mat-mdc-dialog-surface { + border-radius: 8px; + overflow: hidden; +} + +.workflow-status-dialog { + width: min(92vw, 760px); + max-height: 86vh; + box-sizing: border-box; + overflow: auto; + padding: 24px; + + .workflow-status-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; + } + + .workflow-status-title-group { + display: flex; + align-items: flex-start; + min-width: 0; + gap: 12px; + + h2, + p { + margin: 0; + } + + h2 { + font-size: 22px; + font-weight: 800; + line-height: 28px; + } + + p { + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: typography.$mono-font; + font-size: 12px; + line-height: 18px; + @include colors.theme-text-deemphasis; + } + } + + .workflow-status-icon-badge { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 36px; + height: 36px; + border-radius: 8px; + color: colors.color(pending); + background: rgba(colors.color(pending), 0.12); + + .mat-icon { + width: 20px; + height: 20px; + font-size: 20px; + line-height: 20px; + } + } + + .workflow-status-close { + flex: 0 0 auto; + @include colors.theme-text-deemphasis; + + &:hover { + @include colors.theme-property( + color, + colors.on-color(dark), + colors.on-color(light) + ); + @include colors.theme-property( + background, + rgba(colors.on-color(dark), 0.08), + rgba(colors.on-color(light), 0.06) + ); + } + } + + .active-object-banner { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 16px; + border: 1px solid; + border-radius: 8px; + padding: 14px 16px; + @include colors.theme-border-color; + @include colors.theme-property( + background, + rgba(colors.on-color(dark), 0.05), + rgba(colors.on-color(light), 0.04) + ); + + span { + font-size: 12px; + font-weight: 800; + letter-spacing: 0.08em; + line-height: 16px; + text-transform: uppercase; + @include colors.theme-text-deemphasis; + } + + strong { + overflow-wrap: anywhere; + font-size: 20px; + font-weight: 800; + line-height: 26px; + } + } + + .workflow-status-section { + border: 1px solid; + border-radius: 6px; + padding: 16px; + @include colors.theme-border-color; + @include colors.theme-property( + background, + rgba(colors.color(mitre-silver), 0.025), + rgba(colors.color(mitre-black), 0.014) + ); + + & + .workflow-status-section { + margin-top: 16px; + } + } + + .validation-section { + padding: 18px 20px 8px; + min-width: 0; + } + + .workflow-status-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 12px; + } + + h3, + h4 { + margin: 0; + font-weight: 800; + } + + h3 { + display: flex; + align-items: center; + gap: 8px; + font-size: 17px; + line-height: 24px; + + .mat-icon { + width: 20px; + height: 20px; + font-size: 20px; + line-height: 20px; + } + } + + h4 { + font-size: 13px; + letter-spacing: 0.08em; + line-height: 18px; + text-transform: uppercase; + @include colors.theme-text-emphasis; + } + + .validation-status { + border-radius: 4px; + padding: 3px 8px; + font-family: typography.$mono-font; + font-size: 12px; + font-weight: 800; + line-height: 18px; + } + + @each $status, $color in (success: success, warning: warn, error: error) { + .validation-status-#{$status} { + color: colors.color($color); + background: rgba(colors.color($color), 0.14); + } + } + + .validation-context, + .track-loading { + font-size: 12px; + line-height: 18px; + @include colors.theme-text-deemphasis; + } + + .validation-context { + margin: -4px 0 8px; + + strong { + font-weight: 700; + + .dark &, + .light & { + color: colors.color(primary); + } + } + } + + .validation-results { + padding: 2px 0 0; + } + + .validation-item.mat-mdc-list-item { + min-height: 32px; + padding: 2px 0; + --mdc-list-list-item-one-line-container-height: 32px; + --mdc-list-list-item-two-line-container-height: 44px; + --mdc-list-list-item-three-line-container-height: 56px; + } + + .validation-item .mat-mdc-list-item-icon { + align-self: center; + margin-right: 8px; + } + + .validation-item .mat-mdc-list-item-line { + line-height: 20px; + } + + .validation-item .mat-icon { + padding: 2px !important; + } + + .track-card-grid { + display: grid; + gap: 10px; + width: 100%; + } + + .track-card { + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + max-width: 100%; + width: 100%; + border: 1px solid; + border-radius: 8px; + padding: 12px 14px; + text-align: left; + @include colors.theme-property( + border-color, + rgba(colors.on-color(dark), 0.08), + rgba(colors.on-color(light), 0.12) + ); + @include colors.theme-property( + color, + colors.on-color(dark), + colors.on-color(light) + ); + @include colors.theme-property( + background, + rgba(colors.on-color(dark), 0.035), + rgba(colors.on-color(light), 0.025) + ); + } + + .track-card-copy { + display: flex; + flex-direction: column; + min-width: 0; + gap: 3px; + + strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 15px; + font-weight: 800; + line-height: 20px; + } + + span { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + font-size: 12px; + line-height: 17px; + @include colors.theme-text-deemphasis; + } + } + + .track-status-chip { + flex: 0 0 auto; + } + + .track-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + border: 1px dashed; + border-radius: 8px; + padding: 28px 20px; + text-align: center; + font-size: 13px; + line-height: 19px; + @include colors.theme-property( + border-color, + rgba(colors.on-color(dark), 0.12), + rgba(colors.on-color(light), 0.14) + ); + @include colors.theme-text-deemphasis; + @include colors.theme-property( + background, + rgba(colors.on-color(dark), 0.025), + rgba(colors.on-color(light), 0.018) + ); + + strong { + font-size: 15px; + line-height: 21px; + } + + span { + max-width: 420px; + } + } + + .workflow-status-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin: 18px -24px -24px; + padding: 14px 24px; + border-top: 1px solid; + @include colors.theme-border-color; + @include colors.theme-property( + background, + rgba(colors.color(dark), 0.42), + rgba(colors.color(mitre-black), 0.02) + ); + } + + .workflow-status-footer-summary { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + font-size: 13px; + line-height: 20px; + @include colors.theme-text-deemphasis; + + .mat-icon { + width: 18px; + height: 18px; + font-size: 18px; + line-height: 18px; + } + + strong { + border-radius: 4px; + padding: 3px 6px; + font-family: typography.$mono-font; + @include colors.theme-property( + color, + colors.on-color(dark), + colors.on-color(light) + ); + @include colors.theme-property( + background, + rgba(colors.on-color(dark), 0.1), + rgba(colors.on-color(light), 0.08) + ); + } + } + + .workflow-status-footer-actions { + display: flex; + flex: 0 0 auto; + gap: 10px; + } +} + +@media (max-width: 600px) { + .workflow-status-dialog { + width: 92vw; + + .workflow-status-footer { + flex-direction: column; + align-items: stretch; + } + + .workflow-status-footer-actions { + justify-content: flex-end; + } + } +} diff --git a/src/app/components/workflow-status-dialog/workflow-status-dialog.component.spec.ts b/src/app/components/workflow-status-dialog/workflow-status-dialog.component.spec.ts new file mode 100644 index 000000000..3fa9216bf --- /dev/null +++ b/src/app/components/workflow-status-dialog/workflow-status-dialog.component.spec.ts @@ -0,0 +1,200 @@ +import { provideHttpClient } from '@angular/common/http'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { vi } from 'vitest'; + +import { WorkflowStatusDialogComponent } from './workflow-status-dialog.component'; +import { SnapshotTier } from 'src/app/classes/release-tracks'; +import { VersionNumber } from 'src/app/classes/version-number'; +import { ReleaseTracksConnectorService } from 'src/app/services/connectors/rest-api/release-tracks.service'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { + createAsyncObservable, + createMockRestApiConnector, +} from 'src/app/testing/mocks/rest-api-connector.mock'; +import { WorkflowStatus } from 'src/app/utils/types'; + +describe('WorkflowStatusDialogComponent', () => { + let component: WorkflowStatusDialogComponent; + let fixture: ComponentFixture; + let mockObject; + let mockDialogRef; + let mockReleaseTracksService; + + beforeEach(async () => { + mockObject = { + stixID: 'attack-pattern--123', + attackType: 'technique', + modified: new Date('2026-01-01T00:00:00.000Z'), + version: new VersionNumber('1.0'), + workflow: { + state: WorkflowStatus.WorkInProgress, + }, + workspace: { + release_tracks: [ + { + track_id: 'release-track--core', + name: 'Core Objects', + description: 'Core object workflow', + object_ref: 'attack-pattern--123', + object_modified: '2026-01-01T00:00:00.000Z', + object_status: WorkflowStatus.WorkInProgress, + }, + ], + }, + validate: vi.fn( + (_restApi, workflowState = WorkflowStatus.WorkInProgress) => { + mockObject.workflow = { state: workflowState }; + return createAsyncObservable({ + successes: [], + errors: [], + warnings: [], + info: [], + }); + } + ), + save: vi.fn().mockReturnValue(createAsyncObservable({})), + }; + mockDialogRef = { + close: vi.fn(), + }; + mockReleaseTracksService = { + getLatestSnapshot: vi.fn((trackId: string) => + createAsyncObservable({ + name: + trackId === 'release-track--core' + ? 'Core Objects' + : 'Groups & Campaigns', + description: + trackId === 'release-track--core' + ? 'Core object workflow' + : 'Groups workflow', + candidates: + trackId === 'release-track--core' + ? [ + { + object_ref: 'attack-pattern--123', + object_modified: '2026-01-01T00:00:00.000Z', + object_status: WorkflowStatus.WorkInProgress, + }, + ] + : [ + { + object_ref: 'attack-pattern--123', + object_modified: '2026-01-01T00:00:00.000Z', + object_status: WorkflowStatus.AwaitingReview, + }, + ], + staged: [], + }) + ), + reviewCandidates: vi.fn().mockReturnValue(createAsyncObservable({})), + demoteStaged: vi.fn().mockReturnValue(createAsyncObservable({})), + }; + + await TestBed.configureTestingModule({ + declarations: [WorkflowStatusDialogComponent], + imports: [FormsModule, NoopAnimationsModule], + providers: [ + { provide: MatDialogRef, useValue: mockDialogRef }, + { + provide: MAT_DIALOG_DATA, + useValue: { + object: mockObject, + targetStatus: WorkflowStatus.AwaitingReview, + track: { + trackId: 'release-track--core', + name: 'Core Objects', + description: 'Core object workflow', + tier: SnapshotTier.Candidate, + status: WorkflowStatus.WorkInProgress, + objectRef: { + id: 'attack-pattern--123', + modified: '2026-01-01T00:00:00.000Z', + }, + }, + }, + }, + { + provide: RestApiConnectorService, + useValue: createMockRestApiConnector(), + }, + { + provide: ReleaseTracksConnectorService, + useValue: mockReleaseTracksService, + }, + provideHttpClient(), + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(WorkflowStatusDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should validate against the selected status and load the target track', async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(component.dialogTitle).toBe('Submit for Review'); + expect(component.targetStatusLabel).toBe('Awaiting Review'); + expect(mockObject.validate).toHaveBeenCalledWith( + expect.anything(), + WorkflowStatus.AwaitingReview + ); + expect(mockReleaseTracksService.getLatestSnapshot).toHaveBeenCalledWith( + 'release-track--core', + { format: 'workbench', include: 'all' } + ); + expect(component.trackStatus).toEqual( + expect.objectContaining({ + trackId: 'release-track--core', + name: 'Core Objects', + description: 'Core object workflow', + status: WorkflowStatus.WorkInProgress, + }) + ); + }); + + it('should update the target release track without saving the object', async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + + component.onConfirm(); + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockObject.workflow).toEqual({ + state: WorkflowStatus.WorkInProgress, + }); + expect(mockObject.save).not.toHaveBeenCalled(); + expect(mockReleaseTracksService.reviewCandidates).toHaveBeenCalledWith( + 'release-track--core', + { + from: WorkflowStatus.WorkInProgress, + to: WorkflowStatus.AwaitingReview, + object_refs: [ + { + id: 'attack-pattern--123', + modified: '2026-01-01T00:00:00.000Z', + }, + ], + } + ); + expect(mockDialogRef.close).toHaveBeenCalledWith(true); + }); + + it('should restore the previous workflow status on cancel', async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + + component.onCancel(); + + expect(mockObject.workflow).toEqual({ + state: WorkflowStatus.WorkInProgress, + }); + expect(mockDialogRef.close).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/app/components/workflow-status-dialog/workflow-status-dialog.component.ts b/src/app/components/workflow-status-dialog/workflow-status-dialog.component.ts new file mode 100644 index 000000000..b156ec248 --- /dev/null +++ b/src/app/components/workflow-status-dialog/workflow-status-dialog.component.ts @@ -0,0 +1,386 @@ +import { + Component, + Inject, + OnDestroy, + OnInit, + ViewEncapsulation, +} from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Observable, of } from 'rxjs'; +import { catchError, concatMap, map } from 'rxjs/operators'; +import { ExportFormat, SnapshotTier } from 'src/app/classes/release-tracks'; +import type { + ReleaseTrackObjectTier, + StixObjectRef, +} from 'src/app/classes/release-tracks'; +import { ValidationData } from 'src/app/classes/serializable'; +import { StixObject } from 'src/app/classes/stix/stix-object'; +import { ReleaseTracksConnectorService } from 'src/app/services/connectors/rest-api/release-tracks.service'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { WORKFLOW_STATUS_LABELS, WorkflowStatus } from 'src/app/utils/types'; +import type { + ReleaseTrackStatus, + WorkflowStatusDialogData, + WorkflowStatusType, +} from 'src/app/utils/types'; +import { logger } from 'src/app/utils/logger'; + +@Component({ + selector: 'app-workflow-status-dialog', + templateUrl: './workflow-status-dialog.component.html', + styleUrls: ['./workflow-status-dialog.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false, +}) +export class WorkflowStatusDialogComponent implements OnInit, OnDestroy { + public validation: ValidationData = null; + public validating = false; + public saving = false; + public trackStatus: ReleaseTrackStatus | null = null; + public loadingTracks = false; + + private saved = false; + private previousWorkflow?: StixObject['workflow']; + + public get saveEnabled(): boolean { + return ( + !this.validating && + !this.saving && + this.validation && + this.validation.errors.length === 0 && + this.hasTrack + ); + } + + public get validationStatus(): ValidationStatus { + if (!this.validation) return 'success'; + if (this.validation.errors.length) return 'error'; + if (this.validation.warnings.length) return 'warning'; + return 'success'; + } + + public get validationStatusLabel(): string { + switch (this.validationStatus) { + case 'error': + return 'Error'; + case 'warning': + return 'Warning'; + default: + return 'Success'; + } + } + + public get targetStatusLabel(): string { + return this.getWorkflowStatusLabel(this.config.targetStatus); + } + + public get dialogTitle(): string { + switch (this.config.targetStatus) { + case WorkflowStatus.AwaitingReview: + return 'Submit for Review'; + case WorkflowStatus.Reviewed: + return 'Mark as Reviewed'; + case WorkflowStatus.WorkInProgress: + return 'Move to Work In Progress'; + } + } + + public get primaryActionLabel(): string { + return this.config.targetStatus === WorkflowStatus.AwaitingReview + ? 'Submit for Review' + : 'Save Status'; + } + + public get objectDisplayName(): string { + return ( + this.config.object?.['name'] || + this.config.object?.['stix']?.name || + 'Untitled object' + ); + } + + public get hasTrack(): boolean { + return !!this.trackStatus; + } + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public config: WorkflowStatusDialogData, + public restApiService: RestApiConnectorService, + private releaseTracksService: ReleaseTracksConnectorService + ) { + this.previousWorkflow = this.config.object.workflow + ? { ...this.config.object.workflow } + : undefined; + } + + ngOnInit(): void { + this.validateObject(); + this.loadTrack(); + } + + ngOnDestroy(): void { + if (!this.saved) this.restorePreviousWorkflow(); + } + + public onCancel(): void { + this.restorePreviousWorkflow(); + this.dialogRef.close(false); + } + + public onConfirm(): void { + if (!this.saveEnabled) return; + + this.saving = true; + this.restorePreviousWorkflow(); + const subscription = this.syncTrack().subscribe({ + next: () => { + this.saved = true; + this.restorePreviousWorkflow(); + this.dialogRef.close(true); + }, + error: err => { + logger.error(err); + this.restorePreviousWorkflow(); + this.saving = false; + }, + complete: () => subscription.unsubscribe(), + }); + } + + private validateObject(): void { + this.validation = null; + this.validating = true; + + this.config.object + .validate(this.restApiService, this.config.targetStatus) + .subscribe({ + next: result => { + this.validation = result; + this.restorePreviousWorkflow(); + }, + error: err => { + logger.error(err); + this.restorePreviousWorkflow(); + this.validating = false; + }, + complete: () => { + this.restorePreviousWorkflow(); + this.validating = false; + }, + }); + } + + private loadTrack(): void { + if (!this.config.object?.stixID) { + this.trackStatus = null; + return; + } + + this.loadingTracks = true; + const track = this.config.track; + + if (!track) { + this.trackStatus = null; + this.loadingTracks = false; + return; + } + + this.releaseTracksService + .getLatestSnapshot(track.trackId, { + format: ExportFormat.Workbench, + include: 'all', + }) + .pipe( + map(snapshot => this.toTrackStatus(track, snapshot)), + catchError(err => { + logger.error( + 'Failed to load release track snapshot for workflow status dialog', + err + ); + return of(this.toTrackStatus(track, null)); + }), + map(row => (row !== null && this.canUpdateTrack(row) ? row : null)) + ) + .subscribe({ + next: row => { + this.trackStatus = row; + this.loadingTracks = false; + }, + error: err => { + logger.error(err); + this.trackStatus = null; + this.loadingTracks = false; + }, + }); + } + + private toTrackStatus( + track: ReleaseTrackStatus, + snapshot: any + ): ReleaseTrackStatus | null { + const entry = this.getTrackedObjectEntry(snapshot); + if (!entry && !track.tier) return null; + + const tier = this.getEntryTier(entry) || track.tier || null; + const status = + this.getEntryWorkflowStatus(entry) || + track.status || + this.getFallbackWorkflowStatus(tier); + + return { + trackId: this.getTrackId(track) || '', + name: snapshot?.name || track?.name || this.getTrackId(track) || '', + description: snapshot?.description || track.description || '', + tier, + status, + objectRef: entry ? this.getObjectRef(entry) : track.objectRef, + }; + } + + private syncTrack(): Observable { + if (!this.trackStatus) return of(null); + + return this.updateTrack(this.trackStatus).pipe( + catchError(err => { + logger.error('Failed to update release track object status', err); + return of(null); + }) + ); + } + + private updateTrack(row: ReleaseTrackStatus): Observable { + if (!row.trackId) return of(null); + if (row.status === this.config.targetStatus) return of(null); + + if (row.tier === SnapshotTier.Staged) { + return this.releaseTracksService + .demoteStaged(row.trackId, [row.objectRef]) + .pipe( + concatMap(() => + this.releaseTracksService.reviewCandidates(row.trackId, { + from: row.status, + to: this.config.targetStatus, + object_refs: [row.objectRef], + }) + ) + ); + } + + return this.releaseTracksService.reviewCandidates(row.trackId, { + from: row.status, + to: this.config.targetStatus, + object_refs: [row.objectRef], + }); + } + + private getTrackedObjectEntry(snapshot: any): any | null { + if (!snapshot) return null; + return ( + this.getSnapshotWorkflowEntries(snapshot).find( + entry => this.getEntryObjectRef(entry) === this.config.object.stixID + ) || null + ); + } + + private canUpdateTrack(row: ReleaseTrackStatus): boolean { + return row.status !== this.config.targetStatus; + } + + private getSnapshotWorkflowEntries(snapshot: any): any[] { + return [ + this.withTier(snapshot?.candidates, SnapshotTier.Candidate), + this.withTier(snapshot?.staged, SnapshotTier.Staged), + this.withTier(snapshot?.contents?.candidates, SnapshotTier.Candidate), + this.withTier(snapshot?.contents?.staged, SnapshotTier.Staged), + this.withTier(snapshot?.workspace?.candidates, SnapshotTier.Candidate), + this.withTier(snapshot?.workspace?.staged, SnapshotTier.Staged), + ] + .filter(Array.isArray) + .reduce((entries, tierEntries) => entries.concat(tierEntries), []); + } + + private withTier( + entries: any, + tier: ReleaseTrackObjectTier + ): any[] | undefined { + if (!Array.isArray(entries)) return undefined; + return entries.map(entry => ({ + ...entry, + tier: this.getEntryTier(entry) || tier, + })); + } + + private getEntryTier(entry: any): ReleaseTrackObjectTier | null { + if (!entry) return null; + const tier = String(entry.tier || entry.object_tier || '').toLowerCase(); + if (tier === SnapshotTier.Staged) return SnapshotTier.Staged; + if (tier === SnapshotTier.Candidate) return SnapshotTier.Candidate; + if (entry.object_staged_at || entry.staged_at) return SnapshotTier.Staged; + if (this.getEntryWorkflowStatus(entry)) return SnapshotTier.Candidate; + return null; + } + + private getEntryWorkflowStatus(entry: any): WorkflowStatusType | null { + if (!entry) return null; + const status = entry.object_status || entry.status; + if (Object.values(WorkflowStatus).includes(status)) return status; + return null; + } + + private getFallbackWorkflowStatus( + tier: ReleaseTrackObjectTier | null + ): WorkflowStatusType { + return tier === SnapshotTier.Staged + ? WorkflowStatus.Reviewed + : WorkflowStatus.WorkInProgress; + } + + private getObjectRef(entry: any): StixObjectRef { + const modified = + this.toIsoString(entry.object_modified) || + this.toIsoString(entry.modified) || + this.config.object.modified?.toISOString(); + + return modified + ? { + id: this.getEntryObjectRef(entry) || this.config.object.stixID, + modified, + } + : this.getEntryObjectRef(entry) || this.config.object.stixID; + } + + private getEntryObjectRef(entry: any): string | null { + return entry?.object_ref || entry?.ref || entry?.id || null; + } + + private toIsoString(value: any): string | null { + if (!value) return null; + if (value instanceof Date) return value.toISOString(); + return String(value); + } + + private getTrackId(track: any): string | null { + return ( + track?.trackId || + track?.track_id || + track?.release_track_id || + track?.releaseTrackId || + (track?.id?.startsWith('release-track--') ? track.id : null) + ); + } + + private getWorkflowStatusLabel(status: WorkflowStatusType): string { + return WORKFLOW_STATUS_LABELS[status] || status; + } + + private restorePreviousWorkflow(): void { + if (this.previousWorkflow) { + this.config.object.workflow = { ...this.previousWorkflow }; + return; + } + this.config.object.workflow = undefined; + } +} + +type ValidationStatus = 'success' | 'warning' | 'error'; diff --git a/src/app/services/build-info/build-info.service.spec.ts b/src/app/services/build-info/build-info.service.spec.ts new file mode 100644 index 000000000..ccde44bdf --- /dev/null +++ b/src/app/services/build-info/build-info.service.spec.ts @@ -0,0 +1,82 @@ +import { provideHttpClient } from '@angular/common/http'; +import { + HttpTestingController, + provideHttpClientTesting, +} from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { firstValueFrom } from 'rxjs'; +import { environment } from '../../../environments/environment'; +import * as globals from '../../utils/globals'; +import { BuildInfoService } from './build-info.service'; + +describe('BuildInfoService', () => { + let service: BuildInfoService; + let httpTestingController: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(BuildInfoService); + httpTestingController = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTestingController.verify(); + }); + + it('should load frontend and REST API build information', async () => { + const frontend = { + name: 'attack-workbench-frontend', + version: '4.20.0-beta.23', + gitCommit: 'frontend-commit', + buildDate: '2026-08-05T15:13:49.915Z', + }; + const restApi = { + name: 'attack-workbench-rest-api', + version: '4.20.0-beta.22', + gitCommit: 'rest-api-commit', + buildDate: '2026-08-04T15:13:49.915Z', + attackSpecVersion: '3.3.0', + }; + + const result = firstValueFrom(service.getBuildInfo()); + + httpTestingController.expectOne('/assets/build-info.json').flush(frontend); + httpTestingController + .expectOne( + `${environment.integrations.rest_api.url}/config/system-version` + ) + .flush(restApi); + + await expect(result).resolves.toEqual({ frontend, restApi }); + }); + + it('should use safe fallbacks when build information is unavailable', async () => { + const result = firstValueFrom(service.getBuildInfo()); + + httpTestingController + .expectOne('/assets/build-info.json') + .flush('missing', { status: 404, statusText: 'Not Found' }); + httpTestingController + .expectOne( + `${environment.integrations.rest_api.url}/config/system-version` + ) + .flush('unavailable', { status: 503, statusText: 'Unavailable' }); + + await expect(result).resolves.toEqual({ + frontend: { + name: globals.appName, + version: globals.appVersion, + gitCommit: 'unknown', + buildDate: 'unknown', + }, + restApi: { + name: 'attack-workbench-rest-api', + version: 'unknown', + gitCommit: 'unknown', + buildDate: 'unknown', + }, + }); + }); +}); diff --git a/src/app/services/build-info/build-info.service.ts b/src/app/services/build-info/build-info.service.ts new file mode 100644 index 000000000..490288c89 --- /dev/null +++ b/src/app/services/build-info/build-info.service.ts @@ -0,0 +1,59 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { forkJoin, Observable, of } from 'rxjs'; +import { catchError, shareReplay } from 'rxjs/operators'; +import { environment } from '../../../environments/environment'; +import * as globals from '../../utils/globals'; + +export interface BuildInfo { + name: string; + version: string; + gitCommit: string; + buildDate: string; + attackSpecVersion?: string; +} + +export interface WorkbenchBuildInfo { + frontend: BuildInfo; + restApi: BuildInfo; +} + +@Injectable({ + providedIn: 'root', +}) +export class BuildInfoService { + private buildInfoRequest?: Observable; + + private readonly frontendFallback: BuildInfo = { + name: globals.appName, + version: globals.appVersion, + gitCommit: 'unknown', + buildDate: 'unknown', + }; + + private readonly restApiFallback: BuildInfo = { + name: 'attack-workbench-rest-api', + version: 'unknown', + gitCommit: 'unknown', + buildDate: 'unknown', + }; + + constructor(private http: HttpClient) {} + + public getBuildInfo(): Observable { + if (!this.buildInfoRequest) { + this.buildInfoRequest = forkJoin({ + frontend: this.http + .get('/assets/build-info.json') + .pipe(catchError(() => of(this.frontendFallback))), + restApi: this.http + .get( + `${environment.integrations.rest_api.url}/config/system-version` + ) + .pipe(catchError(() => of(this.restApiFallback))), + }).pipe(shareReplay({ bufferSize: 1, refCount: false })); + } + + return this.buildInfoRequest; + } +} diff --git a/src/app/services/connectors/rest-api/membership-section-data.service.spec.ts b/src/app/services/connectors/rest-api/membership-section-data.service.spec.ts new file mode 100644 index 000000000..7b8595687 --- /dev/null +++ b/src/app/services/connectors/rest-api/membership-section-data.service.spec.ts @@ -0,0 +1,262 @@ +import { HttpClient } from '@angular/common/http'; +import { firstValueFrom, of } from 'rxjs'; +import { vi } from 'vitest'; + +import { MembershipSectionDataService } from './membership-section-data.service'; +import { ReleaseTracksConnectorService } from './release-tracks.service'; + +describe('MembershipSectionDataService', () => { + const objectRef = 'attack-pattern--063b5b92-5361-481a-9c3f-95492ed9a2d8'; + const trackId = 'release-track--c3d8c25d-0dfe-4d8a-8249-1fc4016d0555'; + + let service: MembershipSectionDataService; + let httpClient: { get: ReturnType }; + let releaseTracksConnector: { + listReleaseTracks: ReturnType; + listReleasesForObject: ReturnType; + listObjectVersions: ReturnType; + getLatestSnapshot: ReturnType; + }; + + beforeEach(() => { + httpClient = { + get: vi.fn().mockReturnValue( + of([ + { + stix: { modified: '2026-07-10T10:00:00.000Z' }, + workspace: { + release_tracks: [ + { + id: trackId, + tier: 'candidates', + status: 'work-in-progress', + }, + ], + }, + }, + { + stix: { modified: '2026-07-12T12:30:00.000Z' }, + workspace: { + release_tracks: [ + { + id: trackId, + tier: 'candidates', + status: 'awaiting-review', + }, + ], + }, + }, + ]) + ), + }; + + releaseTracksConnector = { + listReleaseTracks: vi.fn().mockReturnValue( + of({ + data: [ + { + id: trackId, + track_id: trackId, + name: 'Release Track v1', + type: 'standard', + }, + ], + }) + ), + listReleasesForObject: vi.fn().mockReturnValue( + of({ + data: [ + { + track_id: trackId, + version: '1.0', + tagged_at: '2026-07-20T14:57:40.134Z', + object_modified: '2025-04-15T19:58:03.170Z', + }, + { + track_id: 'release-track--other', + version: '2.0', + tagged_at: '2026-07-21T14:57:40.134Z', + }, + ], + }) + ), + listObjectVersions: vi.fn().mockReturnValue( + of({ + versions: [ + { + tier: 'members', + object_ref: objectRef, + object_modified: '2025-04-15T19:58:03.170Z', + }, + ], + }) + ), + getLatestSnapshot: vi.fn().mockReturnValue( + of({ + modified: '2026-07-20T15:32:46.907Z', + candidates: [ + { + object_ref: objectRef, + object_status: 'work-in-progress', + object_added_at: '2026-07-20T15:32:46.901Z', + }, + ], + staged: [], + }) + ), + }; + + service = new MembershipSectionDataService( + httpClient as unknown as HttpClient, + releaseTracksConnector as unknown as ReleaseTracksConnectorService + ); + }); + + it('uses all object revisions to date the latest status change', async () => { + releaseTracksConnector.listReleasesForObject.mockReturnValue( + of({ data: [] }) + ); + releaseTracksConnector.listObjectVersions.mockReturnValue( + of({ + versions: [ + { + tier: 'candidates', + object_ref: objectRef, + object_status: 'awaiting-review', + }, + ], + }) + ); + + const tracks = await firstValueFrom( + service.loadMemberships( + objectRef, + { + type: 'attack-pattern', + workspace: { + release_tracks: [ + { + id: trackId, + tier: 'candidates', + status: 'awaiting-review', + }, + ], + }, + }, + null + ) + ); + + expect(tracks[0].current_draft?.updated_at).toBe( + '2026-07-12T12:30:00.000Z' + ); + expect(httpClient.get).toHaveBeenCalledWith( + expect.stringContaining(`/techniques/${objectRef}`), + { params: { versions: 'all' } } + ); + }); + + it('falls back to the track snapshot date when revisions show no transition', async () => { + httpClient.get.mockReturnValue( + of([ + { + stix: { modified: '2025-04-15T19:58:03.170Z' }, + workspace: { + release_tracks: [ + { + id: trackId, + tier: 'candidates', + status: 'work-in-progress', + }, + ], + }, + }, + ]) + ); + releaseTracksConnector.listReleasesForObject.mockReturnValue( + of({ data: [] }) + ); + releaseTracksConnector.listObjectVersions.mockReturnValue( + of({ + versions: [ + { + tier: 'candidates', + object_ref: objectRef, + object_status: 'work-in-progress', + }, + ], + }) + ); + + const tracks = await firstValueFrom( + service.loadMemberships( + objectRef, + { + type: 'attack-pattern', + workspace: { + release_tracks: [ + { + id: trackId, + tier: 'candidates', + status: 'work-in-progress', + }, + ], + }, + }, + null + ) + ); + + expect(tracks[0].current_draft?.updated_at).toBe( + '2026-07-20T15:32:46.901Z' + ); + }); + + it('maps tagged releases to the correct track without retaining a stale draft', async () => { + const tracks = await firstValueFrom( + service.loadMemberships( + objectRef, + { + workspace: { + release_tracks: [ + { + id: trackId, + tier: 'staged', + status: 'reviewed', + }, + ], + }, + }, + null + ) + ); + + const standardTrack = tracks.find(track => track.type === 'standard'); + + expect(standardTrack?.current_draft).toBeNull(); + expect(standardTrack?.production_releases).toHaveLength(1); + expect(standardTrack?.production_releases?.[0]).toMatchObject({ + track_id: trackId, + version: '1.0', + }); + expect(releaseTracksConnector.listReleasesForObject).toHaveBeenCalledWith( + objectRef, + { order: 'desc', limit: 100, offset: 0 } + ); + }); + + it('returns no cards when the object is not a member of any tracks', async () => { + httpClient.get.mockReturnValue( + of({ + type: 'attack-pattern', + workspace: { release_tracks: [] }, + }) + ); + + const tracks = await firstValueFrom( + service.loadMemberships(objectRef, { type: 'attack-pattern' }, null) + ); + + expect(tracks).toEqual([]); + }); +}); diff --git a/src/app/services/connectors/rest-api/membership-section-data.service.ts b/src/app/services/connectors/rest-api/membership-section-data.service.ts new file mode 100644 index 000000000..8b0fdde5c --- /dev/null +++ b/src/app/services/connectors/rest-api/membership-section-data.service.ts @@ -0,0 +1,732 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { forkJoin, Observable, of } from 'rxjs'; +import { catchError, map, switchMap } from 'rxjs/operators'; + +import { environment } from 'src/environments/environment'; +import { ReleaseTracksConnectorService } from 'src/app/services/connectors/rest-api/release-tracks.service'; + +export interface MembershipTrack { + id: string; + name: string; + description: string; + type: string; + + apiId?: string; + track_id?: string; + tier?: string; + status?: string; + + current_draft?: any; + currentDraft?: any; + draft?: any; + + production_releases?: any[]; + productionReleases?: any[]; + releases?: any[]; + versions?: any[]; + + track?: any; + release_track?: any; + + [key: string]: any; +} + +interface MembershipReference { + id: string; + tier?: string; + status?: string; + [key: string]: any; +} + +@Injectable({ + providedIn: 'root', +}) +export class MembershipSectionDataService { + private get apiUrl(): string { + return environment.integrations.rest_api.url.replace(/\/+$/, ''); + } + + constructor( + private readonly http: HttpClient, + private readonly releaseTracksConnector: ReleaseTracksConnectorService + ) {} + + public loadMemberships( + objectRef: string, + objectInput: any, + configInput: any + ): Observable { + const suppliedMemberships = this.extractMemberships( + objectInput, + configInput + ); + + const rawObject$ = suppliedMemberships.length + ? of(objectInput ?? configInput?.object ?? null) + : this.loadRawObject(objectRef, objectInput ?? configInput?.object); + + return forkJoin({ + rawObject: rawObject$, + releaseTracksResponse: this.releaseTracksConnector + .listReleaseTracks({ + limit: 100, + offset: 0, + }) + .pipe( + catchError(error => { + console.error('Failed to load release tracks', error); + return of(null); + }) + ), + releasesResponse: this.releaseTracksConnector.listReleasesForObject( + objectRef, + { order: 'desc', limit: 100, offset: 0 } + ), + objectVersionsResponse: this.loadAllObjectVersions( + objectRef, + objectInput + ), + }).pipe( + map( + ({ + rawObject, + releaseTracksResponse, + releasesResponse, + objectVersionsResponse, + }) => { + const memberships = suppliedMemberships.length + ? suppliedMemberships + : this.extractMemberships(rawObject, { + object: rawObject, + }); + + const availableTracks = this.normalizeReleaseTrackList( + releaseTracksResponse + ); + + return this.applyStatusChangeDates( + this.applyTaggedReleases( + this.buildTracks(availableTracks, memberships, objectRef), + releasesResponse + ), + objectVersionsResponse + ); + } + ), + switchMap(tracks => + this.loadVersionsForMembershipTracks(tracks, objectRef) + ) + ); + } + + private loadRawObject(objectRef: string, objectInput: any): Observable { + const objectType = + objectInput?.type ?? + objectInput?.stix?.type ?? + objectInput?.attackType ?? + this.getTypeFromStixId(objectRef); + + const resource = this.getResourceName(objectType); + + if (!resource) { + console.warn(`Unable to determine REST resource for object ${objectRef}`); + return of(objectInput ?? null); + } + + const url = `${this.apiUrl}/${resource}/${encodeURIComponent(objectRef)}`; + + return this.http.get(url).pipe( + map(response => { + if (Array.isArray(response)) { + return response[0] ?? objectInput ?? null; + } + + return response ?? objectInput ?? null; + }), + catchError(error => { + console.error(`Failed to load raw object ${objectRef}`, error); + return of(objectInput ?? null); + }) + ); + } + + private loadAllObjectVersions( + objectRef: string, + objectInput: any + ): Observable { + const objectType = + objectInput?.type ?? + objectInput?.stix?.type ?? + objectInput?.attackType ?? + this.getTypeFromStixId(objectRef); + const resource = this.getResourceName(objectType); + + if (!resource) { + return of([]); + } + + const url = `${this.apiUrl}/${resource}/${encodeURIComponent(objectRef)}`; + + return this.http.get(url, { params: { versions: 'all' } }).pipe( + catchError(error => { + console.error(`Failed to load versions for object ${objectRef}`, error); + return of([]); + }) + ); + } + + private extractMemberships( + objectInput: any, + configInput: any + ): MembershipReference[] { + const releaseTracks = + objectInput?.workspace?.release_tracks ?? + objectInput?.workspace?.releaseTracks ?? + objectInput?.release_tracks ?? + objectInput?.releaseTracks ?? + configInput?.object?.workspace?.release_tracks ?? + configInput?.object?.workspace?.releaseTracks ?? + configInput?.workspace?.release_tracks ?? + configInput?.workspace?.releaseTracks ?? + []; + + return Array.isArray(releaseTracks) + ? releaseTracks.filter(membership => !!membership?.id) + : []; + } + + private normalizeReleaseTrackList(response: any): MembershipTrack[] { + if (!response) { + return []; + } + + if (Array.isArray(response)) { + return response; + } + + const tracks = + response.data ?? + response.items ?? + response.results ?? + response.release_tracks ?? + response.releaseTracks ?? + response.tracks ?? + []; + + return Array.isArray(tracks) ? tracks : []; + } + + private buildTracks( + availableTracks: MembershipTrack[], + memberships: MembershipReference[], + objectRef: string + ): MembershipTrack[] { + const memberTracks = memberships.map(membership => { + const metadata = availableTracks.find( + track => this.getTrackApiId(track) === membership.id + ); + + const track: MembershipTrack = { + ...(metadata ?? {}), + ...membership, + + id: metadata?.track_id ?? metadata?.id ?? membership.id, + + apiId: membership.id, + + name: + metadata?.name ?? + metadata?.title ?? + this.formatIdentifier(membership.id) ?? + 'Release Track', + + description: + metadata?.description?.trim() || + 'Release track containing this ATT&CK object.', + + type: metadata?.type ?? metadata?.track_type ?? 'STANDARD', + + tier: membership.tier, + status: membership.status, + + current_draft: this.createCurrentDraft(membership, metadata, objectRef), + + production_releases: [], + releases: [], + versions: [], + }; + + return track; + }); + + return memberTracks; + } + + private loadVersionsForMembershipTracks( + tracks: MembershipTrack[], + objectRef: string + ): Observable { + const requests = tracks.map(track => { + if (!track.apiId) { + return of(track); + } + + return forkJoin({ + versionsResponse: this.releaseTracksConnector.listObjectVersions( + track.apiId, + objectRef + ), + snapshot: this.releaseTracksConnector.getLatestSnapshot(track.apiId), + }).pipe( + map(({ versionsResponse, snapshot }) => + this.applySnapshotStatusDate( + this.applyVersionResponse(track, versionsResponse), + snapshot, + objectRef + ) + ), + catchError(error => { + console.error( + `Failed to load versions for track ${track.apiId}`, + error + ); + return of(track); + }) + ); + }); + + return requests.length ? forkJoin(requests) : of(tracks); + } + + private applyVersionResponse( + track: MembershipTrack, + response: any + ): MembershipTrack { + const versions = this.normalizeVersionList(response); + + const responseDraft = + response?.current_draft ?? + response?.currentDraft ?? + response?.draft ?? + null; + + const directReleases = + response?.production_releases ?? + response?.productionReleases ?? + response?.releases ?? + null; + + const detectedDraft = versions.find(version => + this.isDraftVersion(version) + ); + const currentDraft = responseDraft + ? { ...(track.current_draft ?? {}), ...responseDraft } + : versions.length + ? detectedDraft + ? { ...(track.current_draft ?? {}), ...detectedDraft } + : null + : (track.current_draft ?? null); + + const versionReleases = versions.filter(version => + this.isProductionVersion(version) + ); + + const productionReleases = Array.isArray(directReleases) + ? directReleases + : versionReleases.length + ? versionReleases + : (track.production_releases ?? []); + + return { + ...track, + current_draft: currentDraft, + production_releases: productionReleases, + releases: productionReleases, + versions, + }; + } + + private applySnapshotStatusDate( + track: MembershipTrack, + snapshot: any, + objectRef: string + ): MembershipTrack { + if (!track.current_draft || track.current_draft.updated_at || !snapshot) { + return track; + } + + const stagedEntry = snapshot.staged?.find( + (entry: any) => entry?.object_ref === objectRef + ); + const candidateEntry = snapshot.candidates?.find( + (entry: any) => entry?.object_ref === objectRef + ); + const date = stagedEntry + ? (stagedEntry.object_staged_at ?? snapshot.modified) + : candidateEntry + ? ((candidateEntry.object_status === 'work-in-progress' + ? candidateEntry.object_added_at + : snapshot.modified) ?? candidateEntry.object_added_at) + : null; + + return date + ? { + ...track, + current_draft: { + ...track.current_draft, + updated_at: date, + }, + } + : track; + } + + private applyTaggedReleases( + tracks: MembershipTrack[], + response: any + ): MembershipTrack[] { + const releases = Array.isArray(response) + ? response + : Array.isArray(response?.data) + ? response.data + : []; + + return tracks.map(track => { + const trackId = this.getTrackApiId(track); + const trackReleases = releases.filter( + (release: any) => + String(release?.track_id ?? release?.trackId ?? '') === trackId + ); + + return { + ...track, + production_releases: trackReleases, + releases: trackReleases, + }; + }); + } + + private applyStatusChangeDates( + tracks: MembershipTrack[], + response: any + ): MembershipTrack[] { + const revisions = this.normalizeObjectRevisionList(response).sort( + (first, second) => + this.getObjectRevisionTimestamp(first) - + this.getObjectRevisionTimestamp(second) + ); + + return tracks.map(track => { + if (!track.current_draft) { + return track; + } + + const trackId = this.getTrackApiId(track); + let previousStatus: string | null = null; + let changedAt: unknown = null; + + revisions.forEach(revision => { + const membership = this.extractMemberships(revision, { + object: revision, + }).find(item => item.id === trackId); + const status = membership + ? this.normalizeStatus(membership.status ?? membership.tier) + : ''; + + if (!status) { + return; + } + + if (previousStatus === null) { + previousStatus = status; + return; + } + + if (status !== previousStatus) { + previousStatus = status; + changedAt = this.getObjectRevisionDate(revision); + } + }); + + return changedAt + ? { + ...track, + current_draft: { + ...track.current_draft, + updated_at: changedAt, + }, + } + : track; + }); + } + + private normalizeObjectRevisionList(response: any): any[] { + if (Array.isArray(response)) { + return response; + } + + const revisions = + response?.data ?? response?.items ?? response?.results ?? response ?? []; + + return Array.isArray(revisions) ? revisions : []; + } + + private getObjectRevisionTimestamp(revision: any): number { + const value = this.getObjectRevisionDate(revision); + const timestamp = value ? new Date(value).getTime() : 0; + + return Number.isNaN(timestamp) ? 0 : timestamp; + } + + private getObjectRevisionDate(revision: any): string | number | Date | null { + return (revision?.stix?.modified ?? + revision?.modified ?? + revision?.updated_at ?? + revision?.updatedAt ?? + null) as string | number | Date | null; + } + + private normalizeVersionList(response: any): any[] { + if (!response) { + return []; + } + + if (Array.isArray(response)) { + return response; + } + + const versions = + response.data ?? + response.items ?? + response.results ?? + response.versions ?? + response.object_versions ?? + response.objectVersions ?? + response.snapshots ?? + []; + + return Array.isArray(versions) ? versions : []; + } + + private createCurrentDraft( + membership: MembershipReference, + metadata: MembershipTrack | undefined, + objectRef: string + ): any { + const existingDraft = + metadata?.current_draft ?? metadata?.currentDraft ?? metadata?.draft; + + if (existingDraft) { + return { + ...existingDraft, + status: membership.status ?? existingDraft.status, + tier: membership.tier ?? existingDraft.tier, + in_current_draft: true, + }; + } + + const tier = this.normalizeStatus(membership.tier); + const status = this.normalizeStatus(membership.status); + + const draftStatuses = [ + 'candidate', + 'candidates', + 'draft', + 'work-in-progress', + 'wip', + 'awaiting-review', + 'review', + 'reviewed', + 'staged', + ]; + + if (!draftStatuses.includes(tier) && !draftStatuses.includes(status)) { + return null; + } + + return { + id: `${membership.id}:${objectRef}`, + status: membership.status ?? membership.tier ?? 'work-in-progress', + tier: membership.tier ?? membership.status, + in_current_draft: true, + }; + } + + private isDraftVersion(version: any): boolean { + if (version?.is_draft === true || version?.isDraft === true) { + return true; + } + + const status = this.normalizeStatus( + version?.status ?? + version?.state ?? + version?.tier ?? + version?.snapshot_status + ); + + return [ + 'candidate', + 'candidates', + 'draft', + 'work-in-progress', + 'wip', + 'awaiting-review', + 'review', + 'reviewed', + 'staged', + ].includes(status); + } + + private isProductionVersion(version: any): boolean { + if ( + version?.is_release === true || + version?.isRelease === true || + version?.released === true || + version?.tagged === true + ) { + return true; + } + + if ( + version?.released_at || + version?.releasedAt || + version?.release_date || + version?.releaseDate || + version?.tag + ) { + return true; + } + + const status = this.normalizeStatus( + version?.status ?? + version?.state ?? + version?.tier ?? + version?.snapshot_status + ); + + if ( + ['production', 'release', 'released', 'published', 'tagged'].includes( + status + ) + ) { + return true; + } + + return ( + !this.isDraftVersion(version) && + !!this.normalizeVersionValue(version?.version) + ); + } + + private getTrackApiId(track: MembershipTrack): string | null { + const id = + track.track_id ?? + track.apiId ?? + track.track?.id ?? + track.release_track?.id ?? + track.id; + + return id ? String(id) : null; + } + + private getResourceName(type: unknown): string | null { + const normalized = String(type ?? '') + .trim() + .toLowerCase(); + + const resourceMap: Record = { + 'attack-pattern': 'techniques', + 'technique': 'techniques', + + 'x-mitre-tactic': 'tactics', + 'tactic': 'tactics', + + 'intrusion-set': 'groups', + 'group': 'groups', + + 'campaign': 'campaigns', + + 'x-mitre-asset': 'assets', + 'asset': 'assets', + + 'malware': 'software', + 'tool': 'software', + 'software': 'software', + + 'course-of-action': 'mitigations', + 'mitigation': 'mitigations', + + 'x-mitre-data-source': 'data-sources', + 'data-source': 'data-sources', + + 'x-mitre-data-component': 'data-components', + 'data-component': 'data-components', + + 'x-mitre-detection-strategy': 'detection-strategies', + 'detection-strategy': 'detection-strategies', + + 'x-mitre-analytic': 'analytics', + 'analytic': 'analytics', + + 'x-mitre-matrix': 'matrices', + 'matrix': 'matrices', + + 'relationship': 'relationships', + 'note': 'notes', + + 'x-mitre-collection': 'collections', + 'collection': 'collections', + + 'identity': 'identities', + 'marking-definition': 'marking-definitions', + }; + + return resourceMap[normalized] ?? null; + } + + private getTypeFromStixId(objectRef: string): string { + return objectRef.split('--')[0] ?? ''; + } + + private normalizeStatus(value: unknown): string { + return String(value ?? '') + .trim() + .toLowerCase() + .replace(/[\s_]+/g, '-'); + } + + private normalizeVersionValue(version: any): string | null { + if (version === null || version === undefined) { + return null; + } + + if (Array.isArray(version)) { + return version.join('.'); + } + + if (typeof version === 'object') { + const nested = version._version ?? version.version ?? version.value; + + if (Array.isArray(nested)) { + return nested.join('.'); + } + + return nested === null || nested === undefined ? null : String(nested); + } + + return String(version); + } + + private formatIdentifier(identifier: unknown): string | null { + if (!identifier) { + return null; + } + + const value = String(identifier); + const identifierName = value.includes('--') ? value.split('--')[0] : value; + + return identifierName + .replace(/[_-]+/g, ' ') + .replace(/\b\w/g, character => character.toUpperCase()); + } +} diff --git a/src/app/services/connectors/rest-api/release-tracks.integration.spec.ts b/src/app/services/connectors/rest-api/release-tracks.integration.spec.ts new file mode 100644 index 000000000..037b30551 --- /dev/null +++ b/src/app/services/connectors/rest-api/release-tracks.integration.spec.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { environment } from 'src/environments/environment'; + +// Integration tests that exercise the real REST API. These tests will +// silently no-op (skip) if the configured API URL is unreachable. + +const apiUrl = environment.integrations.rest_api.url.replace(/\/+$/, ''); +let serverAvailable = false; +const commonHeaders: Record = { + 'Content-Type': 'application/json', +}; +let discoveredTrackId: string | null = null; + +async function probe(url: string, timeoutMs = 3000): Promise { + const controller = new AbortController(); + const id = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { method: 'GET', signal: controller.signal }); + return res; + } finally { + clearTimeout(id); + } +} + +beforeAll(async () => { + try { + const res = await probe(`${apiUrl}/release-tracks?type=standard&limit=1`); + if (res && res.ok) { + serverAvailable = true; + try { + const body = await res.json().catch(() => null); + if (Array.isArray(body?.data) && body.data[0]?.id) { + discoveredTrackId = body.data[0].id; + } + } catch (e) { + console.error(e); + } + } else { + serverAvailable = false; + // leave discoveredTrackId null + } + } catch (e) { + // network error or timeout + serverAvailable = false; + console.error(e); + } +}); + +describe('Release Tracks API integration (real server)', () => { + it('GET /release-tracks (list) should return 200 when server available', async () => { + if (!serverAvailable) { + console.warn( + 'Skipping /release-tracks test because server is unreachable' + ); + return; + } + const res = await fetch(`${apiUrl}/release-tracks`, { + headers: commonHeaders, + }); + expect(res.ok).toBe(true); + const body = await res.json().catch(() => null); + expect(body).toBeTruthy(); + }); + + it('GET /release-tracks/ephemeral/:domain should return a bundle when available', async () => { + if (!serverAvailable) { + console.warn( + 'Skipping ephemeral bundle test because server is unreachable' + ); + return; + } + const domain = 'enterprise'; + const res = await fetch( + `${apiUrl}/release-tracks/ephemeral/${domain}?format=bundle`, + { headers: commonHeaders } + ); + // The API may return different formats; assert no network error and a JSON body when possible + expect(res.ok).toBe(true); + // attempt to parse JSON but do not fail if parsing isn't possible + try { + const json = await res.json(); + expect(json).toBeDefined(); + } catch (e) { + console.error(e); + } + }); + + it('GET /release-tracks/:id/snapshots/latest should return the latest snapshot', async () => { + if (!serverAvailable) { + console.warn( + 'Skipping getLatestSnapshot test because server is unreachable' + ); + return; + } + if (!discoveredTrackId) { + console.warn( + 'Skipping getLatestSnapshot test because no tracks were found in /release-tracks' + ); + return; + } + const res = await fetch( + `${apiUrl}/release-tracks/${encodeURIComponent(discoveredTrackId)}/snapshots/latest`, + { headers: commonHeaders } + ); + expect(res.ok).toBe(true); + const json = await res.json().catch(() => null); + expect(json).toBeTruthy(); + // If the API returns a shape compatible with ReleaseTrackSnapshot, it usually has id/name/modified + if (json) { + expect(json).toHaveProperty('id'); + } + }); + + it('GET /release-tracks/:id/staged should return 200 (if track exists)', async () => { + if (!serverAvailable) { + console.warn('Skipping listStaged test because server is unreachable'); + return; + } + if (!discoveredTrackId) { + console.warn( + 'Skipping listStaged test because no tracks were found in /release-tracks' + ); + return; + } + const res = await fetch( + `${apiUrl}/release-tracks/${encodeURIComponent(discoveredTrackId)}/staged`, + { headers: commonHeaders } + ); + expect(res.ok).toBe(true); + // body may be array or paginated object + const body = await res.json().catch(() => null); + expect(body).toBeDefined(); + }); +}); diff --git a/src/app/services/connectors/rest-api/release-tracks.service.spec.ts b/src/app/services/connectors/rest-api/release-tracks.service.spec.ts new file mode 100644 index 000000000..a3f4c9249 --- /dev/null +++ b/src/app/services/connectors/rest-api/release-tracks.service.spec.ts @@ -0,0 +1,331 @@ +import { firstValueFrom, of } from 'rxjs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ReleaseTracksConnectorService } from './release-tracks.service'; +import { environment } from 'src/environments/environment'; + +describe('ReleaseTracksConnectorService', () => { + let http: { + get: ReturnType; + post: ReturnType; + put: ReturnType; + delete: ReturnType; + }; + let service: ReleaseTracksConnectorService; + + beforeEach(() => { + http = { + get: vi.fn(() => of({})), + post: vi.fn(() => of({})), + put: vi.fn(() => of({})), + delete: vi.fn(() => of({})), + }; + service = new ReleaseTracksConnectorService( + http as any, + { open: vi.fn() } as any + ); + }); + + it('should retrieve the latest snapshot from the explicit latest endpoint', async () => { + const apiUrl = environment.integrations.rest_api.url; + const trackId = 'release-track--123'; + http.get.mockReturnValue( + of({ + id: trackId, + name: 'Enterprise Release', + }) + ); + + const snapshot = await firstValueFrom( + service.getLatestSnapshot(trackId, { include: 'all' }) + ); + + expect(http.get).toHaveBeenCalledWith( + `${apiUrl}/release-tracks/${trackId}/snapshots/latest`, + { + params: expect.anything(), + } + ); + expect(http.get.mock.calls[0][1].params.get('include')).toBe('all'); + expect(snapshot?.name).toBe('Enterprise Release'); + }); + + it('should export the latest snapshot from the explicit latest endpoint', async () => { + const apiUrl = environment.integrations.rest_api.url; + const trackId = 'release-track--123'; + const exportPayload = { type: 'bundle', objects: [] }; + http.get.mockReturnValue(of(exportPayload)); + + const result = await firstValueFrom( + service.exportLatestSnapshot(trackId, 'bundle', { + include: 'all', + stixVersion: '2.0', + }) + ); + + expect(http.get).toHaveBeenCalledWith( + `${apiUrl}/release-tracks/${trackId}/snapshots/latest`, + { + params: expect.anything(), + } + ); + expect(http.get.mock.calls[0][1].params.get('format')).toBe('bundle'); + expect(http.get.mock.calls[0][1].params.get('include')).toBe('all'); + expect(http.get.mock.calls[0][1].params.get('stixVersion')).toBe('2.0'); + expect(result).toEqual(exportPayload); + }); + + it('should request snapshot history with default pagination', async () => { + const apiUrl = environment.integrations.rest_api.url; + const trackId = 'release-track--123'; + const response = { + data: [ + { + id: trackId, + modified: '2024-05-21T07:00:00.000Z', + version: null, + type: 'standard', + members_count: 10, + staged_count: 2, + candidates_count: 4, + }, + ], + pagination: { + total: 1, + limit: 200, + offset: 0, + }, + }; + http.get.mockReturnValue(of(response)); + + const history = await firstValueFrom(service.listSnapshots(trackId)); + + expect(http.get).toHaveBeenCalledWith( + `${apiUrl}/release-tracks/${trackId}/snapshots`, + { + params: expect.anything(), + } + ); + expect(http.get.mock.calls[0][1].params.get('limit')).toBe('200'); + expect(http.get.mock.calls[0][1].params.get('offset')).toBe('0'); + expect(history).toEqual(response); + }); + + it('should pass tagged and pagination options to the snapshot list endpoint', async () => { + service + .listSnapshots('release-track--standard', { + tagged: false, + limit: 25, + offset: 50, + }) + .subscribe(); + + const [url, options] = http.get.mock.calls[0]; + expect(url).toBe( + `${environment.integrations.rest_api.url}/release-tracks/release-track--standard/snapshots` + ); + expect(options.params.get('tagged')).toBe('false'); + expect(options.params.get('limit')).toBe('25'); + expect(options.params.get('offset')).toBe('50'); + }); + + it('should create a deterministic graph for an exact snapshot', async () => { + const snapshot = { + modified: '2026-07-23T13:37:28.000Z', + version: '1.0', + graph_manifest_id: 'release-track-graph-manifest--123', + }; + http.post.mockReturnValue(of(snapshot)); + + const result = await firstValueFrom( + service.createSnapshotGraph( + 'release-track--standard', + '2026-07-23T13:37:28.000Z' + ) + ); + + expect(http.post).toHaveBeenCalledWith( + `${environment.integrations.rest_api.url}/release-tracks/release-track--standard/snapshots/2026-07-23T13%3A37%3A28.000Z/graph`, + {} + ); + expect(result).toEqual(snapshot); + }); + + it('should delete a deterministic graph for an exact snapshot', async () => { + http.delete.mockReturnValue(of(undefined)); + + const result = await firstValueFrom( + service.deleteSnapshotGraph( + 'release-track--standard', + '2026-07-23T13:37:28.000Z' + ) + ); + + expect(http.delete).toHaveBeenCalledWith( + `${environment.integrations.rest_api.url}/release-tracks/release-track--standard/snapshots/2026-07-23T13%3A37%3A28.000Z/graph` + ); + expect(result).toBeUndefined(); + }); + + it('should create virtual snapshots through the virtual namespace', () => { + service + .createVirtualSnapshot('release-track--virtual', { + description: 'Scheduled draft', + }) + .subscribe(); + + expect(http.post).toHaveBeenCalledWith( + `${environment.integrations.rest_api.url}/release-tracks/release-track--virtual/virtual/snapshots/create`, + { description: 'Scheduled draft' } + ); + }); + + it('should update composition through the virtual namespace', () => { + const composition = { + component_tracks: [ + { + track_id: 'release-track--standard', + resolution_strategy: 'latest_tagged' as const, + priority: 0, + }, + ], + }; + + service + .updateComposition('release-track--virtual', composition) + .subscribe(); + + expect(http.put).toHaveBeenCalledWith( + `${environment.integrations.rest_api.url}/release-tracks/release-track--virtual/virtual/composition`, + composition + ); + }); + + it('should promote an exact quarantined revision', () => { + const body = { + object_ref: 'attack-pattern--one', + object_modified: '2026-07-23T13:37:28.000Z', + }; + + service + .promoteQuarantinedRevision('release-track--virtual', body) + .subscribe(); + + expect(http.post).toHaveBeenCalledWith( + `${environment.integrations.rest_api.url}/release-tracks/release-track--virtual/virtual/quarantine/promote`, + body + ); + }); + + it('should not expose the removed virtual snapshot preview operation', () => { + expect((service as any).previewVirtualSnapshot).toBeUndefined(); + }); + + it('should preview a release with query-based version selection', () => { + service + .previewRelease('release-track--standard', { + format: 'summary', + increment: 'major', + }) + .subscribe(); + + const [url, options] = http.get.mock.calls[0]; + expect(url).toBe( + `${environment.integrations.rest_api.url}/release-tracks/release-track--standard/snapshots/latest/release/preview` + ); + expect(options.params.get('format')).toBe('summary'); + expect(options.params.get('increment')).toBe('major'); + }); + + it('should release the latest snapshot with the current request body', () => { + service + .releaseLatest('release-track--standard', { + increment: 'minor', + description: 'Analyst release context', + }) + .subscribe(); + + expect(http.post).toHaveBeenCalledWith( + `${environment.integrations.rest_api.url}/release-tracks/release-track--standard/snapshots/latest/release`, + { increment: 'minor', description: 'Analyst release context' } + ); + }); + + it('should release a selected snapshot with an exact version', () => { + service + .releaseSnapshot('release-track--standard', '2026-07-23T13:37:28.000Z', { + version: '14.1', + }) + .subscribe(); + + expect(http.post).toHaveBeenCalledWith( + `${environment.integrations.rest_api.url}/release-tracks/release-track--standard/snapshots/2026-07-23T13%3A37%3A28.000Z/release`, + { version: '14.1' } + ); + }); + + it('should not expose bump-oriented operations', () => { + expect((service as any).previewBump).toBeUndefined(); + expect((service as any).bumpByLatest).toBeUndefined(); + expect((service as any).bumpByModified).toBeUndefined(); + }); + + it('should list tracks with only supported query parameters', () => { + service + .listReleaseTracks({ + type: 'virtual', + limit: 25, + offset: 0, + search: 'enterprise', + }) + .subscribe(); + + const [url, options] = http.get.mock.calls[0]; + expect(url).toBe(`${environment.integrations.rest_api.url}/release-tracks`); + expect(options.params.get('type')).toBe('virtual'); + expect(options.params.get('limit')).toBe('25'); + expect(options.params.get('offset')).toBe('0'); + expect(options.params.get('search')).toBe('enterprise'); + }); + + it('should confirm the target ID when deleting a release track', () => { + service.deleteReleaseTrack('release-track--standard').subscribe(); + + const [url, options] = http.delete.mock.calls[0]; + expect(url).toBe( + `${environment.integrations.rest_api.url}/release-tracks/release-track--standard` + ); + expect(options.params.get('confirm_track_id')).toBe( + 'release-track--standard' + ); + }); + + it('should encode timestamps used as snapshot path parameters', () => { + service + .retrieveSnapshotByModified( + 'release-track--standard', + '2026-07-23T13:37:28.000Z' + ) + .subscribe(); + + const [url] = http.get.mock.calls[0]; + expect(url).toBe( + `${environment.integrations.rest_api.url}/release-tracks/release-track--standard/snapshots/2026-07-23T13%3A37%3A28.000Z` + ); + }); + + it('should update notes on an exact snapshot', () => { + service + .updateSnapshotDescription( + 'release-track--standard', + '2026-07-23T13:37:28.000Z', + { description: 'Updated analyst context' } + ) + .subscribe(); + + expect(http.put).toHaveBeenCalledWith( + `${environment.integrations.rest_api.url}/release-tracks/release-track--standard/snapshots/2026-07-23T13%3A37%3A28.000Z/description`, + { description: 'Updated analyst context' } + ); + }); +}); diff --git a/src/app/services/connectors/rest-api/release-tracks.service.ts b/src/app/services/connectors/rest-api/release-tracks.service.ts new file mode 100644 index 000000000..6a5189aa4 --- /dev/null +++ b/src/app/services/connectors/rest-api/release-tracks.service.ts @@ -0,0 +1,868 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Observable } from 'rxjs'; +import { catchError, map, share, tap } from 'rxjs/operators'; +import { environment } from '../../../../environments/environment'; +import { logger } from '../../../utils/logger'; +import { ApiConnector } from '../api-connector'; +import { + ReleasePreviewFormat, + ReleaseTrackSnapshot, +} from 'src/app/classes/release-tracks'; +import type { + ClonePayload, + Composition, + CreateReleaseTrackPayload, + ExportFormatType, + PromoteQuarantinePayload, + ReleasePayload, + ReleasePreviewOptions, + ReleaseTrackConfig, + ReleaseTrackSnapshotHistoryItem, + ReleaseTrackSnapshotOptions, + ReviewPayload, + SnapshotHistoryOptions, + StixBundlePayload, + StixObjectRef, + UpdateMetadataPayload, +} from 'src/app/classes/release-tracks'; +import { Paginated } from './rest-api-connector.service'; + +export type { + ClonePayload, + Composition, + CreateReleaseTrackPayload, + PromoteQuarantinePayload, + ReleasePayload, + ReleasePreviewOptions, + ReleaseTrackSnapshotHistoryItem, + ReleaseTrackSnapshotOptions, + ReviewPayload, + SnapshotHistoryOptions, + StixBundlePayload, + StixObjectRef, + UpdateMetadataPayload, +} from 'src/app/classes/release-tracks'; + +// ----------------------------------------------------------------------------- +// Release Tracks API Connector Service +// ----------------------------------------------------------------------------- + +@Injectable({ + providedIn: 'root', +}) +export class ReleaseTracksConnectorService extends ApiConnector { + private get apiUrl(): string { + return environment.integrations.rest_api.url; + } + + constructor( + private http: HttpClient, + snackbar: MatSnackBar + ) { + super(snackbar); + } + + private buildHttpParams(options?: Record): HttpParams { + let params = new HttpParams(); + if (options) { + Object.keys(options).forEach(k => { + const v = options[k]; + if (v !== undefined && v !== null) params = params.set(k, String(v)); + }); + } + return params; + } + + // ----------------------------------------------------------------------------- + // Ephemeral + // ----------------------------------------------------------------------------- + + /** + * GET /api/release-tracks/ephemeral/:domain + * Retrieve an ephemeral STIX bundle for the given domain. + * @param domain Path parameter selecting ATT&CK domain ('enterprise'|'ics'|'mobile') + * @param format Optional query parameter controlling output format ('bundle'|'filesystemstore'|'workbench') + * @returns Observable Server response + */ + public getEphemeralBundle( + domain: string, + format = 'bundle' + ): Observable { + let params = new HttpParams(); + if (format) params = params.set('format', format); + const url = `${this.apiUrl}/release-tracks/ephemeral/${domain}`; + return this.http.get(url, { params }).pipe( + tap(result => logger.log(`retrieved ephemeral ${domain} bundle`, result)), + catchError(this.handleError_continue(null)), + share() + ); + } + + // ----------------------------------------------------------------------------- + // Track management + // ----------------------------------------------------------------------------- + + /** + * GET /api/release-tracks + * List release tracks. + * @param options Query options: type, limit, offset, search + * @returns Observable paginated list + */ + public listReleaseTracks(options?: { + type?: string; + limit?: number; + offset?: number; + search?: string; + }): Observable> { + const params = this.buildHttpParams(options); + const url = `${this.apiUrl}/release-tracks`; + return this.http.get(url, { params }).pipe( + tap(() => logger.log('retrieved release tracks list')), + catchError(this.handleError_continue(null)), + share() + ); + } + + /** List tagged releases that directly contain an object. */ + public listReleasesForObject( + objectRef: string, + options?: { order?: 'asc' | 'desc'; limit?: number; offset?: number } + ): Observable { + const params = this.buildHttpParams(options); + const url = `${this.apiUrl}/release-tracks/objects/${encodeURIComponent(objectRef)}/releases`; + + return this.http.get(url, { params }).pipe( + tap(() => logger.log(`retrieved tagged releases for ${objectRef}`)), + catchError(this.handleError_continue(null)), + share() + ); + } + + /** + * POST /api/release-tracks/new + * Create a new release track. + * @param body Request payload + * @returns Observable created track + */ + public createReleaseTrack(body: CreateReleaseTrackPayload): Observable { + const url = `${this.apiUrl}/release-tracks/new`; + return this.http.post(url, body).pipe( + tap(result => logger.log('created release track', result)), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * POST /api/release-tracks/new-from-bundle + * Bootstrap a release track from a STIX bundle. + * @param body STIX bundle + * @returns Observable creation result + */ + public createReleaseTrackFromBundle( + body: StixBundlePayload + ): Observable { + const url = `${this.apiUrl}/release-tracks/new-from-bundle`; + return this.http.post(url, body).pipe( + tap(result => logger.log('created release track from bundle', result)), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * POST /api/release-tracks/import + * Import a release track (server may return 501 Not Implemented). + * @param body Import payload (TBD) + * @returns Observable server response + */ + public importReleaseTrack(body: any): Observable { + const url = `${this.apiUrl}/release-tracks/import`; + return this.http.post(url, body).pipe( + tap(result => logger.log('imported release track', result)), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * GET /api/release-tracks/:id/snapshots/latest + * Get latest snapshot for a track. + * @param id Release track id + * @param options Query options forwarded to endpoint + * @returns Observable snapshot or list + */ + public getLatestSnapshot( + id: string, + options?: ReleaseTrackSnapshotOptions + ): Observable { + const params = this.buildHttpParams(options); + const url = `${this.apiUrl}/release-tracks/${id}/snapshots/latest`; + return this.http.get(url, { params }).pipe( + tap(result => + logger.log(`retrieved latest snapshot for track ${id}`, result) + ), + map(result => (result ? new ReleaseTrackSnapshot(result) : null)), + catchError(this.handleError_continue(null)), + share() + ); + } + + /** + * GET /api/release-tracks/:id/snapshots + * List snapshot history summaries. + * @param id Release track id + * @param options Pagination and tagged filter options + * @returns Observable paginated snapshot summaries + */ + public listSnapshots( + id: string, + options: SnapshotHistoryOptions = {} + ): Observable> { + const params = this.buildHttpParams({ + limit: 200, + offset: 0, + ...options, + }); + const url = `${this.apiUrl}/release-tracks/${id}/snapshots`; + return this.http + .get>(url, { + params, + }) + .pipe( + tap(result => + logger.log(`retrieved snapshots for track ${id}`, result) + ), + catchError( + this.handleError_raise>() + ), + share() + ); + } + + /** + * GET /api/release-tracks/:id/snapshots/latest?format=:format + * GET /api/release-tracks/:id/snapshots/latest?format=:format + * Retrieve the latest snapshot in an export format without deserializing it. + * @param id Release track id + * @param format Export format + * @param options Additional query options + * @returns Observable formatted export payload + */ + public exportLatestSnapshot( + id: string, + format: ExportFormatType, + options?: Omit + ): Observable { + const params = this.buildHttpParams({ ...options, format }); + const url = `${this.apiUrl}/release-tracks/${id}/snapshots/latest`; + return this.http.get(url, { params }).pipe( + tap(result => + logger.log( + `retrieved latest snapshot export for track ${id} in ${format} format`, + result + ) + ), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * GET /api/release-tracks/:id/snapshots/:modified?format=:format + * Retrieve a specific snapshot in an export format without deserializing it. + * @param id Release track id + * @param modified ISO timestamp identifying the snapshot + * @param format Export format + * @param options Additional query options + * @returns Observable formatted export payload + */ + public exportSnapshotByModified( + id: string, + modified: string, + format: ExportFormatType, + options?: Omit + ): Observable { + const params = this.buildHttpParams({ ...options, format }); + const url = `${this.apiUrl}/release-tracks/${id}/snapshots/${encodeURIComponent(modified)}`; + return this.http.get(url, { params }).pipe( + tap(result => + logger.log( + `retrieved snapshot ${modified} export for track ${id} in ${format} format`, + result + ) + ), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * POST /api/release-tracks/:id/meta + * Update metadata for latest snapshot (creates new snapshot). + * @param id Release track id + * @param body Metadata payload + * @param userAccountId Optional user account id appended to payload + * @returns Observable + */ + public updateMetadataByLatest( + id: string, + body: UpdateMetadataPayload, + userAccountId?: string + ): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/meta`; + const payload = userAccountId ? { ...body, userAccountId } : body; + return this.http.post(url, payload).pipe( + tap(result => logger.log(`updated metadata for track ${id}`, result)), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * GET /api/release-tracks/:id/snapshots/latest/release/preview + * GET /api/release-tracks/:id/snapshots/:modified/release/preview + * Preview a release for a track snapshot. + * @param id Release track id + * @param format Optional output format + * @param modified Optional snapshot modified timestamp + * @returns Observable preview payload + */ + public previewRelease( + id: string, + options: ReleasePreviewOptions = { format: ReleasePreviewFormat.Summary }, + modified?: string + ): Observable { + const params = this.buildHttpParams({ + format: ReleasePreviewFormat.Summary, + ...options, + }); + const snapshotPath = modified + ? `snapshots/${encodeURIComponent(modified)}` + : 'snapshots/latest'; + const url = `${this.apiUrl}/release-tracks/${id}/${snapshotPath}/release/preview`; + return this.http.get(url, { params }).pipe( + tap(result => + logger.log(`generated release preview for track ${id}`, result) + ), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * POST /api/release-tracks/:id/snapshots/latest/release + * Release/tag the latest snapshot. + * @param id Release track id + * @param body Release version selector + * @returns Observable + */ + public releaseLatest(id: string, body: ReleasePayload): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/snapshots/latest/release`; + return this.http.post(url, body).pipe( + tap(result => + logger.log(`released latest snapshot for track ${id}`, result) + ), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * POST /api/release-tracks/:id/clone + * Clone a new release track from the latest snapshot. + * @param id Source release track id + * @param body Clone options + * @returns Observable + */ + public cloneByLatest(id: string, body?: ClonePayload): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/clone`; + return this.http.post(url, body || {}).pipe( + tap(result => logger.log(`cloned track ${id}`, result)), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * DELETE /api/release-tracks/:id + * Delete a release track. + * @param id Release track id + * @returns Observable + */ + public deleteReleaseTrack(id: string): Observable { + const params = this.buildHttpParams({ confirm_track_id: id }); + const url = `${this.apiUrl}/release-tracks/${id}`; + return this.http.delete(url, { params }).pipe( + tap(() => logger.log(`deleted track ${id}`)), + catchError(this.handleError_raise()), + share() + ); + } + + // ----------------------------------------------------------------------------- + // Snapshot Operations + // ----------------------------------------------------------------------------- + + /** + * GET /api/release-tracks/:id/snapshots/:modified + * Get a specific snapshot by modified timestamp. + * @param id Release track id + * @param modified ISO timestamp identifying the snapshot + * @param options Optional query params + * @returns Observable + */ + public retrieveSnapshotByModified( + id: string, + modified: string, + options?: ReleaseTrackSnapshotOptions + ): Observable { + const params = this.buildHttpParams(options); + const url = `${this.apiUrl}/release-tracks/${id}/snapshots/${encodeURIComponent(modified)}`; + return this.http.get(url, { params }).pipe( + tap(result => + logger.log(`retrieved snapshot ${modified} for track ${id}`, result) + ), + map(result => (result ? new ReleaseTrackSnapshot(result) : null)), + catchError(this.handleError_continue(null)), + share() + ); + } + + /** + * PUT /api/release-tracks/:id/snapshots/:modified/description + * Replace or clear the user-authored notes on one snapshot. + */ + public updateSnapshotDescription( + id: string, + modified: string, + body: { description: string } + ): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/snapshots/${encodeURIComponent(modified)}/description`; + return this.http.put(url, body).pipe( + tap(result => + logger.log(`updated snapshot notes for ${modified}`, result) + ), + catchError( + this.handleError_raise(false) + ), + share() + ); + } + + /** + * POST /api/release-tracks/:id/snapshots/:modified/release + * Tag/release a specific snapshot. + * @param id Release track id + * @param modified Snapshot modified timestamp + * @param body Release version selector + * @returns Observable + */ + public releaseSnapshot( + id: string, + modified: string, + body: ReleasePayload + ): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/snapshots/${encodeURIComponent(modified)}/release`; + return this.http.post(url, body).pipe( + tap(result => logger.log(`released snapshot ${modified}`, result)), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * POST /api/release-tracks/:id/snapshots/:modified/graph + * Materialize the deterministic member graph for a tagged snapshot. + * @param id Release track id + * @param modified Snapshot modified timestamp + * @returns Observable snapshot containing its opaque graph manifest id + */ + public createSnapshotGraph( + id: string, + modified: string + ): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/snapshots/${encodeURIComponent(modified)}/graph`; + return this.http.post(url, {}).pipe( + tap(result => + logger.log( + `created deterministic graph for snapshot ${modified}`, + result + ) + ), + catchError( + this.handleError_raise(false) + ), + share() + ); + } + + /** + * DELETE /api/release-tracks/:id/snapshots/:modified/graph + * Remove the deterministic member graph from a tagged snapshot. + * @param id Release track id + * @param modified Snapshot modified timestamp + * @returns Observable + */ + public deleteSnapshotGraph( + id: string, + modified: string + ): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/snapshots/${encodeURIComponent(modified)}/graph`; + return this.http.delete(url).pipe( + tap(() => + logger.log( + `deleted deterministic graph for snapshot ${modified} from track ${id}` + ) + ), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * POST /api/release-tracks/:id/snapshots/:modified/clone + * Clone a new release track from a specific snapshot. + * @param id Release track id + * @param modified Snapshot modified timestamp + * @param body Clone options + * @returns Observable + */ + public cloneByModified( + id: string, + modified: string, + body?: ClonePayload + ): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/snapshots/${encodeURIComponent(modified)}/clone`; + return this.http.post(url, body || {}).pipe( + tap(result => logger.log(`cloned from snapshot ${modified}`, result)), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * DELETE /api/release-tracks/:id/snapshots/:modified + * Delete the latest untagged draft snapshot. + * @param id Release track id + * @param modified Snapshot modified timestamp + * @returns Observable + */ + public deleteSnapshotByModified( + id: string, + modified: string + ): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/snapshots/${encodeURIComponent(modified)}`; + return this.http.delete(url).pipe( + tap(() => logger.log(`deleted snapshot ${modified} from track ${id}`)), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * POST /api/release-tracks/:id/virtual/snapshots/create + * Resolve component tracks and create a new draft snapshot for a virtual track. + * @param id Release track id + * @param body Optional snapshot creation options + * @returns Observable + */ + public createVirtualSnapshot( + id: string, + body?: { description?: string } + ): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/virtual/snapshots/create`; + return this.http.post(url, body || {}).pipe( + tap(result => + logger.log(`created virtual snapshot for track ${id}`, result) + ), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * PUT /api/release-tracks/:id/virtual/composition + * Update the composition rules for a virtual release track. + * @param id Release track id + * @param body Composition payload + * @returns Observable + */ + public updateComposition( + id: string, + body: Composition, + userAccountId?: string + ): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/virtual/composition`; + const payload = userAccountId ? { ...body, userAccountId } : body; + return this.http.put(url, payload).pipe( + tap(result => + logger.log(`updated virtual composition for track ${id}`, result) + ), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * POST /api/release-tracks/:id/virtual/quarantine/promote + * Resolve one quarantined object to an exact revision in a new virtual draft. + * @param id Virtual release track id + * @param body Exact quarantined object revision to promote + * @returns Observable + */ + public promoteQuarantinedRevision( + id: string, + body: PromoteQuarantinePayload + ): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/virtual/quarantine/promote`; + return this.http.post(url, body).pipe( + tap(result => + logger.log(`promoted quarantined revision for track ${id}`, result) + ), + catchError(this.handleError_raise()), + share() + ); + } + + // ----------------------------------------------------------------------------- + // Candidate Management + // ----------------------------------------------------------------------------- + + /** + * POST /api/release-tracks/:id/candidates + * Add candidate references to latest draft. + * @param id Release track id + * @param object_refs Array of STIX ids or object refs + * @param userAccountId Optional user + * @returns Observable + */ + public addCandidates( + id: string, + object_refs: StixObjectRef[], + userAccountId?: string + ): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/candidates`; + const payload = userAccountId + ? { object_refs, userAccountId } + : { object_refs }; + return this.http.post(url, payload).pipe( + tap(result => logger.log(`added candidates to track ${id}`, result)), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * GET /api/release-tracks/:id/candidates + * List candidates from latest snapshot. + * @param id Release track id + * @param options Query options: status|limit|offset + * @returns Observable + */ + public listCandidates( + id: string, + options?: { status?: string; limit?: number; offset?: number } + ): Observable { + const params = this.buildHttpParams(options); + const url = `${this.apiUrl}/release-tracks/${id}/candidates`; + return this.http.get(url, { params }).pipe( + tap(() => logger.log(`listed candidates for track ${id}`)), + catchError(this.handleError_continue(null)), + share() + ); + } + + /** + * DELETE /api/release-tracks/:id/candidates/:objectRef + * Remove a candidate. + * @param id Release track id + * @param objectRef STIX object id + * @returns Observable + */ + public removeCandidate(id: string, objectRef: string): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/candidates/${objectRef}`; + return this.http.delete(url).pipe( + tap(() => logger.log(`removed candidate ${objectRef} from track ${id}`)), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * POST /api/release-tracks/:id/candidates/review + * Bulk transition candidate statuses. + * @param id Release track id + * @param body Review payload + * @param userAccountId Optional user + * @returns Observable + */ + public reviewCandidates( + id: string, + body: ReviewPayload, + userAccountId?: string + ): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/candidates/review`; + const payload = userAccountId ? { ...body, userAccountId } : body; + return this.http.post(url, payload).pipe( + tap(result => logger.log(`reviewed candidates for track ${id}`, result)), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * POST /api/release-tracks/:id/candidates/promote + * Promote candidates to staged. + * @param id Release track id + * @param object_refs Array of STIX ids + * @param userAccountId Optional user + * @returns Observable + */ + public promoteCandidates( + id: string, + object_refs: string[], + userAccountId?: string + ): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/candidates/promote`; + const payload = userAccountId + ? { object_refs, userAccountId } + : { object_refs }; + return this.http.post(url, payload).pipe( + tap(result => logger.log(`promoted candidates for track ${id}`, result)), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * POST /api/release-tracks/:id/candidates/:objectRef/update-version + * Update candidate version pin. + * @param id Release track id + * @param objectRef STIX object id + * @param body Version update payload + * @returns Observable + */ + public updateCandidateVersion( + id: string, + objectRef: string, + body: { old_modified?: string; new_modified?: string } + ): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/candidates/${objectRef}/update-version`; + return this.http.post(url, body).pipe( + tap(result => + logger.log(`updated version for candidate ${objectRef}`, result) + ), + catchError(this.handleError_raise()), + share() + ); + } + + // ----------------------------------------------------------------------------- + // Staged Object Management + // ----------------------------------------------------------------------------- + + /** + * GET /api/release-tracks/:id/staged + * List staged objects. + * @param id Release track id + * @returns Observable + */ + public listStaged(id: string): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/staged`; + return this.http.get(url).pipe( + tap(() => logger.log(`listed staged objects for track ${id}`)), + catchError(this.handleError_continue(null)), + share() + ); + } + + /** + * POST /api/release-tracks/:id/staged/demote + * Demote staged objects to candidates. + * @param id Release track id + * @param object_refs Array of STIX ids + * @param userAccountId Optional user + * @returns Observable + */ + public demoteStaged( + id: string, + object_refs: StixObjectRef[], + userAccountId?: string + ): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/staged/demote`; + const payload = userAccountId + ? { object_refs, userAccountId } + : { object_refs }; + return this.http.post(url, payload).pipe( + tap(result => + logger.log(`demoted staged objects for track ${id}`, result) + ), + catchError(this.handleError_raise()), + share() + ); + } + + // ----------------------------------------------------------------------------- + // Release Track Configuration + // ----------------------------------------------------------------------------- + + /** + * GET /api/release-tracks/:id/config + * Get track configuration. + * @param id Release track id + * @returns Observable + */ + public getConfig(id: string): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/config`; + return this.http.get(url).pipe( + tap(() => logger.log(`retrieved config for track ${id}`)), + catchError(this.handleError_continue(null)), + share() + ); + } + + /** + * PUT /api/release-tracks/:id/config + * Update track configuration. + * @param id Release track id + * @param body Configuration payload + * @param userAccountId Optional user + * @returns Observable + */ + public updateConfig( + id: string, + body: Partial, + userAccountId?: string + ): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/config`; + const payload = userAccountId ? { ...body, userAccountId } : body; + return this.http.put(url, payload).pipe( + tap(result => logger.log(`updated config for track ${id}`, result)), + catchError(this.handleError_raise()), + share() + ); + } + + // ----------------------------------------------------------------------------- + // Version Management + // ----------------------------------------------------------------------------- + + /** + * GET /api/release-tracks/:id/objects/:objectRef/versions + * List object versions in a track. + * @param id Release track id + * @param objectRef STIX object id + * @returns Observable + */ + public listObjectVersions(id: string, objectRef: string): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/objects/${objectRef}/versions`; + return this.http.get(url).pipe( + tap(() => logger.log(`listed versions for object ${objectRef}`)), + catchError(this.handleError_continue(null)), + share() + ); + } +} diff --git a/src/app/services/connectors/rest-api/rest-api-connector.service.ts b/src/app/services/connectors/rest-api/rest-api-connector.service.ts index 30c9670dc..c7545f966 100644 --- a/src/app/services/connectors/rest-api/rest-api-connector.service.ts +++ b/src/app/services/connectors/rest-api/rest-api-connector.service.ts @@ -49,6 +49,7 @@ import { AttackTypeToPlural } from 'src/app/utils/type-mappings'; import { AttackType } from 'src/app/utils/types'; import { environment } from '../../../../environments/environment'; import { logger } from '../../../utils/logger'; +import { serializeJsonForDownload } from '../../../utils/json-download'; import { ApiConnector } from '../api-connector'; import { CollectionStreamService, @@ -69,6 +70,20 @@ export interface Namespace { range_start: string; } +export interface ValidationBypassRule { + _id?: string; + id?: string; + fieldPath: string[]; + errorCode: string; + stixType: string; + suppressError: boolean; + autoCreated?: boolean; + autoCreatedReason?: string | null; + triggerEvent?: string | null; + warningMessage?: string | null; + __v?: number; +} + export interface MitreIdentityWrites { enabled: boolean; } @@ -517,6 +532,7 @@ export class RestApiConnectorService extends ApiConnector { revoked?: boolean; deprecated?: boolean; deserialize?: boolean; + versions?: 'all' | 'latest'; lastUpdatedBy?: string[]; search?: string; }) { @@ -540,6 +556,7 @@ export class RestApiConnectorService extends ApiConnector { 'includeDeprecated', options.deprecated ? 'true' : 'false' ); + if (options?.versions) query = query.set('versions', options.versions); // searching if (options?.search) query = query.set('search', options.search); // lastUpdatedBy @@ -2634,7 +2651,7 @@ export class RestApiConnectorService extends ApiConnector { return this.http .get(`${this.apiUrl}/config/mitre-identity-writes`) .pipe( - tap(_ => logger.log('retrieved MITRE identity write setting')), + tap(() => logger.log('retrieved MITRE identity write setting')), catchError( this.handleError_continue({ enabled: false }) ), @@ -2657,6 +2674,94 @@ export class RestApiConnectorService extends ApiConnector { ); } + /** + * Get all ADM validation bypass rules. + * @returns {Observable} validation bypass rules + */ + public getValidationBypassRules(): Observable { + return this.http + .get(`${this.apiUrl}/config/validation-bypasses`) + .pipe( + tap(() => logger.log('retrieved validation bypass rules')), + catchError(this.handleError_continue([])), + share() + ); + } + + /** + * Get one ADM validation bypass rule. + * @param id validation bypass rule id + * @returns {Observable} validation bypass rule + */ + public getValidationBypassRule(id: string): Observable { + return this.http + .get( + `${this.apiUrl}/config/validation-bypasses/${id}` + ) + .pipe( + tap(() => logger.log('retrieved validation bypass rule')), + catchError(this.handleError_continue()), + share() + ); + } + + /** + * Create an ADM validation bypass rule. + * @param rule validation bypass rule to create + * @returns {Observable} created validation bypass rule + */ + public postValidationBypassRule( + rule: ValidationBypassRule + ): Observable { + return this.http + .post( + `${this.apiUrl}/config/validation-bypasses`, + rule + ) + .pipe( + tap(this.handleSuccess('validation bypass rule saved')), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * Update an ADM validation bypass rule. + * @param id validation bypass rule id + * @param rule validation bypass rule updates + * @returns {Observable} updated validation bypass rule + */ + public putValidationBypassRule( + id: string, + rule: ValidationBypassRule + ): Observable { + return this.http + .put( + `${this.apiUrl}/config/validation-bypasses/${id}`, + rule + ) + .pipe( + tap(this.handleSuccess('validation bypass rule saved')), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * Delete an ADM validation bypass rule. + * @param id validation bypass rule id + * @returns {Observable} observable of the response body + */ + public deleteValidationBypassRule(id: string): Observable { + return this.http + .delete(`${this.apiUrl}/config/validation-bypasses/${id}`) + .pipe( + tap(this.handleSuccess('validation bypass rule deleted')), + catchError(this.handleError_raise()), + share() + ); + } + /** * Get the next available ATT&CK ID for a given STIX type * @param {string} stixType the STIX type (e.g., 'attack-pattern', 'x-mitre-tactic') @@ -2946,7 +3051,7 @@ export class RestApiConnectorService extends ApiConnector { */ public triggerBrowserDownload(data: any, filename: string) { const url = URL.createObjectURL( - new Blob([JSON.stringify(data, null, 4)], { type: 'text/json' }) + new Blob([serializeJsonForDownload(data)], { type: 'text/json' }) ); const downloadLink = document.createElement('a'); downloadLink.href = url; diff --git a/src/app/services/editor/editor.service.ts b/src/app/services/editor/editor.service.ts index 18b2112b0..3d27f3da0 100644 --- a/src/app/services/editor/editor.service.ts +++ b/src/app/services/editor/editor.service.ts @@ -69,14 +69,6 @@ export class EditorService { this.hasWorkflow = attackType !== 'home' && this.type !== 'identity'; if (!(this.editable && this.hasWorkflow)) this.sidebarService.currentTab = 'search'; - this.sidebarService.setEnabled( - 'history', - this.editable && this.hasWorkflow && !this.router.url.includes('/new') - ); - this.sidebarService.setEnabled( - 'notes', - this.editable && this.hasWorkflow - ); this.isGroup = false; if (this.editable) { this.sidebarService.currentTab = 'references'; diff --git a/src/app/services/sidebar/sidebar.service.ts b/src/app/services/sidebar/sidebar.service.ts index efbe77ca2..878d59e98 100644 --- a/src/app/services/sidebar/sidebar.service.ts +++ b/src/app/services/sidebar/sidebar.service.ts @@ -31,14 +31,9 @@ export class SidebarService { icon: 'superscript', enabled: true, }, - { - name: 'history', - icon: 'history', - enabled: false, - }, { name: 'notes', - icon: 'sticky_note_2_outlined', + icon: 'sticky_note_2', enabled: true, }, ]; @@ -61,7 +56,7 @@ export class SidebarService { // intentionally left blank } } -export type tabOption = 'search' | 'references' | 'history' | 'notes'; +export type tabOption = 'search' | 'references' | 'notes'; interface TabDefinition { name: tabOption; // the tab name diff --git a/src/app/services/user-account-events/user-account-events.service.ts b/src/app/services/user-account-events/user-account-events.service.ts new file mode 100644 index 000000000..bda2f51a5 --- /dev/null +++ b/src/app/services/user-account-events/user-account-events.service.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@angular/core'; +import { Subject } from 'rxjs'; + +@Injectable({ + providedIn: 'root', +}) +export class UserAccountEventsService { + private readonly userAccountsChangedSubject = new Subject(); + + public readonly userAccountsChanged$ = + this.userAccountsChangedSubject.asObservable(); + + public notifyUserAccountsChanged(): void { + this.userAccountsChangedSubject.next(); + } +} diff --git a/src/app/testing/mocks/rest-api-connector.mock.ts b/src/app/testing/mocks/rest-api-connector.mock.ts index 1b5a4e7f7..a70a1bf5c 100644 --- a/src/app/testing/mocks/rest-api-connector.mock.ts +++ b/src/app/testing/mocks/rest-api-connector.mock.ts @@ -60,3 +60,7 @@ export function createMockRestApiConnector(config?: any): any { return { ...defaults, ...config }; } + +export function createMockReleaseTrackApiConnector(config?: any): any { + return { ...config }; +} diff --git a/src/app/utils/json-download.spec.ts b/src/app/utils/json-download.spec.ts new file mode 100644 index 000000000..15d5708f3 --- /dev/null +++ b/src/app/utils/json-download.spec.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; + +import { serializeJsonForDownload } from './json-download'; + +describe('JSON download utilities', () => { + it('serializes the exact indented JSON content expected by backend hashes', () => { + const content = serializeJsonForDownload({ type: 'bundle', objects: [] }); + + expect(content).toBe( + ['{', ' "type": "bundle",', ' "objects": []', '}'].join('\n') + ); + }); +}); diff --git a/src/app/utils/json-download.ts b/src/app/utils/json-download.ts new file mode 100644 index 000000000..eefda5a77 --- /dev/null +++ b/src/app/utils/json-download.ts @@ -0,0 +1,3 @@ +export function serializeJsonForDownload(data: unknown): string { + return JSON.stringify(data, null, 4); +} diff --git a/src/app/utils/types.ts b/src/app/utils/types.ts index 50e02d2db..68a34ac0f 100644 --- a/src/app/utils/types.ts +++ b/src/app/utils/types.ts @@ -1,3 +1,9 @@ +import { + ReleaseTrackObjectTier, + StixObjectRef, +} from '../classes/release-tracks'; +import { StixObject } from '../classes/stix'; + /** * ATT&CK type definitions */ @@ -44,25 +50,70 @@ export type StixType = | 'x-mitre-analytic'; /** - * Workflow state definitions + * Workflow status definitions */ -export type WorkflowState = 'work-in-progress' | 'awaiting-review' | 'reviewed'; +export enum WorkflowStatus { + WorkInProgress = 'work-in-progress', + AwaitingReview = 'awaiting-review', + Reviewed = 'reviewed', +} -/** - * List of all workflow states - */ -export const WorkflowStates: Record = { - 'work-in-progress': 'work in progress', - 'awaiting-review': 'awaiting review', - 'reviewed': 'reviewed', +export type WorkflowStatusType = + | WorkflowStatus.WorkInProgress + | WorkflowStatus.AwaitingReview + | WorkflowStatus.Reviewed; + +export const WORKFLOW_STATUS_LABELS: Record = { + [WorkflowStatus.WorkInProgress]: 'WIP', + [WorkflowStatus.AwaitingReview]: 'Awaiting Review', + [WorkflowStatus.Reviewed]: 'Reviewed', +}; + +export const WorkflowStatusMap = WORKFLOW_STATUS_LABELS; + +export interface WorkflowStatusOption { + value: WorkflowStatusType; + label: string; +} + +export const WORKFLOW_STATUS_OPTIONS: WorkflowStatusOption[] = [ + { + value: WorkflowStatus.WorkInProgress, + label: WORKFLOW_STATUS_LABELS[WorkflowStatus.WorkInProgress], + }, + { + value: WorkflowStatus.AwaitingReview, + label: WORKFLOW_STATUS_LABELS[WorkflowStatus.AwaitingReview], + }, + { + value: WorkflowStatus.Reviewed, + label: WORKFLOW_STATUS_LABELS[WorkflowStatus.Reviewed], + }, +]; + +export const WORKFLOW_STATUS_RANK: Record = { + [WorkflowStatus.WorkInProgress]: 0, + [WorkflowStatus.AwaitingReview]: 1, + [WorkflowStatus.Reviewed]: 2, }; /** * Collection/release changelog categories */ export type ChangelogCategory = - | 'additions' - | 'changes' - | 'minor_changes' - | 'revocations' - | 'deprecations'; + 'additions' | 'changes' | 'minor_changes' | 'revocations' | 'deprecations'; + +export interface ReleaseTrackStatus { + trackId: string; + name: string; + description: string; + tier: ReleaseTrackObjectTier | null; + status: WorkflowStatusType; + objectRef: StixObjectRef; +} + +export interface WorkflowStatusDialogData { + object: StixObject; + targetStatus: WorkflowStatusType; + track: ReleaseTrackStatus; +} diff --git a/src/app/views/dashboard-page/dashboard-page.component.html b/src/app/views/dashboard-page/dashboard-page.component.html index aa9236846..6a09e4a22 100644 --- a/src/app/views/dashboard-page/dashboard-page.component.html +++ b/src/app/views/dashboard-page/dashboard-page.component.html @@ -1,63 +1,440 @@ -
-
-
-

Dashboard

+
+
+
+

Knowledge Base Overview

-
-
-
-
- - -
+
+ + Include revoked + + + Include deprecated + + +
-
-
-

Admin Settings

-
+ + + +
+ {{ loadError }}
-
-
-
- - - - + + +
+
+
All Objects
+
{{ formatCount(totalObjects) }}
-
-
+ +
+
{{ summary.label }}
+
{{ formatCount(summary.count) }}
+
+ + +
+
+
+

Objects by Domain

+ {{ selectedPathLabel }} +
+ +
+
+ + + + {{ segmentTooltip(segment) }} + + + + + + {{ selectedNode?.label || 'ATT&CK Objects' }} + + + {{ formatCount(selectedNode?.value || totalAssignments) }} + + + +
+ + +
+
+ +
+
+

Relationships by Type

+ {{ selectedRelationshipPathLabel }} +
+ +
+
+ + + + {{ segmentTooltip(segment) }} + + + + + + {{ selectedRelationshipNode?.label || 'Relationships' }} + + + {{ + formatCount( + selectedRelationshipNode?.value || totalRelationships + ) + }} + + + +
+ + +
+
+
+ +
+
+

Timeline Analytics

+
+ +
+
+
+
+

Object Growth Over Time

+
+ + +
+
+ {{ formatCount(objectGrowthTotal) }} +
+ + +
+
+ + + + + + + + + + + {{ tick.label }} + + + + {{ objectGrowthYAxisLabels[0] }} + + + {{ objectGrowthYAxisLabels[1] }} + + + {{ objectGrowthYAxisLabels[2] }} + + + + {{ series.label }}: {{ formatCount(series.total) }} + + + +
+ +
+ +
+
+
+
+
+
+
+ + +
No domain-scoped ATT&CK objects were found.
+
+ + +
No relationships were found.
+
+ + +
No dated objects found.
+
diff --git a/src/app/views/dashboard-page/dashboard-page.component.scss b/src/app/views/dashboard-page/dashboard-page.component.scss index 37788dde9..cffc68beb 100644 --- a/src/app/views/dashboard-page/dashboard-page.component.scss +++ b/src/app/views/dashboard-page/dashboard-page.component.scss @@ -1,23 +1,639 @@ -@use '../../../style/globals'; +@use 'sass:color'; @use '../../../style/colors'; .dashboard-page { - .display-icon .mat-icon { - font-size: 48px; - width: 48px; - height: 48px; + width: min(1180px, 100%); + max-width: calc(100vw - 32px); + + .dashboard-header, + .dashboard-actions, + .sunburst-card-heading, + .timeline-card-heading, + .timeline-legend, + .domain-filter, + .drilldown-label { + display: flex; + align-items: center; + } + + .dashboard-header { + justify-content: space-between; + gap: 20px; + margin-bottom: 18px; + + h1 { + margin: 0; + font-size: 28px; + line-height: 34px; + text-align: left; + } + } + + .dashboard-actions { + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px 16px; + } + + .summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; + margin-bottom: 16px; + } + + .summary-card { + min-width: 0; + border-top: 4px solid var(--summary-color, #{colors.color(info)}); + border-radius: 4px; + padding: 14px 16px 12px; + + &.total { + border-top-color: colors.color(primary); + } + + &.object-type { + border-top-color: var(--summary-color, #{colors.color(info)}); + } + } + + .summary-label, + .sunburst-card-heading span, + .breadcrumb-list button, + .breadcrumb-separator, + .chart-empty { + font-size: 13px; + line-height: 18px; } - button { - width: 225px; - height: fit-content !important; - margin: 10px; + + .summary-label { + font-weight: 700; + } + + .summary-value { + margin-top: 8px; + font-size: 30px; + font-weight: 800; + line-height: 34px; + } + + .sunburst-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + align-items: start; + } + + .sunburst-card { + min-width: 0; + border: 1px solid; + border-radius: 4px; + padding: 16px; + } + + .sunburst-card-heading { + justify-content: space-between; + align-items: flex-start; + gap: 12px; + margin-bottom: 12px; h2 { - white-space: normal; + margin: 0; + font-size: 20px; + line-height: 26px; + text-align: left; + } + + span { + min-width: 0; + overflow-wrap: anywhere; + text-align: right; + } + } + + .sunburst-card-body { + display: grid; + gap: 12px; + align-items: start; + } + + .sunburst-card, + .summary-card, + .empty-state, + .dashboard-error { + border: 1px solid; + } + + .sunburst-panel { + display: flex; + justify-content: center; + min-width: 0; + } + + .sunburst { + width: min(100%, 420px); + height: auto; + aspect-ratio: 1; + } + + .sunburst-segment { + stroke: #ffffff; + stroke-width: 1; + cursor: pointer; + transition: + opacity 120ms ease, + filter 120ms ease, + stroke-width 120ms ease; + + &:hover, + &:focus-visible, + &.selected { + filter: brightness(1.08); + stroke-width: 2.5; + outline: none; + } + } + + .sunburst-center { + cursor: pointer; + + circle { + stroke-width: 1; + } + + text { + font-size: 11px; + font-weight: 700; + text-anchor: middle; + pointer-events: none; + } + + .sunburst-center-count { + font-size: 15px; + font-weight: 800; + } + } + + .drilldown-panel { + min-width: 0; + } + + .drilldown-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-width: 0; + } + + .breadcrumb-list { + display: flex; + flex: 1 1 auto; + flex-wrap: wrap; + align-items: center; + gap: 2px 6px; + min-width: 0; + + button { + min-width: 0; + height: 30px; + padding: 0; + font-weight: 700; + } + } + + .breadcrumb-separator { + font-weight: 800; + } + + .selected-total { + flex: 0 0 auto; + font-size: 28px; + font-weight: 800; + line-height: 34px; + text-align: right; + } + + .drilldown-list { + display: grid; + gap: 8px; + margin-top: 12px; + } + + .drilldown-item { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + width: 100%; + border: 1px solid; + border-radius: 4px; + padding: 9px 10px; + font: inherit; + text-align: left; + cursor: pointer; + } + + .drilldown-label { + min-width: 0; + gap: 8px; + } + + .drilldown-swatch { + flex: 0 0 auto; + width: 10px; + height: 10px; + border-radius: 50%; + } + + .timeline-section { + margin-top: 16px; + } + + .timeline-heading { + margin-bottom: 12px; + + h2 { + margin: 0; + font-size: 22px; + line-height: 28px; + text-align: left; + } + } + + .timeline-grid { + display: grid; + grid-template-columns: 1fr; + gap: 16px; + } + + .timeline-card { + min-width: 0; + border: 1px solid; + border-radius: 4px; + padding: 16px; + } + + .timeline-card-heading { + justify-content: space-between; + align-items: flex-start; + gap: 12px; + margin-bottom: 14px; + + h3 { + margin: 0; + font-size: 18px; + line-height: 24px; + text-align: left; + } + + strong { + flex: 0 0 auto; + font-size: 18px; + line-height: 24px; + text-align: right; + } + } + + .growth-heading { + margin-bottom: 10px; + } + + .timeline-chart-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(180px, 240px); + gap: 16px; + align-items: start; + } + + .domain-filter { + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; + + button { + border: 1px solid; + border-radius: 4px; + padding: 4px 8px; + font: inherit; + font-size: 11px; + line-height: 15px; + cursor: pointer; + } + } + + .timeline-chart { + display: block; + width: 100%; + height: auto; + margin-top: 4px; + } + + .chart-grid-line { + stroke-width: 0.45; + } + + .chart-axis { + stroke-width: 0.6; + } + + .chart-axis-tick { + stroke-width: 0.55; + } + + .chart-axis-label { + font-size: 7.25px; + font-weight: 700; + } + + .chart-year-line { + stroke-width: 0.5; + stroke-dasharray: 3 4; + } + + .chart-year-label { + text-anchor: middle; + } + + .timeline-line { + cursor: pointer; + opacity: 1; + pointer-events: stroke; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.6; + transition: + filter 120ms ease, + opacity 120ms ease, + stroke-width 120ms ease; + + &.dimmed { + opacity: 0.18; + } + + &.selected, + &:focus-visible { + filter: brightness(1.08); + outline: none; + stroke-width: 2.1; + } + } + + .timeline-legend { + display: grid; + gap: 8px; + margin-top: 4px; + font-size: 11px; + line-height: 15px; + } + + .timeline-legend-item { + width: 100%; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 6px; + padding: 4px 5px; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + transition: + background 120ms ease, + border-color 120ms ease, + opacity 120ms ease; + + &.dimmed { + opacity: 0.48; + } + + &.selected { + font-weight: 700; + opacity: 1; } + + &:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; + } + + strong { + font-weight: 800; + text-align: right; + } + } + + .timeline-swatch { + flex: 0 0 auto; + width: 9px; + height: 9px; + border-radius: 50%; } - .mat-badge-content.mat-badge-active { - color: colors.on-color(pending); - background-color: colors.color(pending); + + .chart-empty { + padding: 12px; + text-align: center; + } + + .empty-state, + .dashboard-error { + border-radius: 4px; + padding: 18px 12px; + text-align: center; + } + + .dashboard-error { + color: colors.color(error); + } + + .light & { + .sunburst-card-heading span, + .breadcrumb-separator, + .chart-axis-label, + .chart-empty { + color: colors.on-color-deemphasis(light); + } + + .sunburst-card, + .timeline-card, + .summary-card, + .empty-state, + .dashboard-error { + border-color: colors.border-color(light); + background: #ffffff; + } + + .summary-card, + .drilldown-item, + .chart-empty { + background: rgba(colors.color(mitre-black), 0.025); + } + + .chart-grid-line { + stroke: rgba(colors.color(mitre-black), 0.12); + } + + .chart-year-line { + stroke: rgba(colors.color(mitre-black), 0.14); + } + + .chart-axis, + .chart-axis-tick { + stroke: colors.border-color(light); + } + + .timeline-legend-item { + &:hover, + &.selected { + border-color: rgba(colors.color(primary), 0.18); + background: color.mix(colors.color(mitre-light-blue), #ffffff, 24%); + } + } + + .domain-filter button { + border-color: colors.border-color(light); + background: #ffffff; + color: colors.color(dark); + + &.selected, + &:hover { + background: color.mix(colors.color(mitre-light-blue), #ffffff, 28%); + } + } + + .sunburst-center circle { + fill: #ffffff; + stroke: colors.border-color(light); + } + + .sunburst-center text { + fill: colors.color(dark); + } + + .drilldown-item { + border-color: colors.border-color(light); + color: colors.color(dark); + } + } + + .dark & { + .sunburst-card-heading span, + .breadcrumb-separator, + .chart-axis-label, + .chart-empty { + color: colors.on-color-deemphasis(dark); + } + + .sunburst-card, + .timeline-card, + .summary-card, + .empty-state, + .dashboard-error { + border-color: colors.border-color(dark); + background: color.mix(colors.color(dark), #ffffff, 94%); + } + + .summary-card, + .drilldown-item, + .chart-empty { + background: rgba(colors.color(mitre-silver), 0.06); + } + + .chart-grid-line { + stroke: rgba(colors.color(mitre-silver), 0.12); + } + + .chart-year-line { + stroke: rgba(colors.color(mitre-silver), 0.16); + } + + .chart-axis, + .chart-axis-tick { + stroke: colors.border-color(dark); + } + + .timeline-legend-item { + &:hover, + &.selected { + border-color: rgba(colors.color(mitre-light-blue), 0.28); + background: rgba(colors.color(mitre-light-blue), 0.14); + } + } + + .domain-filter button { + border-color: colors.border-color(dark); + background: color.mix(colors.color(dark), #ffffff, 94%); + color: colors.color(light); + + &.selected, + &:hover { + background: rgba(colors.color(mitre-light-blue), 0.16); + } + } + + .sunburst-segment { + stroke: colors.color(dark); + } + + .sunburst-center circle { + fill: colors.color(dark); + stroke: colors.border-color(dark); + } + + .sunburst-center text { + fill: colors.color(light); + } + + .drilldown-item { + border-color: colors.border-color(dark); + color: colors.color(light); + } + } + + @media (max-width: 1080px) { + .sunburst-grid { + grid-template-columns: 1fr; + } + } + + @media (max-width: 720px) { + max-width: calc(100vw - 20px); + + .dashboard-header { + align-items: stretch; + flex-direction: column; + } + + .dashboard-actions { + justify-content: flex-start; + } + + .sunburst-card { + padding: 12px; + } + + .timeline-card { + padding: 12px; + } + + .timeline-chart-layout { + grid-template-columns: 1fr; + } + + .sunburst-card-heading { + flex-direction: column; + align-items: flex-start; + + span { + text-align: left; + } + } + + .drilldown-summary { + align-items: flex-start; + } } } diff --git a/src/app/views/dashboard-page/dashboard-page.component.spec.ts b/src/app/views/dashboard-page/dashboard-page.component.spec.ts index c65f0995f..ac142d536 100644 --- a/src/app/views/dashboard-page/dashboard-page.component.spec.ts +++ b/src/app/views/dashboard-page/dashboard-page.component.spec.ts @@ -1,36 +1,94 @@ import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideHttpClient } from '@angular/common/http'; +import { of } from 'rxjs'; import { DashboardPageComponent } from './dashboard-page.component'; import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; -import { - createAsyncObservable, - createMockRestApiConnector, - createPaginatedResponse, -} from 'src/app/testing/mocks/rest-api-connector.mock'; describe('DashboardPageComponent', () => { let component: DashboardPageComponent; let fixture: ComponentFixture; + let mockRestApiConnector: any; + + const attackObjects = [ + { + stix: { + created: '2024-01-10T00:00:00.000Z', + id: 'attack-pattern--001', + modified: '2024-03-15T00:00:00.000Z', + type: 'attack-pattern', + x_mitre_domains: ['enterprise-attack'], + }, + }, + { + stix: { + created: '2024-01-10T00:00:00.000Z', + id: 'attack-pattern--001', + modified: '2025-01-15T00:00:00.000Z', + type: 'attack-pattern', + x_mitre_domains: ['enterprise-attack'], + }, + }, + { + stix: { + created: '2024-02-10T00:00:00.000Z', + id: 'attack-pattern--002', + modified: '2024-04-15T00:00:00.000Z', + type: 'attack-pattern', + x_mitre_domains: ['enterprise-attack', 'mobile-attack'], + }, + }, + { + stix: { + created: '2024-03-10T00:00:00.000Z', + id: 'intrusion-set--001', + modified: '2024-05-15T00:00:00.000Z', + type: 'intrusion-set', + x_mitre_domains: ['enterprise-attack'], + }, + }, + { + stix: { + created: '2024-02-01T00:00:00.000Z', + id: 'relationship--001', + modified: '2024-04-20T00:00:00.000Z', + type: 'relationship', + relationship_type: 'uses', + }, + }, + { + stix: { + created: '2024-02-01T00:00:00.000Z', + id: 'relationship--001', + modified: '2025-01-20T00:00:00.000Z', + type: 'relationship', + relationship_type: 'uses', + }, + }, + { + stix: { + created: '2024-04-01T00:00:00.000Z', + id: 'relationship--002', + modified: '2024-05-20T00:00:00.000Z', + type: 'relationship', + relationship_type: 'mitigates', + }, + }, + ]; beforeEach(async () => { - const mockRestApiConnector = createMockRestApiConnector({ - getAllUserAccounts: () => - createAsyncObservable(createPaginatedResponse([])), - }); + mockRestApiConnector = { + getAllObjects: vi.fn(() => of(attackObjects)), + }; await TestBed.configureTestingModule({ declarations: [DashboardPageComponent], providers: [ { provide: RestApiConnectorService, useValue: mockRestApiConnector }, - provideHttpClient(), ], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); - }); - beforeEach(() => { fixture = TestBed.createComponent(DashboardPageComponent); component = fixture.componentInstance; fixture.detectChanges(); @@ -39,4 +97,343 @@ describe('DashboardPageComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should aggregate objects by object type', () => { + const techniques = component.objectTypeSummary.find( + summary => summary.typeKey === 'technique' + ); + const enterprise = component.sunburstSegments.find( + node => node.id === 'domain:enterprise-attack' + ); + const mobile = component.sunburstSegments.find( + node => node.id === 'domain:mobile-attack' + ); + + expect(component.totalObjects).toBe(3); + expect(component.totalAssignments).toBe(4); + expect(component.totalRelationships).toBe(2); + expect(techniques?.count).toBe(2); + expect(enterprise?.value).toBe(3); + expect(mobile?.value).toBe(1); + expect( + component.objectTypeSummary.some( + summary => summary.typeKey === 'relationship' + ) + ).toBe(false); + }); + + it('should use x_mitre_domains for matrix domains', () => { + component.buildDashboard([ + { + stix: { + created: '2024-01-10T00:00:00.000Z', + id: 'x-mitre-matrix--enterprise', + type: 'x-mitre-matrix', + x_mitre_domains: ['enterprise-attack'], + }, + }, + { + stix: { + created: '2024-01-10T00:00:00.000Z', + id: 'mobile-attack', + type: 'x-mitre-matrix', + x_mitre_domains: ['mobile-attack'], + }, + }, + { + stix: { + created: '2024-01-10T00:00:00.000Z', + id: 'x-mitre-matrix--ics', + type: 'x-mitre-matrix', + x_mitre_domains: ['ics-attack'], + }, + }, + ]); + + const enterprise = component.sunburstSegments.find( + node => node.id === 'domain:enterprise-attack' + ); + const mobile = component.sunburstSegments.find( + node => node.id === 'domain:mobile-attack' + ); + const ics = component.sunburstSegments.find( + node => node.id === 'domain:ics-attack' + ); + const matrixSummary = component.objectTypeSummary.find( + summary => summary.typeKey === 'matrix' + ); + + expect(component.totalObjects).toBe(3); + expect(component.totalAssignments).toBe(3); + expect(matrixSummary?.count).toBe(3); + expect(enterprise?.value).toBe(1); + expect(mobile?.value).toBe(1); + expect(ics?.value).toBe(1); + expect( + component.sunburstSegments.some(node => node.id === 'domain:no-domain') + ).toBe(false); + }); + + it('should summarize totals by object type across all domains', () => { + expect(component.objectTypeSummary.map(summary => summary.typeKey)).toEqual( + [ + 'matrix', + 'tactic', + 'technique', + 'group', + 'software', + 'campaign', + 'mitigation', + 'asset', + 'detection-strategy', + 'analytic', + 'data-component', + ] + ); + expect( + component.objectTypeSummary.find( + summary => summary.typeKey === 'technique' + ) + ).toEqual(expect.objectContaining({ label: 'Techniques', count: 2 })); + expect( + component.objectTypeSummary.find(summary => summary.typeKey === 'group') + ).toEqual(expect.objectContaining({ label: 'Groups', count: 1 })); + expect( + component.objectTypeSummary.find(summary => summary.typeKey === 'matrix') + ).toEqual(expect.objectContaining({ label: 'Matrices', count: 0 })); + }); + + it('should aggregate relationships by relationship type', () => { + const usesRelationships = component.relationshipSunburstSegments.find( + node => node.id === 'relationship-type:uses' + ); + const mitigatesRelationships = component.relationshipSunburstSegments.find( + node => node.id === 'relationship-type:mitigates' + ); + + expect(usesRelationships?.value).toBe(1); + expect(mitigatesRelationships?.value).toBe(1); + }); + + it('should deduplicate object and relationship versions by stix id', () => { + const techniques = component.objectTypeSummary.find( + summary => summary.typeKey === 'technique' + ); + const enterprise = component.sunburstSegments.find( + node => node.id === 'domain:enterprise-attack' + ); + const enterpriseTechniques = component.sunburstSegments.find( + node => node.id === 'domain:enterprise-attack/type:technique' + ); + const usesRelationships = component.relationshipSunburstSegments.find( + node => node.id === 'relationship-type:uses' + ); + const timelineTechniques = component.objectGrowthLineSeries.find( + series => series.key === 'technique' + ); + + expect(component.totalObjects).toBe(3); + expect(component.totalRelationships).toBe(2); + expect(techniques?.count).toBe(2); + expect(enterprise?.value).toBe(3); + expect(enterpriseTechniques?.value).toBe(2); + expect(timelineTechniques?.total).toBe(2); + expect(usesRelationships?.value).toBe(1); + }); + + it('should build timeline chart data from dated objects', () => { + const techniques = component.objectGrowthLineSeries.find( + series => series.key === 'technique' + ); + const groups = component.objectGrowthLineSeries.find( + series => series.key === 'group' + ); + + expect(component.objectGrowthYearTicks).toEqual([ + expect.objectContaining({ label: '2024', x: '30.00' }), + ]); + expect(component.objectGrowthYAxisLabels).toEqual(['2', '1', '0']); + expect(techniques).toEqual( + expect.objectContaining({ label: 'Techniques', total: 2 }) + ); + expect(techniques?.markers).toHaveLength(3); + expect(groups).toEqual( + expect.objectContaining({ label: 'Groups', total: 1 }) + ); + expect(component.objectGrowthTotal).toBe(3); + expect( + component.objectGrowthLineSeries.some( + series => series.key === 'relationship' + ) + ).toBe(false); + + component.selectObjectGrowthDomain('mobile-attack'); + expect( + component.objectGrowthLineSeries.map(series => [series.key, series.total]) + ).toEqual([['technique', 1]]); + }); + + it('should render a selected timeline series above other object types', () => { + const techniques = component.objectGrowthLineSeries.find( + series => series.key === 'technique' + ); + const groups = component.objectGrowthLineSeries.find( + series => series.key === 'group' + ); + + component.selectObjectGrowthType('technique'); + + const displaySeries = component.objectGrowthLineSeriesForDisplay; + + expect(component.selectedObjectGrowthType).toBe('technique'); + expect(displaySeries[displaySeries.length - 1]?.key).toBe('technique'); + expect(component.isObjectGrowthSeriesSelected(techniques!)).toBe(true); + expect(component.isObjectGrowthSeriesDimmed(groups!)).toBe(true); + + component.selectObjectGrowthType('technique'); + + expect(component.selectedObjectGrowthType).toBeUndefined(); + expect(component.isObjectGrowthSeriesDimmed(groups!)).toBe(false); + }); + + it('should assign unique timeline colors for known object types', () => { + const objectTypes = [ + ['x-mitre-matrix', 'matrix'], + ['x-mitre-tactic', 'tactic'], + ['attack-pattern', 'technique'], + ['intrusion-set', 'group'], + ['malware', 'software'], + ['campaign', 'campaign'], + ['course-of-action', 'mitigation'], + ['x-mitre-asset', 'asset'], + ['x-mitre-detection-strategy', 'detection-strategy'], + ['x-mitre-analytic', 'analytic'], + ['x-mitre-data-component', 'data-component'], + ['marking-definition', 'marking-definition'], + ]; + component.buildDashboard( + objectTypes.map(([stixType], index) => ({ + stix: { + created: '2024-01-10T00:00:00.000Z', + id: `${stixType}--${index}`, + type: stixType, + x_mitre_domains: ['enterprise-attack'], + }, + })) + ); + + const colors = component.objectGrowthLineSeries.map(series => series.color); + + expect( + component.objectGrowthLineSeries.map(series => series.key).sort() + ).toEqual(objectTypes.map(([, typeKey]) => typeKey).sort()); + expect(new Set(colors).size).toBe(colors.length); + }); + + it('should exclude notes, collections, data sources, and identities from dashboard object charts', () => { + component.buildDashboard([ + { + stix: { + created: '2024-01-10T00:00:00.000Z', + id: 'attack-pattern--tracked', + type: 'attack-pattern', + x_mitre_domains: ['enterprise-attack'], + }, + }, + { + stix: { + created: '2024-01-10T00:00:00.000Z', + id: 'x-mitre-collection--excluded', + type: 'x-mitre-collection', + x_mitre_domains: ['enterprise-attack'], + }, + }, + { + stix: { + created: '2024-01-10T00:00:00.000Z', + id: 'note--excluded', + type: 'note', + x_mitre_domains: ['enterprise-attack'], + }, + }, + { + stix: { + created: '2024-01-10T00:00:00.000Z', + id: 'x-mitre-data-source--excluded', + type: 'x-mitre-data-source', + x_mitre_domains: ['enterprise-attack'], + }, + }, + { + stix: { + created: '2024-01-10T00:00:00.000Z', + id: 'identity--excluded', + type: 'identity', + x_mitre_domains: ['enterprise-attack'], + }, + }, + ]); + + const chartTypeKeys = [ + ...component.sunburstSegments.map(segment => segment.key), + ...component.objectGrowthLineSeries.map(series => series.key), + ]; + + expect(component.totalObjects).toBe(1); + expect(component.totalAssignments).toBe(1); + expect(component.objectGrowthTotal).toBe(1); + expect(chartTypeKeys).not.toContain('collection'); + expect(chartTypeKeys).not.toContain('note'); + expect(chartTypeKeys).not.toContain('data-source'); + expect(chartTypeKeys).not.toContain('identity'); + }); + + it('should use the same object type colors in the sunburst and timeline', () => { + const timelineTechnique = component.objectGrowthLineSeries.find( + series => series.key === 'technique' + ); + const enterpriseTechnique = component.sunburstSegments.find( + node => node.id === 'domain:enterprise-attack/type:technique' + ); + const mobileTechnique = component.sunburstSegments.find( + node => node.id === 'domain:mobile-attack/type:technique' + ); + + expect(enterpriseTechnique?.color).toBe(timelineTechnique?.color); + expect(mobileTechnique?.color).toBe(timelineTechnique?.color); + }); + + it('should drill into domain and type without status nodes', () => { + component.selectNodeById('domain:enterprise-attack'); + + expect(component.selectedPathLabel).toBe('ATT&CK Objects / Enterprise'); + expect(component.selectedChildren.map(child => child.label)).toEqual([ + 'Techniques', + 'Groups', + ]); + + component.selectNodeById('domain:enterprise-attack/type:technique'); + + expect(component.selectedPathLabel).toBe( + 'ATT&CK Objects / Enterprise / Techniques' + ); + expect(component.selectedChildren).toEqual([]); + expect( + component.sunburstSegments.some(node => node.id.includes('/status:')) + ).toBe(false); + }); + + it('should drill into a selected relationship type without status nodes', () => { + component.selectRelationshipNodeById('relationship-type:uses'); + + expect(component.selectedRelationshipPathLabel).toBe( + 'Relationships / Uses' + ); + expect(component.selectedRelationshipChildren).toEqual([]); + expect( + component.relationshipSunburstSegments.some(node => + node.id.includes('/status:') + ) + ).toBe(false); + }); }); diff --git a/src/app/views/dashboard-page/dashboard-page.component.ts b/src/app/views/dashboard-page/dashboard-page.component.ts index f49d7ea5c..dd853f611 100644 --- a/src/app/views/dashboard-page/dashboard-page.component.ts +++ b/src/app/views/dashboard-page/dashboard-page.component.ts @@ -1,7 +1,171 @@ import { Component, OnInit, ViewEncapsulation } from '@angular/core'; +import { finalize, take } from 'rxjs/operators'; import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; -import { AuthenticationService } from 'src/app/services/connectors/authentication/authentication.service'; -import { Role } from 'src/app/classes/authn/role'; +import { + AttackTypeToPlural, + StixTypeToAttackType, +} from 'src/app/utils/type-mappings'; +import { AttackType, StixType } from 'src/app/utils/types'; + +type DashboardLevel = 'root' | 'domain' | 'type' | 'relationship-type'; + +interface RawAttackObject { + stix?: { + created?: string; + id?: string; + modified?: string; + type?: string | StixType; + relationship_type?: string; + x_mitre_domains?: string[]; + }; +} + +interface DashboardNode { + id: string; + key: string; + label: string; + value: number; + level: DashboardLevel; + depth: number; + children: DashboardNode[]; + parent?: DashboardNode; + path?: string; + color?: string; + startAngle?: number; + endAngle?: number; + innerRadius?: number; + outerRadius?: number; +} + +interface ObjectTypeSummary { + typeKey: AttackType; + label: string; + count: number; + color: string; +} + +interface DomainFilterOption { + key: string; + label: string; +} + +interface TimelineObject { + typeKey: AttackType; + domains: string[]; + created?: Date; +} + +interface DashboardObjectRecord { + object: RawAttackObject; + typeKey: AttackType; + created?: Date; +} + +interface TimelineBucket { + key: string; + objects: TimelineObject[]; +} + +interface TimelineSeries { + key: string; + label: string; + color: string; + points?: string; + markers: TimelineMarker[]; + total: number; +} + +interface TimelineMarker { + x: string; + y: string; + count: number; +} + +interface TimelineAxisTick { + x: string; + label: string; +} + +const NO_DOMAIN_KEY = 'no-domain'; +const NO_RELATIONSHIP_TYPE_KEY = 'unspecified'; +const ROOT_ID = 'root'; +const RELATIONSHIP_ROOT_ID = 'relationships-root'; +const CHART_VIEW_BOX = '0 0 320 180'; +const OBJECT_SUNBURST_ROOT_LABEL = 'ATT&CK Objects'; +const RELATIONSHIP_SUNBURST_ROOT_LABEL = 'Relationships'; +const RING_INNER_RADIUS = 50; +const RING_WIDTH = 56; +const RING_GAP = 1.5; + +const CHART_PLOT = { + left: 30, + right: 10, + top: 12, + bottom: 30, +}; + +const DOMAIN_LABELS: Record = { + 'enterprise-attack': 'Enterprise', + 'mobile-attack': 'Mobile', + 'ics-attack': 'ICS', + [NO_DOMAIN_KEY]: 'No Domain', +}; + +const DOMAIN_PALETTE = [ + '#005b94', + '#599e2f', + '#eb6635', + '#6d5bd0', + '#00838f', + '#b26a00', + '#455a64', +]; + +const RELATIONSHIP_TYPE_PALETTE = [ + '#6d5bd0', + '#00838f', + '#b26a00', + '#005b94', + '#599e2f', + '#eb6635', + '#455a64', +]; + +const OBJECT_TYPE_PALETTE: Partial> = { + 'matrix': '#0072b2', + 'tactic': '#d55e00', + 'technique': '#009e73', + 'group': '#aa4499', + 'software': '#e69f00', + 'campaign': '#56b4e9', + 'mitigation': '#882255', + 'asset': '#332288', + 'detection-strategy': '#44aa99', + 'analytic': '#cc6677', + 'data-component': '#999933', + 'marking-definition': '#6699cc', +}; + +const EXCLUDED_ATTACK_TYPES = new Set([ + 'collection', + 'note', + 'data-source', + 'identity', +]); + +const SUMMARY_OBJECT_TYPES: AttackType[] = [ + 'matrix', + 'tactic', + 'technique', + 'group', + 'software', + 'campaign', + 'mitigation', + 'asset', + 'detection-strategy', + 'analytic', + 'data-component', +]; @Component({ selector: 'app-dashboard-page', @@ -11,30 +175,941 @@ import { Role } from 'src/app/classes/authn/role'; standalone: false, }) export class DashboardPageComponent implements OnInit { - public pendingUsers; + public loadingObjects = false; + public loadError?: string; + public includeRevoked = false; + public includeDeprecated = false; + + public totalObjects = 0; + public totalAssignments = 0; + public totalRelationships = 0; + public objectTypeSummary: ObjectTypeSummary[] = []; + public objectGrowthLineSeries: TimelineSeries[] = []; + public objectGrowthYearTicks: TimelineAxisTick[] = []; + public objectGrowthYAxisLabels: string[] = []; + public timelineDomainOptions: DomainFilterOption[] = []; + public selectedObjectGrowthDomain = 'all'; + public selectedObjectGrowthType?: string; + public readonly chartViewBox = CHART_VIEW_BOX; + public sunburstSegments: DashboardNode[] = []; + public selectedNode?: DashboardNode; + public selectedBreadcrumb: DashboardNode[] = []; + public selectedChildren: DashboardNode[] = []; + public selectedPathLabel = OBJECT_SUNBURST_ROOT_LABEL; + public selectedNodeIds = new Set(); + public sunburstViewBox = '-164 -164 328 328'; + + public relationshipSunburstSegments: DashboardNode[] = []; + public selectedRelationshipNode?: DashboardNode; + public selectedRelationshipBreadcrumb: DashboardNode[] = []; + public selectedRelationshipChildren: DashboardNode[] = []; + public selectedRelationshipPathLabel = RELATIONSHIP_SUNBURST_ROOT_LABEL; + public selectedRelationshipNodeIds = new Set(); + public relationshipSunburstViewBox = '-108 -108 216 216'; + + private readonly nodeById = new Map(); + private readonly relationshipNodeById = new Map(); + private timelineObjects: TimelineObject[] = []; + private rootNode?: DashboardNode; + private relationshipRootNode?: DashboardNode; - constructor( - private restApiConnector: RestApiConnectorService, - private authenticationService: AuthenticationService - ) {} + constructor(private restApiConnector: RestApiConnectorService) {} ngOnInit(): void { - const userSubscription = this.restApiConnector - .getAllUserAccounts({ status: ['pending'] }) + this.loadObjects(); + } + + public loadObjects(): void { + this.loadingObjects = true; + this.loadError = undefined; + + this.restApiConnector + .getAllObjects({ + revoked: this.includeRevoked, + deprecated: this.includeDeprecated, + }) + .pipe( + take(1), + finalize(() => { + this.loadingObjects = false; + }) + ) .subscribe({ - next: results => { - const users = results as any; - if (users && users.length) this.pendingUsers = users.length; + next: response => { + this.buildDashboard(this.unwrapObjects(response)); }, - complete: () => userSubscription.unsubscribe(), + error: () => { + this.loadError = 'Unable to load knowledge base dashboard data.'; + this.resetDashboard(); + }, + }); + } + + public toggleRevoked(includeRevoked: boolean): void { + this.includeRevoked = includeRevoked; + this.loadObjects(); + } + + public toggleDeprecated(includeDeprecated: boolean): void { + this.includeDeprecated = includeDeprecated; + this.loadObjects(); + } + + public buildDashboard(objects: RawAttackObject[]): void { + const aggregates = new Map>(); + const objectTypeAggregates = new Map(); + const relationshipAggregates = new Map(); + const timelineObjects: TimelineObject[] = []; + let assignmentCount = 0; + const { + objectRecords, + relationshipRecords, + totalObjects, + totalRelationships, + } = this.deduplicateObjectsByStixId(objects); + + for (const record of relationshipRecords) { + this.incrementCount( + relationshipAggregates, + this.relationshipTypeForObject(record.object) + ); + } + + for (const record of objectRecords) { + this.incrementCount(objectTypeAggregates, record.typeKey); + + const domains = this.domainsForObject(record.object); + timelineObjects.push(this.timelineObjectForObject(record, domains)); + for (const domain of domains) { + this.incrementAggregate(aggregates, domain, record.typeKey); + assignmentCount += 1; + } + } + + this.totalObjects = totalObjects; + this.totalAssignments = assignmentCount; + this.totalRelationships = totalRelationships; + this.objectTypeSummary = this.buildObjectTypeSummary(objectTypeAggregates); + this.timelineObjects = timelineObjects; + this.timelineDomainOptions = this.buildTimelineDomainOptions(aggregates); + if ( + this.selectedObjectGrowthDomain !== 'all' && + !this.timelineDomainOptions.some( + option => option.key === this.selectedObjectGrowthDomain + ) + ) { + this.selectedObjectGrowthDomain = 'all'; + } + this.objectGrowthLineSeries = this.buildObjectGrowthLineSeries( + this.timelineObjects, + this.selectedObjectGrowthDomain + ); + this.ensureSelectedObjectGrowthTypeIsAvailable(); + this.rootNode = this.buildSunburstTree(aggregates); + this.layoutSunburst(this.rootNode); + this.nodeById.clear(); + this.indexNodes(this.rootNode); + this.sunburstSegments = this.flattenNodes(this.rootNode).filter( + node => node.level !== 'root' + ); + this.sunburstViewBox = this.sunburstViewBoxForSegments( + this.sunburstSegments + ); + this.selectNode(this.rootNode); + + this.relationshipRootNode = this.buildRelationshipSunburstTree( + relationshipAggregates + ); + this.layoutSunburst(this.relationshipRootNode); + this.relationshipNodeById.clear(); + this.indexNodes(this.relationshipRootNode, this.relationshipNodeById); + this.relationshipSunburstSegments = this.flattenNodes( + this.relationshipRootNode + ).filter(node => node.level !== 'root'); + this.relationshipSunburstViewBox = this.sunburstViewBoxForSegments( + this.relationshipSunburstSegments + ); + this.selectRelationshipNode(this.relationshipRootNode); + } + + public selectNode(node?: DashboardNode): void { + if (!node) return; + this.selectedNode = node; + this.selectedBreadcrumb = this.buildBreadcrumb(node); + this.selectedChildren = [...node.children].sort( + (a, b) => b.value - a.value + ); + this.selectedPathLabel = this.selectedBreadcrumb + .map(crumb => crumb.label) + .join(' / '); + this.selectedNodeIds = this.buildSelectedNodeSet(node); + } + + public selectNodeById(id: string): void { + this.selectNode(this.nodeById.get(id)); + } + + public selectRelationshipNode(node?: DashboardNode): void { + if (!node) return; + this.selectedRelationshipNode = node; + this.selectedRelationshipBreadcrumb = this.buildBreadcrumb(node); + this.selectedRelationshipChildren = [...node.children].sort( + (a, b) => b.value - a.value + ); + this.selectedRelationshipPathLabel = this.selectedRelationshipBreadcrumb + .map(crumb => crumb.label) + .join(' / '); + this.selectedRelationshipNodeIds = this.buildSelectedNodeSet(node); + } + + public selectRelationshipNodeById(id: string): void { + this.selectRelationshipNode(this.relationshipNodeById.get(id)); + } + + public isSegmentDimmed(segment: DashboardNode): boolean { + return ( + this.selectedNode?.level !== 'root' && + !this.selectedNodeIds.has(segment.id) + ); + } + + public isRelationshipSegmentDimmed(segment: DashboardNode): boolean { + return ( + this.selectedRelationshipNode?.level !== 'root' && + !this.selectedRelationshipNodeIds.has(segment.id) + ); + } + + public formatCount(count: number): string { + return count.toLocaleString(); + } + + public segmentTooltip(segment: DashboardNode): string { + return `${this.buildBreadcrumb(segment) + .map(crumb => crumb.label) + .join(' / ')}: ${this.formatCount(segment.value)}`; + } + + public trackNode(_index: number, node: DashboardNode): string { + return node.id; + } + + public trackObjectTypeSummary( + _index: number, + summary: ObjectTypeSummary + ): string { + return summary.typeKey; + } + + public trackTimelineSeries(_index: number, series: TimelineSeries): string { + return series.key; + } + + public trackDomainFilter(_index: number, option: DomainFilterOption): string { + return option.key; + } + + public get objectGrowthTotal(): number { + return this.objectGrowthLineSeries.reduce( + (sum, series) => sum + series.total, + 0 + ); + } + + public get objectGrowthLineSeriesForDisplay(): TimelineSeries[] { + if (!this.selectedObjectGrowthType) return this.objectGrowthLineSeries; + const selectedSeries = this.objectGrowthLineSeries.find( + series => series.key === this.selectedObjectGrowthType + ); + if (!selectedSeries) return this.objectGrowthLineSeries; + return [ + ...this.objectGrowthLineSeries.filter( + series => series.key !== this.selectedObjectGrowthType + ), + selectedSeries, + ]; + } + + public selectObjectGrowthDomain(domainKey: string): void { + this.selectedObjectGrowthDomain = domainKey; + this.objectGrowthLineSeries = this.buildObjectGrowthLineSeries( + this.timelineObjects, + domainKey + ); + this.ensureSelectedObjectGrowthTypeIsAvailable(); + } + + public selectObjectGrowthType(typeKey: string): void { + this.selectedObjectGrowthType = + this.selectedObjectGrowthType === typeKey ? undefined : typeKey; + } + + public isObjectGrowthSeriesSelected(series: TimelineSeries): boolean { + return this.selectedObjectGrowthType === series.key; + } + + public isObjectGrowthSeriesDimmed(series: TimelineSeries): boolean { + return ( + !!this.selectedObjectGrowthType && + this.selectedObjectGrowthType !== series.key + ); + } + + private resetDashboard(): void { + this.totalObjects = 0; + this.totalAssignments = 0; + this.totalRelationships = 0; + this.objectTypeSummary = []; + this.objectGrowthLineSeries = []; + this.objectGrowthYearTicks = []; + this.objectGrowthYAxisLabels = []; + this.timelineDomainOptions = []; + this.selectedObjectGrowthDomain = 'all'; + this.selectedObjectGrowthType = undefined; + this.timelineObjects = []; + this.sunburstSegments = []; + this.rootNode = this.createNode( + ROOT_ID, + 'root', + OBJECT_SUNBURST_ROOT_LABEL + ); + this.selectedNode = this.rootNode; + this.selectedBreadcrumb = [this.rootNode]; + this.selectedChildren = []; + this.selectedPathLabel = OBJECT_SUNBURST_ROOT_LABEL; + this.selectedNodeIds = new Set([ROOT_ID]); + this.sunburstViewBox = this.sunburstViewBoxForSegments([]); + this.relationshipSunburstSegments = []; + this.relationshipRootNode = this.createNode( + RELATIONSHIP_ROOT_ID, + 'root', + RELATIONSHIP_SUNBURST_ROOT_LABEL + ); + this.selectedRelationshipNode = this.relationshipRootNode; + this.selectedRelationshipBreadcrumb = [this.relationshipRootNode]; + this.selectedRelationshipChildren = []; + this.selectedRelationshipPathLabel = RELATIONSHIP_SUNBURST_ROOT_LABEL; + this.selectedRelationshipNodeIds = new Set([RELATIONSHIP_ROOT_ID]); + this.relationshipSunburstViewBox = this.sunburstViewBoxForSegments([]); + } + + private unwrapObjects(response: any): RawAttackObject[] { + if (Array.isArray(response)) return response; + if (Array.isArray(response?.data)) return response.data; + return []; + } + + private objectTypeKey(object: RawAttackObject): AttackType | undefined { + const stixType = object?.stix?.type; + if (!stixType) return undefined; + if (!(stixType in StixTypeToAttackType)) return undefined; + + const attackType = StixTypeToAttackType[stixType as StixType]; + if (EXCLUDED_ATTACK_TYPES.has(attackType)) return undefined; + return attackType; + } + + private domainsForObject(object: RawAttackObject): string[] { + const domains = object?.stix?.x_mitre_domains; + if (!domains?.length) return [NO_DOMAIN_KEY]; + return [...new Set(domains.filter(domain => !!domain))]; + } + + private relationshipTypeForObject(object: RawAttackObject): string { + return object?.stix?.relationship_type || NO_RELATIONSHIP_TYPE_KEY; + } + + private timelineObjectForObject( + record: DashboardObjectRecord, + domains: string[] + ): TimelineObject { + return { + typeKey: record.typeKey, + domains, + created: record.created, + }; + } + + private deduplicateObjectsByStixId(objects: RawAttackObject[]): { + objectRecords: DashboardObjectRecord[]; + relationshipRecords: DashboardObjectRecord[]; + totalObjects: number; + totalRelationships: number; + } { + const objectRecordsById = new Map(); + const relationshipRecordsById = new Map(); + const fallbackObjectRecords: DashboardObjectRecord[] = []; + const fallbackRelationshipRecords: DashboardObjectRecord[] = []; + + for (const object of objects) { + const typeKey = this.objectTypeKey(object); + if (!typeKey) continue; + + const record = this.dashboardObjectRecord(object, typeKey); + const stixId = object?.stix?.id; + if (!stixId) { + if (typeKey === 'relationship') + fallbackRelationshipRecords.push(record); + else fallbackObjectRecords.push(record); + continue; + } + + const records = + typeKey === 'relationship' + ? relationshipRecordsById + : objectRecordsById; + const existing = records.get(stixId); + if (!existing) { + records.set(stixId, record); + continue; + } + + if (this.isNewerVersion(record.object, existing.object)) { + existing.object = record.object; + existing.typeKey = record.typeKey; + } + if ( + record.created && + (!existing.created || record.created < existing.created) + ) { + existing.created = record.created; + } + } + + return { + objectRecords: [ + ...Array.from(objectRecordsById.values()), + ...fallbackObjectRecords, + ], + relationshipRecords: [ + ...Array.from(relationshipRecordsById.values()), + ...fallbackRelationshipRecords, + ], + totalObjects: objectRecordsById.size + fallbackObjectRecords.length, + totalRelationships: + relationshipRecordsById.size + fallbackRelationshipRecords.length, + }; + } + + private dashboardObjectRecord( + object: RawAttackObject, + typeKey: AttackType + ): DashboardObjectRecord { + return { + object, + typeKey, + created: this.parseDate(object?.stix?.created), + }; + } + + private isNewerVersion( + candidate: RawAttackObject, + current: RawAttackObject + ): boolean { + const candidateDate = + this.parseDate(candidate?.stix?.modified) || + this.parseDate(candidate?.stix?.created); + const currentDate = + this.parseDate(current?.stix?.modified) || + this.parseDate(current?.stix?.created); + if (!candidateDate) return false; + if (!currentDate) return true; + return candidateDate > currentDate; + } + + private parseDate(value?: string): Date | undefined { + if (!value) return undefined; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? undefined : date; + } + + private incrementAggregate( + aggregates: Map>, + domainKey: string, + typeKey: AttackType + ): void { + let typeMap = aggregates.get(domainKey); + if (!typeMap) { + typeMap = new Map(); + aggregates.set(domainKey, typeMap); + } + typeMap.set(typeKey, (typeMap.get(typeKey) || 0) + 1); + } + + private incrementCount(aggregates: Map, key: TKey): void { + aggregates.set(key, (aggregates.get(key) || 0) + 1); + } + + private buildObjectTypeSummary( + aggregates: Map + ): ObjectTypeSummary[] { + return SUMMARY_OBJECT_TYPES.map(typeKey => ({ + typeKey, + label: this.objectTypeLabel(typeKey), + count: aggregates.get(typeKey) || 0, + color: this.colorForObjectType(typeKey), + })); + } + + private buildTimelineDomainOptions( + aggregates: Map> + ): DomainFilterOption[] { + return Array.from(aggregates.keys()) + .filter(domainKey => domainKey !== NO_DOMAIN_KEY) + .map(domainKey => ({ + key: domainKey, + label: this.domainLabel(domainKey), + })) + .sort((a, b) => a.label.localeCompare(b.label)); + } + + private buildObjectGrowthLineSeries( + objects: TimelineObject[], + domainKey: string + ): TimelineSeries[] { + const filteredObjects = objects.filter(object => { + if (domainKey === 'all') return true; + return object.domains.includes(domainKey); + }); + const buckets = this.buildMonthlyBuckets( + filteredObjects, + object => object.created, + true + ); + if (!buckets.length) { + this.objectGrowthYearTicks = []; + this.objectGrowthYAxisLabels = []; + return []; + } + this.objectGrowthYearTicks = this.timelineYearTicksForBuckets(buckets); + + const objectsWithMonth = filteredObjects + .map(object => ({ + ...object, + monthKey: this.monthKey(object.created), + })) + .filter( + (object): object is TimelineObject & { monthKey: string } => + !!object.monthKey + ); + const seriesKeys = Array.from( + new Set(objectsWithMonth.map(object => object.typeKey)) + ); + const seriesCounts = new Map(); + let maxCount = 0; + + for (const typeKey of seriesKeys) { + const counts = buckets.map(bucket => { + return objectsWithMonth.filter( + object => object.typeKey === typeKey && object.monthKey <= bucket.key + ).length; + }); + seriesCounts.set(typeKey, counts); + maxCount = Math.max(maxCount, ...counts); + } + this.objectGrowthYAxisLabels = [ + this.formatCount(maxCount), + this.formatCount(Math.ceil(maxCount / 2)), + '0', + ]; + + return seriesKeys + .map((typeKey, index) => { + const counts = seriesCounts.get(typeKey) || []; + const total = counts[counts.length - 1] || 0; + const markers = counts.map((count, countIndex) => ({ + x: this.chartX(countIndex, buckets.length), + y: this.chartY(count, maxCount), + count, + })); + const points = markers + .map(marker => `${marker.x},${marker.y}`) + .join(' '); + + return { + key: typeKey, + label: this.objectTypeLabel(typeKey), + color: this.colorForObjectType(typeKey, index), + points, + markers, + total, + }; + }) + .filter(series => series.total > 0) + .sort((a, b) => { + const totalSort = b.total - a.total; + if (totalSort !== 0) return totalSort; + return a.label.localeCompare(b.label); }); } - public get isAdmin(): boolean { - return this.authenticationService.isAuthorized([Role.ADMIN]); + private buildMonthlyBuckets( + objects: TimelineObject[], + dateForObject: (object: TimelineObject) => Date | undefined, + fillGaps: boolean + ): TimelineBucket[] { + const objectsWithDate = objects + .map(object => ({ object, date: dateForObject(object) })) + .filter( + (entry): entry is { object: TimelineObject; date: Date } => !!entry.date + ); + if (!objectsWithDate.length) return []; + + const monthKeys = Array.from( + new Set(objectsWithDate.map(entry => this.monthKey(entry.date))) + ).sort(); + const bucketKeys = fillGaps + ? this.monthRange(monthKeys[0], monthKeys[monthKeys.length - 1]) + : monthKeys; + + return bucketKeys.map(key => ({ + key, + objects: objectsWithDate + .filter(entry => this.monthKey(entry.date) === key) + .map(entry => entry.object), + })); + } + + private timelineYearTicksForBuckets( + buckets: TimelineBucket[] + ): TimelineAxisTick[] { + const ticks: TimelineAxisTick[] = []; + let previousYear: string | undefined; + + buckets.forEach((bucket, index) => { + const year = bucket.key.slice(0, 4); + if (year === previousYear) return; + ticks.push({ + x: this.chartX(index, buckets.length), + label: year, + }); + previousYear = year; + }); + + return ticks; + } + + private chartX(index: number, totalPoints: number): string { + const width = 320 - CHART_PLOT.left - CHART_PLOT.right; + if (totalPoints <= 1) { + return (CHART_PLOT.left + width / 2).toFixed(2); + } + return (CHART_PLOT.left + (index / (totalPoints - 1)) * width).toFixed(2); + } + + private chartY(value: number, maxValue: number): string { + const height = 180 - CHART_PLOT.top - CHART_PLOT.bottom; + const ratio = maxValue > 0 ? value / maxValue : 0; + return (CHART_PLOT.top + (1 - ratio) * height).toFixed(2); + } + + private colorForObjectType(typeKey: string, index = 0): string { + const objectTypeColor = OBJECT_TYPE_PALETTE[typeKey as AttackType]; + if (objectTypeColor) return objectTypeColor; + return RELATIONSHIP_TYPE_PALETTE[index % RELATIONSHIP_TYPE_PALETTE.length]; + } + + private ensureSelectedObjectGrowthTypeIsAvailable(): void { + if ( + this.selectedObjectGrowthType && + !this.objectGrowthLineSeries.some( + series => series.key === this.selectedObjectGrowthType + ) + ) { + this.selectedObjectGrowthType = undefined; + } + } + + private monthKey(date?: Date): string | undefined { + if (!date) return undefined; + return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart( + 2, + '0' + )}`; + } + + private monthDate(key: string): Date { + const [year, month] = key.split('-').map(value => parseInt(value, 10)); + return new Date(Date.UTC(year, month - 1, 1)); + } + + private monthRange(startKey: string, endKey: string): string[] { + const start = this.monthDate(startKey); + const end = this.monthDate(endKey); + const keys: string[] = []; + const current = new Date(start); + + while (current <= end) { + const key = this.monthKey(current); + if (key) keys.push(key); + current.setUTCMonth(current.getUTCMonth() + 1); + } + + return keys; + } + + private buildSunburstTree( + aggregates: Map> + ): DashboardNode { + const root = this.createNode(ROOT_ID, 'root', OBJECT_SUNBURST_ROOT_LABEL); + + const sortedDomains = this.sortedDomainEntries(aggregates); + + sortedDomains.forEach(([domainKey, typeMap], domainIndex) => { + const domainNode = this.createNode( + this.domainNodeId(domainKey), + domainKey, + this.domainLabel(domainKey), + 'domain', + root + ); + domainNode.color = DOMAIN_PALETTE[domainIndex % DOMAIN_PALETTE.length]; + root.children.push(domainNode); + + const sortedTypes = Array.from(typeMap.entries()).sort( + ([left], [right]) => + this.objectTypeLabel(left).localeCompare(this.objectTypeLabel(right)) + ); + + for (const [typeKey, count] of sortedTypes) { + const typeNode = this.createNode( + this.typeNodeId(domainKey, typeKey), + typeKey, + this.objectTypeLabel(typeKey), + 'type', + domainNode + ); + typeNode.color = this.colorForObjectType(typeKey); + typeNode.value = count; + domainNode.children.push(typeNode); + } + + domainNode.value = domainNode.children.reduce( + (sum, child) => sum + child.value, + 0 + ); + }); + + root.value = root.children.reduce((sum, child) => sum + child.value, 0); + return root; + } + + private buildRelationshipSunburstTree( + aggregates: Map + ): DashboardNode { + const root = this.createNode( + RELATIONSHIP_ROOT_ID, + 'root', + RELATIONSHIP_SUNBURST_ROOT_LABEL + ); + + const sortedTypes = Array.from(aggregates.entries()).sort( + ([left], [right]) => + this.relationshipTypeLabel(left).localeCompare( + this.relationshipTypeLabel(right) + ) + ); + + sortedTypes.forEach(([relationshipType, count], typeIndex) => { + const typeNode = this.createNode( + this.relationshipTypeNodeId(relationshipType), + relationshipType, + this.relationshipTypeLabel(relationshipType), + 'relationship-type', + root + ); + typeNode.color = + RELATIONSHIP_TYPE_PALETTE[typeIndex % RELATIONSHIP_TYPE_PALETTE.length]; + typeNode.value = count; + root.children.push(typeNode); + }); + + root.value = root.children.reduce((sum, child) => sum + child.value, 0); + return root; + } + + private layoutSunburst(root: DashboardNode): void { + root.depth = 0; + root.startAngle = 0; + root.endAngle = Math.PI * 2; + this.layoutChildren(root, 0, Math.PI * 2); + } + + private layoutChildren( + parent: DashboardNode, + startAngle: number, + endAngle: number + ): void { + let cursor = startAngle; + const availableAngle = endAngle - startAngle; + + for (const child of parent.children) { + const angle = + parent.value > 0 ? availableAngle * (child.value / parent.value) : 0; + child.depth = parent.depth + 1; + child.startAngle = cursor; + child.endAngle = cursor + angle; + child.innerRadius = RING_INNER_RADIUS + (child.depth - 1) * RING_WIDTH; + child.outerRadius = + RING_INNER_RADIUS + child.depth * RING_WIDTH - RING_GAP; + child.path = this.describeArc( + child.innerRadius, + child.outerRadius, + child.startAngle, + child.endAngle + ); + this.layoutChildren(child, child.startAngle, child.endAngle); + cursor += angle; + } + } + + private describeArc( + innerRadius: number, + outerRadius: number, + startAngle: number, + endAngle: number + ): string { + if (endAngle - startAngle >= Math.PI * 2) { + endAngle = startAngle + Math.PI * 2 - 0.0001; + } + + const outerStart = this.polarToCartesian(outerRadius, startAngle); + const outerEnd = this.polarToCartesian(outerRadius, endAngle); + const innerEnd = this.polarToCartesian(innerRadius, endAngle); + const innerStart = this.polarToCartesian(innerRadius, startAngle); + const largeArcFlag = endAngle - startAngle > Math.PI ? 1 : 0; + + return [ + `M ${outerStart.x} ${outerStart.y}`, + `A ${outerRadius} ${outerRadius} 0 ${largeArcFlag} 1 ${outerEnd.x} ${outerEnd.y}`, + `L ${innerEnd.x} ${innerEnd.y}`, + `A ${innerRadius} ${innerRadius} 0 ${largeArcFlag} 0 ${innerStart.x} ${innerStart.y}`, + 'Z', + ].join(' '); + } + + private polarToCartesian( + radius: number, + angle: number + ): { x: string; y: string } { + const adjustedAngle = angle - Math.PI / 2; + return { + x: (Math.cos(adjustedAngle) * radius).toFixed(3), + y: (Math.sin(adjustedAngle) * radius).toFixed(3), + }; + } + + private createNode( + id: string, + key: string, + label: string, + level: DashboardLevel = 'root', + parent?: DashboardNode + ): DashboardNode { + return { + id, + key, + label, + value: 0, + level, + depth: parent ? parent.depth + 1 : 0, + children: [], + parent, + }; + } + + private indexNodes(node: DashboardNode, nodeById = this.nodeById): void { + nodeById.set(node.id, node); + for (const child of node.children) this.indexNodes(child, nodeById); + } + + private flattenNodes(node: DashboardNode): DashboardNode[] { + const nodes = [node]; + for (const child of node.children) { + nodes.push(...this.flattenNodes(child)); + } + return nodes; + } + + private sunburstViewBoxForSegments(segments: DashboardNode[]): string { + const centerRadius = 47; + const padding = 4; + const maxOuterRadius = segments.reduce( + (max, segment) => Math.max(max, segment.outerRadius || 0), + centerRadius + ); + const bounds = Math.ceil(maxOuterRadius + padding); + return `${-bounds} ${-bounds} ${bounds * 2} ${bounds * 2}`; + } + + private buildBreadcrumb(node: DashboardNode): DashboardNode[] { + const crumbs: DashboardNode[] = []; + let current: DashboardNode | undefined = node; + while (current) { + crumbs.unshift(current); + current = current.parent; + } + return crumbs; + } + + private buildSelectedNodeSet(node: DashboardNode): Set { + const selected = new Set(); + for (const crumb of this.buildBreadcrumb(node)) selected.add(crumb.id); + this.collectDescendants(node, selected); + return selected; + } + + private collectDescendants(node: DashboardNode, selected: Set): void { + for (const child of node.children) { + selected.add(child.id); + this.collectDescendants(child, selected); + } + } + + private domainNodeId(domainKey: string): string { + return `domain:${domainKey}`; + } + + private sortedDomainEntries( + aggregates: Map> + ): [string, Map][] { + return Array.from(aggregates.entries()).sort(([left], [right]) => + this.domainLabel(left).localeCompare(this.domainLabel(right)) + ); + } + + private typeNodeId(domainKey: string, typeKey: AttackType): string { + return `${this.domainNodeId(domainKey)}/type:${typeKey}`; + } + + private relationshipTypeNodeId(relationshipType: string): string { + return `relationship-type:${relationshipType}`; + } + + private domainLabel(domain: string): string { + return DOMAIN_LABELS[domain] || this.titleCase(domain); + } + + private objectTypeLabel(type: string): string { + return this.titleCase(AttackTypeToPlural[type as AttackType] || type); + } + + private relationshipTypeLabel(relationshipType: string): string { + if (relationshipType === NO_RELATIONSHIP_TYPE_KEY) { + return 'Unspecified Type'; + } + return this.titleCase(relationshipType); } - public get isTeamLead(): boolean { - return this.authenticationService.isAuthorized([Role.TEAM_LEAD]); + private titleCase(value: string): string { + return value + .replace(/-/g, ' ') + .split(' ') + .filter(word => word.length > 0) + .map(word => { + if (word.toLowerCase() === 'ics') return 'ICS'; + return `${word.charAt(0).toUpperCase()}${word.slice(1).toLowerCase()}`; + }) + .join(' '); } } diff --git a/src/app/views/dashboard-page/org-settings-page/org-settings-page.component.html b/src/app/views/dashboard-page/org-settings-page/org-settings-page.component.html index e6f1ecc3c..14fdd0683 100644 --- a/src/app/views/dashboard-page/org-settings-page/org-settings-page.component.html +++ b/src/app/views/dashboard-page/org-settings-page/org-settings-page.component.html @@ -28,7 +28,10 @@

Organization Identity


diff --git a/src/app/views/dashboard-page/release-management/release-management.component.html b/src/app/views/dashboard-page/release-management/release-management.component.html new file mode 100644 index 000000000..384c7ccf3 --- /dev/null +++ b/src/app/views/dashboard-page/release-management/release-management.component.html @@ -0,0 +1,47 @@ +
+
+

Release Management

+
+ +
+
+ +
+
+ +
+

+ Release Tracks ({{ standardTracks.length }}) +

+
+ +
+
+ + +
+

+ Virtual Tracks ({{ virtualTracks.length }}) +

+
+ +
+
+
+
+
diff --git a/src/app/views/dashboard-page/release-management/release-management.component.scss b/src/app/views/dashboard-page/release-management/release-management.component.scss new file mode 100644 index 000000000..785bc7295 --- /dev/null +++ b/src/app/views/dashboard-page/release-management/release-management.component.scss @@ -0,0 +1,41 @@ +@use '../../../../style/globals'; +@use '../../../../style/colors'; + +.release-management { + .controls { + margin-bottom: 16px; + } + + .sections-row { + display: block; + width: 100%; + } + + .cards-container { + display: flex; + flex-direction: column; + gap: 12px; + padding: 10px; + + .cards-grid { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 24px; + align-items: flex-start; + } + + .tiny-icon { + font-size: 14px !important; + width: 14px !important; + height: 14px !important; + line-height: 14px !important; + margin-right: 6px; + } + + .version { + display: flex; + align-items: center; + } + } +} diff --git a/src/app/views/dashboard-page/release-management/release-management.component.ts b/src/app/views/dashboard-page/release-management/release-management.component.ts new file mode 100644 index 000000000..c77e576dc --- /dev/null +++ b/src/app/views/dashboard-page/release-management/release-management.component.ts @@ -0,0 +1,134 @@ +import { + Component, + OnInit, + OnDestroy, + Output, + EventEmitter, + ViewEncapsulation, +} from '@angular/core'; +import { Subscription } from 'rxjs'; +import { ReleaseTracksConnectorService } from 'src/app/services/connectors/rest-api/release-tracks.service'; +import { ReleaseTrackType } from 'src/app/classes/release-tracks'; +import { MatDialog } from '@angular/material/dialog'; +import { NewTrackDialogComponent } from 'src/app/components/new-track-dialog/new-track-dialog.component'; +import { MultipleChoiceDialogComponent } from 'src/app/components/multiple-choice-dialog/multiple-choice-dialog.component'; +import { Router } from '@angular/router'; + +@Component({ + selector: 'app-release-tracks-list', + templateUrl: './release-management.component.html', + styleUrls: ['./release-management.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false, +}) +export class ReleaseManagementComponent implements OnInit, OnDestroy { + @Output() viewTrack = new EventEmitter(); + + public filter: 'all' | 'Standard' | 'Virtual' = 'all'; + public allTracks: any[] = []; + + private subscription: Subscription | null = null; + + public get standardTracks(): any[] { + return this.allTracks.filter( + t => t.type === ReleaseTrackType.Standard || t.type === 'standard' + ); + } + + public get virtualTracks(): any[] { + return this.allTracks.filter( + t => t.type === ReleaseTrackType.Virtual || t.type === 'virtual' + ); + } + + constructor( + private connector: ReleaseTracksConnectorService, + private router: Router, + private dialog: MatDialog + ) {} + + ngOnInit(): void { + this.loadTracks(); + } + + ngOnDestroy(): void { + this.subscription?.unsubscribe(); + } + + private loadTracks(): void { + this.subscription = this.connector.listReleaseTracks().subscribe(result => { + this.allTracks = this.tracksWithComputedData(result?.data ?? []); + }); + } + + public setFilter(value: 'all' | 'Standard' | 'Virtual'): void { + this.filter = value; + } + + public onViewTrack(id: string): void { + this.router.navigate([`/dashboard/release-management/${id}`]); + } + + private tracksWithComputedData(data: any[]): any[] { + return data.map((track: any) => { + const summary = track.summary ?? {}; + + return { + ...track, + latestVersion: track.latest_tagged_version ?? 'No tagged releases', + latestModified: track.latest_snapshot_modified, + stats: { + candidates: summary.candidates_count ?? 0, + staged: summary.staged_count ?? 0, + members: summary.members_count ?? 0, + quarantined: 0, + }, + }; + }); + } + + public openNewTrackDialog(): void { + const choiceRef = this.dialog.open(MultipleChoiceDialogComponent, { + width: '30em', + autoFocus: false, + data: { + title: 'Create a new track', + choices: [ + { + label: 'Standard', + description: + 'Recommended for most cases. Traditional release track that directly manages objects through the candidate, staged, and released workflow.', + }, + { + label: 'Virtual', + description: + 'Composite release track made up of standard tracks, used to build releases from multiple source tracks.', + }, + ], + }, + }); + + choiceRef.afterClosed().subscribe(choice => { + if (!choice) return; + const selectedType = + String(choice).toLowerCase() === 'virtual' + ? ReleaseTrackType.Virtual + : ReleaseTrackType.Standard; + + const dialogRef = this.dialog.open(NewTrackDialogComponent, { + width: '50em', + autoFocus: false, + data: { + type: selectedType, + }, + }); + + dialogRef.afterClosed().subscribe(result => { + if (!result) return; + const createdId = result?.id || result?.track_id || null; + if (createdId) this.viewTrack.emit(createdId); + this.loadTracks(); + }); + }); + } +} diff --git a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html new file mode 100644 index 000000000..a5c56555b --- /dev/null +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html @@ -0,0 +1,1575 @@ +
+
+

+ {{ releaseTrackName }} +

+
+ + + + +
+
+ + + + + +
+
+
+
+
+

Description

+ +
+ +

+ {{ releaseTrackDescription || 'No description provided.' }} +

+ +
+ + + +
+ + +
+
+
+ +
+ +
+ + {{ virtualComponentTracks.length || 0 }} + +
+ Components + standard tracks +
+
+ +
+ + {{ virtualResolvedObjectCount || 0 }} + +
+ Resolved Objects + latest snapshot +
+
+ +
+ + {{ quarantineObjects.length || 0 }} + +
+ Quarantined + needs review +
+
+
+ + +
+ + {{ candidates.length || 0 }} + +
+ Candidates + in this draft +
+
+ +
+ + {{ staged.length || 0 }} + +
+ Staged + for next release +
+
+ +
+ + {{ members.length || 0 }} + +
+ Released Members + published +
+
+
+
+
+
+ + +
+
+
+
+

+ Component Tracks ({{ virtualComponentTracks.length }}) +

+
+ + + +
+
+ No component tracks configured. +
+ +
+
+

{{ getComponentTrackLabel(track) }}

+

{{ track.track_id }}

+
+
+ + {{ formatConfigOption(track.resolution_strategy) }} + + + priority {{ track.priority ?? 0 }} + + + {{ getComponentTrackFilters(track).join(', ') }} + +
+
+
+
+
+ +
+
+
+

Latest Resolution

+
+ + + +
+
+ No resolved snapshot yet. Create a draft snapshot to resolve + component contents. +
+ +
+
+

+ {{ + component.track_name || + component.track_id || + 'Component track' + }} +

+

+ {{ getResolvedComponentLabel(component) }} +

+
+
+ + {{ component.objects_contributed || 0 }} contributed + + + {{ component.objects_after_filter || 0 }} after filters + +
+
+
+
+
+ +
+
+
+

Resolution Details

+
+ + + +
+
+ {{ virtualResolvedObjectCount || 0 }} + Total Objects +
+
+ {{ virtualDuplicateCount || 0 }} + Duplicates +
+
+ {{ virtualConflictCount || 0 }} + Conflicts +
+
+ {{ quarantineObjects.length || 0 }} + Quarantined +
+
+
+
+
+
+ + +
+
+
+ + + +
+
+

+ {{ lane.title }} ({{ lane.items.length }}) +

+
+ + +
+
+ + + +
+
+ {{ lane.emptyLabel }} +
+
+ +
+
+
+
+
+
+
+
+
+ + +
+
+
+
+

Composition Resolution

+ + Resolved at: + {{ virtualResolvedAt | date: 'MMM d, y, h:mm:ss a' }} + +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Component TrackStrategyResolved VersionCandidatesStagedMembers
+ No component tracks configured. +
+ {{ row.trackName }} + {{ row.trackId }} + {{ formatConfigOption(row.strategy) }} + + {{ getVirtualResolvedVersion(row) }} + + + {{ + getVirtualTierCount(row.candidatesCount) + }} + + {{ + getVirtualTierCount(row.stagedCount) + }} + + {{ + getVirtualTierCount(row.membersCount) + }} +
+
+
+
+ +
+
+
+
+

Members ({{ members.length || 0 }})

+
+ + + +
+
+ No member objects. +
+ +
+
+

{{ getVirtualObjectTitle(item) }}

+

{{ getVirtualObjectSubtitle(item) }}

+
+ +
+
+
+
+ +
+
+
+

+ Quarantine ({{ quarantineObjects.length || 0 }}) +

+
+ + + +
+
+ No quarantined objects. +
+ +
+
+

{{ getVirtualObjectTitle(item) }}

+

{{ getVirtualObjectSubtitle(item) }}

+
+ +
+
+
+
+
+
+
+
+ + + + + +
+ + + +
+
+
+ + +
+
+ Loading snapshots +
+ +
+ No tagged releases or current draft snapshot +
+ +
+ Tagged releases: {{ taggedSnapshotCount }} + Current draft: + {{ hasCurrentDraftSnapshot ? 'available' : 'none' }} +
+ +
+
+
+ {{ item.isTagged ? 'sell' : 'schedule' }} +
+ +
+
+
+
+

+ + {{ item.title }} + + + + {{ item.created | date: 'MMM d, y, h:mm:ss a' }} + + +

+
+ + + + + {{ + item.isBundleCached ? 'Bundle cached' : 'Not cached' + }} + +
+
+

+ + Tagged: + {{ item.taggedAt | date: 'MMM d, y, h:mm:ss a' }} + + + Snapshot: + + {{ item.modified | date: 'MMM d, y, h:mm:ss a' }} + + +

+ +

Draft release snapshot

+
+

+ Snapshot: + {{ item.modified | date: 'MMM d, y, h:mm:ss a' }} +

+
+ +

{{ item.snapshot.snapshot_description }}

+
+
+ +
+ + + + + +
+
+ +
+
+
+ {{ stat.value }} +
+
{{ stat.label }}
+
+
+ +
+
+
+ + Graph cache +
+ {{ item.graphCacheTotal }} cached items +
+
+
+
+ {{ stat.value }} +
+
+ {{ stat.label }} +
+
+
+
+ +
+
+ + Bundle SHA-256 +
+ +
+ STIX 2.0 + + {{ hash }} + + + Unavailable + + +
+ +
+ STIX 2.1 + + {{ hash }} + + + Unavailable + + +
+
+
+
+
+
+
+ + Unknown + + +
+
+ + +
+ + +
+
+ +
+ +
+
+

Composition

+ +
+
+
Component Tracks
+

+ Standard release tracks resolved into this virtual track. +

+
+ + {{ displayedVirtualConfigComponentTracks.length }} + +
+ + + +
+ + Add component track + + arrow_drop_down + + + + + {{ track.name }} + + + ({{ getVirtualComponentTrackSnapshotLabel(track) }}) + + + + + No matching standard tracks + + + + Select one or more standard tracks to include in this + virtual track. + + +
+ +
+
+
+
+
+ {{ getComponentTrackLabel(track) }} +
+

{{ track.track_id }}

+

+ {{ getVirtualComponentTrackDescription(track) }} +

+
+ +
+ +
+ + {{ formatConfigOption(track.resolution_strategy) }} + + + priority + {{ getVirtualTrackPriority(track, componentIndex) }} + + + {{ getComponentTrackFilters(track).join(', ') }} + +
+ + + Object type filter + + + {{ option.label }} + + + Leave empty to include all object types + + + + Domain filter + + + {{ domain }} + + + Leave empty to include all domains + +
+
+ + +
No component tracks configured.
+
+
+
+ +
+
+

Object Deduplication

+ +
+
+
Strategy
+

Determines which object wins when components overlap.

+
+ + + Strategy + + + {{ formatConfigOption(option) }} + + + + + + + {{ + formatConfigOption( + configForm.get('virtualDeduplicationStrategy')?.value + ) + }} + + +
+ + + +
+
+
Preferred Tier
+

+ Breaks ties by preferring objects from the selected tier. +

+
+ + + Preferred Tier + + + {{ formatConfigOption(option) }} + + + + + + + {{ + formatConfigOption( + configForm.get('virtualDeduplicationTier')?.value + ) + }} + + +
+ + + +
+
+
Preferred Status
+

+ Breaks ties by preferring objects with the selected workflow + state. +

+
+ + + Preferred Status + + + {{ formatConfigOption(option) }} + + + + + + + {{ + formatConfigOption( + configForm.get('virtualDeduplicationStatus')?.value + ) + }} + + +
+
+
+ +
+
+

Snapshot Schedule

+ +
+
+
Mode
+

Controls how draft snapshots are scheduled.

+
+ + {{ + formatConfigOption( + configForm.get('virtualSnapshotScheduleMode')?.value + ) + }} + +
+ + + +
+
+
Cron Expression
+

+ UTC cron expression used when the schedule mode is cron. +

+
+ + {{ getVirtualScheduleValue('cron') }} + +
+
+
+
+ + +
+
+

Promotion Policy

+ +
+
+
Auto-promote Candidates
+

+ Automatically promote candidates to Staged when they meet + the configured workflow threshold. +

+
+ + + + + + {{ configForm.get('autoPromote')?.value ? 'ON' : 'OFF' }} + + +
+ + + +
+
+
Candidacy Threshold
+

+ Defines the minimum workflow state required for a candidate + to be promoted to Staged. +

+
+ + + Candidacy Threshold + + + {{ formatConfigOption(option) }} + + + + + + + {{ + formatConfigOption( + configForm.get('candidacyThreshold')?.value + ) + }} + + +
+
+
+ +
+
+

Lifecycle Settings

+ +
+
+
Member Sync Strategy
+

+ Determines if new revisions to existing member objects are + automatically added as candidates. +

+
+ + + Member Sync Strategy + + + {{ formatConfigOption(option) }} + + + + + + + {{ + formatConfigOption( + configForm.get('memberSyncStrategy')?.value + ) + }} + + +
+ + + +
+
+
Supplant Behavior
+

+ Controls how a new version is handled when the object is + already tracked by this release track. +

+
+ + + Supplant Behavior + + + {{ formatConfigOption(option) }} + + + + + + + {{ + formatConfigOption( + configForm.get('memberSyncSupplantBehavior')?.value + ) + }} + + +
+ + + +
+
+
Supplant Status Policy
+

+ Sets whether a queued replacement keeps or resets the + tracked workflow status. +

+
+ + + Supplant Status Policy + + + {{ formatConfigOption(option) }} + + + + + + + {{ + formatConfigOption( + configForm.get('memberSyncSupplantStatusPolicy')?.value + ) + }} + + +
+
+
+ +
+
+

Conflict Resolution

+ +
+
+
Candidates to Staged
+

+ Decides what happens when a candidate conflicts with another + version already staged. +

+
+ + + Candidates to Staged + + + {{ formatConfigOption(option) }} + + + + + + + {{ + formatConfigOption( + configForm.get('candidatesToStagedConflict')?.value + ) + }} + + +
+ + + +
+
+
Staged to Members
+

+ Decides whether release tagging can continue when staged + objects conflict with released member versions. +

+
+ + + Staged to Members + + + {{ formatConfigOption(option) }} + + + + + + + {{ + formatConfigOption( + configForm.get('stagedToMembersConflict')?.value + ) + }} + + +
+
+
+ +
+
+

Secondary Objects

+ +
+
+
Include Secondary Objects
+

+ Includes supporting objects when they meet the configured + workflow state threshold. +

+
+ + + + + + {{ + configForm.get('includeSecondaryObjects')?.value + ? 'ON' + : 'OFF' + }} + + +
+ + + +
+
+
Secondary Object Threshold
+

+ Defines the minimum workflow state for automatically + included supporting objects. +

+
+ + + Secondary Object Threshold + + + {{ formatConfigOption(option) }} + + + + + + + {{ + formatConfigOption( + configForm.get('secondaryObjectThreshold')?.value + ) + }} + + +
+
+
+
+
+
+
+
diff --git a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.scss b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.scss new file mode 100644 index 000000000..48a622830 --- /dev/null +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.scss @@ -0,0 +1,1830 @@ +@use 'sass:color'; +@use '../../../../../style/colors' as colors; + +$awaiting-review-color: color.mix( + colors.color(warn), + colors.color(spark-yellow), + 70% +); +$released-members-disabled-light: colors.on-color-deemphasis(light); +$released-members-disabled-dark: colors.on-color-deemphasis(dark); + +.release-track-page { + .promote-color { + color: colors.color(success); + } + .view-color { + color: colors.color(info); + } + .review-color { + color: $awaiting-review-color; + } + .demote-color { + color: $awaiting-review-color; + } + .quarantine-color { + color: colors.color(error); + } + + .release-track-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; + + h1 { + margin: 0; + font-size: 28px; + line-height: 34px; + } + } + + .release-track-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; + } + + .review-tpl-wrapper { + margin-top: 20px; + } + + .history-tpl { + margin-top: 20px; + } + + .config-tpl { + max-width: 768px; + margin: 24px auto 0; + } + + .config-actions { + display: flex; + justify-content: flex-end; + margin-bottom: 16px; + } + + .config-card-list { + display: flex; + flex-direction: column; + gap: 16px; + } + + .config-card > .content { + display: block; + padding: 22px 24px; + + h3 { + margin: 0 0 20px; + font-size: 20px; + font-weight: 700; + line-height: 26px; + + .dark & { + color: colors.color(mitre-light-blue); + } + + .light & { + color: colors.color(mitre-blue); + } + } + + .mat-divider { + margin: 18px 0; + + .dark & { + border-top-color: colors.border-color(dark); + } + + .light & { + border-top-color: colors.border-color(light); + } + } + } + + .config-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + + > .config-field { + flex: 0 0 320px; + margin-top: -8px; + } + + > .config-state, + > .config-value, + > .mat-mdc-slide-toggle { + flex: 0 0 auto; + margin-left: auto; + } + } + + .config-component-list { + display: flex; + flex-direction: column; + gap: 12px; + } + + .virtual-component-track-picker { + width: 100%; + margin-bottom: 18px; + + .mat-mdc-form-field { + width: 100%; + } + + .mat-mdc-form-field-subscript-wrapper { + min-height: 20px; + } + } + + .config-component-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + box-sizing: border-box; + border: 1px solid; + border-radius: 4px; + padding: 12px; + + .dark & { + border-color: colors.border-color(dark); + background: rgba(colors.color(mitre-silver), 0.025); + } + + .light & { + border-color: colors.border-color(light); + background: rgba(colors.color(mitre-black), 0.014); + } + } + + .config-component-row-editing { + flex-direction: column; + width: 100%; + } + + .config-component-main { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + width: 100%; + + > .config-copy { + flex: 1 1 auto; + } + + button { + flex: 0 0 auto; + width: 32px; + height: 32px; + padding: 0; + } + } + + .config-component-description { + margin-top: 8px !important; + } + + .config-component-filter { + width: 100%; + + .mat-mdc-form-field-subscript-wrapper { + min-height: 20px; + } + } + + .config-value-list { + display: flex; + flex: 0 0 auto; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; + max-width: 36%; + } + + .component-track-id { + margin-top: 3px; + overflow-wrap: normal; + font-family: + Roboto Mono, + monospace; + font-size: 11px; + font-weight: 400; + line-height: 15px; + white-space: nowrap; + + .dark & { + color: color.mix( + colors.on-color-deemphasis(dark), + colors.color(dark), + 72% + ); + } + + .light & { + color: color.mix( + colors.on-color-deemphasis(light), + colors.color(light), + 76% + ); + } + } + + .component-track-option-label { + display: inline-flex; + align-items: baseline; + gap: 4px; + min-width: 0; + } + + .component-track-option-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + } + + .component-track-option-meta { + flex: 0 0 auto; + font-size: 0.82em; + + .dark & { + color: colors.on-color-deemphasis(dark); + } + + .light & { + color: colors.on-color-deemphasis(light); + } + } + + .config-row-disabled { + .config-label, + .config-copy p, + .config-value { + color: #7d8792; + } + + .config-value { + background: rgba(29, 39, 51, 0.06); + } + + .dark & { + .config-label, + .config-copy p, + .config-value { + color: colors.on-color-deemphasis(dark); + } + + .config-value { + background: rgba(colors.on-color(dark), 0.08); + } + } + + .light & { + .config-label, + .config-copy p, + .config-value { + color: colors.on-color-deemphasis(light); + } + + .config-value { + background: rgba(colors.on-color(light), 0.06); + } + } + } + + .config-copy { + flex: 1 1 auto; + min-width: 0; + + p { + margin: 4px 0 0; + font-size: 14px; + line-height: 20px; + + .dark & { + color: colors.on-color-deemphasis(dark); + } + + .light & { + color: colors.on-color-deemphasis(light); + } + } + } + + .config-label { + font-size: 14px; + font-weight: 700; + line-height: 20px; + + .dark & { + color: colors.on-color(dark); + } + + .light & { + color: colors.on-color(light); + } + } + + .config-state, + .config-value { + display: inline-flex; + align-items: center; + width: fit-content; + border-radius: 4px; + padding: 3px 6px; + font-family: monospace; + font-size: 14px; + font-weight: 700; + line-height: 18px; + + .dark & { + color: color.mix(colors.color(secondary), colors.on-color(dark), 42%); + background: rgba(colors.color(secondary), 0.34); + } + + .light & { + color: colors.color(secondary); + background: rgba(colors.color(secondary), 0.08); + } + } + + .config-state { + font-family: inherit; + font-size: 13px; + text-transform: uppercase; + + .dark & { + color: color.mix(colors.color(secondary), colors.on-color(dark), 42%); + background: rgba(colors.color(secondary), 0.34); + } + + .light & { + color: colors.color(secondary); + background: rgba(colors.color(secondary), 0.08); + } + } + + .config-state-on { + .dark & { + color: color.mix(colors.color(secondary), colors.on-color(dark), 42%); + background: rgba(colors.color(secondary), 0.34); + } + + .light & { + color: colors.color(secondary); + background: rgba(colors.color(secondary), 0.08); + } + } + + .config-value-disabled { + color: #7d8792; + background: rgba(29, 39, 51, 0.06); + + .dark & { + color: colors.on-color-deemphasis(dark); + background: rgba(colors.on-color(dark), 0.08); + } + + .light & { + color: colors.on-color-deemphasis(light); + background: rgba(colors.on-color(light), 0.06); + } + } + + .clickable-row { + cursor: pointer; + transition: background-color 120ms ease; + + &:focus-visible { + outline: 2px solid colors.color(secondary); + outline-offset: 2px; + } + + .dark &:hover { + background: rgba(colors.color(secondary), 0.1); + } + + .light &:hover { + background: rgba(colors.color(secondary), 0.055); + } + } + + .config-field { + width: min(100%, 320px); + } + + .snapshot-history-list { + display: flex; + flex-direction: column; + gap: 32px; + max-width: 896px; + margin: 20px auto 0; + padding-left: 72px; + } + + .snapshot-history-summary { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; + max-width: 896px; + margin: 24px auto 0; + padding-left: 72px; + font-size: 13px; + line-height: 18px; + + .dark & { + color: colors.on-color-deemphasis(dark); + } + + .light & { + color: colors.on-color-deemphasis(light); + } + } + + .snapshot-history-entry { + position: relative; + + &:not(:last-child)::before { + content: ''; + position: absolute; + top: 48px; + bottom: -32px; + left: -37px; + border-left: 1px solid; + border-left-color: colors.border-color(light); + + .dark & { + border-left-color: colors.border-color(dark); + } + + .light & { + border-left-color: colors.border-color(light); + } + } + } + + .snapshot-history-marker { + position: absolute; + top: 0; + left: -58px; + display: grid; + place-items: center; + width: 40px; + height: 40px; + border: 1px solid; + border-radius: 50%; + border-color: colors.border-color(light); + + .dark & { + border-color: colors.border-color(dark); + color: colors.on-color-deemphasis(dark); + background: color.mix( + colors.color(mitre-navy), + colors.color(mitre-silver), + 62% + ); + } + + .light & { + border-color: colors.border-color(light); + color: colors.on-color-deemphasis(light); + background: color.mix( + colors.color(mitre-silver), + colors.color(mitre-navy), + 84% + ); + } + + &.snapshot-history-marker-tagged { + border-color: colors.color(success); + color: colors.color(success); + } + + .mat-icon { + width: 18px; + height: 18px; + font-size: 18px; + line-height: 18px; + } + } + + .snapshot-history-card { + overflow: hidden; + border: 1px solid; + border-color: colors.border-color(light); + border-radius: 6px; + + .dark & { + border-color: colors.border-color(dark); + background: color.mix(colors.color(dark), colors.color(mitre-navy), 72%); + } + + .light & { + border-color: colors.border-color(light); + background: colors.color(mitre-silver); + } + + &.snapshot-history-card-tagged { + border-color: colors.color(success); + + .snapshot-history-card-header { + border-bottom-color: colors.color(success); + } + } + } + + .snapshot-history-card-header { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 20px 16px 22px; + border-bottom: 1px solid; + border-bottom-color: colors.border-color(light); + + .dark & { + border-bottom-color: colors.border-color(dark); + background: color.mix(colors.color(dark), colors.color(mitre-navy), 68%); + } + + .light & { + border-bottom-color: colors.border-color(light); + background: colors.color(light); + } + } + + .snapshot-history-title { + min-width: 0; + + .snapshot-history-title-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + } + + h3 { + margin: 0; + font-size: 20px; + line-height: 26px; + + .dark & { + color: colors.on-color-deemphasis(dark); + } + + .light & { + color: colors.on-color-deemphasis(light); + } + } + + .snapshot-history-chips { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + } + + .snapshot-cache-status { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-weight: 700; + color: colors.color(warn); + cursor: help; + + &.snapshot-cache-status-cached { + color: colors.color(success); + } + + .mat-icon { + width: 17px; + height: 17px; + font-size: 17px; + line-height: 17px; + } + } + + p { + margin: 4px 0 0; + font-size: 13px; + line-height: 18px; + + .dark & { + color: colors.on-color-deemphasis(dark); + } + + .light & { + color: colors.on-color-deemphasis(light); + } + } + + .snapshot-history-meta { + margin-top: 2px; + font-size: 12px; + } + + .snapshot-history-description { + display: flex; + align-items: flex-start; + gap: 8px; + max-width: 720px; + margin-top: 12px; + + .mat-icon { + flex: 0 0 auto; + width: 18px; + height: 18px; + margin-top: 1px; + font-size: 18px; + line-height: 18px; + color: colors.color(secondary); + } + + p { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + } + } + + .snapshot-history-actions { + flex: 0 0 auto; + justify-content: flex-end; + padding-top: 6px; + } + + .snapshot-action-spinner { + display: inline-block; + margin-right: 8px; + vertical-align: middle; + } + + .snapshot-history-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(104px, 1fr)); + gap: 16px; + padding: 14px 16px 12px; + } + + .snapshot-history-stat { + min-width: 0; + text-align: center; + } + + .snapshot-history-stat-value { + font-size: 26px; + font-weight: 800; + line-height: 32px; + } + + .snapshot-history-stat-label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + line-height: 16px; + text-transform: uppercase; + + .dark & { + color: colors.on-color-deemphasis(dark); + } + + .light & { + color: colors.on-color-deemphasis(light); + } + } + + .snapshot-graph-cache { + margin: 0 16px 16px; + overflow: hidden; + border: 1px solid colors.color(success); + border-radius: 5px; + + .dark & { + background: color.mix(colors.color(dark), colors.color(success), 90%); + } + + .light & { + background: color.mix(colors.color(light), colors.color(success), 92%); + } + } + + .snapshot-graph-cache-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 12px; + border-bottom: 1px solid colors.color(success); + font-size: 12px; + font-weight: 700; + + > span { + font-weight: 600; + + .dark & { + color: colors.on-color-deemphasis(dark); + } + + .light & { + color: colors.on-color-deemphasis(light); + } + } + } + + .snapshot-graph-cache-title { + display: inline-flex; + align-items: center; + gap: 6px; + color: colors.color(success); + + .mat-icon { + width: 18px; + height: 18px; + font-size: 18px; + line-height: 18px; + } + } + + .snapshot-graph-cache-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + padding: 10px 12px 12px; + } + + .snapshot-graph-cache-stat { + min-width: 0; + text-align: center; + } + + .snapshot-graph-cache-stat-value { + font-size: 20px; + font-weight: 800; + line-height: 26px; + } + + .snapshot-bundle-hashes { + margin: 0 16px 16px; + padding: 10px 12px; + border: 1px solid; + border-radius: 5px; + + .dark & { + border-color: colors.border-color(dark); + background: rgba(colors.color(mitre-silver), 0.025); + } + + .light & { + border-color: colors.border-color(light); + background: rgba(colors.color(mitre-navy), 0.025); + } + } + + .snapshot-bundle-hashes-header { + display: inline-flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; + font-size: 12px; + font-weight: 700; + color: colors.color(secondary); + + .mat-icon { + width: 18px; + height: 18px; + font-size: 18px; + line-height: 18px; + } + } + + .snapshot-bundle-hash-row { + display: grid; + grid-template-columns: 64px minmax(0, 1fr) 40px; + align-items: center; + gap: 8px; + min-height: 40px; + + code { + min-width: 0; + overflow-wrap: anywhere; + font-size: 12px; + line-height: 18px; + } + + button { + justify-self: end; + } + } + + .snapshot-bundle-hash-label { + font-size: 12px; + font-weight: 700; + } + + .snapshot-bundle-hash-error { + font-size: 12px; + color: colors.color(warn); + } + + .modified-color { + color: color.mix(colors.color(spark-yellow), colors.color(warn), 72%); + } + + .virtual-track-layout { + display: flex; + flex-direction: column; + gap: 16px; + height: calc(100dvh - 148px); + min-height: 0; + } + + .virtual-resolution-table, + .virtual-object-panel, + .virtual-component-row, + .virtual-stat, + .config-component-row, + .empty-state { + .dark & { + border-color: colors.border-color(dark); + } + + .light & { + border-color: colors.border-color(light); + } + } + + .labelled-box.virtual-resolution-panel > .content, + .labelled-box.virtual-object-panel > .content { + display: flex; + flex-direction: column; + align-items: stretch; + min-height: 0; + padding: 14px 12px 16px; + overflow: hidden; + } + + .virtual-resolution-panel { + flex: 0 0 auto; + } + + .virtual-resolution-header { + align-items: flex-start !important; + + h3 { + min-width: 0; + } + + .text-deemphasis { + flex: 0 0 auto; + font-size: 12px; + line-height: 18px; + text-align: right; + } + } + + .virtual-resolution-table { + margin-top: 12px; + overflow-x: auto; + + table { + width: 100%; + border-collapse: collapse; + min-width: 820px; + table-layout: fixed; + } + + .virtual-resolution-component-col { + width: 46%; + } + + .virtual-resolution-strategy-col { + width: 17%; + } + + .virtual-resolution-version-col { + width: 13%; + } + + .virtual-resolution-count-col { + width: 8%; + } + + th, + td { + padding: 10px 12px; + text-align: left; + vertical-align: middle; + } + + th:nth-child(n + 4), + td:nth-child(n + 4) { + text-align: center; + white-space: nowrap; + } + + .virtual-resolution-row { + &:focus-visible { + outline: 2px solid colors.color(secondary); + outline-offset: -3px; + } + + .dark &:hover td { + background: rgba(colors.color(secondary), 0.1); + } + + .light &:hover td { + background: rgba(colors.color(secondary), 0.055); + } + } + + th { + font-family: + Roboto Condensed, + Arial, + sans-serif; + font-size: 13px; + font-weight: 700; + line-height: 18px; + text-transform: uppercase; + + .dark & { + color: colors.on-color-deemphasis(dark); + background: rgba(colors.color(mitre-silver), 0.035); + } + + .light & { + color: colors.on-color-deemphasis(light); + background: rgba(colors.color(mitre-black), 0.018); + } + } + + td { + border-top: 1px solid rgba(colors.on-color(light), 0.06); + font-size: 14px; + line-height: 20px; + + .dark & { + border-color: rgba(colors.on-color(dark), 0.1); + } + + .light & { + border-color: rgba(colors.on-color(light), 0.06); + } + + strong, + span { + display: block; + } + + span { + margin-top: 2px; + font-size: 12px; + line-height: 16px; + + .dark & { + color: colors.on-color-deemphasis(dark); + } + + .light & { + color: colors.on-color-deemphasis(light); + } + } + } + } + + .virtual-empty-cell { + height: 64px; + text-align: center !important; + + .dark & { + color: colors.on-color-deemphasis(dark); + } + + .light & { + color: colors.on-color-deemphasis(light); + } + } + + .virtual-object-panels { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 16px; + flex: 1 1 auto; + min-height: 0; + } + + .virtual-object-panel { + min-height: 0; + } + + .virtual-object-list { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 12px; + min-height: 0; + padding-top: 12px; + overflow-y: auto; + padding-right: 4px; + scrollbar-gutter: stable; + } + + .virtual-object-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + border: 1px solid; + border-radius: 4px; + padding: 12px; + + .dark & { + border-color: colors.border-color(dark); + background: rgba(colors.color(mitre-silver), 0.025); + } + + .light & { + border-color: colors.border-color(light); + background: rgba(colors.color(mitre-black), 0.014); + } + + h4 { + margin: 0; + font-size: 15px; + font-weight: 800; + line-height: 20px; + } + + p { + margin: 4px 0 0; + font-size: 12px; + line-height: 16px; + + .dark & { + color: colors.on-color-deemphasis(dark); + } + + .light & { + color: colors.on-color-deemphasis(light); + } + } + + button { + flex: 0 0 auto; + height: 32px; + min-width: 0; + padding: 0 10px; + + .mat-icon { + width: 18px; + height: 18px; + margin-left: 4px; + font-size: 18px; + line-height: 18px; + } + } + } + + .release-track-board { + display: flex; + flex-direction: column; + gap: 16px; + height: calc(100dvh - 148px); + min-height: 0; + } + + .summary-frame { + flex: 0 0 auto; + } + + .summary-frame > .summary-overview.content { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(520px, 620px); + overflow: hidden; + padding: 0 !important; + + .light & { + background: rgba(colors.color(mitre-black), 0.018); + } + + .dark & { + background: rgba(colors.color(mitre-silver), 0.035); + } + } + + .summary-description { + min-width: 0; + padding: 14px 18px; + } + + .summary-section-header { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 6px; + min-height: 32px; + + h2 { + margin: 0; + font-size: 15px; + font-weight: 800; + line-height: 20px; + } + } + + .description-edit-button { + flex: 0 0 auto; + width: 28px; + height: 28px; + padding: 0; + + .mat-icon { + width: 18px; + height: 18px; + font-size: 18px; + line-height: 18px; + } + } + + .summary-description-text { + display: -webkit-box; + max-width: 72ch; + margin: 4px 0 0; + overflow: hidden; + font-size: 14px; + line-height: 20px; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + + &.empty { + font-style: italic; + } + + .light & { + color: colors.on-color(light); + + &.empty { + color: colors.on-color-deemphasis(light); + } + } + + .dark & { + color: colors.on-color(dark); + + &.empty { + color: colors.on-color-deemphasis(dark); + } + } + } + + .description-editor { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 8px; + + .mat-mdc-form-field { + width: 100%; + } + + textarea { + min-height: 72px; + resize: vertical; + } + } + + .description-actions { + justify-content: flex-end; + } + + .summary-stats { + display: grid; + grid-template-columns: + minmax(150px, 1fr) + minmax(130px, 0.85fr) + minmax(190px, 1.2fr); + + .light & { + border-left: 1px solid colors.border-color(light); + background: rgba(colors.color(mitre-black), 0.014); + } + + .dark & { + border-left: 1px solid colors.border-color(dark); + background: rgba(colors.color(mitre-silver), 0.025); + } + } + + .summary-metric { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + padding: 14px 16px; + + & + .summary-metric { + .light & { + border-left: 1px solid colors.border-color(light); + } + + .dark & { + border-left: 1px solid colors.border-color(dark); + } + } + } + + .summary-copy { + display: flex; + flex-direction: column; + gap: 2px; + justify-content: center; + min-width: 0; + font-size: 14px; + line-height: 18px; + } + + .summary-heading { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .summary-copy .text-deemphasis { + display: block; + width: 100%; + overflow: hidden; + font-size: 12px; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; + } + + .summary-count { + flex: 0 0 auto; + min-width: 1.25ch; + font-size: 32px; + font-weight: 800; + line-height: 36px; + text-align: right; + } + + .release-columns { + display: flex; + gap: 16px; + flex: 1 1 auto; + align-items: stretch; + min-height: 0; + } + + .virtual-track-workspace { + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(0, 1.15fr) minmax( + 280px, + 0.7fr + ); + gap: 16px; + flex: 1 1 auto; + min-height: 0; + } + + .virtual-track-card { + min-height: 0; + + > .content { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; + padding: 14px 12px 16px; + } + } + + .virtual-card-body { + display: flex; + flex-direction: column; + gap: 12px; + min-height: 0; + padding-top: 12px; + overflow-y: auto; + scrollbar-gutter: stable; + } + + .virtual-component-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + border: 1px solid; + border-radius: 4px; + padding: 12px; + + > div:first-child { + flex: 1 1 auto; + min-width: 0; + } + + .light & { + border-color: colors.border-color(light); + background: rgba(colors.color(mitre-black), 0.014); + } + + .dark & { + border-color: colors.border-color(dark); + background: rgba(colors.color(mitre-silver), 0.025); + } + + h4 { + margin: 0; + font-size: 15px; + line-height: 20px; + } + + p { + margin: 4px 0 0; + overflow-wrap: anywhere; + font-size: 12px; + line-height: 16px; + + .light & { + color: colors.on-color-deemphasis(light); + } + + .dark & { + color: colors.on-color-deemphasis(dark); + } + } + } + + .virtual-component-meta { + display: flex; + flex: 0 0 auto; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; + max-width: 36%; + } + + .virtual-resolution-stats { + display: grid; + grid-template-columns: 1fr; + gap: 10px; + overflow: visible; + } + + .virtual-stat { + border: 1px solid; + border-radius: 4px; + padding: 12px; + text-align: center; + + .light & { + border-color: colors.border-color(light); + background: rgba(colors.color(mitre-black), 0.014); + } + + .dark & { + border-color: colors.border-color(dark); + background: rgba(colors.color(mitre-silver), 0.025); + } + + strong { + display: block; + font-size: 28px; + font-weight: 800; + line-height: 32px; + } + + span { + display: block; + margin-top: 2px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + line-height: 16px; + text-transform: uppercase; + + .light & { + color: colors.on-color-deemphasis(light); + } + + .dark & { + color: colors.on-color-deemphasis(dark); + } + } + } + + .release-column { + display: flex; + flex: 1 1 0; + align-self: stretch; + min-width: 260px; + min-height: 0; + } + + .released-members-collapsed { + flex: 0 0 46px; + min-width: 46px; + max-width: 46px; + } + + .track-panel { + height: 100%; + } + + .track-panel { + width: 100%; + min-height: 0; + } + + .released-members-rail { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + min-height: 0; + border: 1px dashed currentColor; + border-radius: 4px; + padding: 14px 6px; + color: $released-members-disabled-light; + font: inherit; + font-weight: 800; + text-transform: uppercase; + background: transparent; + cursor: pointer; + + .light & { + color: $released-members-disabled-light; + background: rgba(colors.color(mitre-black), 0.025); + } + + .dark & { + color: $released-members-disabled-dark; + background: rgba(colors.color(mitre-silver), 0.04); + } + + &:hover { + .light & { + background: rgba(colors.color(mitre-black), 0.045); + } + + .dark & { + background: rgba(colors.color(mitre-silver), 0.075); + } + } + + &:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; + } + } + + .released-members-label { + writing-mode: vertical-rl; + transform: rotate(180deg); + white-space: nowrap; + font-size: 11px; + line-height: 14px; + } + + .labelled-box > .content { + display: block; + + .card-header { + display: flex; + align-items: center; + gap: 2px; + min-width: 0; + + &.member-list { + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; + + h3 { + line-height: 32px; + } + } + + h3 { + margin: 0; + } + } + + .card-body:not(.list) { + display: flex; + align-items: center; + } + } + + .track-panel-candidates > .content { + box-shadow: inset 0 4px 0 colors.color(info); + } + + .track-panel-awaiting-review > .content { + box-shadow: inset 0 4px 0 $awaiting-review-color; + } + + .track-panel-staged > .content { + box-shadow: inset 0 4px 0 colors.color(success); + } + + .track-panel-members > .content { + box-shadow: inset 0 4px 0 colors.border-color(light); + } + + .track-panel > .content { + display: flex; + flex-direction: column; + align-items: stretch; + height: 100%; + min-height: 0; + padding: 14px 12px 16px; + overflow: hidden; + + .card-body.list { + flex: 1 1 auto; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 12px; + min-height: 0; + padding-top: 12px; + padding-right: 4px; + overflow-y: auto; + scrollbar-gutter: stable; + } + } + + .small-buttons { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + + .mdc-button + .mdc-button { + margin-left: 0; + } + + .mdc-button { + height: 28px; + min-width: 0; + padding: 0 8px; + + .mat-mdc-button-touch-target { + height: 32px !important; + } + } + + .mat-icon { + width: 18px; + height: 18px; + margin-right: 4px; + font-size: 18px; + line-height: 18px; + } + } + + .empty-state { + border: 1px dashed; + border-radius: 4px; + padding: 18px 12px; + font-size: 14px; + line-height: 20px; + text-align: center; + + .light & { + color: colors.on-color-deemphasis(light); + border-color: colors.border-color(light); + background: rgba(colors.color(mitre-black), 0.02); + } + + .dark & { + color: colors.on-color-deemphasis(dark); + border-color: colors.border-color(dark); + background: rgba(colors.color(mitre-silver), 0.04); + } + } + + @media (max-width: 960px) { + .release-track-board, + .virtual-track-layout { + height: auto; + } + + .release-columns { + flex-wrap: wrap; + } + + .release-column { + flex-basis: calc(50% - 8px); + } + + .virtual-track-workspace { + grid-template-columns: 1fr; + } + + .virtual-object-panels { + grid-template-columns: 1fr; + } + + .summary-frame > .summary-overview.content { + grid-template-columns: 1fr; + } + + .summary-stats { + .light & { + border-top: 1px solid colors.border-color(light); + border-left: 0; + } + + .dark & { + border-top: 1px solid colors.border-color(dark); + border-left: 0; + } + } + + .released-members-collapsed { + flex-basis: 100%; + max-width: none; + min-height: 52px; + } + + .released-members-rail { + flex-direction: row; + min-height: 52px; + } + + .released-members-label { + writing-mode: horizontal-tb; + transform: none; + } + + .track-panel > .content { + height: clamp(360px, calc(100dvh - 360px), 720px); + } + } + + @media (max-width: 720px) { + .release-track-header { + flex-direction: column; + align-items: stretch; + } + + .release-track-actions { + justify-content: flex-start; + } + + .release-column { + flex-basis: 100%; + } + + .summary-stats { + grid-template-columns: 1fr; + } + + .virtual-resolution-header { + flex-direction: column; + + .text-deemphasis { + text-align: left; + } + } + + .virtual-resolution-table { + overflow-x: auto; + + table { + min-width: 720px; + } + } + + .summary-metric { + & + .summary-metric { + .light &, + .dark & { + border-left: 0; + border-top: 1px solid; + } + + .light & { + border-top-color: colors.border-color(light); + } + + .dark & { + border-top-color: colors.border-color(dark); + } + } + } + + .snapshot-history-list { + margin-top: 20px; + padding-left: 50px; + } + + .snapshot-history-card-header, + .snapshot-history-actions { + align-items: flex-start; + } + + .snapshot-history-card-header { + flex-direction: column; + } + + .snapshot-history-actions { + justify-content: flex-start; + padding-top: 0; + } + + .snapshot-history-stats { + grid-template-columns: 1fr; + text-align: left; + } + + .snapshot-graph-cache-header { + align-items: flex-start; + flex-direction: column; + } + + .snapshot-graph-cache-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .config-tpl { + margin-top: 20px; + } + + .config-card > .content { + padding: 18px; + } + + .config-row { + flex-direction: column; + gap: 12px; + + > .config-field { + flex: 0 1 auto; + margin-top: 0; + } + + > .config-state, + > .config-value, + > .mat-mdc-slide-toggle { + margin-left: 0; + } + } + + .config-component-row { + flex-direction: column; + gap: 10px; + } + + .config-value-list { + justify-content: flex-start; + max-width: none; + } + + .component-track-id { + overflow-wrap: anywhere; + white-space: normal; + } + } +} + +::ng-deep .virtual-component-track-autocomplete { + .component-track-option-label { + display: inline-flex; + align-items: baseline; + gap: 4px; + min-width: 0; + } + + .component-track-option-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + } + + .component-track-option-meta { + flex: 0 0 auto; + font-size: 0.82em; + + .dark & { + color: colors.on-color-deemphasis(dark); + } + + .light & { + color: colors.on-color-deemphasis(light); + } + } +} diff --git a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.spec.ts b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.spec.ts new file mode 100644 index 000000000..0f958c2e7 --- /dev/null +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.spec.ts @@ -0,0 +1,2344 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Clipboard } from '@angular/cdk/clipboard'; + +import { ReleaseTrackPageComponent } from './release-track-page.component'; +import { ReleaseTracksConnectorService } from 'src/app/services/connectors/rest-api/release-tracks.service'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + createAsyncObservable, + createMockReleaseTrackApiConnector, + createPaginatedResponse, +} from 'src/app/testing/mocks/rest-api-connector.mock'; +import { ActivatedRoute, Router } from '@angular/router'; +import { of, Subject, throwError } from 'rxjs'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { BreadcrumbService } from 'src/app/services/helpers/breadcrumb.service'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { MultipleChoiceDialogComponent } from 'src/app/components/multiple-choice-dialog/multiple-choice-dialog.component'; +import { AddDialogComponent } from 'src/app/components/add-dialog/add-dialog.component'; +import { DeleteDialogComponent } from 'src/app/components/delete-dialog/delete-dialog.component'; +import { ReleasePreviewDialogComponent } from 'src/app/components/release-preview-dialog/release-preview-dialog.component'; +import { ConfirmationDialogComponent } from 'src/app/components/confirmation-dialog/confirmation-dialog.component'; +import { SnapshotDescriptionDialogComponent } from 'src/app/components/snapshot-description-dialog/snapshot-description-dialog.component'; +import { AuthenticationService } from 'src/app/services/connectors/authentication/authentication.service'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { + ConflictPolicy, + DeduplicationStrategy, + MemberSyncBehavior, + MemberSyncPolicy, + MemberSyncStrategy, + ReleaseTrackType, + SnapshotScheduleMode, + SnapshotTier, +} from 'src/app/classes/release-tracks'; + +describe('ReleaseTrackPageComponent', () => { + let component: ReleaseTrackPageComponent; + let fixture: ComponentFixture; + let mockReleaseTrackApiConnector: any; + let mockDialog: any; + let mockRestApiConnector: any; + let mockRouter: any; + let mockAuthenticationService: any; + let mockSnackbar: any; + let mockClipboard: any; + + beforeEach(async () => { + mockReleaseTrackApiConnector = createMockReleaseTrackApiConnector({ + getLatestSnapshot: vi.fn(() => createAsyncObservable(null)), + listReleaseTracks: vi.fn(() => createAsyncObservable({ data: [] })), + listSnapshots: vi.fn(() => + createAsyncObservable({ + data: [], + pagination: { total: 0, limit: 50, offset: 0 }, + }) + ), + exportLatestSnapshot: vi.fn(() => createAsyncObservable({})), + exportSnapshotByModified: vi.fn(() => createAsyncObservable({})), + retrieveSnapshotByModified: vi.fn(() => createAsyncObservable(null)), + updateSnapshotDescription: vi.fn(() => createAsyncObservable({})), + createVirtualSnapshot: vi.fn(() => createAsyncObservable({})), + previewRelease: vi.fn(() => createAsyncObservable({})), + releaseLatest: vi.fn(() => createAsyncObservable({})), + releaseSnapshot: vi.fn(() => createAsyncObservable({})), + createSnapshotGraph: vi.fn(() => createAsyncObservable({})), + deleteSnapshotGraph: vi.fn(() => createAsyncObservable(undefined)), + getConfig: vi.fn(() => createAsyncObservable(null)), + updateConfig: vi.fn(() => createAsyncObservable({})), + updateComposition: vi.fn(() => createAsyncObservable({})), + reviewCandidates: vi.fn(() => createAsyncObservable({})), + updateMetadataByLatest: vi.fn(() => createAsyncObservable({})), + addCandidates: vi.fn(() => createAsyncObservable({})), + deleteReleaseTrack: vi.fn(() => createAsyncObservable({})), + }); + mockDialog = { + open: vi.fn(), + }; + mockSnackbar = { + open: vi.fn(), + }; + mockClipboard = { + copy: vi.fn(() => true), + }; + mockRestApiConnector = { + getAllObjects: vi.fn(() => of(createPaginatedResponse([]))), + triggerBrowserDownload: vi.fn(), + }; + const mockBreadcrumbService = { + changeBreadcrumb: vi.fn(), + }; + mockRouter = { + navigate: vi.fn(), + }; + mockAuthenticationService = { + canEdit: vi.fn(() => true), + }; + + await TestBed.configureTestingModule({ + declarations: [ReleaseTrackPageComponent], + imports: [FormsModule, ReactiveFormsModule], + providers: [ + { + provide: ReleaseTracksConnectorService, + useValue: mockReleaseTrackApiConnector, + }, + { + provide: RestApiConnectorService, + useValue: mockRestApiConnector, + }, + { + provide: AuthenticationService, + useValue: mockAuthenticationService, + }, + { + provide: MatDialog, + useValue: mockDialog, + }, + { + provide: MatSnackBar, + useValue: mockSnackbar, + }, + { + provide: Clipboard, + useValue: mockClipboard, + }, + { + provide: BreadcrumbService, + useValue: mockBreadcrumbService, + }, + { + provide: Router, + useValue: mockRouter, + }, + { + provide: ActivatedRoute, + useValue: { + params: of({}), + queryParams: of({}), + snapshot: {}, + }, + }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(ReleaseTrackPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should only show a diff for the latest pin when an object is both staged and a candidate', () => { + const staged = { + object_ref: 'attack-pattern--shared', + object_modified: '2026-01-01T00:00:00.000Z', + } as any; + const candidate = { + object_ref: 'attack-pattern--shared', + object_modified: '2026-02-01T00:00:00.000Z', + } as any; + const unrelatedCandidate = { + object_ref: 'attack-pattern--candidate-only', + object_modified: '2026-01-01T00:00:00.000Z', + } as any; + component.releaseTrack = { + staged: [staged], + candidates: [candidate, unrelatedCandidate], + } as any; + + expect(component.shouldShowDiff(staged)).toBe(false); + expect(component.shouldShowDiff(candidate)).toBe(true); + expect(component.shouldShowDiff(unrelatedCandidate)).toBe(true); + expect(component.getDiffUnavailableMessage(staged)).toBe( + 'A newer revision of this object is available in the release track. View its diff instead.' + ); + expect(component.getDiffUnavailableMessage(candidate)).toBeNull(); + }); + + it('should delete the release track after confirmation', () => { + mockDialog.open.mockReturnValue({ + afterClosed: () => of(true), + }); + mockReleaseTrackApiConnector.deleteReleaseTrack.mockReturnValue(of({})); + component.id = 'release-track--123'; + component.releaseTrack = { name: 'Enterprise Release' } as any; + + component.onDeleteReleaseTrack(); + + expect(mockDialog.open).toHaveBeenCalledWith( + DeleteDialogComponent, + expect.objectContaining({ + maxWidth: '35em', + disableClose: true, + autoFocus: false, + data: expect.objectContaining({ + title: 'Are you sure you want to delete this release track?', + warning: + 'Enterprise Release and its snapshots will be permanently deleted.', + stixId: 'release-track--123', + }), + }) + ); + expect( + mockReleaseTrackApiConnector.deleteReleaseTrack + ).toHaveBeenCalledWith('release-track--123'); + expect(mockRouter.navigate).toHaveBeenCalledWith([ + '/dashboard/release-management', + ]); + }); + + it('should not delete the release track when confirmation is cancelled', () => { + mockDialog.open.mockReturnValue({ + afterClosed: () => of(false), + }); + component.id = 'release-track--123'; + + component.onDeleteReleaseTrack(); + + expect( + mockReleaseTrackApiConnector.deleteReleaseTrack + ).not.toHaveBeenCalled(); + expect(mockRouter.navigate).not.toHaveBeenCalled(); + }); + + it('should not open the delete dialog without editor authorization', () => { + mockAuthenticationService.canEdit.mockReturnValue(false); + component.id = 'release-track--123'; + + component.onDeleteReleaseTrack(); + + expect(mockDialog.open).not.toHaveBeenCalled(); + }); + + it('should download the release track in the selected export format', () => { + const exportPayload = { type: 'bundle', objects: [] }; + mockDialog.open.mockReturnValue({ + afterClosed: () => of('bundle-stix-2.1'), + }); + mockReleaseTrackApiConnector.exportLatestSnapshot.mockReturnValue( + of(exportPayload) + ); + component.id = 'release-track--123'; + component.releaseTrack = { name: 'Enterprise Release' } as any; + + component.onExport(); + + const choices = mockDialog.open.mock.calls[0][1].data.choices; + expect(mockDialog.open).toHaveBeenCalledWith( + MultipleChoiceDialogComponent, + expect.anything() + ); + expect(choices.map((choice: any) => choice.value)).toEqual([ + 'bundle-stix-2.0', + 'bundle-stix-2.1', + 'workbench', + ]); + expect(choices.map((choice: any) => choice.label)).toEqual([ + 'Bundle (STIX 2.0)', + 'Bundle (STIX 2.1)', + 'Workbench', + ]); + expect( + mockReleaseTrackApiConnector.exportLatestSnapshot + ).toHaveBeenCalledWith('release-track--123', 'bundle', { + include: 'all', + stixVersion: '2.1', + }); + expect(mockRestApiConnector.triggerBrowserDownload).toHaveBeenCalledWith( + exportPayload, + 'enterprise-release-latest-bundle-stix-2.1.json' + ); + }); + + it('should not export when the dialog is dismissed', () => { + mockDialog.open.mockReturnValue({ + afterClosed: () => of(null), + }); + component.id = 'release-track--123'; + + component.onExport(); + + expect( + mockReleaseTrackApiConnector.exportLatestSnapshot + ).not.toHaveBeenCalled(); + }); + + it('should not open the export dialog without a release track id', () => { + component.id = ''; + + component.onExport(); + + expect(mockDialog.open).not.toHaveBeenCalled(); + }); + + it('should export a standard draft snapshot with staged content', () => { + const exportPayload = { type: 'bundle', objects: [] }; + mockDialog.open.mockReturnValue({ + afterClosed: () => of('bundle-stix-2.0'), + }); + mockReleaseTrackApiConnector.exportSnapshotByModified.mockReturnValue( + of(exportPayload) + ); + component.id = 'release-track--123'; + component.releaseTrack = { + name: 'Enterprise Release', + type: ReleaseTrackType.Standard, + } as any; + + component.onExportSnapshot({ + snapshot: { + modified: '2024-05-21T07:00:00.000Z', + type: ReleaseTrackType.Standard, + version: null, + }, + title: 'Draft Snapshot', + created: new Date('2024-05-21T07:00:00.000Z'), + modified: '2024-05-21T07:00:00.000Z', + taggedAt: null, + isTagged: false, + stats: [], + addedCount: 0, + modifiedCount: 0, + totalObjects: 0, + } as any); + + expect( + mockReleaseTrackApiConnector.exportSnapshotByModified + ).toHaveBeenCalledWith( + 'release-track--123', + '2024-05-21T07:00:00.000Z', + 'bundle', + { include: 'staged', stixVersion: '2.0' } + ); + expect(mockRestApiConnector.triggerBrowserDownload).toHaveBeenCalledWith( + exportPayload, + 'enterprise-release-draft-bundle-stix-2.0.json' + ); + }); + + it.each([ + ['2.0', 'bundle-stix-2.0'], + ['2.1', 'bundle-stix-2.1'], + ] as const)( + 'should export a cached tagged standard snapshot as STIX %s without staged content', + (stixVersion, choice) => { + mockDialog.open.mockReturnValue({ + afterClosed: () => of(choice), + }); + mockReleaseTrackApiConnector.exportSnapshotByModified.mockReturnValue( + of({ type: 'bundle', objects: [] }) + ); + component.id = 'release-track--123'; + component.releaseTrack = { + name: 'Enterprise Release', + type: ReleaseTrackType.Standard, + } as any; + + component.onExportSnapshot({ + snapshot: { + modified: '2024-04-15T06:00:00.000Z', + type: ReleaseTrackType.Standard, + version: '1.3', + snapshot_description: 'Published analyst context', + }, + title: 'v1.3', + created: new Date('2024-04-15T06:00:00.000Z'), + modified: '2024-04-15T06:00:00.000Z', + taggedAt: new Date('2024-04-15T06:30:00.000Z'), + isTagged: true, + isBundleCached: true, + stats: [], + addedCount: 0, + modifiedCount: 0, + totalObjects: 0, + } as any); + + expect( + mockReleaseTrackApiConnector.exportSnapshotByModified + ).toHaveBeenCalledWith( + 'release-track--123', + '2024-04-15T06:00:00.000Z', + 'bundle', + { stixVersion } + ); + expect(mockRestApiConnector.triggerBrowserDownload).toHaveBeenCalledWith( + { type: 'bundle', objects: [] }, + `enterprise-release-v1.3-bundle-stix-${stixVersion}.json` + ); + } + ); + + it('should export a virtual draft snapshot without staged content', () => { + mockDialog.open.mockReturnValue({ + afterClosed: () => of('bundle-stix-2.0'), + }); + mockReleaseTrackApiConnector.exportSnapshotByModified.mockReturnValue( + of({ type: 'bundle', objects: [] }) + ); + component.id = 'release-track--virtual'; + component.releaseTrack = { + type: ReleaseTrackType.Virtual, + } as any; + + component.onExportSnapshot({ + snapshot: { + modified: '2024-05-21T07:00:00.000Z', + type: ReleaseTrackType.Virtual, + version: null, + }, + title: 'Draft Snapshot', + created: new Date('2024-05-21T07:00:00.000Z'), + modified: '2024-05-21T07:00:00.000Z', + taggedAt: null, + isTagged: false, + stats: [], + addedCount: 0, + modifiedCount: 0, + totalObjects: 0, + } as any); + + expect( + mockReleaseTrackApiConnector.exportSnapshotByModified + ).toHaveBeenCalledWith( + 'release-track--virtual', + '2024-05-21T07:00:00.000Z', + 'bundle', + { stixVersion: '2.0' } + ); + }); + + it('should export a snapshot in workbench format with all tiers', () => { + const exportPayload = { id: 'release-track--123', members: [] }; + mockDialog.open.mockReturnValue({ + afterClosed: () => of('workbench'), + }); + mockReleaseTrackApiConnector.exportSnapshotByModified.mockReturnValue( + of(exportPayload) + ); + component.id = 'release-track--123'; + component.releaseTrack = { + name: 'Enterprise Release', + type: ReleaseTrackType.Standard, + } as any; + + component.onExportSnapshot({ + snapshot: { + modified: '2024-05-21T07:00:00.000Z', + type: ReleaseTrackType.Standard, + version: null, + }, + title: 'Draft Snapshot', + created: new Date('2024-05-21T07:00:00.000Z'), + modified: '2024-05-21T07:00:00.000Z', + taggedAt: null, + isTagged: false, + stats: [], + addedCount: 0, + modifiedCount: 0, + totalObjects: 0, + } as any); + + expect( + mockReleaseTrackApiConnector.exportSnapshotByModified + ).toHaveBeenCalledWith( + 'release-track--123', + '2024-05-21T07:00:00.000Z', + 'workbench', + { include: 'all' } + ); + expect(mockRestApiConnector.triggerBrowserDownload).toHaveBeenCalledWith( + exportPayload, + 'enterprise-release-draft-workbench.json' + ); + }); + + it('should copy a concise snapshot summary without requesting an export', () => { + mockDialog.open.mockReturnValue({ + afterClosed: () => of('copy-summary'), + }); + component.id = 'release-track--123'; + component.releaseTrack = { + name: 'Enterprise Release', + type: ReleaseTrackType.Standard, + } as any; + + component.onExportSnapshot({ + snapshot: { + id: 'release-track--123', + name: 'Enterprise Release', + modified: '2024-04-15T06:00:00.000Z', + type: ReleaseTrackType.Standard, + version: '1.3', + snapshot_description: 'Published analyst context', + members_count: 120, + staged_count: 4, + candidates_count: 9, + graph_manifest_id: 'release-track-graph-manifest--cached', + graph_statistics: { + primary_count: 120, + secondary_count: 18, + relationship_count: 42, + supporting_count: 3, + link_target_count: 2, + total_count: 185, + }, + }, + title: 'v1.3', + created: new Date('2024-04-15T06:00:00.000Z'), + modified: '2024-04-15T06:00:00.000Z', + taggedAt: new Date('2024-04-15T06:30:00.000Z'), + isTagged: true, + isLatest: false, + isBundleCached: true, + stats: [], + graphCacheStats: [], + graphCacheTotal: 185, + addedCount: 0, + modifiedCount: 0, + totalObjects: 120, + } as any); + + const choices = mockDialog.open.mock.calls[0][1].data.choices; + expect(choices.map((choice: any) => choice.value)).toEqual([ + 'bundle-stix-2.0', + 'bundle-stix-2.1', + 'workbench', + 'copy-summary', + ]); + expect(choices.map((choice: any) => choice.label)).toEqual([ + 'Bundle (STIX 2.0)', + 'Bundle (STIX 2.1)', + 'Workbench', + 'Summary', + ]); + expect( + mockReleaseTrackApiConnector.exportSnapshotByModified + ).not.toHaveBeenCalled(); + expect(mockClipboard.copy).toHaveBeenCalledOnce(); + expect(JSON.parse(mockClipboard.copy.mock.calls[0][0])).toEqual({ + id: 'release-track--123', + name: 'Enterprise Release', + type: 'standard', + version: '1.3', + notes: 'Published analyst context', + modified: '2024-04-15T06:00:00.000Z', + tagged: true, + latest: false, + counts: { + members: 120, + staged: 4, + candidates: 9, + }, + graph_cache: { + cached: true, + manifest_id: 'release-track-graph-manifest--cached', + statistics: { + primary_count: 120, + secondary_count: 18, + relationship_count: 42, + supporting_count: 3, + link_target_count: 2, + total_count: 185, + }, + }, + }); + expect(mockSnackbar.open).toHaveBeenCalledWith( + 'Snapshot summary copied to the clipboard.', + null, + { duration: 3000 } + ); + }); + + it('should report when a snapshot summary cannot be copied', () => { + mockDialog.open.mockReturnValue({ + afterClosed: () => of('copy-summary'), + }); + mockClipboard.copy.mockReturnValue(false); + component.id = 'release-track--123'; + + component.onExportSnapshot({ + snapshot: { + modified: '2024-05-21T07:00:00.000Z', + type: ReleaseTrackType.Virtual, + version: null, + members_count: 8, + quarantine_count: 2, + }, + title: 'Draft Snapshot', + modified: '2024-05-21T07:00:00.000Z', + isTagged: false, + isLatest: true, + isBundleCached: false, + } as any); + + expect(mockSnackbar.open).toHaveBeenCalledWith( + 'Unable to copy the snapshot summary.', + null, + { duration: 5000, panelClass: 'error' } + ); + }); + + it('should load the latest release track with the snapshot model response', () => { + mockReleaseTrackApiConnector.getLatestSnapshot.mockReturnValue( + of({ name: 'Enterprise Release' }) + ); + component.id = 'release-track--123'; + + component.getReleaseTrack(); + + expect(mockReleaseTrackApiConnector.getLatestSnapshot).toHaveBeenCalledWith( + 'release-track--123', + { format: 'workbench', include: 'all' } + ); + expect(component.releaseTrack?.name).toBe('Enterprise Release'); + }); + + it('should load release track summary when latest snapshot is unavailable', () => { + mockReleaseTrackApiConnector.getLatestSnapshot.mockReturnValue(of(null)); + mockReleaseTrackApiConnector.listReleaseTracks.mockReturnValue( + of({ + data: [ + { + track_id: 'release-track--virtual', + type: ReleaseTrackType.Virtual, + name: 'Virtual Release', + composition: { + component_tracks: [{ track_id: 'release-track--standard' }], + }, + }, + ], + }) + ); + component.id = 'release-track--virtual'; + + component.getReleaseTrack(); + + expect(component.releaseTrack?.name).toBe('Virtual Release'); + expect(component.canCreateDraft).toBe(true); + }); + + it('should expose virtual release track composition and resolution details', () => { + component.releaseTrack = { + type: ReleaseTrackType.Virtual, + composition: { + component_tracks: [ + { + track_id: 'release-track--component-one', + resolution_strategy: 'latest_tagged', + priority: 0, + filters: { + object_types: ['attack-pattern'], + domains: ['enterprise-attack'], + }, + }, + { + track_id: 'release-track--component-two', + resolution_strategy: 'latest_tagged', + priority: 1, + }, + ], + }, + composition_resolution: { + component_snapshots: [ + { + track_id: 'release-track--component-one', + track_name: 'Resolved Component One', + resolved_version: '1.0', + objects_contributed: 8, + objects_after_filter: 8, + }, + { + track_id: 'release-track--component-two', + track_name: 'Component Two', + resolved_version: '2.0', + objects_contributed: 12, + objects_after_filter: 12, + }, + ], + summary: { + total_objects: 20, + }, + deduplication: { + duplicates_found: 2, + conflicts_resolved: [{ object_ref: 'attack-pattern--one' }], + }, + }, + members: [ + { + object_ref: 'x-mitre-collection--enterprise', + object_modified: '2026-01-01T00:00:00.000Z', + attack_id: 'NX0001', + name: 'Enterprise ATT&CK', + }, + ], + quarantine: [{ object_ref: 'attack-pattern--quarantined' }], + } as any; + (component as any).virtualComponentTrackSummaries = new Map([ + [ + 'release-track--component-one', + { + trackId: 'release-track--component-one', + name: 'Component One', + candidatesCount: 9, + stagedCount: 3, + membersCount: 72, + }, + ], + [ + 'release-track--component-two', + { + trackId: 'release-track--component-two', + name: 'Component Two', + candidatesCount: 2, + stagedCount: 1, + membersCount: 20, + }, + ], + ]); + + expect(component.isVirtualReleaseTrack).toBe(true); + expect(component.virtualComponentTracks).toHaveLength(2); + expect(component.resolvedComponentSnapshots).toHaveLength(2); + expect(component.virtualResolvedObjectCount).toBe(20); + expect(component.virtualDuplicateCount).toBe(2); + expect(component.virtualConflictCount).toBe(1); + expect(component.quarantineObjects).toHaveLength(1); + expect( + component.getComponentTrackLabel(component.virtualComponentTracks[0]) + ).toBe('Component One'); + expect( + component.getComponentTrackFilters(component.virtualComponentTracks[0]) + ).toEqual(['attack pattern', 'enterprise']); + expect( + component.getVirtualComponentTrackDomains( + component.virtualComponentTracks[0] + ) + ).toEqual(['enterprise']); + expect(component.virtualResolutionRows[0]).toEqual( + expect.objectContaining({ + trackId: 'release-track--component-one', + trackName: 'Component One', + strategy: 'latest_tagged', + resolvedVersion: '1.0', + candidatesCount: 9, + stagedCount: 3, + membersCount: 72, + }) + ); + expect( + component.getVirtualResolvedVersion(component.virtualResolutionRows[0]) + ).toBe('v1.0'); + expect(component.getVirtualTierCount(9)).toBe('9'); + expect(component.getVirtualObjectTitle(component.members[0])).toBe( + 'Enterprise ATT&CK' + ); + expect(component.getVirtualObjectSubtitle(component.members[0])).toBe( + 'NX0001' + ); + }); + + it('should load component track summaries alongside a virtual track', () => { + mockReleaseTrackApiConnector.getLatestSnapshot.mockReturnValue( + of({ + type: ReleaseTrackType.Virtual, + name: 'Virtual Release', + composition: { + component_tracks: [ + { + track_id: 'release-track--component-one', + resolution_strategy: 'latest_tagged', + }, + ], + }, + }) + ); + mockReleaseTrackApiConnector.listReleaseTracks.mockReturnValue( + of({ + data: [ + { + track_id: 'release-track--component-one', + name: 'Component One', + summary: { + candidates_count: 9, + staged_count: 3, + members_count: 72, + }, + }, + ], + }) + ); + component.id = 'release-track--virtual'; + + component.getReleaseTrack(); + + expect(mockReleaseTrackApiConnector.listReleaseTracks).toHaveBeenCalled(); + expect(component.virtualResolutionRows[0]).toEqual( + expect.objectContaining({ + trackName: 'Component One', + candidatesCount: 9, + stagedCount: 3, + membersCount: 72, + }) + ); + }); + + it('should navigate to a component release track page', () => { + component.onOpenComponentTrack({ + trackId: 'release-track--component-one', + }); + + expect(mockRouter.navigate).toHaveBeenCalledWith([ + '/dashboard/release-management', + 'release-track--component-one', + ]); + }); + + it('should load snapshot history from summary counts', () => { + mockReleaseTrackApiConnector.listSnapshots.mockReturnValue( + of({ + data: [ + { + id: 'release-track--123', + modified: '2024-05-21T07:00:00.000Z', + version: null, + type: ReleaseTrackType.Standard, + name: 'Enterprise', + added_count: 2, + modified_count: 1, + candidates_count: 4, + staged_count: 3, + members_count: 20, + }, + { + id: 'release-track--456', + version: '1.3', + graph_manifest_id: 'release-track-graph-manifest--cached', + tagged_at: '2024-04-15T06:30:00.000Z', + modified: '2024-04-15T06:00:00.000Z', + type: ReleaseTrackType.Virtual, + name: 'Combined', + members_count: 5, + quarantine_count: 1, + graph_statistics: { + primary_count: 5, + secondary_count: 8, + relationship_count: 12, + supporting_count: 2, + link_target_count: 1, + total_count: 28, + }, + }, + ], + pagination: { total: 2, limit: 50, offset: 0 }, + }) + ); + component.id = 'release-track--123'; + + component.getSnapshotHistory(); + + expect(mockReleaseTrackApiConnector.listSnapshots).toHaveBeenCalledWith( + 'release-track--123' + ); + expect(component.snapshotHistory[0]).toEqual( + expect.objectContaining({ + title: 'Draft Snapshot', + addedCount: 2, + modifiedCount: 1, + totalObjects: 27, + isTagged: false, + isBundleCached: false, + canCacheBundle: false, + stats: [ + expect.objectContaining({ label: 'Added', value: '+2' }), + expect.objectContaining({ label: 'Modified', value: 1 }), + expect.objectContaining({ label: 'Candidates', value: 4 }), + expect.objectContaining({ label: 'Staged', value: 3 }), + expect.objectContaining({ label: 'Members', value: 20 }), + ], + }) + ); + expect(component.snapshotHistory[1]).toEqual( + expect.objectContaining({ + title: 'v1.3', + addedCount: 0, + modifiedCount: 0, + totalObjects: 6, + taggedAt: new Date('2024-04-15T06:30:00.000Z'), + isTagged: true, + isBundleCached: true, + canCacheBundle: false, + graphCacheTotal: 28, + graphCacheStats: [ + expect.objectContaining({ label: 'Primary', value: 5 }), + expect.objectContaining({ label: 'Secondary', value: 8 }), + expect.objectContaining({ label: 'Relationships', value: 12 }), + expect.objectContaining({ label: 'Dependencies', value: 3 }), + ], + stats: [ + expect.objectContaining({ label: 'Members', value: 5 }), + expect.objectContaining({ label: 'Quarantine', value: 1 }), + ], + }) + ); + expect(component.taggedSnapshotCount).toBe(1); + expect(component.hasCurrentDraftSnapshot).toBe(true); + }); + + it('should explain cached, uncached, and draft bundle states', () => { + const cached = { + isTagged: true, + isBundleCached: true, + } as any; + const uncached = { + isTagged: true, + isBundleCached: false, + } as any; + const draft = { + isTagged: false, + isBundleCached: false, + } as any; + + expect(component.getBundleCacheTooltip(cached)).toContain( + 'repeated exports are deterministic' + ); + expect(component.getBundleCacheTooltip(uncached)).toContain( + 'not guaranteed to be deterministic' + ); + expect(component.getBundleCacheTooltip(draft)).toContain( + 'Tag this snapshot before caching it' + ); + }); + + it('should cache a tagged snapshot and update its history state', () => { + const item = { + snapshot: {}, + title: 'v1.0', + modified: '2026-07-23T13:37:28.000Z', + isTagged: true, + isBundleCached: false, + canCacheBundle: true, + stats: [], + } as any; + mockReleaseTrackApiConnector.createSnapshotGraph.mockReturnValue( + of({ + modified: item.modified, + version: '1.0', + graph_manifest_id: 'release-track-graph-manifest--cached', + bundle_hashes: { + manifest_id: 'release-track-graph-manifest--cached', + stix_2_0: 'a'.repeat(64), + stix_2_1: 'b'.repeat(64), + }, + }) + ); + component.id = 'release-track--123'; + + component.onCacheSnapshotBundle(item); + + expect( + mockReleaseTrackApiConnector.createSnapshotGraph + ).toHaveBeenCalledWith('release-track--123', item.modified); + expect(item.snapshot.graph_manifest_id).toBe( + 'release-track-graph-manifest--cached' + ); + expect(component.getSnapshotBundleHash(item, '2.0')).toBe('a'.repeat(64)); + expect(component.getSnapshotBundleHash(item, '2.1')).toBe('b'.repeat(64)); + expect(item.isBundleCached).toBe(true); + expect(item.canCacheBundle).toBe(false); + expect(mockReleaseTrackApiConnector.listSnapshots).toHaveBeenCalledWith( + 'release-track--123' + ); + expect(mockSnackbar.open).toHaveBeenCalledWith( + 'Bundle cached. Member-only bundle exports are now deterministic.', + null, + expect.objectContaining({ duration: 5000 }) + ); + expect(component.isCachingSnapshot(item)).toBe(false); + }); + + it('should expose cache materialization as in progress until it completes', () => { + const graphResult = new Subject(); + const item = { + snapshot: {}, + title: 'v1.0', + modified: '2026-07-23T13:37:28.000Z', + isTagged: true, + isBundleCached: false, + canCacheBundle: true, + stats: [], + } as any; + mockReleaseTrackApiConnector.createSnapshotGraph.mockReturnValue( + graphResult + ); + component.id = 'release-track--123'; + + component.onCacheSnapshotBundle(item); + + expect(component.isCachingSnapshot(item)).toBe(true); + + graphResult.next({ + modified: item.modified, + version: '1.0', + graph_manifest_id: 'release-track-graph-manifest--cached', + }); + graphResult.complete(); + + expect(component.isCachingSnapshot(item)).toBe(false); + }); + + it('should edit notes on a snapshot without a bundle cache', () => { + const modified = '2026-07-23T13:37:28.000Z'; + const item = { + snapshot: { + snapshot_description: 'Original context', + }, + title: 'v1.0', + modified, + isTagged: true, + isBundleCached: false, + } as any; + mockDialog.open.mockReturnValue({ + afterClosed: () => of('Updated analyst context'), + }); + mockReleaseTrackApiConnector.updateSnapshotDescription.mockReturnValue( + of({ + modified, + snapshot_description: 'Updated analyst context', + }) + ); + component.id = 'release-track--123'; + component.releaseTrack = { + modified: new Date(modified), + snapshot_description: 'Original context', + } as any; + + component.onEditSnapshotDescription(item); + + expect(mockDialog.open).toHaveBeenCalledWith( + SnapshotDescriptionDialogComponent, + expect.objectContaining({ + data: expect.objectContaining({ + title: 'Edit snapshot notes', + description: 'Original context', + }), + }) + ); + expect( + mockReleaseTrackApiConnector.updateSnapshotDescription + ).toHaveBeenCalledWith('release-track--123', modified, { + description: 'Updated analyst context', + }); + expect(item.modified).toBe(modified); + expect(item.snapshot.snapshot_description).toBe('Updated analyst context'); + expect(component.releaseTrack?.snapshot_description).toBe( + 'Updated analyst context' + ); + expect(component.isUpdatingSnapshotDescription(item)).toBe(false); + expect(mockSnackbar.open).toHaveBeenCalledWith( + 'Snapshot notes saved.', + null, + { duration: 3000 } + ); + }); + + it('should not open the notes editor for a cached snapshot', () => { + const item = { + snapshot: { + graph_manifest_id: 'release-track-graph-manifest--cached', + snapshot_description: 'Frozen release notes', + }, + title: 'v1.0', + modified: '2026-07-23T13:37:28.000Z', + isTagged: true, + isBundleCached: true, + } as any; + component.id = 'release-track--123'; + + component.onEditSnapshotDescription(item); + + expect(mockDialog.open).not.toHaveBeenCalled(); + expect( + mockReleaseTrackApiConnector.updateSnapshotDescription + ).not.toHaveBeenCalled(); + }); + + it('should copy a server-provided bundle hash to the clipboard', () => { + const modified = '2026-07-23T13:37:28.000Z'; + const item = { + snapshot: { + graph_manifest_id: 'release-track-graph-manifest--cached', + bundle_hashes: { + manifest_id: 'release-track-graph-manifest--cached', + stix_2_0: 'a'.repeat(64), + stix_2_1: 'b'.repeat(64), + }, + }, + title: 'v1.0', + modified, + isTagged: true, + } as any; + + component.copySnapshotBundleHash(item, '2.1'); + + expect(mockClipboard.copy).toHaveBeenCalledWith('b'.repeat(64)); + expect(mockSnackbar.open).toHaveBeenCalledWith( + 'STIX 2.1 bundle SHA-256 copied to the clipboard.', + null, + { duration: 3000 } + ); + }); + + it('should delete a cached snapshot graph after confirmation', () => { + const item = { + snapshot: { + graph_manifest_id: 'release-track-graph-manifest--cached', + graph_statistics: { total_count: 28 }, + bundle_hashes: { + manifest_id: 'release-track-graph-manifest--cached', + stix_2_0: 'a'.repeat(64), + stix_2_1: 'b'.repeat(64), + }, + }, + title: 'v1.0', + modified: '2026-07-23T13:37:28.000Z', + isTagged: true, + isBundleCached: true, + canCacheBundle: false, + graphCacheStats: [{ label: 'Primary', value: 5 }], + graphCacheTotal: 28, + stats: [], + } as any; + mockDialog.open.mockReturnValue({ + afterClosed: () => of(true), + }); + mockReleaseTrackApiConnector.deleteSnapshotGraph.mockReturnValue( + of(undefined) + ); + component.id = 'release-track--123'; + + component.onDeleteSnapshotCache(item); + + expect(mockDialog.open).toHaveBeenCalledWith( + ConfirmationDialogComponent, + expect.objectContaining({ + data: expect.objectContaining({ + title: 'Delete bundle cache?', + confirm_color: 'warn', + }), + }) + ); + expect( + mockReleaseTrackApiConnector.deleteSnapshotGraph + ).toHaveBeenCalledWith('release-track--123', item.modified); + expect(item.snapshot.graph_manifest_id).toBeUndefined(); + expect(item.snapshot.graph_statistics).toBeUndefined(); + expect(item.snapshot.bundle_hashes).toBeUndefined(); + expect(item.isBundleCached).toBe(false); + expect(item.canCacheBundle).toBe(true); + expect(item.graphCacheStats).toEqual([]); + expect(item.graphCacheTotal).toBe(0); + expect(mockReleaseTrackApiConnector.listSnapshots).toHaveBeenCalledWith( + 'release-track--123' + ); + expect(mockSnackbar.open).toHaveBeenCalledWith( + 'Bundle cache deleted. Member-only bundle exports are no longer guaranteed to be deterministic.', + null, + expect.objectContaining({ duration: 5000 }) + ); + expect(component.isDeletingSnapshotCache(item)).toBe(false); + }); + + it('should use latest snapshot summary counts for the latest history row', () => { + component.releaseTrack = { + modified: new Date('2024-05-21T07:00:00.000Z'), + type: ReleaseTrackType.Standard, + summary: { + added_count: 5, + modified_count: 2, + }, + } as any; + mockReleaseTrackApiConnector.listSnapshots.mockReturnValue( + of([ + { + modified: '2024-05-21T07:00:00.000Z', + version: null, + type: ReleaseTrackType.Standard, + is_latest: true, + candidates_count: 1, + staged_count: 2, + members_count: 3, + }, + ]) + ); + component.id = 'release-track--123'; + + component.getSnapshotHistory(); + + expect(component.snapshotHistory[0].stats).toEqual([ + expect.objectContaining({ label: 'Added', value: '+5' }), + expect.objectContaining({ label: 'Modified', value: 2 }), + expect.objectContaining({ label: 'Candidates', value: 1 }), + expect.objectContaining({ label: 'Staged', value: 2 }), + expect.objectContaining({ label: 'Members', value: 3 }), + ]); + }); + + it('should only mark the latest draft snapshot as current', () => { + mockReleaseTrackApiConnector.listSnapshots.mockReturnValue( + of([ + { + modified: '2024-05-21T07:00:00.000Z', + version: null, + type: ReleaseTrackType.Standard, + }, + { + modified: '2024-05-20T07:00:00.000Z', + version: null, + type: ReleaseTrackType.Standard, + }, + { + modified: '2024-05-19T07:00:00.000Z', + version: '1.0', + type: ReleaseTrackType.Standard, + }, + ]) + ); + component.id = 'release-track--123'; + + component.getSnapshotHistory(); + + expect(component.snapshotHistory[0]).toEqual( + expect.objectContaining({ + modified: '2024-05-21T07:00:00.000Z', + isTagged: false, + isLatest: true, + isCurrentDraft: true, + }) + ); + expect(component.snapshotHistory[1]).toEqual( + expect.objectContaining({ + modified: '2024-05-20T07:00:00.000Z', + isTagged: false, + isLatest: false, + isCurrentDraft: false, + }) + ); + expect(component.snapshotHistory[2]).toEqual( + expect.objectContaining({ + modified: '2024-05-19T07:00:00.000Z', + isTagged: true, + isLatest: false, + isCurrentDraft: false, + }) + ); + expect(component.hasCurrentDraftSnapshot).toBe(true); + }); + + it('should mark a tagged current snapshot as latest', () => { + mockReleaseTrackApiConnector.listSnapshots.mockReturnValue( + of([ + { + modified: '2024-05-21T07:00:00.000Z', + version: '1.1', + type: ReleaseTrackType.Standard, + }, + { + modified: '2024-05-20T07:00:00.000Z', + version: null, + type: ReleaseTrackType.Standard, + }, + ]) + ); + component.id = 'release-track--123'; + + component.getSnapshotHistory(); + + expect(component.snapshotHistory[0]).toEqual( + expect.objectContaining({ + modified: '2024-05-21T07:00:00.000Z', + isTagged: true, + isLatest: true, + isCurrentDraft: false, + }) + ); + expect(component.snapshotHistory[1]).toEqual( + expect.objectContaining({ + modified: '2024-05-20T07:00:00.000Z', + isTagged: false, + isLatest: false, + isCurrentDraft: false, + }) + ); + expect(component.hasCurrentDraftSnapshot).toBe(false); + }); + + it('should create a draft snapshot and refresh the release track', () => { + const refreshSpy = vi + .spyOn(component, 'getReleaseTrack') + .mockImplementation(() => undefined); + const historySpy = vi + .spyOn(component, 'getSnapshotHistory') + .mockImplementation(() => undefined); + mockDialog.open.mockReturnValue({ + afterClosed: () => of('Draft analyst context'), + }); + mockReleaseTrackApiConnector.createVirtualSnapshot.mockReturnValue( + of({ + stix: { + modified: '2024-05-21T07:00:00.000Z', + x_mitre_version: null, + }, + members: [{ object_ref: 'attack-pattern--member' }], + quarantine: [{ object_ref: 'attack-pattern--quarantined' }], + }) + ); + component.id = 'release-track--123'; + component.releaseTrack = { + type: ReleaseTrackType.Virtual, + name: 'Virtual Release', + composition: { + component_tracks: [{ track_id: 'release-track--standard' }], + }, + } as any; + + component.onDraft(); + + expect(mockDialog.open).toHaveBeenCalledWith( + SnapshotDescriptionDialogComponent, + expect.objectContaining({ + data: expect.objectContaining({ + title: 'Create draft snapshot', + message: expect.stringContaining( + 'Create a copy of the latest snapshot for Virtual Release' + ), + confirmLabel: 'Create draft', + }), + }) + ); + expect( + mockReleaseTrackApiConnector.createVirtualSnapshot + ).toHaveBeenCalledWith('release-track--123', { + description: 'Draft analyst context', + }); + expect(refreshSpy).toHaveBeenCalled(); + expect(historySpy).toHaveBeenCalled(); + expect(component.hasCurrentDraftSnapshot).toBe(true); + expect(component.snapshotHistory[0]).toEqual( + expect.objectContaining({ + title: 'Draft Snapshot', + totalObjects: 2, + isTagged: false, + }) + ); + expect(component.snapshotHistory[0].stats).toEqual([ + expect.objectContaining({ label: 'Members', value: 1 }), + expect.objectContaining({ label: 'Quarantine', value: 1 }), + ]); + expect(component.isCreatingDraft).toBe(false); + }); + + it('should not create a draft snapshot without a release track id', () => { + component.id = ''; + component.releaseTrack = { type: ReleaseTrackType.Virtual } as any; + + component.onDraft(); + + expect(mockDialog.open).not.toHaveBeenCalled(); + expect( + mockReleaseTrackApiConnector.createVirtualSnapshot + ).not.toHaveBeenCalled(); + }); + + it('should not create a draft snapshot for a standard release track', () => { + component.id = 'release-track--123'; + component.releaseTrack = { type: ReleaseTrackType.Standard } as any; + + component.onDraft(); + + expect(mockDialog.open).not.toHaveBeenCalled(); + expect( + mockReleaseTrackApiConnector.createVirtualSnapshot + ).not.toHaveBeenCalled(); + }); + + it('should not create a draft snapshot for a virtual track without components', () => { + component.id = 'release-track--123'; + component.releaseTrack = { + type: ReleaseTrackType.Virtual, + composition: { + component_tracks: [], + }, + } as any; + + component.onDraft(); + + expect(mockDialog.open).not.toHaveBeenCalled(); + expect( + mockReleaseTrackApiConnector.createVirtualSnapshot + ).not.toHaveBeenCalled(); + }); + + it('should open the all objects table to add candidates', () => { + mockDialog.open.mockImplementation((_component: any, config: any) => { + config.data.select.select('attack-pattern--1234'); + return { + afterClosed: () => of(true), + }; + }); + mockReleaseTrackApiConnector.addCandidates.mockReturnValue(of({})); + component.id = 'release-track--123'; + component.releaseTrack = { id: 'release-track--123' } as any; + + component.onAddCandidate(); + + expect(mockRestApiConnector.getAllObjects).not.toHaveBeenCalled(); + expect(mockDialog.open).toHaveBeenCalledWith( + AddDialogComponent, + expect.objectContaining({ + data: expect.objectContaining({ + title: 'Add candidates', + stixListConfig: expect.objectContaining({ + showUserSearch: true, + excludeAttackTypes: ['relationship', 'note', 'collection'], + select: 'many', + }), + }), + }) + ); + expect(mockReleaseTrackApiConnector.addCandidates).toHaveBeenCalledWith( + 'release-track--123', + ['attack-pattern--1234'] + ); + }); + + it('should preview and tag the latest draft release', () => { + const refreshSpy = vi + .spyOn(component, 'getReleaseTrack') + .mockImplementation(() => undefined); + const historySpy = vi + .spyOn(component, 'getSnapshotHistory') + .mockImplementation(() => undefined); + mockReleaseTrackApiConnector.previewRelease.mockReturnValue( + of({ + type: ReleaseTrackType.Standard, + version: '1.2', + before: { + members_count: 10, + staged_count: 3, + candidates_count: 1, + }, + after: { + members_count: 13, + staged_count: 0, + candidates_count: 1, + }, + changes: { + promoted_count: 3, + }, + conflicts: [], + }) + ); + mockReleaseTrackApiConnector.releaseLatest.mockReturnValue(of({})); + component.releaseTrack = { + id: 'release-track--123', + name: 'Core Objects', + version: null, + members: [], + staged: [], + candidates: [ + { + object_ref: 'attack-pattern--candidate', + name: 'Canonical Candidate', + attack_type: 'technique', + x_mitre_version: '2.1', + object_status: 'awaiting-review', + }, + ], + } as any; + mockDialog.open.mockReturnValue({ + afterClosed: () => + of({ increment: 'minor', description: 'First release context' }), + }); + component.id = 'release-track--123'; + + component.onPreviewRelease(); + + expect(mockReleaseTrackApiConnector.previewRelease).toHaveBeenCalledWith( + 'release-track--123', + { format: 'summary', increment: 'minor' } + ); + expect(mockRestApiConnector.getAllObjects).toHaveBeenCalledWith({ + revoked: true, + deprecated: true, + versions: 'all', + }); + expect(mockDialog.open).toHaveBeenCalledWith( + ReleasePreviewDialogComponent, + expect.objectContaining({ + data: expect.objectContaining({ + track: expect.objectContaining({ + id: 'release-track--123', + candidates: [ + expect.objectContaining({ + name: 'Canonical Candidate', + attack_type: 'technique', + x_mitre_version: '2.1', + object_status: 'awaiting-review', + }), + ], + }), + }), + }) + ); + expect(mockReleaseTrackApiConnector.releaseLatest).toHaveBeenCalledWith( + 'release-track--123', + { increment: 'minor', description: 'First release context' } + ); + expect(refreshSpy).toHaveBeenCalled(); + expect(historySpy).toHaveBeenCalled(); + expect(component.isReleasing).toBe(false); + }); + + it('should tag the selected draft snapshot from history', () => { + const refreshSpy = vi + .spyOn(component, 'getReleaseTrack') + .mockImplementation(() => undefined); + const historySpy = vi + .spyOn(component, 'getSnapshotHistory') + .mockImplementation(() => undefined); + mockReleaseTrackApiConnector.previewRelease.mockReturnValue( + of({ + type: ReleaseTrackType.Standard, + version: '2.0', + before: { + members_count: 10, + staged_count: 3, + candidates_count: 1, + }, + after: { + members_count: 13, + staged_count: 0, + candidates_count: 1, + }, + changes: { + promoted_count: 3, + }, + conflicts: [], + }) + ); + mockReleaseTrackApiConnector.releaseSnapshot.mockReturnValue(of({})); + mockReleaseTrackApiConnector.retrieveSnapshotByModified.mockReturnValue( + of({ + id: 'release-track--123', + members: [ + { + object_ref: 'malware--replacement', + object_modified: '2026-07-01T12:00:00.000Z', + }, + ], + staged: [ + { + object_ref: 'malware--replacement', + object_modified: '2026-07-23T12:00:00.000Z', + }, + ], + candidates: [], + }) + ); + mockRestApiConnector.getAllObjects.mockReturnValue( + of( + createPaginatedResponse([ + { + workspace: { attack_id: 'S0001' }, + stix: { + id: 'malware--replacement', + modified: '2026-07-01T12:00:00.000Z', + name: 'Replacement Example', + type: 'malware', + x_mitre_version: '1.0', + }, + }, + { + workspace: { attack_id: 'S0001' }, + stix: { + id: 'malware--replacement', + modified: '2026-07-23T12:00:00.000Z', + name: 'Replacement Example', + type: 'malware', + x_mitre_version: '1.1', + }, + }, + ]) + ) + ); + mockDialog.open.mockReturnValue({ + afterClosed: () => + of({ version: '1.5', description: 'Exact release context' }), + }); + component.id = 'release-track--123'; + + component.onTagSnapshot({ + modified: '2026-07-23T13:37:28.000Z', + isTagged: false, + snapshot: {}, + } as any); + + expect(mockReleaseTrackApiConnector.previewRelease).toHaveBeenCalledWith( + 'release-track--123', + { format: 'summary', increment: 'minor' }, + '2026-07-23T13:37:28.000Z' + ); + expect( + mockReleaseTrackApiConnector.retrieveSnapshotByModified + ).toHaveBeenCalledWith('release-track--123', '2026-07-23T13:37:28.000Z', { + format: 'workbench', + include: 'all', + }); + expect(mockRestApiConnector.getAllObjects).toHaveBeenCalledWith({ + revoked: true, + deprecated: true, + versions: 'all', + }); + expect(mockDialog.open).toHaveBeenCalledWith( + ReleasePreviewDialogComponent, + expect.objectContaining({ + data: expect.objectContaining({ + track: expect.objectContaining({ + members: [ + expect.objectContaining({ + x_mitre_version: '1.0', + }), + ], + staged: [ + expect.objectContaining({ + x_mitre_version: '1.1', + }), + ], + }), + }), + }) + ); + expect(mockReleaseTrackApiConnector.releaseSnapshot).toHaveBeenCalledWith( + 'release-track--123', + '2026-07-23T13:37:28.000Z', + { version: '1.5', description: 'Exact release context' } + ); + expect(mockReleaseTrackApiConnector.releaseLatest).not.toHaveBeenCalled(); + expect(refreshSpy).toHaveBeenCalled(); + expect(historySpy).toHaveBeenCalled(); + expect(component.isReleasing).toBe(false); + }); + + it('should preview the newest draft when multiple drafts exist', () => { + mockReleaseTrackApiConnector.previewRelease.mockReturnValue( + of({ version: '1.1', conflicts: [] }) + ); + mockReleaseTrackApiConnector.retrieveSnapshotByModified.mockReturnValue( + of({ + id: 'release-track--123', + modified: '2026-07-30T14:00:00.000Z', + members: [], + staged: [], + candidates: [], + }) + ); + mockDialog.open.mockReturnValue({ + afterClosed: () => of(undefined), + }); + component.id = 'release-track--123'; + component.releaseTrack = { + id: 'release-track--123', + version: null, + } as any; + component.snapshotHistory = [ + { + modified: '2026-07-30T14:00:00.000Z', + isTagged: false, + }, + { + modified: '2026-07-29T14:00:00.000Z', + isTagged: false, + }, + ] as any; + + component.onPreviewRelease(); + + expect(mockReleaseTrackApiConnector.previewRelease).toHaveBeenCalledWith( + 'release-track--123', + { format: 'summary', increment: 'minor' }, + '2026-07-30T14:00:00.000Z' + ); + expect( + mockReleaseTrackApiConnector.retrieveSnapshotByModified + ).toHaveBeenCalledWith('release-track--123', '2026-07-30T14:00:00.000Z', { + format: 'workbench', + include: 'all', + }); + }); + + it('should not tag a release when preview returns conflicts', () => { + mockReleaseTrackApiConnector.previewRelease.mockReturnValue( + of({ + type: ReleaseTrackType.Standard, + version: '1.2', + conflicts: [ + { + object_ref: 'attack-pattern--123', + incumbent_version: '2024-01-15T10:00:00Z', + incoming_version: '2024-02-20T10:00:00Z', + }, + ], + }) + ); + component.releaseTrack = { + id: 'release-track--123', + members: [], + staged: [], + candidates: [], + } as any; + mockDialog.open.mockReturnValue({ + afterClosed: () => of(undefined), + }); + component.id = 'release-track--123'; + + component.onPreviewRelease(); + + expect(mockDialog.open).toHaveBeenCalledWith( + ReleasePreviewDialogComponent, + expect.objectContaining({ + data: expect.objectContaining({ + conflicts: expect.arrayContaining([ + expect.objectContaining({ + object_ref: 'attack-pattern--123', + }), + ]), + }), + }) + ); + expect(mockReleaseTrackApiConnector.releaseLatest).not.toHaveBeenCalled(); + }); + + it('should not create a snapshot when the preview is cancelled', () => { + mockReleaseTrackApiConnector.previewRelease.mockReturnValue( + of({ + version: '1.1', + conflicts: [], + }) + ); + component.releaseTrack = { + id: 'release-track--123', + members: [], + staged: [], + candidates: [], + } as any; + mockDialog.open.mockReturnValue({ + afterClosed: () => of(undefined), + }); + component.id = 'release-track--123'; + + component.onPreviewRelease(); + + expect(mockDialog.open).toHaveBeenCalledWith( + ReleasePreviewDialogComponent, + expect.objectContaining({ + data: expect.objectContaining({ + track: expect.objectContaining({ + id: 'release-track--123', + }), + }), + }) + ); + expect(mockReleaseTrackApiConnector.releaseLatest).not.toHaveBeenCalled(); + }); + + it('should stop releasing when the preview request fails', () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + mockReleaseTrackApiConnector.previewRelease.mockReturnValue( + throwError(() => new Error('preview failed')) + ); + component.id = 'release-track--123'; + component.releaseTrack = { + id: 'release-track--123', + version: null, + } as any; + + component.onPreviewRelease(); + + expect(component.isReleasing).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to load objects for release preview', + expect.any(Error) + ); + expect(mockDialog.open).not.toHaveBeenCalled(); + }); + + it('should notify the user when the preview response is empty', () => { + mockReleaseTrackApiConnector.previewRelease.mockReturnValue(of(null)); + component.id = 'release-track--123'; + component.releaseTrack = { + id: 'release-track--123', + version: null, + } as any; + + component.onPreviewRelease(); + + expect(mockSnackbar.open).toHaveBeenCalledWith( + 'Unable to load the release preview. Please try again.', + null, + { + duration: 5000, + panelClass: 'error', + } + ); + expect(mockDialog.open).not.toHaveBeenCalled(); + expect(component.isReleasing).toBe(false); + }); + + it('should keep Preview & Release enabled for a tagged snapshot', () => { + component.id = 'release-track--123'; + component.releaseTrack = { + id: 'release-track--123', + version: '1.0', + } as any; + fixture.detectChanges(); + + const previewButton = Array.from( + fixture.nativeElement.querySelectorAll('button') + ).find((button: Element) => + button.textContent?.includes('Preview & Release') + ) as HTMLButtonElement; + + expect(previewButton).toBeTruthy(); + expect(previewButton.disabled).toBe(false); + + component.onPreviewRelease(); + + expect(mockReleaseTrackApiConnector.previewRelease).not.toHaveBeenCalled(); + expect(mockDialog.open).toHaveBeenCalledWith( + MultipleChoiceDialogComponent, + expect.objectContaining({ + data: expect.objectContaining({ + title: 'No draft snapshot available', + }), + }) + ); + }); + + it('should load release track config into the config form', () => { + mockReleaseTrackApiConnector.getConfig.mockReturnValue( + of({ + auto_promote: false, + candidacy_threshold: 'awaiting-review', + promotion_conflicts: { + candidates_to_staged: 'always_reject', + staged_to_members: 'abort', + }, + member_sync: { + strategy: 'track_latest', + supplant: { + behavior: 'queue', + status_policy: 'reset', + }, + }, + include_secondary_objects: { + enabled: true, + status_threshold: 'work-in-progress', + }, + }) + ); + component.id = 'release-track--123'; + + component.getConfig(); + + expect(mockReleaseTrackApiConnector.getConfig).toHaveBeenCalledWith( + 'release-track--123' + ); + expect(component.configForm.getRawValue()).toEqual( + expect.objectContaining({ + autoPromote: false, + candidacyThreshold: 'awaiting-review', + memberSyncStrategy: 'track_latest', + memberSyncSupplantBehavior: 'queue', + memberSyncSupplantStatusPolicy: 'reset', + candidatesToStagedConflict: 'always_reject', + stagedToMembersConflict: 'abort', + includeSecondaryObjects: true, + secondaryObjectThreshold: 'work-in-progress', + }) + ); + expect(component.configForm.get('candidacyThreshold')?.disabled).toBe(true); + }); + + it('should disable the secondary object threshold when secondary objects are not included', () => { + mockReleaseTrackApiConnector.getConfig.mockReturnValue( + of({ + include_secondary_objects: { + enabled: false, + status_threshold: 'awaiting-review', + }, + }) + ); + component.id = 'release-track--123'; + + component.getConfig(); + + expect(component.configForm.getRawValue()).toEqual( + expect.objectContaining({ + includeSecondaryObjects: false, + secondaryObjectThreshold: 'awaiting-review', + }) + ); + expect(component.configForm.get('secondaryObjectThreshold')?.disabled).toBe( + true + ); + + component.configForm.patchValue({ + includeSecondaryObjects: true, + }); + + expect(component.configForm.get('secondaryObjectThreshold')?.enabled).toBe( + true + ); + }); + + it('should save release track config and refresh state', () => { + const refreshSpy = vi + .spyOn(component, 'getReleaseTrack') + .mockImplementation(() => undefined); + const historySpy = vi + .spyOn(component, 'getSnapshotHistory') + .mockImplementation(() => undefined); + mockReleaseTrackApiConnector.updateConfig.mockReturnValue(of({})); + component.id = 'release-track--123'; + component.releaseTrack = { config: {} } as any; + component.configForm.patchValue({ + autoPromote: true, + candidacyThreshold: 'reviewed', + memberSyncStrategy: MemberSyncStrategy.Manual, + memberSyncSupplantBehavior: MemberSyncBehavior.Replace, + memberSyncSupplantStatusPolicy: MemberSyncPolicy.Preserve, + candidatesToStagedConflict: ConflictPolicy.PreferLatest, + stagedToMembersConflict: ConflictPolicy.Abort, + includeSecondaryObjects: false, + secondaryObjectThreshold: 'reviewed', + }); + component.isEditingConfig = true; + + component.onSaveConfig(); + + expect(mockReleaseTrackApiConnector.updateConfig).toHaveBeenCalledWith( + 'release-track--123', + { + auto_promote: true, + candidacy_threshold: 'reviewed', + include_secondary_objects: { + enabled: false, + status_threshold: 'reviewed', + }, + promotion_conflicts: { + candidates_to_staged: 'prefer_latest', + staged_to_members: 'abort', + }, + member_sync: { + strategy: 'manual', + supplant: { + behavior: 'replace', + status_policy: 'preserve', + }, + }, + } + ); + expect(component.isEditingConfig).toBe(false); + expect(refreshSpy).toHaveBeenCalled(); + expect(historySpy).toHaveBeenCalled(); + }); + + it('should save release track config without a candidacy threshold when auto-promotion is off', () => { + mockReleaseTrackApiConnector.updateConfig.mockReturnValue(of({})); + component.id = 'release-track--123'; + component.releaseTrack = { config: {} } as any; + component.configForm.patchValue({ + autoPromote: false, + candidacyThreshold: 'reviewed', + memberSyncStrategy: MemberSyncStrategy.Manual, + memberSyncSupplantBehavior: MemberSyncBehavior.Replace, + memberSyncSupplantStatusPolicy: MemberSyncPolicy.Preserve, + candidatesToStagedConflict: ConflictPolicy.PreferLatest, + stagedToMembersConflict: ConflictPolicy.Abort, + includeSecondaryObjects: false, + secondaryObjectThreshold: 'reviewed', + }); + component.isEditingConfig = true; + + component.onSaveConfig(); + + expect(mockReleaseTrackApiConnector.updateConfig).toHaveBeenCalledWith( + 'release-track--123', + expect.not.objectContaining({ + candidacy_threshold: expect.anything(), + }) + ); + expect( + mockReleaseTrackApiConnector.updateConfig.mock.calls[0][1] + .candidacy_threshold + ).toBeUndefined(); + }); + + it('should load virtual release track config from composition fields', () => { + mockReleaseTrackApiConnector.getLatestSnapshot.mockReturnValue( + of({ + type: ReleaseTrackType.Virtual, + name: 'Virtual Release', + composition: { + component_tracks: [ + { + track_id: 'release-track--component-one', + resolution_strategy: 'latest_tagged', + priority: 0, + }, + ], + deduplication: { + strategy: DeduplicationStrategy.Quarantine, + tier_resolution: SnapshotTier.Staged, + status_resolution: 'awaiting-review', + }, + }, + snapshot_schedule: { + mode: SnapshotScheduleMode.Cron, + cron: '0 0 1 1,7 *', + }, + config: {}, + }) + ); + component.id = 'release-track--virtual'; + + component.getReleaseTrack(); + + expect(component.configForm.getRawValue()).toEqual( + expect.objectContaining({ + virtualDeduplicationStrategy: DeduplicationStrategy.Quarantine, + virtualDeduplicationTier: SnapshotTier.Staged, + virtualDeduplicationStatus: 'awaiting-review', + virtualSnapshotScheduleMode: SnapshotScheduleMode.Cron, + virtualSnapshotScheduleCron: '0 0 1 1,7 *', + }) + ); + }); + + it('should save virtual release track composition config', () => { + const refreshSpy = vi + .spyOn(component, 'getReleaseTrack') + .mockImplementation(() => undefined); + const historySpy = vi + .spyOn(component, 'getSnapshotHistory') + .mockImplementation(() => undefined); + mockReleaseTrackApiConnector.updateComposition.mockReturnValue(of({})); + component.id = 'release-track--virtual'; + component.releaseTrack = { + type: ReleaseTrackType.Virtual, + composition: { + component_tracks: [ + { + track_id: 'release-track--component-one', + resolution_strategy: 'latest_tagged', + }, + ], + deduplication: { + strategy: DeduplicationStrategy.PrioritizeLatestObject, + }, + }, + } as any; + component.configForm.patchValue({ + virtualDeduplicationStrategy: DeduplicationStrategy.Quarantine, + virtualDeduplicationTier: SnapshotTier.Staged, + virtualDeduplicationStatus: 'reviewed', + }); + component.onEditConfig(); + component.configForm.patchValue({ + virtualDeduplicationStrategy: DeduplicationStrategy.Quarantine, + virtualDeduplicationTier: SnapshotTier.Staged, + virtualDeduplicationStatus: 'reviewed', + }); + + component.onSaveConfig(); + + expect(mockReleaseTrackApiConnector.updateComposition).toHaveBeenCalledWith( + 'release-track--virtual', + { + component_tracks: [ + { + track_id: 'release-track--component-one', + resolution_strategy: 'latest_tagged', + priority: 0, + }, + ], + deduplication: { + strategy: DeduplicationStrategy.Quarantine, + tier_resolution: SnapshotTier.Staged, + status_resolution: 'reviewed', + }, + } + ); + expect(mockReleaseTrackApiConnector.updateConfig).not.toHaveBeenCalled(); + expect(component.isEditingConfig).toBe(false); + expect(refreshSpy).toHaveBeenCalled(); + expect(historySpy).toHaveBeenCalled(); + }); + + it('should retain object types when clearing component-track domains', () => { + const track: any = { + filters: { + object_types: ['malware'], + domains: ['mobile'], + }, + }; + + (component as any).setVirtualComponentTrackDomains(track, []); + + expect(track.filters).toEqual({ + object_types: ['malware'], + }); + }); + + it('should edit virtual release track component tracks', () => { + mockReleaseTrackApiConnector.updateComposition.mockReturnValue(of({})); + component.id = 'release-track--virtual'; + component.releaseTrack = { + type: ReleaseTrackType.Virtual, + composition: { + component_tracks: [ + { + track_id: 'release-track--component-one', + resolution_strategy: 'latest_tagged', + priority: 0, + }, + ], + }, + } as any; + component.virtualComponentTrackOptions = [ + { + trackId: 'release-track--component-one', + name: 'Component One', + description: '', + latestTaggedVersion: '1.0', + taggedReleaseCount: 1, + }, + { + trackId: 'release-track--component-two', + name: 'Component Two', + description: '', + latestTaggedVersion: null, + taggedReleaseCount: 0, + }, + ]; + component.onEditConfig(); + + expect(component.filteredVirtualComponentTrackOptions).toEqual([ + expect.objectContaining({ + trackId: 'release-track--component-two', + }), + ]); + + component.selectVirtualComponentTrack({ + option: { + value: component.virtualComponentTrackOptions[1], + }, + }); + component.setVirtualComponentTrackObjectTypes( + component.virtualConfigComponentTracks[1], + ['attack-pattern'] + ); + component.removeVirtualComponentTrack( + component.virtualConfigComponentTracks[0] + ); + + component.onSaveConfig(); + + expect(mockReleaseTrackApiConnector.updateComposition).toHaveBeenCalledWith( + 'release-track--virtual', + expect.objectContaining({ + component_tracks: [ + { + track_id: 'release-track--component-two', + resolution_strategy: 'latest_tagged', + priority: 0, + filters: { + object_types: ['attack-pattern'], + }, + }, + ], + }) + ); + }); + + it('should split auto-promotion release tracks into workflow lanes', () => { + component.releaseTrack = { + config: { auto_promote: true }, + candidates: [ + { + object_ref: 'attack-pattern--wip', + }, + { + object_ref: 'attack-pattern--candidate', + object_status: 'awaiting-review', + }, + { + object_ref: 'attack-pattern--reviewed-candidate', + object_status: 'reviewed', + }, + ], + staged: [ + { + object_ref: 'attack-pattern--staged', + object_status: 'reviewed', + }, + ], + members: [ + { + object_ref: 'attack-pattern--member', + }, + ], + } as any; + + const lanes = component.workspaceLanes; + + expect(lanes.map(lane => lane.title)).toEqual([ + 'Candidates WIP', + 'Candidates Awaiting Review', + 'Staged', + 'Released Members', + ]); + expect(lanes.map(lane => lane.items.map(item => item.object_ref))).toEqual([ + ['attack-pattern--wip'], + ['attack-pattern--candidate'], + ['attack-pattern--staged'], + ['attack-pattern--member'], + ]); + expect(component.canReviewAndApprove(lanes[1].items[0], lanes[1])).toBe( + true + ); + expect(component.canManuallyPromote(lanes[0])).toBe(false); + expect(lanes[3].isReleasedMembers).toBe(true); + }); + + it('should keep manual release tracks in candidate and staged lanes', () => { + component.releaseTrack = { + config: { auto_promote: false }, + candidates: [ + { + object_ref: 'attack-pattern--candidate', + object_status: 'awaiting-review', + }, + ], + staged: [ + { + object_ref: 'attack-pattern--staged', + }, + ], + members: [], + } as any; + + const lanes = component.workspaceLanes; + + expect(lanes.map(lane => lane.title)).toEqual([ + 'Candidates', + 'Staged', + 'Released Members', + ]); + expect(lanes[0].items.map(item => item.object_ref)).toEqual([ + 'attack-pattern--candidate', + ]); + expect(component.canManuallyPromote(lanes[0])).toBe(true); + expect(component.canManuallyDemote(lanes[1])).toBe(true); + expect(component.canReviewAndApprove(lanes[0].items[0], lanes[0])).toBe( + false + ); + }); + + it('should hide released members until toggled open', () => { + expect(component.showReleasedMembers).toBe(false); + + component.toggleReleasedMembers(); + + expect(component.showReleasedMembers).toBe(true); + }); + + it('should update the release track description', () => { + const refreshSpy = vi + .spyOn(component, 'getReleaseTrack') + .mockImplementation(() => undefined); + mockReleaseTrackApiConnector.updateMetadataByLatest.mockReturnValue(of({})); + component.id = 'release-track--123'; + component.releaseTrack = { + name: 'Enterprise Release', + description: 'Original description', + } as any; + + component.onEditDescription(); + component.descriptionDraft = 'Updated description'; + component.onSaveDescription(); + + expect( + mockReleaseTrackApiConnector.updateMetadataByLatest + ).toHaveBeenCalledWith('release-track--123', { + description: 'Updated description', + }); + expect(component.isEditingDescription).toBe(false); + expect(refreshSpy).toHaveBeenCalled(); + }); + + it('should review and approve a single awaiting-review candidate', () => { + const refreshSpy = vi + .spyOn(component, 'getReleaseTrack') + .mockImplementation(() => undefined); + mockReleaseTrackApiConnector.reviewCandidates.mockReturnValue(of({})); + component.id = 'release-track--123'; + + component.onReviewAndApprove({ + object_ref: 'attack-pattern--123', + object_modified: new Date('2024-04-20T00:00:00.000Z'), + }); + + expect(mockReleaseTrackApiConnector.reviewCandidates).toHaveBeenCalledWith( + 'release-track--123', + { + from: 'awaiting-review', + to: 'reviewed', + object_refs: [ + { + id: 'attack-pattern--123', + modified: '2024-04-20T00:00:00.000Z', + }, + ], + } + ); + expect(refreshSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts new file mode 100644 index 000000000..d94af7eae --- /dev/null +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts @@ -0,0 +1,3301 @@ +import { Clipboard } from '@angular/cdk/clipboard'; +import { SelectionModel } from '@angular/cdk/collections'; +import { Component, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { ActivatedRoute, Router } from '@angular/router'; +import { forkJoin, Observable, of } from 'rxjs'; +import { finalize, map, take } from 'rxjs/operators'; +import { + ConflictPolicy, + ConflictPolicyType, + DeduplicationStrategy, + DeduplicationStrategyType, + ExportFormat, + ExportFormatType, + MemberSyncBehavior, + MemberSyncBehaviorType, + MemberSyncPolicy, + MemberSyncPolicyType, + MemberSyncStrategy, + MemberSyncStrategyType, + ReleasePayload, + ReleasePreviewFormat, + ReleaseTrackConfig, + ReleaseTrackSnapshot, + ReleaseTrackSnapshotHistoryItem, + ReleaseTrackSnapshotOptions, + ReleaseTrackType, + ResolutionStrategy, + SnapshotScheduleMode, + SnapshotScheduleModeType, + SnapshotTier, + SnapshotTierType, + StixObjectRef, +} from 'src/app/classes/release-tracks'; +import { StixObject } from 'src/app/classes/stix'; +import { AddDialogComponent } from 'src/app/components/add-dialog/add-dialog.component'; +import { ConfirmationDialogComponent } from 'src/app/components/confirmation-dialog/confirmation-dialog.component'; +import { DeleteDialogComponent } from 'src/app/components/delete-dialog/delete-dialog.component'; +import { MultipleChoiceDialogComponent } from 'src/app/components/multiple-choice-dialog/multiple-choice-dialog.component'; +import { + ReleasePreviewDialogComponent, + ReleasePreviewSelection, +} from 'src/app/components/release-preview-dialog/release-preview-dialog.component'; +import { ReleaseTrackObjectItem } from 'src/app/components/release-track-object-card/release-track-object-card.component'; +import { SnapshotDescriptionDialogComponent } from 'src/app/components/snapshot-description-dialog/snapshot-description-dialog.component'; +import { AuthenticationService } from 'src/app/services/connectors/authentication/authentication.service'; +import { ReleaseTracksConnectorService } from 'src/app/services/connectors/rest-api/release-tracks.service'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { BreadcrumbService } from 'src/app/services/helpers/breadcrumb.service'; +import { + AttackTypeToPlural, + StixTypeToAttackType, +} from 'src/app/utils/type-mappings'; +import { + StixType, + WorkflowStatus, + WorkflowStatusType, +} from 'src/app/utils/types'; + +import { ALL_OBJECTS_STIX_LIST_CONFIG } from 'src/app/views/stix/all-objects-page/all-objects-page.component'; +import { StixDialogComponent } from 'src/app/views/stix/stix-dialog/stix-dialog.component'; + +type ReleaseTrackLaneType = 'candidate' | 'staged' | 'member'; +type StixVersion = '2.0' | '2.1'; +type SnapshotExportChoice = + | 'bundle-stix-2.0' + | 'bundle-stix-2.1' + | ExportFormat.Workbench + | 'copy-summary'; + +interface SnapshotExportSelection { + format: ExportFormatType; + stixVersion?: StixVersion; +} + +interface ReleaseTrackWorkspaceLane { + key: string; + title: string; + type: ReleaseTrackLaneType; + modifier: string; + items: ReleaseTrackObjectItem[]; + emptyLabel: string; + statusFallback: WorkflowStatusType; + isReleasedMembers?: boolean; +} + +interface SnapshotMemberRef { + object_ref: string; + object_modified?: string; +} + +interface SnapshotHistoryViewModel { + snapshot: ReleaseTrackSnapshotHistoryItem; + title: string; + created: Date | null; + modified: string | null; + taggedAt: Date | null; + isTagged: boolean; + isLatest: boolean; + isCurrentDraft: boolean; + isBundleCached: boolean; + canCacheBundle: boolean; + stats: SnapshotHistoryStat[]; + graphCacheStats: SnapshotHistoryStat[]; + graphCacheTotal: number; + addedCount: number; + modifiedCount: number; + totalObjects: number; +} + +interface SnapshotHistoryStat { + label: string; + value: string | number; + modifier?: string; + tooltip?: string; +} + +interface ReleaseTrackConfigFormValue { + autoPromote: boolean; + candidacyThreshold: WorkflowStatusType | null; + memberSyncStrategy: MemberSyncStrategyType; + memberSyncSupplantBehavior: MemberSyncBehaviorType; + memberSyncSupplantStatusPolicy: MemberSyncPolicyType; + candidatesToStagedConflict: ConflictPolicyType; + stagedToMembersConflict: ConflictPolicyType; + includeSecondaryObjects: boolean; + secondaryObjectThreshold: WorkflowStatusType; +} + +interface VirtualReleaseTrackConfigFormValue { + virtualDeduplicationStrategy: DeduplicationStrategyType; + virtualDeduplicationTier: SnapshotTierType; + virtualDeduplicationStatus: WorkflowStatusType; + virtualSnapshotScheduleMode: SnapshotScheduleModeType; + virtualSnapshotScheduleCron: string; +} + +interface VirtualResolutionRow { + trackId: string; + trackName: string; + strategy: string; + resolvedVersion?: string | null; + candidatesCount?: number | null; + stagedCount?: number | null; + membersCount?: number | null; +} + +interface VirtualComponentTrackSummary { + trackId: string; + name: string; + description: string; + type: string; + latestTaggedVersion: string | null; + taggedReleaseCount: number; + candidatesCount: number; + stagedCount: number; + membersCount: number; +} + +interface VirtualComponentTrackOption { + trackId: string; + name: string; + description: string; + latestTaggedVersion: string | null; + taggedReleaseCount: number; +} + +const VIRTUAL_OBJECT_TYPE_OPTIONS: StixType[] = [ + 'attack-pattern', + 'campaign', + 'course-of-action', + 'intrusion-set', + 'malware', + 'tool', + 'x-mitre-asset', + 'x-mitre-data-source', + 'x-mitre-data-component', + 'x-mitre-detection-strategy', + 'x-mitre-analytic', + 'x-mitre-matrix', + 'x-mitre-tactic', +]; + +const VIRTUAL_DOMAIN_FILTER_OPTIONS = ['enterprise', 'ics', 'mobile']; + +@Component({ + selector: 'app-release-track-page', + standalone: false, + templateUrl: './release-track-page.component.html', + styleUrls: ['./release-track-page.component.scss'], +}) +export class ReleaseTrackPageComponent implements OnInit { + public id = ''; + public releaseTrack: ReleaseTrackSnapshot | null = null; + public showReleasedMembers = false; + public descriptionDraft = ''; + public isEditingDescription = false; + public isSavingDescription = false; + public isCreatingDraft = false; + public isDeleting = false; + public isLoadingSnapshotHistory = false; + public isReleasing = false; + public isLoadingConfig = false; + public isEditingConfig = false; + public isSavingConfig = false; + public releaseTrackConfig: ReleaseTrackConfig = {}; + public snapshotHistory: SnapshotHistoryViewModel[] = []; + public configForm: FormGroup; + private virtualComponentTrackSummaries = new Map< + string, + VirtualComponentTrackSummary + >(); + public virtualComponentTrackOptions: VirtualComponentTrackOption[] = []; + public virtualConfigComponentTracks: any[] = []; + private createdDraftSnapshot: ReleaseTrackSnapshotHistoryItem | null = null; + private cachingSnapshotModified = new Set(); + private deletingSnapshotCacheModified = new Set(); + private updatingSnapshotDescriptionModified = new Set(); + + public candidacyOptions = Object.values(WorkflowStatus); + public memberSyncStrategyOptions = Object.values(MemberSyncStrategy); + public memberSyncBehaviorOptions = Object.values(MemberSyncBehavior); + public memberSyncStatusPolicyOptions = Object.values(MemberSyncPolicy); + public candidatesToStagedConflictOptions = Object.values( + ConflictPolicy + ).filter(policy => policy !== ConflictPolicy.Abort); + public stagedToMembersConflictOptions = Object.values(ConflictPolicy); + public virtualDeduplicationOptions = Object.values(DeduplicationStrategy); + public virtualDeduplicationTierOptions = Object.values(SnapshotTier); + public virtualDeduplicationStatusOptions = Object.values(WorkflowStatus); + public virtualSnapshotScheduleModeOptions = + Object.values(SnapshotScheduleMode); + public virtualObjectTypeOptions = VIRTUAL_OBJECT_TYPE_OPTIONS.map(type => ({ + label: this.formatStixType(type), + value: type, + })); + public virtualDomainOptions = VIRTUAL_DOMAIN_FILTER_OPTIONS; + + constructor( + private connector: ReleaseTracksConnectorService, + private breadcrumbService: BreadcrumbService, + private route: ActivatedRoute, + private router: Router, + private dialog: MatDialog, + private snackbar: MatSnackBar, + private restApiConnectorService: RestApiConnectorService, + private authenticationService: AuthenticationService, + private clipboard: Clipboard, + private fb: FormBuilder + ) { + this.configForm = this.fb.group({ + autoPromote: [true], + candidacyThreshold: [WorkflowStatus.Reviewed], + memberSyncStrategy: [MemberSyncStrategy.Manual], + memberSyncSupplantBehavior: [MemberSyncBehavior.Replace], + memberSyncSupplantStatusPolicy: [MemberSyncPolicy.Preserve], + candidatesToStagedConflict: [ConflictPolicy.PreferLatest], + stagedToMembersConflict: [ConflictPolicy.Abort], + includeSecondaryObjects: [false], + secondaryObjectThreshold: [WorkflowStatus.Reviewed], + virtualDeduplicationStrategy: [ + DeduplicationStrategy.PrioritizeLatestObject, + ], + virtualDeduplicationTier: [SnapshotTier.Member], + virtualDeduplicationStatus: [WorkflowStatus.Reviewed], + virtualSnapshotScheduleMode: [SnapshotScheduleMode.Manual], + virtualSnapshotScheduleCron: [''], + virtualComponentTrackSearch: [''], + }); + this.configForm.get('autoPromote')?.valueChanges.subscribe(autoPromote => { + this.syncCandidacyThresholdControl(!!autoPromote); + }); + this.configForm + .get('includeSecondaryObjects') + ?.valueChanges.subscribe(includeSecondaryObjects => { + this.syncSecondaryObjectThresholdControl(!!includeSecondaryObjects); + }); + this.syncSecondaryObjectThresholdControl(false); + } + + ngOnInit(): void { + this.route.params.subscribe(params => { + if (this.id !== params.id) this.showReleasedMembers = false; + this.id = params.id; + if (this.id) { + this.getReleaseTrack(); + this.getSnapshotHistory(); + this.getConfig(); + } + }); + } + + public get releaseTrackName(): string { + return this.releaseTrack?.name ?? ''; + } + + public get releaseTrackDescription(): string { + return this.releaseTrack?.description?.trim() ?? ''; + } + + public get candidates(): any[] { + return this.releaseTrack?.candidates ?? []; + } + + public get staged(): any[] { + return this.releaseTrack?.staged ?? []; + } + + public get members(): any[] { + return this.releaseTrack?.members ?? []; + } + + public get virtualComponentTracks(): any[] { + return this.releaseTrack?.composition?.component_tracks ?? []; + } + + public get displayedVirtualConfigComponentTracks(): any[] { + return this.isEditingConfig + ? this.virtualConfigComponentTracks + : this.virtualComponentTracks; + } + + public get filteredVirtualComponentTrackOptions(): VirtualComponentTrackOption[] { + const search = this.getVirtualComponentTrackSearchText(); + const selectedIds = new Set( + this.virtualConfigComponentTracks + .map(track => track.track_id) + .filter((trackId): trackId is string => !!trackId) + ); + + return this.virtualComponentTrackOptions + .filter(track => !selectedIds.has(track.trackId)) + .filter(track => this.matchesVirtualComponentTrackSearch(track, search)); + } + + public get virtualSnapshotSchedule(): any { + return (this.releaseTrack as any)?.snapshot_schedule || {}; + } + + public get hasCurrentDraftSnapshot(): boolean { + return this.snapshotHistory.some((item, index) => + this.isCurrentDraftHistoryItem(item, index) + ); + } + + public get taggedSnapshotCount(): number { + return this.snapshotHistory.filter(item => item.isTagged).length; + } + + public get resolvedComponentSnapshots(): any[] { + return this.releaseTrack?.composition_resolution?.component_snapshots ?? []; + } + + public get quarantineObjects(): any[] { + return this.releaseTrack?.quarantine ?? []; + } + + public get virtualResolvedAt(): Date | null { + return this.releaseTrack?.composition_resolution?.resolved_at ?? null; + } + + public get virtualResolutionRows(): VirtualResolutionRow[] { + if (this.resolvedComponentSnapshots.length) { + return this.resolvedComponentSnapshots.map(component => { + const configuredTrack = this.virtualComponentTracks.find( + track => track.track_id === component.track_id + ); + return { + trackId: component.track_id, + trackName: + this.getComponentTrackSummary(component.track_id)?.name || + component.track_name || + component.track_id, + strategy: + component.strategy_used || + configuredTrack?.resolution_strategy || + '', + resolvedVersion: + component.resolved_version || + component.version || + component.version_label || + null, + ...this.getVirtualComponentCounts(component.track_id), + }; + }); + } + + return this.virtualComponentTracks.map(track => ({ + trackId: track.track_id, + trackName: this.getComponentTrackLabel(track), + strategy: track.resolution_strategy, + resolvedVersion: null, + ...this.getVirtualComponentCounts(track.track_id), + })); + } + + public get virtualResolvedObjectCount(): number { + const resolved = this.releaseTrack?.composition_resolution as any; + return ( + this.getResolutionNumber( + resolved, + 'total_objects', + 'totalObjects', + 'objects_contributed', + 'total_objects_after' + ) || + this.resolvedComponentSnapshots.reduce( + (total, component) => + total + Number(component.objects_contributed || 0), + 0 + ) + ); + } + + public get virtualDuplicateCount(): number { + return this.getResolutionNumber( + this.releaseTrack?.composition_resolution as any, + 'duplicates_found', + 'duplicates', + 'duplicate_count' + ); + } + + public get virtualConflictCount(): number { + const resolved = this.releaseTrack?.composition_resolution as any; + const conflictCount = this.getResolutionNumber( + resolved, + 'conflicts_found', + 'conflicts', + 'conflict_count' + ); + if (conflictCount) return conflictCount; + const resolvedConflicts = resolved?.deduplication?.conflicts_resolved; + return Array.isArray(resolvedConflicts) ? resolvedConflicts.length : 0; + } + + public get autoPromotionEnabled(): boolean { + return !!this.releaseTrack?.config?.auto_promote; + } + + public get workspaceLanes(): ReleaseTrackWorkspaceLane[] { + if (this.autoPromotionEnabled) { + return [ + { + key: 'candidates-wip', + title: 'Candidates WIP', + type: 'candidate', + modifier: 'candidates', + items: this.candidates.filter(item => + this.isWorkInProgressCandidate(item) + ), + emptyLabel: 'No work in progress candidates', + statusFallback: WorkflowStatus.WorkInProgress, + }, + { + key: 'candidates-awaiting-review', + title: 'Candidates Awaiting Review', + type: 'candidate', + modifier: 'awaiting-review', + items: this.candidates.filter( + item => this.getObjectStatus(item) === WorkflowStatus.AwaitingReview + ), + emptyLabel: 'No candidates awaiting review', + statusFallback: WorkflowStatus.AwaitingReview, + }, + { + key: 'staged', + title: 'Staged', + type: 'staged', + modifier: 'staged', + items: this.staged, + emptyLabel: 'No staged objects', + statusFallback: WorkflowStatus.Reviewed, + }, + this.releasedMembersLane, + ]; + } + + return [ + { + key: 'candidates', + title: 'Candidates', + type: 'candidate', + modifier: 'candidates', + items: this.candidates, + emptyLabel: 'No candidates', + statusFallback: WorkflowStatus.WorkInProgress, + }, + { + key: 'staged', + title: 'Staged', + type: 'staged', + modifier: 'staged', + items: this.staged, + emptyLabel: 'No staged objects', + statusFallback: WorkflowStatus.Reviewed, + }, + this.releasedMembersLane, + ]; + } + + private get releasedMembersLane(): ReleaseTrackWorkspaceLane { + return { + key: 'released-members', + title: 'Released Members', + type: 'member', + modifier: 'members', + items: this.members, + emptyLabel: 'No released members', + statusFallback: WorkflowStatus.Reviewed, + isReleasedMembers: true, + }; + } + + public get isVirtualReleaseTrack(): boolean { + return this.releaseTrack?.type === ReleaseTrackType.Virtual; + } + + public get canCreateDraft(): boolean { + return ( + !!this.id && + this.isVirtualReleaseTrack && + this.virtualComponentTracks.length > 0 && + !this.isCreatingDraft + ); + } + + private get latestDraftSnapshot(): SnapshotHistoryViewModel | undefined { + return this.snapshotHistory.find((snapshot, index) => + this.isCurrentDraftHistoryItem(snapshot, index) + ); + } + + private isCurrentDraftHistoryItem( + item: SnapshotHistoryViewModel, + index: number + ): boolean { + return ( + !item.isTagged && + (typeof item.isCurrentDraft === 'boolean' + ? item.isCurrentDraft + : typeof item.isLatest === 'boolean' + ? item.isLatest + : index === 0) + ); + } + + public get canEditReleaseTrack(): boolean { + return this.authenticationService.canEdit(); + } + + public getReleaseTrack(): void { + this.connector + .getLatestSnapshot(this.id, { + format: ExportFormat.Workbench, + include: 'all', + }) + .pipe(take(1)) + .subscribe({ + next: res => { + if (res) { + this.setReleaseTrack(res); + } else { + this.loadReleaseTrackSummary(); + } + + // this.loadCandidates(); + }, + }); + } + + private loadReleaseTrackSummary(): void { + this.connector + .listReleaseTracks() + .pipe(take(1)) + .subscribe({ + next: result => { + const track = this.getReleaseTrackList(result).find( + item => this.getReleaseTrackId(item) === this.id + ); + if (track) this.setReleaseTrack(track); + }, + }); + } + + private setReleaseTrack(track: any): void { + this.releaseTrack = track; + if (!this.releaseTrack) return; + this.hydrateDynamicEntryDates(); + + this.breadcrumbService.changeBreadcrumb( + this.route.snapshot, + this.releaseTrack.name + ); + + if (this.snapshotHistory.length) { + this.snapshotHistory = this.buildSnapshotHistory( + this.snapshotHistory.map(item => item.snapshot) + ); + } + + if (this.isVirtualReleaseTrack) { + this.loadVirtualComponentTrackSummaries(); + } else { + this.virtualComponentTrackSummaries.clear(); + } + + if (!this.isEditingConfig) { + if (this.isVirtualReleaseTrack) { + this.setVirtualConfig(); + } else if (this.releaseTrack.config) { + this.setConfig(this.releaseTrack.config); + } + } + } + + private refreshReleaseTrackState(): void { + this.getReleaseTrack(); + this.getSnapshotHistory(); + } + + private hydrateDynamicEntryDates(): void { + const entries = ([] as ReleaseTrackObjectItem[]).concat( + ...['candidates', 'staged', 'members'].map(tier => + (this.releaseTrack?.[tier] || []).filter( + (entry: ReleaseTrackObjectItem) => entry.object_modified === 'latest' + ) + ) + ); + if (!entries.length) return; + + forkJoin( + entries.map(entry => + this.fetchLatestObject(entry.object_ref).pipe( + map(object => ({ entry, modified: object?.modified })) + ) + ) + ) + .pipe(take(1)) + .subscribe(results => { + results.forEach(({ entry, modified }) => { + if (modified) { + entry.resolved_object_modified = + modified instanceof Date ? modified.toISOString() : modified; + } + }); + }); + } + + private fetchLatestObject(objectRef: string): Observable { + const attackType = + StixTypeToAttackType[objectRef.split('--')[0] as StixType]; + const getters: Partial Observable>> = { + 'technique': () => this.restApiConnectorService.getTechnique(objectRef), + 'tactic': () => this.restApiConnectorService.getTactic(objectRef), + 'group': () => this.restApiConnectorService.getGroup(objectRef), + 'campaign': () => this.restApiConnectorService.getCampaign(objectRef), + 'asset': () => this.restApiConnectorService.getAsset(objectRef), + 'software': () => this.restApiConnectorService.getSoftware(objectRef), + 'mitigation': () => this.restApiConnectorService.getMitigation(objectRef), + 'matrix': () => this.restApiConnectorService.getMatrix(objectRef), + 'data-source': () => + this.restApiConnectorService.getDataSource(objectRef), + 'data-component': () => + this.restApiConnectorService.getDataComponent(objectRef), + 'detection-strategy': () => + this.restApiConnectorService.getDetectionStrategy(objectRef), + 'analytic': () => this.restApiConnectorService.getAnalytic(objectRef), + }; + const getObject = getters[attackType]; + return getObject + ? getObject().pipe(map(objects => objects[0] || null)) + : of(null); + } + + public onDeleteReleaseTrack(): void { + if (!this.id || !this.canEditReleaseTrack || this.isDeleting) return; + + const prompt = this.dialog.open(DeleteDialogComponent, { + maxWidth: '35em', + disableClose: true, + autoFocus: false, + data: { + title: 'Are you sure you want to delete this release track?', + warning: `${this.releaseTrackName || 'This release track'} and its snapshots will be permanently deleted.`, + stixId: this.id, + }, + }); + + prompt + .afterClosed() + .pipe(take(1)) + .subscribe(confirm => { + if (!confirm) return; + + this.isDeleting = true; + this.connector + .deleteReleaseTrack(this.id) + .pipe( + take(1), + finalize(() => { + this.isDeleting = false; + }) + ) + .subscribe({ + next: () => { + this.router.navigate(['/dashboard/release-management']); + }, + error: err => { + console.error('Failed to delete release track', err); + }, + }); + }); + } + + public getSnapshotHistory(): void { + if (!this.id) return; + + this.isLoadingSnapshotHistory = true; + this.connector + .listSnapshots(this.id) + .pipe( + take(1), + finalize(() => { + this.isLoadingSnapshotHistory = false; + }) + ) + .subscribe({ + next: result => { + const snapshots = Array.isArray(result) ? result : result?.data || []; + this.snapshotHistory = this.buildSnapshotHistory( + this.withCreatedDraftSnapshot(snapshots) + ); + }, + error: err => { + console.error('Failed to load release track snapshot history', err); + }, + }); + } + + public getConfig(): void { + if (!this.id) return; + + this.isLoadingConfig = true; + this.connector + .getConfig(this.id) + .pipe( + take(1), + finalize(() => { + this.isLoadingConfig = false; + }) + ) + .subscribe({ + next: config => { + if (!this.isEditingConfig) { + if (this.isVirtualReleaseTrack) { + this.setVirtualConfig(); + } else { + this.setConfig( + this.getConfigFromResponse(config, this.releaseTrack?.config) + ); + } + } + }, + error: err => { + console.error('Failed to load release track config', err); + }, + }); + } + + /** + * Load candidates using ReleaseTracksConnectorService.listCandidates() + */ + // private loadCandidates(): void { + // if (!this.id) return; + // const sub = this.connector.listCandidates(this.id).subscribe({ + // next: res => { + // const list = (res && (res as any).data) ? (res as any).data : (Array.isArray(res) ? res : []); + // this._candidates = list as any[]; + // if (this.releaseTrack) this.releaseTrack.candidates = this._candidates; + // }, + // error: err => { + // console.error('Failed to list candidates for track', this.id, err); + // }, + // complete: () => sub.unsubscribe(), + // }); + // } + + public onAddCandidate(): void { + if (!this.releaseTrack) return; + + const selection = new SelectionModel(true); + const selectedObjectRefs = new Map< + string, + { + id: string; + modified: string; + } + >(); + const dialogRef = this.dialog.open(AddDialogComponent, { + data: { + select: selection, + type: 'all', + selectionType: 'many', + buttonLabel: 'Add', + title: 'Add candidates', + clearSelection: true, + stixListConfig: { + ...ALL_OBJECTS_STIX_LIST_CONFIG, + select: 'many', + selectionModel: selection, + selectedObjectRefs, + clickBehavior: 'expand', + }, + }, + maxWidth: '90vw', + width: '80vw', + maxHeight: '85vh', + }); + + dialogRef.afterClosed().subscribe({ + next: result => { + if (!result || !selection.selected.length) return; + + const objectRefs = selection.selected.map( + id => selectedObjectRefs.get(id) || id + ); + + this.connector.addCandidates(this.id, objectRefs).subscribe({ + next: () => { + this.refreshReleaseTrackState(); + }, + error: err => { + console.error('Failed to add candidates', err); + }, + }); + }, + }); + } + + public getViewUrl(stixId: string): string | null { + const [stixType] = stixId.split('--'); + const attackType = StixTypeToAttackType[stixType as StixType]; + return attackType ? `/${attackType}/${stixId}` : null; + } + + public promote(objectIds: string[]): void { + if (!objectIds.length) return; + const sub = this.connector.promoteCandidates(this.id, objectIds).subscribe({ + next: () => { + this.refreshReleaseTrackState(); + }, + error: err => { + console.error('Failed to promote candidates', err); + }, + complete: () => sub.unsubscribe(), + }); + } + + public onPromoteAll(): void { + if (!this.id || !this.releaseTrack) return; + const candidateIds: string[] = this.candidates + .map(c => c.object_ref) + .filter((r: any) => !!r); + this.promote(candidateIds); + } + + public onPromote(candidateId: string): void { + if (!this.id || !candidateId) return; + this.promote([candidateId]); + } + + public demote(objectRefs: StixObjectRef[]): void { + if (!objectRefs.length) return; + const sub = this.connector.demoteStaged(this.id, objectRefs).subscribe({ + next: () => { + this.refreshReleaseTrackState(); + }, + error: err => { + console.error('Failed to demote staged objects', err); + }, + complete: () => sub.unsubscribe(), + }); + } + + public onDemoteAll(): void { + if (!this.id || !this.releaseTrack) return; + const stagedRefs: StixObjectRef[] = this.staged + .map(s => { + return { + id: s.object_ref, + modified: s.object_modified, + }; + }) + .filter((s: any) => !!s); + this.demote(stagedRefs); + } + + public onDemote(staged: any): void { + if (!this.id || !staged) return; + const stagedRef: StixObjectRef = { + id: staged.object_ref, + modified: staged.object_modified, + }; + this.demote([stagedRef]); + } + + public onView(id: string): void { + const viewUrl = this.getViewUrl(id); + if (!viewUrl) { + console.error('Unable to resolve object route', id); + return; + } + this.router.navigate([viewUrl]); + } + + public onDiff(item: ReleaseTrackObjectItem): void { + if (!item?.object_ref) { + this.snackbar.open( + 'Unable to determine which object to compare.', + undefined, + { + duration: 3000, + } + ); + return; + } + + const tier = this.getDiffTier(item); + if (!tier) { + this.snackbar.open( + 'Unable to determine which lane to compare.', + undefined, + { + duration: 3000, + } + ); + return; + } + + const diff = + tier === 'candidate' + ? this.resolveCandidateDiffObjects(item) + : this.resolveStagedDiffObjects(item); + const relationshipBaseline = + tier === 'candidate' + ? (this.findStagedEntry(item.object_ref) ?? + this.findMemberEntry(item.object_ref)) + : this.findMemberEntry(item.object_ref); + const relationshipAddedAfter = + this.getDiffObjectModified(relationshipBaseline); + diff.pipe(take(1)).subscribe(({ current, prior, expectedBaseline }) => { + if (!current) { + this.snackbar.open( + 'Unable to load the current object version.', + undefined, + { + duration: 3000, + } + ); + return; + } + + if (expectedBaseline && !prior) { + this.snackbar.open( + 'Unable to load the comparison baseline version.', + undefined, + { + duration: 3000, + } + ); + return; + } + + this.openDiffDialog( + current, + prior, + tier === 'staged' ? this.getDiffObjectModified(item) : undefined, + relationshipAddedAfter + ); + }); + } + + public onReviewAndApprove(item: any): void { + this.reviewCandidateStatus( + WorkflowStatus.AwaitingReview, + WorkflowStatus.Reviewed, + [item] + ); + } + + public canAddCandidates(lane: ReleaseTrackWorkspaceLane): boolean { + return lane.key === 'candidates' || lane.key === 'candidates-wip'; + } + + public canReviewAndApprove( + item: ReleaseTrackObjectItem, + lane: ReleaseTrackWorkspaceLane + ): boolean { + return ( + this.autoPromotionEnabled && + lane.type === 'candidate' && + this.getLaneStatus(item, lane) === WorkflowStatus.AwaitingReview + ); + } + + public canManuallyPromote(lane: ReleaseTrackWorkspaceLane): boolean { + return !this.autoPromotionEnabled && lane.type === 'candidate'; + } + + public canManuallyDemote(lane: ReleaseTrackWorkspaceLane): boolean { + return !this.autoPromotionEnabled && lane.type === 'staged'; + } + + public getLaneStatus( + item: ReleaseTrackObjectItem, + lane: ReleaseTrackWorkspaceLane + ): WorkflowStatusType { + return item.object_status || lane.statusFallback; + } + + public shouldShowLaneDescription(lane: ReleaseTrackWorkspaceLane): boolean { + return this.autoPromotionEnabled && !lane.isReleasedMembers; + } + + /** + * When different revisions of an object occupy both workflow tiers, only the + * newest pin can present a non-misleading relationship diff. + */ + public shouldShowDiff(item: ReleaseTrackObjectItem): boolean { + if (!item?.object_ref) return true; + + const staged = (this.releaseTrack?.staged || []).filter( + entry => entry.object_ref === item.object_ref + ); + const candidates = (this.releaseTrack?.candidates || []).filter( + entry => entry.object_ref === item.object_ref + ); + + if (!staged.length || !candidates.length) return true; + + const itemModified = this.getModifiedTimestamp(item.object_modified); + const pins = [...staged, ...candidates].map(entry => + this.getModifiedTimestamp(entry.object_modified) + ); + if ( + !Number.isFinite(itemModified) || + pins.some(time => !Number.isFinite(time)) + ) { + return true; + } + + return itemModified === Math.max(...pins); + } + + public getDiffUnavailableMessage( + item: ReleaseTrackObjectItem + ): string | null { + return this.shouldShowDiff(item) + ? null + : 'A newer revision of this object is available in the release track. View its diff instead.'; + } + + public toggleReleasedMembers(): void { + this.showReleasedMembers = !this.showReleasedMembers; + } + + private getModifiedTimestamp(value?: Date | string): number { + return value ? new Date(value).getTime() : Number.NaN; + } + + public onEditDescription(): void { + this.descriptionDraft = this.releaseTrack?.description ?? ''; + this.isEditingDescription = true; + } + + public onCancelDescriptionEdit(): void { + this.descriptionDraft = ''; + this.isEditingDescription = false; + } + + public onSaveDescription(): void { + if (!this.id || !this.releaseTrack || this.isSavingDescription) return; + + const description = this.descriptionDraft.trim(); + if (description === this.releaseTrackDescription) { + this.onCancelDescriptionEdit(); + return; + } + + this.isSavingDescription = true; + this.connector + .updateMetadataByLatest(this.id, { description }) + .pipe(take(1)) + .subscribe({ + next: () => { + this.isEditingDescription = false; + this.descriptionDraft = ''; + this.getReleaseTrack(); + }, + error: err => { + this.isSavingDescription = false; + console.error('Failed to update release track description', err); + }, + complete: () => { + this.isSavingDescription = false; + }, + }); + } + + public trackByLaneKey( + _index: number, + lane: ReleaseTrackWorkspaceLane + ): string { + return lane.key; + } + + public trackByObjectRef( + _index: number, + item: ReleaseTrackObjectItem + ): string { + return `${item.object_ref}-${item.object_modified || ''}`; + } + + public trackByComponentTrack(_index: number, track: any): string { + return track?.track_id || `${_index}`; + } + + public trackByResolvedComponent(_index: number, component: any): string { + return component?.track_id || component?.track_name || `${_index}`; + } + + public trackByVirtualResolutionRow( + _index: number, + row: VirtualResolutionRow + ): string { + return row.trackId || `${_index}`; + } + + public onOpenComponentTrack(track: any): void { + const trackId = track?.track_id || track?.trackId; + if (!trackId) return; + this.router.navigate(['/dashboard/release-management', trackId]); + } + + public getComponentTrackLabel(track: any): string { + const resolvedComponent = this.resolvedComponentSnapshots.find( + component => component.track_id === track.track_id + ); + return ( + this.getComponentTrackSummary(track.track_id)?.name || + resolvedComponent?.track_name || + track.track_name || + track.track_id + ); + } + + public getComponentTrackFilters(track: any): string[] { + const objectTypes = track?.filters?.object_types; + const domains = track?.filters?.domains; + return [ + ...(Array.isArray(objectTypes) + ? objectTypes.map(type => this.formatConfigOption(type)) + : []), + ...(Array.isArray(domains) ? domains.map(this.formatDomain) : []), + ]; + } + + public getResolvedComponentLabel(component: any): string { + const version = + component.resolved_version || + component.version || + component.version_label; + if (version) return `Resolved version ${version}`; + + const snapshot = + component.resolved_snapshot_id || component.resolved_snapshot; + if (snapshot) return `Resolved snapshot ${this.toDisplayDate(snapshot)}`; + + return 'Resolved snapshot'; + } + + public getVirtualResolvedVersion(row: VirtualResolutionRow): string { + if (!row.resolvedVersion) return '-'; + return row.resolvedVersion.startsWith('v') + ? row.resolvedVersion + : `v${row.resolvedVersion}`; + } + + public getVirtualTierCount(value: number | null | undefined): string { + if (value === null || value === undefined) return '-'; + return String(value); + } + + public displayVirtualComponentTrackOption( + track: VirtualComponentTrackOption + ): string { + return track?.name || ''; + } + + public getVirtualComponentTrackSnapshotLabel( + track: VirtualComponentTrackOption | any + ): string { + const version = + track?.latestTaggedVersion || + track?.latest_tagged_version || + track?.latest_version; + return version ? `v${version}` : 'no tagged snapshots'; + } + + public selectVirtualComponentTrack(event: any): void { + const option = event?.option?.value as VirtualComponentTrackOption; + if (!option) return; + + this.virtualConfigComponentTracks = [ + ...this.virtualConfigComponentTracks, + { + track_id: option.trackId, + resolution_strategy: ResolutionStrategy.LatestTagged, + priority: this.virtualConfigComponentTracks.length, + }, + ]; + this.configForm + .get('virtualComponentTrackSearch') + ?.setValue('', { emitEvent: false }); + } + + public removeVirtualComponentTrack(track: any): void { + this.virtualConfigComponentTracks = + this.virtualConfigComponentTracks.filter(item => item !== track); + } + + public getVirtualComponentTrackObjectTypes(track: any): string[] { + const objectTypes = track?.filters?.object_types; + return Array.isArray(objectTypes) ? objectTypes : []; + } + + public setVirtualComponentTrackObjectTypes( + track: any, + objectTypes: string[] + ): void { + if (!objectTypes.length) { + const filters = { ...(track.filters || {}) }; + delete filters.object_types; + track.filters = Object.keys(filters).length ? filters : undefined; + return; + } + + track.filters = { + ...(track.filters || {}), + object_types: objectTypes, + }; + } + + public getVirtualComponentTrackDomains(track: any): string[] { + const domains = track?.filters?.domains; + return Array.isArray(domains) ? domains.map(this.formatDomain) : []; + } + + public setVirtualComponentTrackDomains(track: any, domains: string[]): void { + const filters = { ...(track.filters || {}) }; + if (domains.length) filters.domains = domains; + else delete filters.domains; + track.filters = Object.keys(filters).length ? filters : undefined; + } + + private formatDomain(domain: string): string { + return domain.replace(/-attack$/, ''); + } + + public getVirtualComponentTrackDescription(track: any): string { + return this.getComponentTrackSummary(track?.track_id)?.description || ''; + } + + public getVirtualObjectTitle(item: any): string { + return item?.name || this.getFallbackObjectLabel(item?.object_ref); + } + + public getVirtualObjectSubtitle(item: any): string { + return ( + item?.attack_id || + item?.attackId || + item?.source_snapshot_version || + item?.conflict_reason || + item?.object_ref || + '' + ); + } + + private getResolutionNumber(resolution: any, ...keys: string[]): number { + if (!resolution) return 0; + const sources = [resolution, resolution.summary, resolution.deduplication]; + for (const source of sources) { + if (!source) continue; + for (const key of keys) { + if (typeof source[key] === 'number') return source[key]; + } + } + return 0; + } + + private loadVirtualComponentTrackSummaries(): void { + this.connector + .listReleaseTracks() + .pipe(take(1)) + .subscribe({ + next: result => { + const summaries = new Map(); + const options: VirtualComponentTrackOption[] = []; + for (const track of this.getReleaseTrackList(result)) { + const trackId = this.getReleaseTrackId(track); + if (!trackId) continue; + summaries.set(trackId, this.toVirtualComponentTrackSummary(track)); + if (this.isStandardReleaseTrack(track)) { + options.push(this.toVirtualComponentTrackOption(track)); + } + } + this.virtualComponentTrackSummaries = summaries; + this.virtualComponentTrackOptions = options; + }, + error: err => { + this.virtualComponentTrackSummaries.clear(); + this.virtualComponentTrackOptions = []; + console.error('Failed to load component track summaries', err); + }, + }); + } + + private getReleaseTrackList(result: any): any[] { + if (Array.isArray(result?.data)) return result.data; + if (Array.isArray(result?.release_tracks)) return result.release_tracks; + if (Array.isArray(result)) return result; + return []; + } + + private getReleaseTrackId(track: any): string | null { + return track?.track_id || track?.id || null; + } + + private isStandardReleaseTrack(track: any): boolean { + return String(track?.type).toLowerCase() === ReleaseTrackType.Standard; + } + + private toVirtualComponentTrackSummary( + track: any + ): VirtualComponentTrackSummary { + const summary = track?.summary || {}; + return { + trackId: this.getReleaseTrackId(track) as string, + name: track?.name || 'Untitled release track', + description: track?.description || '', + type: track?.type || '', + latestTaggedVersion: this.getLatestTaggedVersion(track), + taggedReleaseCount: this.getTaggedReleaseCount(track), + candidatesCount: Number(summary.candidates_count || 0), + stagedCount: Number(summary.staged_count || 0), + membersCount: Number(summary.members_count || 0), + }; + } + + private toVirtualComponentTrackOption( + track: any + ): VirtualComponentTrackOption { + return { + trackId: this.getReleaseTrackId(track) as string, + name: track?.name || 'Untitled release track', + description: track?.description || '', + latestTaggedVersion: this.getLatestTaggedVersion(track), + taggedReleaseCount: this.getTaggedReleaseCount(track), + }; + } + + private getLatestTaggedVersion(track: any): string | null { + return ( + track?.latest_tagged_version || + track?.latestTaggedVersion || + track?.latest_version || + track?.latestVersion || + null + ); + } + + private getTaggedReleaseCount(track: any): number { + return Number( + track?.tagged_release_count || + track?.taggedReleaseCount || + track?.tagged_releases_count || + 0 + ); + } + + private getComponentTrackSummary( + trackId: string + ): VirtualComponentTrackSummary | undefined { + return this.virtualComponentTrackSummaries.get(trackId); + } + + private getVirtualComponentCounts( + trackId: string + ): Pick< + VirtualResolutionRow, + 'candidatesCount' | 'stagedCount' | 'membersCount' + > { + const summary = this.getComponentTrackSummary(trackId); + return { + candidatesCount: summary?.candidatesCount ?? null, + stagedCount: summary?.stagedCount ?? null, + membersCount: summary?.membersCount ?? null, + }; + } + + private cloneVirtualComponentTracks(tracks: any[]): any[] { + return tracks.map(track => ({ + ...track, + filters: track.filters + ? { + ...track.filters, + object_types: Array.isArray(track.filters.object_types) + ? [...track.filters.object_types] + : undefined, + domains: Array.isArray(track.filters.domains) + ? [...track.filters.domains] + : undefined, + } + : undefined, + })); + } + + private getVirtualComponentTrackSearchText(): string { + const value = this.configForm.get('virtualComponentTrackSearch')?.value; + if (!value) return ''; + if (typeof value === 'string') return value.trim().toLowerCase(); + return String(value.name || value.trackId || '') + .trim() + .toLowerCase(); + } + + private matchesVirtualComponentTrackSearch( + track: VirtualComponentTrackOption, + search: string + ): boolean { + if (!search) return true; + return [track.name, track.trackId, track.description] + .filter(Boolean) + .some(value => value.toLowerCase().includes(search)); + } + + private toDisplayDate(value: any): string { + if (!value) return ''; + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return String(value); + return date.toLocaleString(); + } + + private toOptionalNumber(value: any): number | null { + if (value === null || value === undefined || value === '') return null; + const numberValue = Number(value); + return Number.isFinite(numberValue) ? numberValue : null; + } + + private formatStixType(type: StixType): string { + const attackType = StixTypeToAttackType[type]; + return AttackTypeToPlural[attackType]?.replace(/-/g, ' ') || type; + } + + private getFallbackObjectLabel(objectRef: string | undefined): string { + if (!objectRef) return 'Unknown object'; + const [type, id] = objectRef.split('--'); + if (!type || !id) return objectRef; + const attackType = StixTypeToAttackType[type as StixType]; + const typeLabel = attackType + ? attackType.replace(/-/g, ' ') + : type.replace(/-/g, ' '); + return `${typeLabel.replace(/\b\w/g, char => char.toUpperCase())} ${id.slice(0, 8)}`; + } + + public onExport(): void { + if (!this.id) return; + + this.openExportFormatDialog('Export latest release snapshot') + .pipe(take(1)) + .subscribe(choice => { + const selection = this.getSnapshotExportSelection(choice); + if (!selection) return; + this.downloadLatestReleaseTrack(selection); + }); + } + + private openExportFormatDialog( + title: string, + includeSummary = false + ): Observable { + const choices: { + label: string; + value: SnapshotExportChoice; + description: string; + }[] = [ + { + label: 'Bundle (STIX 2.0)', + value: 'bundle-stix-2.0', + description: + 'Download a STIX 2.0 JSON bundle for publication or interchange.', + }, + { + label: 'Bundle (STIX 2.1)', + value: 'bundle-stix-2.1', + description: + 'Download a STIX 2.1 JSON bundle for publication or interchange.', + }, + { + label: 'Workbench', + value: ExportFormat.Workbench, + description: + 'Download the snapshot with workflow tiers, metadata, and other Workbench-specific data.', + }, + ]; + + if (includeSummary) { + choices.push({ + label: 'Summary', + value: 'copy-summary', + description: + 'Copy lightweight snapshot metadata, object counts, and graph-cache statistics to the clipboard.', + }); + } + + const formatRef = this.dialog.open(MultipleChoiceDialogComponent, { + width: '30em', + autoFocus: false, + data: { + title, + description: includeSummary + ? 'Choose a downloadable snapshot format or copy its concise history summary.' + : 'Choose a downloadable snapshot format.', + choices, + }, + }); + + return formatRef + .afterClosed() + .pipe( + map(format => + format === 'bundle-stix-2.0' || + format === 'bundle-stix-2.1' || + format === ExportFormat.Workbench || + (includeSummary && format === 'copy-summary') + ? format + : null + ) + ); + } + + private getSnapshotExportSelection( + choice: SnapshotExportChoice | null + ): SnapshotExportSelection | null { + if (choice === 'bundle-stix-2.0') { + return { format: ExportFormat.Bundle, stixVersion: '2.0' }; + } + if (choice === 'bundle-stix-2.1') { + return { format: ExportFormat.Bundle, stixVersion: '2.1' }; + } + if (choice === ExportFormat.Workbench) { + return { format: ExportFormat.Workbench }; + } + return null; + } + + private downloadLatestReleaseTrack(selection: SnapshotExportSelection): void { + const options: Omit = { + include: 'all', + ...(selection.stixVersion ? { stixVersion: selection.stixVersion } : {}), + }; + this.connector + .exportLatestSnapshot(this.id, selection.format, options) + .pipe(take(1)) + .subscribe({ + next: result => { + this.restApiConnectorService.triggerBrowserDownload( + result, + this.getExportFilename(selection) + ); + }, + error: err => { + console.error('Failed to export release track', err); + }, + }); + } + + private getExportFilename(selection: SnapshotExportSelection): string { + const name = this.releaseTrackName || this.id || 'release-track'; + const safeName = + name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'release-track'; + return `${safeName}-latest-${this.getExportFilenameSuffix(selection)}.json`; + } + + public onDraft(): void { + if (!this.canCreateDraft) return; + + const trackName = this.releaseTrackName || this.id || 'this release track'; + const dialogRef = this.dialog.open(SnapshotDescriptionDialogComponent, { + autoFocus: false, + data: { + title: 'Create draft snapshot', + message: `Create a copy of the latest snapshot for ${trackName}. Add optional notes so other analysts can understand this draft in history.`, + description: this.releaseTrack?.snapshot_description || '', + confirmLabel: 'Create draft', + }, + }); + + dialogRef.afterClosed().subscribe(description => { + if ( + description === undefined || + !this.id || + !this.isVirtualReleaseTrack + ) { + return; + } + this.createVirtualDraftSnapshot(description); + }); + } + + private createVirtualDraftSnapshot(description: string): void { + this.isCreatingDraft = true; + this.connector + .createVirtualSnapshot(this.id, { + description, + }) + .pipe( + take(1), + finalize(() => { + this.isCreatingDraft = false; + }) + ) + .subscribe({ + next: snapshot => { + this.addCreatedDraftSnapshotToHistory(snapshot); + this.refreshReleaseTrackState(); + }, + error: err => { + console.error('Failed to create draft snapshot', err); + }, + }); + } + + private addCreatedDraftSnapshotToHistory(snapshot: any): void { + const draftSnapshot = this.getCreatedDraftSnapshot(snapshot); + if (!draftSnapshot) return; + + this.createdDraftSnapshot = draftSnapshot; + this.snapshotHistory = this.buildSnapshotHistory( + this.withCreatedDraftSnapshot( + this.snapshotHistory.map(item => item.snapshot) + ) + ); + } + + private getCreatedDraftSnapshot( + snapshot: any + ): ReleaseTrackSnapshotHistoryItem | null { + const draftSnapshot = snapshot?.data || snapshot?.snapshot || snapshot; + if (!draftSnapshot || this.isTaggedSnapshot(draftSnapshot)) return null; + + const modified = this.getSnapshotModified(draftSnapshot); + if (!modified) return null; + const resolvedTotalObjects = + typeof draftSnapshot.composition_resolution?.total_objects === 'number' + ? draftSnapshot.composition_resolution.total_objects + : null; + const membersCount = + this.getSnapshotCount(draftSnapshot, 'members_count') ?? + this.getSnapshotMembers(draftSnapshot).length; + const quarantineCount = + this.getSnapshotCount( + draftSnapshot, + 'quarantine_count', + 'quarantined_count' + ) ?? this.getSnapshotQuarantineCount(draftSnapshot); + const shouldUseResolvedTotal = + !membersCount && !quarantineCount && resolvedTotalObjects !== null; + + return { + ...draftSnapshot, + modified, + version: null, + type: ReleaseTrackType.Virtual, + members_count: shouldUseResolvedTotal + ? resolvedTotalObjects + : membersCount, + quarantine_count: quarantineCount, + }; + } + + private withCreatedDraftSnapshot( + snapshots: ReleaseTrackSnapshotHistoryItem[] + ): ReleaseTrackSnapshotHistoryItem[] { + if (!this.createdDraftSnapshot) return snapshots; + + const createdDraftModified = this.getSnapshotModified( + this.createdDraftSnapshot + ); + const draftAlreadyLoaded = snapshots.some( + snapshot => this.getSnapshotModified(snapshot) === createdDraftModified + ); + + if (draftAlreadyLoaded) { + this.createdDraftSnapshot = null; + return snapshots; + } + + return [...snapshots, this.createdDraftSnapshot]; + } + + public onEditConfig(): void { + if (this.isVirtualReleaseTrack) this.setVirtualConfig(); + this.isEditingConfig = true; + } + + public onCancelConfigEdit(): void { + if (this.isVirtualReleaseTrack) { + this.setVirtualConfig(); + } else { + this.setConfig(this.releaseTrackConfig); + } + this.isEditingConfig = false; + } + + public onSaveConfig(): void { + if (!this.id || this.isSavingConfig) return; + if (this.isVirtualReleaseTrack) { + this.saveVirtualConfig(); + return; + } + + const payload = this.getConfigPayload(); + this.isSavingConfig = true; + this.connector + .updateConfig(this.id, payload) + .pipe( + take(1), + finalize(() => { + this.isSavingConfig = false; + }) + ) + .subscribe({ + next: result => { + this.isEditingConfig = false; + this.setConfig(this.getConfigFromResponse(result, payload)); + if (this.releaseTrack) + this.releaseTrack.config = this.releaseTrackConfig; + this.refreshReleaseTrackState(); + }, + error: err => { + console.error('Failed to update release track config', err); + }, + }); + } + + public onPreviewRelease(): void { + if (!this.latestDraftSnapshot && this.releaseTrack?.version != null) { + this.openNoDraftSnapshotDialog(); + return; + } + + this.previewRelease(this.latestDraftSnapshot); + } + + private openNoDraftSnapshotDialog(): void { + this.dialog.open(MultipleChoiceDialogComponent, { + width: '30em', + autoFocus: false, + restoreFocus: true, + data: { + title: 'No draft snapshot available', + description: + 'The latest snapshot has already been released. Modify the release track to create a new draft before previewing another release.', + choices: [ + { + label: 'Close', + value: 'close', + }, + ], + }, + }); + } + + public onTagSnapshot(item: SnapshotHistoryViewModel): void { + if (!item || item.isTagged || !item.modified) return; + this.previewRelease(item); + } + + private previewRelease(item?: SnapshotHistoryViewModel): void { + if (!this.id || this.isReleasing) return; + + this.isReleasing = true; + const selection: ReleasePayload = { increment: 'minor' }; + const preview = forkJoin({ + preview: item?.modified + ? this.connector.previewRelease( + this.id, + { format: ReleasePreviewFormat.Summary, ...selection }, + item.modified + ) + : this.connector.previewRelease(this.id, { + format: ReleasePreviewFormat.Summary, + ...selection, + }), + track: item?.modified + ? this.connector.retrieveSnapshotByModified(this.id, item.modified, { + format: ExportFormat.Workbench, + include: 'all', + }) + : of(this.releaseTrack), + objects: this.restApiConnectorService.getAllObjects({ + revoked: true, + deprecated: true, + versions: 'all', + }), + }); + + preview + .pipe( + take(1), + finalize(() => { + this.isReleasing = false; + }) + ) + .subscribe({ + next: result => { + if (!result.preview || !result.track) { + this.snackbar.open( + 'Unable to load the release preview. Please try again.', + null, + { + duration: 5000, + panelClass: 'error', + } + ); + return; + } + + this.openReleasePreviewDialog( + result.preview, + this.enrichReleasePreviewTrack(result.track, result.objects), + item + ); + }, + error: err => { + console.error('Failed to load objects for release preview', err); + }, + }); + } + + public onExportSnapshot(item: SnapshotHistoryViewModel): void { + if (!this.id || !item.modified) return; + const modified = item.modified; + + this.openExportFormatDialog('Export release track snapshot', true) + .pipe(take(1)) + .subscribe(choice => { + if (!choice) return; + if (choice === 'copy-summary') { + this.copySnapshotSummary(item); + return; + } + const selection = this.getSnapshotExportSelection(choice); + if (selection) this.downloadSnapshot(item, modified, selection); + }); + } + + private copySnapshotSummary(item: SnapshotHistoryViewModel): void { + const copied = this.clipboard.copy( + JSON.stringify(this.getSnapshotClipboardSummary(item), null, 2) + ); + + this.snackbar.open( + copied + ? 'Snapshot summary copied to the clipboard.' + : 'Unable to copy the snapshot summary.', + null, + copied ? { duration: 3000 } : { duration: 5000, panelClass: 'error' } + ); + } + + private getSnapshotClipboardSummary( + item: SnapshotHistoryViewModel + ): Record { + const snapshot = item.snapshot; + const type = this.getSnapshotType(snapshot); + const counts = + type === ReleaseTrackType.Virtual + ? { + members: this.getSnapshotCount(snapshot, 'members_count') ?? 0, + quarantine: + this.getSnapshotCount( + snapshot, + 'quarantine_count', + 'quarantined_count' + ) ?? 0, + } + : { + members: this.getSnapshotCount(snapshot, 'members_count') ?? 0, + staged: this.getSnapshotCount(snapshot, 'staged_count') ?? 0, + candidates: + this.getSnapshotCount(snapshot, 'candidates_count') ?? 0, + }; + + return { + id: snapshot.id || this.id, + name: snapshot.name || this.releaseTrackName, + type, + version: snapshot.version ?? null, + notes: snapshot.snapshot_description ?? null, + modified: item.modified, + tagged: item.isTagged, + latest: item.isLatest, + counts, + graph_cache: item.isBundleCached + ? { + cached: true, + manifest_id: snapshot.graph_manifest_id, + statistics: snapshot.graph_statistics, + } + : { cached: false }, + }; + } + + public isCachingSnapshot(item: SnapshotHistoryViewModel): boolean { + return !!item.modified && this.cachingSnapshotModified.has(item.modified); + } + + public isDeletingSnapshotCache(item: SnapshotHistoryViewModel): boolean { + return ( + !!item.modified && this.deletingSnapshotCacheModified.has(item.modified) + ); + } + + public isUpdatingSnapshotDescription( + item: SnapshotHistoryViewModel + ): boolean { + return ( + !!item.modified && + this.updatingSnapshotDescriptionModified.has(item.modified) + ); + } + + public onEditSnapshotDescription(item: SnapshotHistoryViewModel): void { + if ( + !this.id || + !item.modified || + !this.canEditReleaseTrack || + item.isBundleCached || + !!item.snapshot.graph_manifest_id || + this.isUpdatingSnapshotDescription(item) + ) { + return; + } + + const modified = item.modified; + const dialogRef = this.dialog.open(SnapshotDescriptionDialogComponent, { + autoFocus: false, + data: { + title: item.snapshot.snapshot_description + ? 'Edit snapshot notes' + : 'Add snapshot notes', + description: item.snapshot.snapshot_description || '', + message: + 'These notes are visible on this snapshot in history and in its STIX bundles. Notes cannot be changed while the bundle is cached.', + confirmLabel: 'Save notes', + }, + }); + + dialogRef + .afterClosed() + .pipe(take(1)) + .subscribe((description: string | undefined) => { + if (description === undefined) return; + + this.updatingSnapshotDescriptionModified.add(modified); + this.connector + .updateSnapshotDescription(this.id, modified, { description }) + .pipe( + take(1), + finalize(() => { + this.updatingSnapshotDescriptionModified.delete(modified); + }) + ) + .subscribe({ + next: snapshot => { + const updatedDescription = snapshot.snapshot_description; + item.snapshot = { ...item.snapshot, ...snapshot }; + + if ( + this.releaseTrack && + this.getSnapshotModified(this.releaseTrack) === modified + ) { + Object.assign(this.releaseTrack, snapshot); + } + if ( + this.createdDraftSnapshot && + this.getSnapshotModified(this.createdDraftSnapshot) === modified + ) { + Object.assign(this.createdDraftSnapshot, snapshot); + } + + this.snackbar.open( + updatedDescription + ? 'Snapshot notes saved.' + : 'Snapshot notes cleared.', + null, + { duration: 3000 } + ); + }, + error: err => { + console.error('Failed to update snapshot notes', err); + this.snackbar.open( + 'Unable to save snapshot notes. Please try again.', + null, + { duration: 5000, panelClass: 'error' } + ); + }, + }); + }); + } + + public getBundleCacheTooltip(item: SnapshotHistoryViewModel): string { + if (item.isBundleCached) { + return 'Member-only bundle exports use exact object and relationship revisions, so repeated exports are deterministic. Candidate and staged content remains live.'; + } + if (!item.isTagged) { + return 'Draft snapshots cannot be cached. Tag this snapshot before caching it for deterministic member-only bundle exports.'; + } + return 'Member-only bundle exports are not guaranteed to be deterministic until this snapshot is cached.'; + } + + public onCacheSnapshotBundle(item: SnapshotHistoryViewModel): void { + if ( + !this.id || + !item.modified || + !item.isTagged || + item.isBundleCached || + !this.canEditReleaseTrack || + this.isCachingSnapshot(item) + ) { + return; + } + + const modified = item.modified; + this.cachingSnapshotModified.add(modified); + this.connector + .createSnapshotGraph(this.id, modified) + .pipe( + take(1), + finalize(() => { + this.cachingSnapshotModified.delete(modified); + }) + ) + .subscribe({ + next: snapshot => { + item.snapshot = { ...item.snapshot, ...snapshot }; + item.isBundleCached = !!snapshot.graph_manifest_id; + item.canCacheBundle = item.isTagged && !item.isBundleCached; + this.getSnapshotHistory(); + this.snackbar.open( + 'Bundle cached. Member-only bundle exports are now deterministic.', + null, + { duration: 5000 } + ); + }, + error: err => { + console.error('Failed to cache snapshot bundle graph', err); + this.snackbar.open( + 'Unable to cache this snapshot. Please try again.', + null, + { duration: 5000, panelClass: 'error' } + ); + }, + }); + } + + public onDeleteSnapshotCache(item: SnapshotHistoryViewModel): void { + if ( + !this.id || + !item.modified || + !item.isBundleCached || + !this.canEditReleaseTrack || + this.isCachingSnapshot(item) || + this.isDeletingSnapshotCache(item) + ) { + return; + } + + const modified = item.modified; + const dialogRef = this.dialog.open(ConfirmationDialogComponent, { + width: '30em', + autoFocus: false, + data: { + title: 'Delete bundle cache?', + message: `Delete the bundle cache for ${item.title}? Member-only bundle exports will no longer be guaranteed to be deterministic until the cache is rebuilt.`, + no_label: 'Cancel', + yes_label: 'Delete Cache', + confirm_color: 'warn', + confirm_appearance: 'raised', + layout: 'simple', + }, + }); + + dialogRef + .afterClosed() + .pipe(take(1)) + .subscribe(confirmed => { + if (!confirmed || !this.id) return; + + this.deletingSnapshotCacheModified.add(modified); + this.connector + .deleteSnapshotGraph(this.id, modified) + .pipe( + take(1), + finalize(() => { + this.deletingSnapshotCacheModified.delete(modified); + }) + ) + .subscribe({ + next: () => { + delete item.snapshot.graph_manifest_id; + delete item.snapshot.bundle_hashes; + delete item.snapshot.graph_statistics; + item.isBundleCached = false; + item.canCacheBundle = item.isTagged; + item.graphCacheStats = []; + item.graphCacheTotal = 0; + this.getSnapshotHistory(); + this.snackbar.open( + 'Bundle cache deleted. Member-only bundle exports are no longer guaranteed to be deterministic.', + null, + { duration: 5000 } + ); + }, + error: err => { + console.error('Failed to delete snapshot bundle graph', err); + this.snackbar.open( + 'Unable to delete this bundle cache. Please try again.', + null, + { duration: 5000, panelClass: 'error' } + ); + }, + }); + }); + } + + private downloadSnapshot( + item: SnapshotHistoryViewModel, + modified: string, + selection: SnapshotExportSelection + ): void { + this.connector + .exportSnapshotByModified( + this.id, + modified, + selection.format, + this.getSnapshotExportOptions(item, selection) + ) + .pipe(take(1)) + .subscribe({ + next: result => { + this.restApiConnectorService.triggerBrowserDownload( + result, + this.getSnapshotExportFilename(item, selection) + ); + }, + error: err => { + console.error('Failed to export release track snapshot', err); + }, + }); + } + + public getSnapshotBundleHash( + item: SnapshotHistoryViewModel, + stixVersion: StixVersion + ): string | null { + const hashes = item.snapshot.bundle_hashes; + if (!hashes || hashes.manifest_id !== item.snapshot.graph_manifest_id) { + return null; + } + return stixVersion === '2.0' ? hashes.stix_2_0 : hashes.stix_2_1; + } + + public copySnapshotBundleHash( + item: SnapshotHistoryViewModel, + stixVersion: StixVersion + ): void { + const hash = this.getSnapshotBundleHash(item, stixVersion); + if (!hash) return; + + const copied = this.clipboard.copy(hash); + this.snackbar.open( + copied + ? `STIX ${stixVersion} bundle SHA-256 copied to the clipboard.` + : `Unable to copy the STIX ${stixVersion} bundle SHA-256.`, + null, + copied ? { duration: 3000 } : { duration: 5000, panelClass: 'error' } + ); + } + + private getSnapshotExportOptions( + item: SnapshotHistoryViewModel, + selection: SnapshotExportSelection + ): Omit | undefined { + if (selection.format === ExportFormat.Workbench) { + return { include: 'all' }; + } + + const stixVersionOptions = selection.stixVersion + ? { stixVersion: selection.stixVersion } + : {}; + const trackType = + this.getSnapshotType(item.snapshot) || this.releaseTrack?.type; + if (trackType === ReleaseTrackType.Standard && !item.isTagged) { + return { include: 'staged', ...stixVersionOptions }; + } + return Object.keys(stixVersionOptions).length + ? stixVersionOptions + : undefined; + } + + private reviewCandidateStatus( + from: WorkflowStatusType, + to: WorkflowStatusType, + items: any[] + ): void { + if (!this.id || !items.length) return; + + const objectRefs = items + .map(item => this.getReviewObjectRef(item)) + .filter((ref): ref is StixObjectRef => !!ref); + + if (!objectRefs.length) return; + + this.connector + .reviewCandidates(this.id, { + from, + to, + object_refs: objectRefs, + }) + .pipe(take(1)) + .subscribe({ + next: () => this.refreshReleaseTrackState(), + error: err => { + console.error('Failed to update candidate review status', err); + }, + }); + } + + private getReviewObjectRef(item: any): StixObjectRef | null { + if (!item?.object_ref) return null; + const modified = this.toIsoString(item.object_modified); + return modified ? { id: item.object_ref, modified } : item.object_ref; + } + + private getReleaseTrackTier(item: any): 'candidate' | 'staged' | null { + if (item?.release_track_tier) { + return item.release_track_tier === 'candidate' || + item.release_track_tier === 'staged' + ? item.release_track_tier + : null; + } + if (item?.object_staged_at || item?.object_staged_by) return 'staged'; + return item?.object_ref ? 'candidate' : null; + } + + private getDiffTier( + item: ReleaseTrackObjectItem + ): 'candidate' | 'staged' | null { + return this.getReleaseTrackTier(item); + } + + private findStagedEntry(objectRef: string): ReleaseTrackObjectItem | null { + return ( + this.releaseTrack?.staged?.find(item => item.object_ref === objectRef) ?? + null + ); + } + + private findMemberEntry(objectRef: string): ReleaseTrackObjectItem | null { + return ( + this.releaseTrack?.members?.find(item => item.object_ref === objectRef) ?? + null + ); + } + + private getDiffObjectModified( + item: ReleaseTrackObjectItem | null + ): Date | string | undefined { + return item?.resolved_object_modified ?? item?.object_modified; + } + + private resolveCandidateDiffObjects( + item: ReleaseTrackObjectItem + ): Observable<{ + current: StixObject | null; + prior: StixObject | null; + expectedBaseline: boolean; + }> { + const stagedEntry = this.findStagedEntry(item.object_ref); + const memberEntry = stagedEntry + ? null + : this.findMemberEntry(item.object_ref); + const baselineEntry = stagedEntry ?? memberEntry; + + return forkJoin({ + current: this.fetchObjectVersion( + item.object_ref, + this.getDiffObjectModified(item) + ), + prior: baselineEntry + ? this.fetchObjectVersion( + baselineEntry.object_ref, + this.getDiffObjectModified(baselineEntry) + ) + : of(null), + }).pipe( + map(({ current, prior }) => ({ + current, + prior, + expectedBaseline: !!baselineEntry, + })) + ); + } + + private resolveStagedDiffObjects(item: ReleaseTrackObjectItem): Observable<{ + current: StixObject | null; + prior: StixObject | null; + expectedBaseline: boolean; + }> { + const memberEntry = this.findMemberEntry(item.object_ref); + + return forkJoin({ + current: this.fetchObjectVersion( + item.object_ref, + this.getDiffObjectModified(item) + ), + prior: memberEntry + ? this.fetchObjectVersion( + memberEntry.object_ref, + this.getDiffObjectModified(memberEntry) + ) + : of(null), + }).pipe( + map(({ current, prior }) => ({ + current, + prior, + expectedBaseline: !!memberEntry, + })) + ); + } + + private fetchObjectVersion( + objectRef: string, + modified?: Date | string + ): Observable { + const stixType = objectRef.split('--')[0] as StixType; + const attackType = StixTypeToAttackType[stixType]; + const requestedModified = modified === 'latest' ? undefined : modified; + + let requestObject: Observable; + switch (attackType) { + case 'technique': + requestObject = this.restApiConnectorService.getTechnique( + objectRef, + requestedModified + ); + break; + case 'tactic': + requestObject = this.restApiConnectorService.getTactic( + objectRef, + requestedModified + ); + break; + case 'group': + requestObject = this.restApiConnectorService.getGroup( + objectRef, + requestedModified + ); + break; + case 'campaign': + requestObject = this.restApiConnectorService.getCampaign( + objectRef, + requestedModified + ); + break; + case 'asset': + requestObject = this.restApiConnectorService.getAsset( + objectRef, + requestedModified + ); + break; + case 'software': + requestObject = this.restApiConnectorService.getSoftware( + objectRef, + requestedModified + ); + break; + case 'mitigation': + requestObject = this.restApiConnectorService.getMitigation( + objectRef, + requestedModified + ); + break; + case 'matrix': + requestObject = this.restApiConnectorService.getMatrix( + objectRef, + requestedModified + ); + break; + case 'data-source': + requestObject = this.restApiConnectorService.getDataSource( + objectRef, + requestedModified + ); + break; + case 'data-component': + requestObject = this.restApiConnectorService.getDataComponent( + objectRef, + requestedModified + ); + break; + case 'detection-strategy': + requestObject = this.restApiConnectorService.getDetectionStrategy( + objectRef, + requestedModified + ); + break; + case 'analytic': + requestObject = this.restApiConnectorService.getAnalytic( + objectRef, + requestedModified + ); + break; + default: + return of(null); + } + + return requestObject.pipe( + take(1), + map(results => results[0] ?? null) + ); + } + + private openDiffDialog( + current: StixObject, + prior: StixObject | null, + relationshipCreatedBefore?: Date | string, + relationshipAddedAfter?: Date | string + ): void { + this.dialog.open(StixDialogComponent, { + data: { + object: [current, prior], + mode: 'diff', + editable: false, + sidebarControl: 'disable', + relationshipCreatedBefore, + relationshipAddedAfter, + }, + maxHeight: '75vh', + autoFocus: false, + }); + } + + private getObjectStatus(item: ReleaseTrackObjectItem): WorkflowStatusType { + return item.object_status || WorkflowStatus.WorkInProgress; + } + + private isWorkInProgressCandidate(item: ReleaseTrackObjectItem): boolean { + return ( + this.getObjectStatus(item) === WorkflowStatus.WorkInProgress || + String(item.object_status) === 'modified-in-place' + ); + } + + public formatConfigOption(value: any): string { + if (value === null || value === undefined || value === '') return 'not set'; + return String(value).replace(/[_-]+/g, ' '); + } + + public getVirtualTrackPriority(track: any, index: number): number { + return typeof track?.priority === 'number' ? track.priority : index; + } + + public getVirtualScheduleValue(key: string): string { + const value = this.virtualSnapshotSchedule?.[key]; + return value === null || value === undefined || value === '' + ? 'not set' + : String(value); + } + + private setConfig(config: any): void { + const normalizedConfig = this.normalizeConfig(config); + this.releaseTrackConfig = normalizedConfig; + this.configForm.patchValue(this.getConfigFormValue(normalizedConfig), { + emitEvent: false, + }); + this.syncCandidacyThresholdControl(!!normalizedConfig.auto_promote); + this.syncSecondaryObjectThresholdControl( + !!normalizedConfig.include_secondary_objects?.enabled + ); + } + + private setVirtualConfig(): void { + const deduplication = this.releaseTrack?.composition?.deduplication || {}; + const snapshotSchedule = this.virtualSnapshotSchedule; + this.virtualConfigComponentTracks = this.cloneVirtualComponentTracks( + this.virtualComponentTracks + ); + + this.configForm.patchValue( + { + virtualComponentTrackSearch: '', + virtualDeduplicationStrategy: + deduplication.strategy ?? + DeduplicationStrategy.PrioritizeLatestObject, + virtualDeduplicationTier: + deduplication.tier_resolution ?? SnapshotTier.Member, + virtualDeduplicationStatus: + deduplication.status_resolution ?? WorkflowStatus.Reviewed, + virtualSnapshotScheduleMode: + snapshotSchedule.mode ?? SnapshotScheduleMode.Manual, + virtualSnapshotScheduleCron: snapshotSchedule.cron ?? '', + }, + { emitEvent: false } + ); + } + + private saveVirtualConfig(): void { + const payload = this.getVirtualCompositionPayload(); + this.isSavingConfig = true; + this.connector + .updateComposition(this.id, payload) + .pipe( + take(1), + finalize(() => { + this.isSavingConfig = false; + }) + ) + .subscribe({ + next: result => { + this.isEditingConfig = false; + if (this.releaseTrack) { + this.releaseTrack.composition = this.getCompositionFromResponse( + result, + payload + ); + } + this.refreshReleaseTrackState(); + }, + error: err => { + console.error('Failed to update virtual release track config', err); + }, + }); + } + + private getCompositionFromResponse(response: any, fallback: any): any { + if (!response) return fallback; + return response.composition || response; + } + + private syncCandidacyThresholdControl(autoPromote: boolean): void { + this.syncWorkflowStatusControl('candidacyThreshold', autoPromote); + } + + private syncSecondaryObjectThresholdControl( + includeSecondaryObjects: boolean + ): void { + this.syncWorkflowStatusControl( + 'secondaryObjectThreshold', + includeSecondaryObjects + ); + } + + private syncWorkflowStatusControl( + controlName: string, + isEnabled: boolean + ): void { + const thresholdControl = this.configForm.get(controlName); + if (!thresholdControl) return; + + if (isEnabled) { + thresholdControl.enable({ emitEvent: false }); + if (!thresholdControl.value) { + thresholdControl.setValue(WorkflowStatus.Reviewed, { + emitEvent: false, + }); + } + return; + } + + if (!thresholdControl.value) { + thresholdControl.setValue(WorkflowStatus.Reviewed, { + emitEvent: false, + }); + } + thresholdControl.disable({ emitEvent: false }); + } + + private getConfigFromResponse( + response: any, + fallback?: ReleaseTrackConfig + ): any { + if (!response) return fallback || {}; + if (response.config) return response.config; + + const configKeys = [ + 'auto_promote', + 'candidacy_threshold', + 'include_secondary_objects', + 'promotion_conflicts', + 'member_sync', + ]; + return configKeys.some(key => key in response) ? response : fallback || {}; + } + + private normalizeConfig(config: any): ReleaseTrackConfig { + const source = config?.config || config || {}; + const rawSupplant = source.member_sync?.supplant; + const supplant = + rawSupplant && typeof rawSupplant === 'object' + ? rawSupplant + : { behavior: rawSupplant }; + const candidatesToStagedConflict = + source.promotion_conflicts?.candidates_to_staged === ConflictPolicy.Abort + ? ConflictPolicy.PreferLatest + : source.promotion_conflicts?.candidates_to_staged; + const includeSecondaryObjects = + typeof source.include_secondary_objects === 'boolean' + ? { enabled: source.include_secondary_objects } + : source.include_secondary_objects; + + return { + auto_promote: source.auto_promote ?? true, + candidacy_threshold: + source.candidacy_threshold ?? WorkflowStatus.Reviewed, + include_secondary_objects: { + enabled: includeSecondaryObjects?.enabled ?? false, + status_threshold: + includeSecondaryObjects?.status_threshold ?? WorkflowStatus.Reviewed, + }, + promotion_conflicts: { + candidates_to_staged: + candidatesToStagedConflict ?? ConflictPolicy.PreferLatest, + staged_to_members: + source.promotion_conflicts?.staged_to_members ?? ConflictPolicy.Abort, + }, + member_sync: { + strategy: source.member_sync?.strategy ?? MemberSyncStrategy.Manual, + supplant: { + behavior: supplant?.behavior ?? MemberSyncBehavior.Replace, + status_policy: supplant?.status_policy ?? MemberSyncPolicy.Preserve, + }, + }, + }; + } + + private getConfigFormValue( + config: ReleaseTrackConfig + ): ReleaseTrackConfigFormValue { + const autoPromote = config.auto_promote ?? true; + return { + autoPromote, + candidacyThreshold: config.candidacy_threshold ?? WorkflowStatus.Reviewed, + memberSyncStrategy: + config.member_sync?.strategy ?? MemberSyncStrategy.Manual, + memberSyncSupplantBehavior: + config.member_sync?.supplant?.behavior ?? MemberSyncBehavior.Replace, + memberSyncSupplantStatusPolicy: + config.member_sync?.supplant?.status_policy ?? + MemberSyncPolicy.Preserve, + candidatesToStagedConflict: + config.promotion_conflicts?.candidates_to_staged ?? + ConflictPolicy.PreferLatest, + stagedToMembersConflict: + config.promotion_conflicts?.staged_to_members ?? ConflictPolicy.Abort, + includeSecondaryObjects: + config.include_secondary_objects?.enabled ?? false, + secondaryObjectThreshold: + config.include_secondary_objects?.status_threshold ?? + WorkflowStatus.Reviewed, + }; + } + + private getConfigPayload(): ReleaseTrackConfig { + const value = this.configForm.getRawValue() as ReleaseTrackConfigFormValue; + const payload: ReleaseTrackConfig = { + auto_promote: value.autoPromote, + include_secondary_objects: { + enabled: value.includeSecondaryObjects, + status_threshold: value.secondaryObjectThreshold, + }, + promotion_conflicts: { + candidates_to_staged: value.candidatesToStagedConflict, + staged_to_members: value.stagedToMembersConflict, + }, + member_sync: { + strategy: value.memberSyncStrategy, + supplant: { + behavior: value.memberSyncSupplantBehavior, + status_policy: value.memberSyncSupplantStatusPolicy, + }, + }, + }; + + if (value.autoPromote && value.candidacyThreshold) { + payload.candidacy_threshold = value.candidacyThreshold; + } + + return payload; + } + + private getVirtualCompositionPayload(): any { + const value = + this.configForm.getRawValue() as VirtualReleaseTrackConfigFormValue; + const currentComposition = this.releaseTrack?.composition || {}; + + return { + ...currentComposition, + component_tracks: this.virtualConfigComponentTracks.map( + (track, index) => ({ + ...track, + priority: index, + }) + ), + deduplication: { + strategy: + value.virtualDeduplicationStrategy || + DeduplicationStrategy.PrioritizeLatestObject, + tier_resolution: value.virtualDeduplicationTier || SnapshotTier.Member, + status_resolution: + value.virtualDeduplicationStatus || WorkflowStatus.Reviewed, + }, + }; + } + + private openReleasePreviewDialog( + preview: any, + track: ReleaseTrackSnapshot, + item?: SnapshotHistoryViewModel + ): void { + const releaseRef = this.dialog.open(ReleasePreviewDialogComponent, { + maxWidth: 'none', + autoFocus: false, + restoreFocus: true, + ariaLabelledBy: 'release-preview-dialog-title', + panelClass: 'release-preview-dialog-panel', + backdropClass: 'release-preview-dialog-backdrop', + data: { + track, + conflicts: this.getReleaseConflicts(preview), + proposedMinorVersion: preview?.version, + previewSummary: preview, + }, + }); + + releaseRef + .afterClosed() + .pipe(take(1)) + .subscribe((selection: ReleasePreviewSelection | undefined) => { + if (!selection) return; + this.releaseSnapshot(selection, item); + }); + } + + private enrichReleasePreviewTrack( + track: ReleaseTrackSnapshot, + response: any + ) { + const objects = Array.isArray(response) + ? response + : Array.isArray(response?.data) + ? response.data + : []; + const objectsByRevision = new Map(); + + objects.forEach((object: any) => { + const objectRef = object?.stix?.id ?? object?.stixID ?? object?.id; + const modified = + object?.stix?.modified ?? object?.modified ?? object?.object_modified; + if (objectRef && modified) { + objectsByRevision.set( + this.getReleasePreviewRevisionKey(objectRef, modified), + object + ); + } + }); + + const enrich = (entry: any) => { + const object = objectsByRevision.get( + this.getReleasePreviewRevisionKey( + entry?.object_ref, + entry?.object_modified + ) + ); + if (!object) return entry; + + const stix = object?.stix ?? object; + return { + ...entry, + name: object?.name ?? stix?.name ?? entry?.name, + attack_id: + object?.attackID ?? + object?.attack_id ?? + object?.workspace?.attack_id ?? + entry?.attack_id, + attack_type: + object?.attackType ?? + StixTypeToAttackType[stix?.type as StixType] ?? + entry?.attack_type, + type: stix?.type ?? entry?.type, + x_mitre_version: + object?.version?.toString?.() ?? + object?.version ?? + stix?.x_mitre_version ?? + entry?.x_mitre_version, + }; + }; + + return { + ...track, + members: (track.members ?? []).map(enrich), + staged: (track.staged ?? []).map(enrich), + candidates: (track.candidates ?? []).map(enrich), + } as ReleaseTrackSnapshot; + } + + private getReleasePreviewRevisionKey( + objectRef: unknown, + modified: unknown + ): string { + const timestamp = new Date(modified as any).getTime(); + return `${String(objectRef ?? '')}::${timestamp}`; + } + + private releaseSnapshot( + selection: ReleasePayload, + item?: SnapshotHistoryViewModel + ): void { + if (!this.id) return; + + this.isReleasing = true; + const release = item?.modified + ? this.connector.releaseSnapshot(this.id, item.modified, selection) + : this.connector.releaseLatest(this.id, selection); + + release + .pipe( + take(1), + finalize(() => { + this.isReleasing = false; + }) + ) + .subscribe({ + next: () => { + if (item?.modified === this.createdDraftSnapshot?.modified) { + this.createdDraftSnapshot = null; + } + this.refreshReleaseTrackState(); + }, + error: err => { + console.error('Failed to tag release track snapshot', err); + }, + }); + } + + private getReleaseConflicts(preview: any): any[] { + return Array.isArray(preview?.conflicts) ? preview.conflicts : []; + } + + private buildSnapshotHistory( + snapshots: ReleaseTrackSnapshotHistoryItem[] + ): SnapshotHistoryViewModel[] { + const sorted = [...snapshots].sort( + (a, b) => this.getSnapshotTime(b) - this.getSnapshotTime(a) + ); + const latestSnapshot = + sorted.find(snapshot => this.isLatestHistorySnapshot(snapshot)) ?? + sorted[0]; + + return sorted.map((snapshot, index) => { + const previousSnapshot = sorted[index + 1]; + const isTagged = this.isTaggedSnapshot(snapshot); + const isBundleCached = !!snapshot.graph_manifest_id; + const isLatest = snapshot === latestSnapshot; + const currentMembers = this.getSnapshotMembers(snapshot); + const previousMembers = previousSnapshot + ? this.getSnapshotMembers(previousSnapshot) + : []; + const addedCount = this.getAddedCount( + snapshot, + currentMembers, + previousMembers + ); + const modifiedCount = this.getModifiedCount( + snapshot, + currentMembers, + previousMembers + ); + const totalObjects = this.getSnapshotTotalObjects( + snapshot, + currentMembers + ); + + return { + snapshot, + title: this.getSnapshotTitle(snapshot), + created: this.getSnapshotDate(snapshot), + modified: this.getSnapshotModified(snapshot), + taggedAt: this.getSnapshotTaggedAt(snapshot), + isTagged, + isLatest, + isCurrentDraft: !isTagged && isLatest, + isBundleCached, + canCacheBundle: isTagged && !isBundleCached, + stats: this.getSnapshotStats(snapshot, addedCount, modifiedCount), + graphCacheStats: this.getGraphCacheStats(snapshot), + graphCacheTotal: snapshot.graph_statistics?.total_count ?? 0, + addedCount, + modifiedCount, + totalObjects, + }; + }); + } + + private getSnapshotTitle(snapshot: ReleaseTrackSnapshotHistoryItem): string { + const version = snapshot.version || snapshot.stix?.x_mitre_version; + if (!version) return 'Draft Snapshot'; + return String(version).startsWith('v') ? String(version) : `v${version}`; + } + + private getSnapshotDate( + snapshot: ReleaseTrackSnapshotHistoryItem + ): Date | null { + const value = + snapshot.modified || + snapshot.snapshot_id || + snapshot.stix?.modified || + snapshot.created || + snapshot.tagged_at; + return value ? new Date(value) : null; + } + + private getSnapshotModified( + snapshot: ReleaseTrackSnapshotHistoryItem + ): string | null { + const value = + snapshot.modified || snapshot.snapshot_id || snapshot.stix?.modified; + if (!value) return null; + return value instanceof Date ? value.toISOString() : String(value); + } + + private getSnapshotTaggedAt( + snapshot: ReleaseTrackSnapshotHistoryItem + ): Date | null { + const value = snapshot.tagged_at; + return value ? new Date(value) : null; + } + + private getSnapshotTime(snapshot: ReleaseTrackSnapshotHistoryItem): number { + return this.getSnapshotDate(snapshot)?.getTime() || 0; + } + + private isTaggedSnapshot(snapshot: ReleaseTrackSnapshotHistoryItem): boolean { + return !!(snapshot.version || snapshot.stix?.x_mitre_version); + } + + private getSnapshotStats( + snapshot: ReleaseTrackSnapshotHistoryItem, + addedCount: number, + modifiedCount: number + ): SnapshotHistoryStat[] { + const stats: SnapshotHistoryStat[] = []; + const hasAddedCount = this.hasSnapshotCount(snapshot, 'added_count'); + const hasModifiedCount = this.hasSnapshotCount(snapshot, 'modified_count'); + + if (hasAddedCount) { + stats.push({ + label: 'Added', + value: `+${addedCount}`, + modifier: 'promote-color', + }); + } + + if (hasModifiedCount) { + stats.push({ + label: 'Modified', + value: modifiedCount, + modifier: 'modified-color', + }); + } + + if (this.getSnapshotType(snapshot) === ReleaseTrackType.Virtual) { + return [ + ...stats, + { + label: 'Members', + value: this.getSnapshotCount(snapshot, 'members_count') ?? 0, + }, + { + label: 'Quarantine', + value: + this.getSnapshotCount( + snapshot, + 'quarantine_count', + 'quarantined_count' + ) ?? 0, + }, + ]; + } + + const tierStats: SnapshotHistoryStat[] = [ + { + label: 'Candidates', + value: this.getSnapshotCount(snapshot, 'candidates_count') ?? 0, + modifier: 'view-color', + }, + { + label: 'Staged', + value: this.getSnapshotCount(snapshot, 'staged_count') ?? 0, + modifier: 'promote-color', + }, + { + label: 'Members', + value: this.getSnapshotCount(snapshot, 'members_count') ?? 0, + }, + ]; + + if (tierStats.some(stat => Number(stat.value) > 0)) { + return [...stats, ...tierStats]; + } + + return [ + ...stats, + { + label: 'Total Objects', + value: this.getSnapshotTotalObjects( + snapshot, + this.getSnapshotMembers(snapshot) + ), + }, + ]; + } + + private getGraphCacheStats( + snapshot: ReleaseTrackSnapshotHistoryItem + ): SnapshotHistoryStat[] { + const statistics = snapshot.graph_statistics; + if (!snapshot.graph_manifest_id || !statistics) return []; + + return [ + { + label: 'Primary', + value: statistics.primary_count, + tooltip: 'Objects deliberately included as snapshot members.', + }, + { + label: 'Secondary', + value: statistics.secondary_count, + tooltip: 'Related objects pulled in while resolving the member graph.', + }, + { + label: 'Relationships', + value: statistics.relationship_count, + tooltip: 'Connections pinned between cached graph objects.', + }, + { + label: 'Dependencies', + value: statistics.supporting_count + statistics.link_target_count, + tooltip: + 'Supporting identities, markings, and LinkById targets used by the cache.', + }, + ]; + } + + private getSnapshotType( + snapshot: ReleaseTrackSnapshotHistoryItem + ): ReleaseTrackType | null { + const type = + snapshot.type || + (this.isLatestHistorySnapshot(snapshot) ? this.releaseTrack?.type : null); + return type === ReleaseTrackType.Virtual + ? ReleaseTrackType.Virtual + : type === ReleaseTrackType.Standard + ? ReleaseTrackType.Standard + : null; + } + + private getSnapshotMembers( + snapshot: ReleaseTrackSnapshotHistoryItem + ): SnapshotMemberRef[] { + const members = + snapshot.members || + snapshot.contents?.members || + snapshot.stix?.x_mitre_contents || + []; + + return members + .map((member: any) => this.getSnapshotMemberRef(member)) + .filter((member): member is SnapshotMemberRef => !!member); + } + + private getSnapshotQuarantineCount( + snapshot: ReleaseTrackSnapshotHistoryItem + ): number { + const quarantine = + snapshot.quarantine || snapshot.contents?.quarantine || []; + return Array.isArray(quarantine) ? quarantine.length : 0; + } + + private getSnapshotMemberRef(member: any): SnapshotMemberRef | null { + if (!member) return null; + + if (typeof member === 'string') { + return { object_ref: member }; + } + + const objectRef = + member.object_ref || + member.id || + member.object_id || + member.stixID || + member.stix?.id; + + if (!objectRef) return null; + + const objectModified = + member.object_modified || member.modified || member.stix?.modified; + + return { + object_ref: objectRef, + object_modified: objectModified + ? this.toIsoString(objectModified) + : undefined, + }; + } + + private getAddedCount( + snapshot: ReleaseTrackSnapshotHistoryItem, + currentMembers: SnapshotMemberRef[], + previousMembers: SnapshotMemberRef[] + ): number { + const addedCount = this.getSnapshotCount( + snapshot, + 'added_count', + 'promoted_count' + ); + if (addedCount !== null) { + return addedCount; + } + if (!previousMembers.length) return 0; + + const previousRefs = new Set( + previousMembers.map(member => member.object_ref) + ); + return currentMembers.filter(member => !previousRefs.has(member.object_ref)) + .length; + } + + private getModifiedCount( + snapshot: ReleaseTrackSnapshotHistoryItem, + currentMembers: SnapshotMemberRef[], + previousMembers: SnapshotMemberRef[] + ): number { + const modifiedCount = this.getSnapshotCount(snapshot, 'modified_count'); + if (modifiedCount !== null) { + return modifiedCount; + } + if (!previousMembers.length) return 0; + + const previousByRef = new Map( + previousMembers.map(member => [member.object_ref, member.object_modified]) + ); + + return currentMembers.filter(member => { + const previousModified = previousByRef.get(member.object_ref); + return ( + !!member.object_modified && + !!previousModified && + member.object_modified !== previousModified + ); + }).length; + } + + private getSnapshotTotalObjects( + snapshot: ReleaseTrackSnapshotHistoryItem, + members: SnapshotMemberRef[] + ): number { + if (this.getSnapshotType(snapshot) === ReleaseTrackType.Virtual) { + const membersCount = this.getSnapshotCount(snapshot, 'members_count'); + const quarantineCount = this.getSnapshotCount( + snapshot, + 'quarantine_count', + 'quarantined_count' + ); + if (membersCount !== null || quarantineCount !== null) { + return (membersCount ?? 0) + (quarantineCount ?? 0); + } + } + + const membersCount = this.getSnapshotCount(snapshot, 'members_count'); + const stagedCount = this.getSnapshotCount(snapshot, 'staged_count'); + const candidatesCount = this.getSnapshotCount(snapshot, 'candidates_count'); + if ( + membersCount !== null || + stagedCount !== null || + candidatesCount !== null + ) { + return (membersCount ?? 0) + (stagedCount ?? 0) + (candidatesCount ?? 0); + } + if (typeof snapshot.composition_resolution?.total_objects === 'number') { + return snapshot.composition_resolution.total_objects; + } + if (members.length) return members.length; + + const allRefs = [ + ...(snapshot.candidates || snapshot.contents?.candidates || []), + ...(snapshot.staged || snapshot.contents?.staged || []), + ] + .map(item => this.getSnapshotMemberRef(item)?.object_ref) + .filter((ref): ref is string => !!ref); + + return new Set(allRefs).size; + } + + private hasSnapshotCount( + snapshot: ReleaseTrackSnapshotHistoryItem, + ...keys: string[] + ): boolean { + return this.getSnapshotCount(snapshot, ...keys) !== null; + } + + private getSnapshotCount( + snapshot: ReleaseTrackSnapshotHistoryItem, + ...keys: string[] + ): number | null { + const latestSnapshot = this.isLatestHistorySnapshot(snapshot) + ? this.releaseTrack + : null; + + for (const key of keys) { + const value = + snapshot[key] ?? + snapshot.summary?.[key] ?? + latestSnapshot?.summary?.[key] ?? + snapshot.statistics?.[key] ?? + null; + if (typeof value === 'number') return value; + } + + return null; + } + + private isLatestHistorySnapshot( + snapshot: ReleaseTrackSnapshotHistoryItem + ): boolean { + if (snapshot.is_latest) return true; + if (!this.releaseTrack?.modified) return false; + return ( + this.getSnapshotModified(snapshot) === + this.toIsoString(this.releaseTrack.modified) + ); + } + + private getSnapshotExportFilename( + item: SnapshotHistoryViewModel, + selection: SnapshotExportSelection + ): string { + const name = this.releaseTrackName || this.id || 'release-track'; + const safeName = + name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'release-track'; + const snapshotName = item.isTagged + ? item.title.replace(/^v/, 'v') + : 'draft'; + return `${safeName}-${snapshotName}-${this.getExportFilenameSuffix(selection)}.json`; + } + + private getExportFilenameSuffix(selection: SnapshotExportSelection): string { + return selection.stixVersion + ? `${selection.format}-stix-${selection.stixVersion}` + : selection.format; + } + + private toIsoString(value: Date | string | undefined): string | undefined { + if (!value) return undefined; + return value instanceof Date ? value.toISOString() : value; + } +} diff --git a/src/app/views/dashboard-page/validation-bypasses/validation-bypass-rule-dialog/validation-bypass-rule-dialog.component.html b/src/app/views/dashboard-page/validation-bypasses/validation-bypass-rule-dialog/validation-bypass-rule-dialog.component.html new file mode 100644 index 000000000..ef9a2135e --- /dev/null +++ b/src/app/views/dashboard-page/validation-bypasses/validation-bypass-rule-dialog/validation-bypass-rule-dialog.component.html @@ -0,0 +1,76 @@ +

{{ title }}

+ +
+ +
+ + field path + + + Field path is required + + + + + error code + + + + {{ errorCode }} + + + + Error code is required + + + + + STIX type + + + + {{ stixType }} + + + + STIX type is required + + + +
+ + suppress error + +
+ + + warning message + + +
+
+ + + + + +
diff --git a/src/app/views/dashboard-page/validation-bypasses/validation-bypass-rule-dialog/validation-bypass-rule-dialog.component.scss b/src/app/views/dashboard-page/validation-bypasses/validation-bypass-rule-dialog/validation-bypass-rule-dialog.component.scss new file mode 100644 index 000000000..4bbce7c21 --- /dev/null +++ b/src/app/views/dashboard-page/validation-bypasses/validation-bypass-rule-dialog/validation-bypass-rule-dialog.component.scss @@ -0,0 +1,35 @@ +@use '../../../../../style/globals'; + +.validation-bypass-rule-dialog { + .form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: 16px; + row-gap: 8px; + min-width: min(42em, 80vw); + } + + .full-width, + .toggle-row { + grid-column: 1 / -1; + } + + .toggle-row { + min-height: 48px; + display: flex; + align-items: center; + } + + textarea { + resize: vertical; + } +} + +@media (max-width: 720px) { + .validation-bypass-rule-dialog { + .form-grid { + grid-template-columns: 1fr; + min-width: 0; + } + } +} diff --git a/src/app/views/dashboard-page/validation-bypasses/validation-bypass-rule-dialog/validation-bypass-rule-dialog.component.spec.ts b/src/app/views/dashboard-page/validation-bypasses/validation-bypass-rule-dialog/validation-bypass-rule-dialog.component.spec.ts new file mode 100644 index 000000000..5b5cd53c3 --- /dev/null +++ b/src/app/views/dashboard-page/validation-bypasses/validation-bypass-rule-dialog/validation-bypass-rule-dialog.component.spec.ts @@ -0,0 +1,62 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; + +import { ValidationBypassRuleDialogComponent } from './validation-bypass-rule-dialog.component'; + +describe('ValidationBypassRuleDialogComponent', () => { + let component: ValidationBypassRuleDialogComponent; + let fixture: ComponentFixture; + let close: ReturnType; + + beforeEach(async () => { + close = vi.fn(); + + await TestBed.configureTestingModule({ + declarations: [ValidationBypassRuleDialogComponent], + imports: [ + MatAutocompleteModule, + MatSlideToggleModule, + ReactiveFormsModule, + ], + providers: [ + { provide: MAT_DIALOG_DATA, useValue: {} }, + { provide: MatDialogRef, useValue: { close } }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ValidationBypassRuleDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should close with a validation bypass rule payload', () => { + component.form.setValue({ + fieldPath: 'external_references.0.external_id', + errorCode: 'custom', + stixType: 'x-mitre-tactic', + suppressError: false, + warningMessage: 'Use a custom tactic shortname warning.', + }); + + component.confirm(); + + expect(close).toHaveBeenCalledWith({ + fieldPath: ['external_references', '0', 'external_id'], + errorCode: 'custom', + stixType: 'x-mitre-tactic', + suppressError: false, + warningMessage: 'Use a custom tactic shortname warning.', + }); + }); +}); diff --git a/src/app/views/dashboard-page/validation-bypasses/validation-bypass-rule-dialog/validation-bypass-rule-dialog.component.ts b/src/app/views/dashboard-page/validation-bypasses/validation-bypass-rule-dialog/validation-bypass-rule-dialog.component.ts new file mode 100644 index 000000000..806fe51de --- /dev/null +++ b/src/app/views/dashboard-page/validation-bypasses/validation-bypass-rule-dialog/validation-bypass-rule-dialog.component.ts @@ -0,0 +1,121 @@ +import { Component, Inject, ViewEncapsulation } from '@angular/core'; +import { + AbstractControl, + FormBuilder, + FormGroup, + ValidationErrors, + Validators, +} from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { ValidationBypassRule } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { StixTypeToAttackType } from 'src/app/utils/type-mappings'; + +export interface ValidationBypassRuleDialogData { + rule?: ValidationBypassRule; +} + +function parseFieldPath(value: string): string[] { + const trimmed = (value || '').trim(); + if (!trimmed) return []; + + if (trimmed.startsWith('[')) { + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + return parsed + .map(String) + .map(part => part.trim()) + .filter(Boolean); + } + } catch { + return []; + } + } + + return trimmed + .split('.') + .map(part => part.trim()) + .filter(Boolean); +} + +function fieldPathValidator(control: AbstractControl): ValidationErrors | null { + return parseFieldPath(control.value).length ? null : { fieldPath: true }; +} + +@Component({ + selector: 'app-validation-bypass-rule-dialog', + templateUrl: './validation-bypass-rule-dialog.component.html', + styleUrls: ['./validation-bypass-rule-dialog.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false, +}) +export class ValidationBypassRuleDialogComponent { + public form: FormGroup; + public stixTypes = ['all', ...Object.keys(StixTypeToAttackType).sort()]; + public errorCodes = [ + 'custom', + 'invalid_type', + 'invalid_value', + 'invalid_format', + 'invalid_union', + 'unrecognized_keys', + 'too_big', + 'too_small', + 'not_multiple_of', + 'invalid_key', + 'invalid_element', + ]; + + public get title(): string { + return this.data?.rule ? 'Edit Validation Bypass Rule' : 'Create Rule'; + } + + constructor( + @Inject(MAT_DIALOG_DATA) public data: ValidationBypassRuleDialogData, + public dialogRef: MatDialogRef, + private formBuilder: FormBuilder + ) { + const rule = data?.rule; + this.form = this.formBuilder.group({ + fieldPath: [ + this.fieldPathToString(rule?.fieldPath), + [Validators.required, fieldPathValidator], + ], + errorCode: [rule?.errorCode || '', Validators.required], + stixType: [rule?.stixType || '', Validators.required], + suppressError: [rule?.suppressError ?? true], + warningMessage: [rule?.warningMessage || ''], + }); + } + + public hasError(controlName: string, errorName: string): boolean { + return !!this.form.get(controlName)?.hasError(errorName); + } + + public confirm(): void { + if (this.form.invalid) { + this.form.markAllAsTouched(); + return; + } + + const value = this.form.value; + const warningMessage = value.warningMessage?.trim(); + const result: ValidationBypassRule = { + fieldPath: parseFieldPath(value.fieldPath), + errorCode: value.errorCode.trim(), + stixType: value.stixType.trim(), + suppressError: !!value.suppressError, + warningMessage: warningMessage || null, + }; + + this.dialogRef.close(result); + } + + public cancel(): void { + this.dialogRef.close(); + } + + private fieldPathToString(fieldPath?: string[]): string { + return fieldPath?.length ? fieldPath.join('.') : ''; + } +} diff --git a/src/app/views/dashboard-page/validation-bypasses/validation-bypasses.component.html b/src/app/views/dashboard-page/validation-bypasses/validation-bypasses.component.html new file mode 100644 index 000000000..2167146c0 --- /dev/null +++ b/src/app/views/dashboard-page/validation-bypasses/validation-bypasses.component.html @@ -0,0 +1,128 @@ +
+
+

ADM Validation Bypasses

+
+ +
+
+ +
+
+
+ + search + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Field Path + + {{ fieldPath(rule) }} + + Error Code + {{ rule.errorCode }} + STIX Type + {{ rule.stixType }} + Behavior + + + {{ behavior(rule) }} + + + Source + {{ source(rule) }} + Warning Message + + {{ rule.warningMessage || '-' }} + + + +
+ + + +
+ + + + + + + +
+
diff --git a/src/app/views/dashboard-page/validation-bypasses/validation-bypasses.component.scss b/src/app/views/dashboard-page/validation-bypasses/validation-bypasses.component.scss new file mode 100644 index 000000000..a0936d33f --- /dev/null +++ b/src/app/views/dashboard-page/validation-bypasses/validation-bypasses.component.scss @@ -0,0 +1,117 @@ +@use '../../../../style/globals'; +@use '../../../../style/colors'; + +.validation-bypasses-page { + .input-group { + font-size: 16px; + width: 100%; + display: flex; + flex-direction: row; + + .search { + flex-grow: 1; + } + + .mat-mdc-form-field-subscript-wrapper { + display: none; + } + } + + .table-container { + max-width: 88em; + margin: 0 auto; + border-radius: 4px; + + .dark & { + border: 1px solid colors.border-color(dark); + } + + .light & { + border: 1px solid colors.border-color(light); + } + + .scroll-container { + width: 100%; + overflow: auto; + max-width: 100%; + display: flex; + flex-direction: column; + + .dark & { + border-bottom: 1px solid colors.border-color(dark); + } + + .light & { + border-bottom: 1px solid colors.border-color(light); + } + + table { + flex: 1 0 auto; + width: 100%; + + th, + td { + .light & { + background: colors.color(light); + border-color: colors.border-color(light); + } + + .dark & { + background: colors.color(dark); + border-color: colors.border-color(dark); + } + } + + th { + font-size: 18px; + @extend .text-label; + } + + tr.element-row:not(.expanded):hover { + .light & td { + background: colors.color-alternate(light); + } + + .dark & td { + background: colors.color-alternate(dark); + } + } + + .mat-mdc-cell + .mat-mdc-cell, + .mat-mdc-header-cell + .mat-mdc-header-cell { + padding-left: 12px; + } + + .path-cell { + min-width: 14em; + font-family: monospace; + } + + .warning-cell { + min-width: 18em; + max-width: 34em; + white-space: normal; + } + + .action-cell { + width: 96px; + white-space: nowrap; + } + } + } + } + + .behavior-label { + white-space: nowrap; + + &.inactive { + .light & { + color: colors.on-color-deemphasis(light); + } + + .dark & { + color: colors.on-color-deemphasis(dark); + } + } + } +} diff --git a/src/app/views/dashboard-page/validation-bypasses/validation-bypasses.component.spec.ts b/src/app/views/dashboard-page/validation-bypasses/validation-bypasses.component.spec.ts new file mode 100644 index 000000000..b1c163332 --- /dev/null +++ b/src/app/views/dashboard-page/validation-bypasses/validation-bypasses.component.spec.ts @@ -0,0 +1,75 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { MatDialog } from '@angular/material/dialog'; +import { of } from 'rxjs'; + +import { ValidationBypassesComponent } from './validation-bypasses.component'; +import { + RestApiConnectorService, + ValidationBypassRule, +} from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { + createAsyncObservable, + createMockRestApiConnector, +} from 'src/app/testing/mocks/rest-api-connector.mock'; + +describe('ValidationBypassesComponent', () => { + let component: ValidationBypassesComponent; + let fixture: ComponentFixture; + + const rules: ValidationBypassRule[] = [ + { + _id: '6a3ab4064663ff5bba83e889', + fieldPath: ['x_mitre_modified_by_ref'], + errorCode: 'invalid_value', + stixType: 'x-mitre-tactic', + suppressError: true, + autoCreated: true, + autoCreatedReason: 'static', + triggerEvent: null, + warningMessage: null, + }, + ]; + + beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({ + getValidationBypassRules: () => createAsyncObservable(rules), + postValidationBypassRule: vi.fn(() => of(rules[0])), + putValidationBypassRule: vi.fn(() => of(rules[0])), + deleteValidationBypassRule: vi.fn(() => of({})), + }); + const mockDialog = { + open: vi.fn(() => ({ afterClosed: () => of(false) })), + }; + + await TestBed.configureTestingModule({ + declarations: [ValidationBypassesComponent], + providers: [ + provideHttpClient(), + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + { provide: MatDialog, useValue: mockDialog }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ValidationBypassesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should load validation bypass rules', async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(component.dataSource.data).toEqual(rules); + expect(component.fieldPath(rules[0])).toBe('x_mitre_modified_by_ref'); + expect(component.behavior(rules[0])).toBe('suppress'); + expect(component.source(rules[0])).toBe('auto: static'); + }); +}); diff --git a/src/app/views/dashboard-page/validation-bypasses/validation-bypasses.component.ts b/src/app/views/dashboard-page/validation-bypasses/validation-bypasses.component.ts new file mode 100644 index 000000000..ea8b8ef7f --- /dev/null +++ b/src/app/views/dashboard-page/validation-bypasses/validation-bypasses.component.ts @@ -0,0 +1,196 @@ +import { + AfterViewInit, + Component, + OnInit, + ViewChild, + ViewEncapsulation, +} from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { MatPaginator } from '@angular/material/paginator'; +import { MatSort } from '@angular/material/sort'; +import { MatTableDataSource } from '@angular/material/table'; +import { finalize, take } from 'rxjs/operators'; +import { ConfirmationDialogComponent } from 'src/app/components/confirmation-dialog/confirmation-dialog.component'; +import { + RestApiConnectorService, + ValidationBypassRule, +} from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { ValidationBypassRuleDialogComponent } from './validation-bypass-rule-dialog/validation-bypass-rule-dialog.component'; + +@Component({ + selector: 'app-validation-bypasses', + templateUrl: './validation-bypasses.component.html', + styleUrls: ['./validation-bypasses.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false, +}) +export class ValidationBypassesComponent implements OnInit, AfterViewInit { + @ViewChild(MatPaginator) paginator: MatPaginator; + @ViewChild(MatSort) sort: MatSort; + + public dataSource = new MatTableDataSource([]); + public columnsToDisplay = [ + 'fieldPath', + 'errorCode', + 'stixType', + 'behavior', + 'source', + 'warningMessage', + 'actions', + ]; + public loadingRules = false; + public searchQuery = ''; + + constructor( + private restAPIConnector: RestApiConnectorService, + private dialog: MatDialog + ) {} + + ngOnInit(): void { + this.configureTable(); + this.loadRules(); + } + + ngAfterViewInit(): void { + this.dataSource.paginator = this.paginator; + this.dataSource.sort = this.sort; + } + + public loadRules(): void { + this.loadingRules = true; + this.restAPIConnector + .getValidationBypassRules() + .pipe( + take(1), + finalize(() => (this.loadingRules = false)) + ) + .subscribe({ + next: rules => { + this.dataSource.data = rules || []; + if (this.paginator) this.paginator.firstPage(); + if (this.searchQuery) this.applySearch(this.searchQuery); + }, + }); + } + + public applySearch(query: string): void { + this.searchQuery = query; + this.dataSource.filter = (query || '').trim().toLowerCase(); + if (this.dataSource.paginator) this.dataSource.paginator.firstPage(); + } + + public createRule(): void { + this.openRuleDialog(); + } + + public editRule(rule: ValidationBypassRule): void { + this.openRuleDialog(rule); + } + + public deleteRule(rule: ValidationBypassRule): void { + const id = this.ruleId(rule); + if (!id) return; + + const confirmationPrompt = this.dialog.open(ConfirmationDialogComponent, { + maxWidth: '35em', + data: { + message: 'This validation bypass rule will be deleted.', + }, + autoFocus: false, + }); + + confirmationPrompt + .afterClosed() + .pipe(take(1)) + .subscribe(result => { + if (!result) return; + + this.restAPIConnector + .deleteValidationBypassRule(id) + .pipe(take(1)) + .subscribe({ next: () => this.loadRules() }); + }); + } + + public fieldPath(rule: ValidationBypassRule): string { + return (rule.fieldPath || []).join('.'); + } + + public behavior(rule: ValidationBypassRule): string { + const hasWarning = !!rule.warningMessage; + if (rule.suppressError && hasWarning) return 'suppress + warn'; + if (rule.suppressError) return 'suppress'; + if (hasWarning) return 'warn'; + return 'inactive'; + } + + public source(rule: ValidationBypassRule): string { + if (!rule.autoCreated) return 'manual'; + return rule.autoCreatedReason + ? `auto: ${rule.autoCreatedReason}` + : 'auto-created'; + } + + public ruleId(rule: ValidationBypassRule): string | undefined { + return rule._id || rule.id; + } + + private configureTable(): void { + this.dataSource.filterPredicate = (rule, filter) => { + return this.ruleSearchText(rule).includes(filter); + }; + + this.dataSource.sortingDataAccessor = (rule, column) => { + switch (column) { + case 'fieldPath': + return this.fieldPath(rule); + case 'behavior': + return this.behavior(rule); + case 'source': + return this.source(rule); + case 'warningMessage': + return rule.warningMessage || ''; + default: + return (rule as any)[column] || ''; + } + }; + } + + private openRuleDialog(rule?: ValidationBypassRule): void { + const prompt = this.dialog.open(ValidationBypassRuleDialogComponent, { + maxWidth: '48em', + width: '48em', + data: { rule }, + autoFocus: false, + }); + + prompt + .afterClosed() + .pipe(take(1)) + .subscribe((result?: ValidationBypassRule) => { + if (!result) return; + + const id = rule ? this.ruleId(rule) : undefined; + const request = id + ? this.restAPIConnector.putValidationBypassRule(id, result) + : this.restAPIConnector.postValidationBypassRule(result); + + request.pipe(take(1)).subscribe({ next: () => this.loadRules() }); + }); + } + + private ruleSearchText(rule: ValidationBypassRule): string { + return [ + this.fieldPath(rule), + rule.errorCode, + rule.stixType, + this.behavior(rule), + this.source(rule), + rule.warningMessage, + rule.triggerEvent, + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + } +} diff --git a/src/app/views/landing-page/landing-page.component.html b/src/app/views/landing-page/landing-page.component.html index 7bb38dc70..a4deb5274 100644 --- a/src/app/views/landing-page/landing-page.component.html +++ b/src/app/views/landing-page/landing-page.component.html @@ -53,18 +53,4 @@

Explore

-
- -
diff --git a/src/app/views/landing-page/landing-page.component.scss b/src/app/views/landing-page/landing-page.component.scss index 1393f5910..18c98d33f 100644 --- a/src/app/views/landing-page/landing-page.component.scss +++ b/src/app/views/landing-page/landing-page.component.scss @@ -53,12 +53,4 @@ border-left: 1px solid colors.border-color(light); } } - - .admin-link { - margin-top: 40px; - .mat-badge-content.mat-badge-active { - color: colors.on-color(pending); - background-color: colors.color(pending); - } - } } diff --git a/src/app/views/landing-page/landing-page.component.ts b/src/app/views/landing-page/landing-page.component.ts index aa33c0b5e..04d6c7072 100644 --- a/src/app/views/landing-page/landing-page.component.ts +++ b/src/app/views/landing-page/landing-page.component.ts @@ -16,11 +16,11 @@ import { stixRoutes } from '../../app-routing-stix.module'; standalone: false, }) export class LandingPageComponent implements OnInit, OnDestroy { - private readonly placeholderIdentityName = 'Placeholder Organization Identity'; + private readonly placeholderIdentityName = + 'Placeholder Organization Identity'; private readonly placeholderIdentityReminderKey = 'attack-workbench.placeholder-organization-identity-reminder-dismissed'; private loginSubscription: Subscription; - public pendingUsers; public routes: any[] = []; constructor( @@ -30,7 +30,7 @@ export class LandingPageComponent implements OnInit, OnDestroy { private router: Router ) { this.routes = stixRoutes.filter( - route => route.data.headerSection !== 'more' && !route.data.deprecated + route => route.data.group !== 'more' && !route.data.deprecated ); } @@ -48,30 +48,14 @@ export class LandingPageComponent implements OnInit, OnDestroy { this.loginSubscription = this.authenticationService.onLogin.subscribe({ // called on initial user login next: event => { - this.getPendingUsers(); this.openOrgIdentityDialog(); }, }); setTimeout(() => { - this.getPendingUsers(); this.openOrgIdentityDialog(); }, 500); // called on page refresh or re-route } - private getPendingUsers(): void { - if (this.authenticationService.isAuthorized([Role.ADMIN])) { - const userSubscription = this.restApiConnector - .getAllUserAccounts({ status: ['pending'] }) - .subscribe({ - next: results => { - const users = results as any; - if (users && users.length) this.pendingUsers = users.length; - }, - complete: () => userSubscription.unsubscribe(), - }); - } - } - // bug the admin about editing their organization identity private openOrgIdentityDialog(): void { if (localStorage.getItem(this.placeholderIdentityReminderKey) === 'true') { diff --git a/src/app/views/profile-page/profile-page.component.html b/src/app/views/profile-page/profile-page.component.html index 541116acf..815732d11 100644 --- a/src/app/views/profile-page/profile-page.component.html +++ b/src/app/views/profile-page/profile-page.component.html @@ -1,8 +1,11 @@
- +
- +
diff --git a/src/app/views/profile-page/profile-page.component.scss b/src/app/views/profile-page/profile-page.component.scss index ef3259207..def16c86f 100644 --- a/src/app/views/profile-page/profile-page.component.scss +++ b/src/app/views/profile-page/profile-page.component.scss @@ -1,6 +1,7 @@ @use '../../../style/globals'; .profile-page { .profile-icon { + display: flex; height: 150px; width: 150px; margin: auto; diff --git a/src/app/views/profile-page/profile-page.component.spec.ts b/src/app/views/profile-page/profile-page.component.spec.ts index 24a14cb65..dd16e8025 100644 --- a/src/app/views/profile-page/profile-page.component.spec.ts +++ b/src/app/views/profile-page/profile-page.component.spec.ts @@ -8,8 +8,14 @@ import { createMockAuthenticationService, createMockUserAccount, } from 'src/app/testing/mocks/authentication-service.mock'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { + createAsyncObservable, + createMockRestApiConnector, +} from 'src/app/testing/mocks/rest-api-connector.mock'; import { ProfilePageComponent } from './profile-page.component'; +import { UserAvatarComponent } from '../../components/user-avatar/user-avatar.component'; describe('ProfilePageComponent', () => { let component: ProfilePageComponent; @@ -24,9 +30,13 @@ describe('ProfilePageComponent', () => { }), canEdit: () => false, }); + const mockRestApiConnector = createMockRestApiConnector({ + getTeamsByUserId: () => createAsyncObservable([]), + }); await TestBed.configureTestingModule({ declarations: [ProfilePageComponent], + imports: [UserAvatarComponent], schemas: [NO_ERRORS_SCHEMA], providers: [ provideHttpClient(), @@ -41,6 +51,10 @@ describe('ProfilePageComponent', () => { provide: AuthenticationService, useValue: mockAuthService, }, + { + provide: RestApiConnectorService, + useValue: mockRestApiConnector, + }, ], }).compileComponents(); }); diff --git a/src/app/views/stix/all-objects-page/all-objects-page.component.html b/src/app/views/stix/all-objects-page/all-objects-page.component.html new file mode 100644 index 000000000..519eb44f0 --- /dev/null +++ b/src/app/views/stix/all-objects-page/all-objects-page.component.html @@ -0,0 +1,8 @@ +
+
+

{{ title }}

+

{{ description }}

+
+ + +
diff --git a/src/app/views/stix/all-objects-page/all-objects-page.component.scss b/src/app/views/stix/all-objects-page/all-objects-page.component.scss new file mode 100644 index 000000000..923cff157 --- /dev/null +++ b/src/app/views/stix/all-objects-page/all-objects-page.component.scss @@ -0,0 +1,6 @@ +.all-objects-page { + .list-page-header { + max-width: 48rem; + margin: 0 auto 16px; + } +} diff --git a/src/app/views/stix/all-objects-page/all-objects-page.component.spec.ts b/src/app/views/stix/all-objects-page/all-objects-page.component.spec.ts new file mode 100644 index 000000000..aa64784d0 --- /dev/null +++ b/src/app/views/stix/all-objects-page/all-objects-page.component.spec.ts @@ -0,0 +1,30 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; + +import { AllObjectsPageComponent } from './all-objects-page.component'; + +describe('AllObjectsPageComponent', () => { + let component: AllObjectsPageComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [AllObjectsPageComponent], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(AllObjectsPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should use the all objects column preset', () => { + expect(component.stixListConfig.columnsPreset).toBe('all-objects'); + }); +}); diff --git a/src/app/views/stix/all-objects-page/all-objects-page.component.ts b/src/app/views/stix/all-objects-page/all-objects-page.component.ts new file mode 100644 index 000000000..8287b111f --- /dev/null +++ b/src/app/views/stix/all-objects-page/all-objects-page.component.ts @@ -0,0 +1,21 @@ +import { Component } from '@angular/core'; +import { StixListConfig } from 'src/app/components/stix/stix-list/stix-list.component'; + +export const ALL_OBJECTS_STIX_LIST_CONFIG: StixListConfig = { + showUserSearch: true, + excludeAttackTypes: ['relationship', 'note', 'collection'], + columnsPreset: 'all-objects', +}; + +@Component({ + selector: 'app-all-objects-page', + templateUrl: './all-objects-page.component.html', + styleUrls: ['./all-objects-page.component.scss'], + standalone: false, +}) +export class AllObjectsPageComponent { + public readonly title = 'All Objects'; + public readonly description = + 'Search and browse every object currently available in the knowledge base.'; + public readonly stixListConfig = ALL_OBJECTS_STIX_LIST_CONFIG; +} diff --git a/src/app/views/stix/analytic-view/analytic-view.component.html b/src/app/views/stix/analytic-view/analytic-view.component.html index 7ed1ebd25..89c8b3ccd 100644 --- a/src/app/views/stix/analytic-view/analytic-view.component.html +++ b/src/app/views/stix/analytic-view/analytic-view.component.html @@ -1,4 +1,4 @@ -
+
@@ -7,127 +7,132 @@ mode: config.mode === 'edit' ? 'view' : config.mode, object: config.object, }"> - +
-
-
- - - -
-
- - -
-
-
- -
- -
-
- - -
-
- @if (config.mode === 'view') { - - -
-
- + + + + + +
+
+
+ + + +
+
+ + +
+
+
+ +
+ +
+
+ + +
+
+ @if (config.mode === 'view') { + + +
+
+ +
+
+ } +
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
- } -
-
- - -
-
-
-
- - -
-
-
-
- - -
-
+
diff --git a/src/app/views/stix/asset-view/asset-view.component.html b/src/app/views/stix/asset-view/asset-view.component.html index ae6fb287e..1d49ab3d5 100644 --- a/src/app/views/stix/asset-view/asset-view.component.html +++ b/src/app/views/stix/asset-view/asset-view.component.html @@ -1,4 +1,4 @@ -
+
@@ -7,131 +7,156 @@ mode: config.mode, object: config.object, }"> - +
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - + + + +
+
+
+ + +
+
+ + +
+
+
+
+ + -
-
-
-
- - + }"> +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+

References

+
+
+
+
+ +
+
+
-
- -
- + + +

Techniques Used

@@ -155,35 +180,15 @@

Techniques Used

#techniqueList [config]="{ type: 'relationship', + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, targetRef: asset.stixID, sourceType: 'technique', relationshipType: 'targets', clickBehavior: 'dialog', - allowEdits: true, + allowEdits: canEdit, }">
-
- -
-
-
-

References

-
-
-
-
- -
-
-
+
diff --git a/src/app/views/stix/campaign-view/campaign-view.component.html b/src/app/views/stix/campaign-view/campaign-view.component.html index f61041ff7..47f066dde 100644 --- a/src/app/views/stix/campaign-view/campaign-view.component.html +++ b/src/app/views/stix/campaign-view/campaign-view.component.html @@ -1,4 +1,4 @@ -
+
@@ -7,127 +7,146 @@ mode: config.mode, object: config.object, }"> - +
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - + + + + + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
-
- -
- + + +

Groups

@@ -151,16 +170,19 @@

Groups

#groupsList [config]="{ type: 'relationship', + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, sourceRef: campaign.stixID, targetType: 'group', relationshipType: 'attributed-to', clickBehavior: 'dialog', - allowEdits: true, + allowEdits: canEdit, }">
+
- +

Techniques Used

@@ -184,16 +206,19 @@

Techniques Used

#techniqueList [config]="{ type: 'relationship', + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, sourceRef: campaign.stixID, targetType: 'technique', relationshipType: 'uses', clickBehavior: 'dialog', - allowEdits: true, + allowEdits: canEdit, }">
+
- +

Software Used

@@ -217,35 +242,15 @@

Software Used

#softwareList [config]="{ type: 'relationship', + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, sourceRef: campaign.stixID, targetType: 'software', relationshipType: 'uses', clickBehavior: 'dialog', - allowEdits: true, + allowEdits: canEdit, }">
-
- -
-
-
-

References

-
-
-
-
- -
-
-
+
diff --git a/src/app/views/stix/canonical-domains-views.spec.ts b/src/app/views/stix/canonical-domains-views.spec.ts new file mode 100644 index 000000000..f7dd8a1b2 --- /dev/null +++ b/src/app/views/stix/canonical-domains-views.spec.ts @@ -0,0 +1,31 @@ +import { readFileSync } from 'node:fs'; + +const targetViews = [ + ['technique', './technique-view/technique-view.component.html'], + ['campaign', './campaign-view/campaign-view.component.html'], + ['mitigation', './mitigation-view/mitigation-view.component.html'], + ['group', './group-view/group-view.component.html'], + ['software', './software-view/software-view.component.html'], + ['analytic', './analytic-view/analytic-view.component.html'], + ['asset', './asset-view/asset-view.component.html'], + [ + 'data component', + './data-component-view/data-component-view.component.html', + ], + ['data source', './data-source-view/data-source-view.component.html'], + [ + 'detection strategy', + './detection-strategy-view/detection-strategy-view.component.html', + ], + ['matrix', './matrix/matrix-view/matrix-view.component.html'], + ['tactic', './tactic-view/tactic-view.component.html'], +] as const; + +describe('canonical domain-bearing STIX object views', () => { + it.each(targetViews)('exposes an editable domain field for %s', (_, path) => { + const template = readFileSync(new URL(path, import.meta.url), 'utf8'); + + expect(template).toMatch(/field:\s*'domains'/); + expect(template).toMatch(/field:\s*'domains'[\s\S]*?editType:\s*'select'/); + }); +}); diff --git a/src/app/views/stix/collection/collection-view/collection-view.component.html b/src/app/views/stix/collection/collection-view/collection-view.component.html index f9330711f..82ad9c9d6 100644 --- a/src/app/views/stix/collection/collection-view/collection-view.component.html +++ b/src/app/views/stix/collection/collection-view/collection-view.component.html @@ -11,11 +11,7 @@ object: config.object, }"> - +
diff --git a/src/app/views/stix/data-component-view/data-component-view.component.html b/src/app/views/stix/data-component-view/data-component-view.component.html index 7f05e50a8..e6d611eb0 100644 --- a/src/app/views/stix/data-component-view/data-component-view.component.html +++ b/src/app/views/stix/data-component-view/data-component-view.component.html @@ -1,4 +1,4 @@ -
+
- +
-
-
- - -
-
- - -
-
-
-
- - -
-
-
-
- - -
-
-
-
- - -
-
- - @if ( - (config.showRelationships || !config.hasOwnProperty('showRelationships')) && - !editing - ) { -
+ + + + + +
-

Techniques Detected

- + + +
+
+ +
- + - + mode: config.mode, + object: config.object, + field: 'domains', + editType: 'select', + }">
-
- } - -
-
-
-

References

+
+
+ + +
-
-
-
- +
+
+ + +
+
+ + @if ( + (config.showRelationships || + !config.hasOwnProperty('showRelationships')) && + !editing + ) { +
+
+
+

Techniques Detected

+ +
+
+
+
+ + +
+
+
+ } + +
+
+
+

References

+
+
+
+
+ +
+
-
+
diff --git a/src/app/views/stix/data-source-view/data-source-view.component.html b/src/app/views/stix/data-source-view/data-source-view.component.html index 84f62cdff..06e94feaf 100644 --- a/src/app/views/stix/data-source-view/data-source-view.component.html +++ b/src/app/views/stix/data-source-view/data-source-view.component.html @@ -1,4 +1,4 @@ -
+
@@ -7,103 +7,129 @@ mode: config.mode, object: config.object, }"> - +
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - + + + + + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+
+

References

+
+
+
+
+ +
+
+
-
- -
- + + +

Data Components

@@ -119,13 +145,14 @@

Data Components

type: 'data-component', showDeprecatedFilter: true, clickBehavior: 'dialog', - allowEdits: true, + allowEdits: canEdit, }" (refresh)="getDataComponents()">
+
- +

Techniques Detected

@@ -138,6 +165,8 @@

Techniques Detected

[config]="{ stixObjects: techniquesDetected, type: 'relationship', + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, showDeprecatedFilter: true, clickBehavior: 'dialog', allowEdits: false, @@ -145,27 +174,5 @@

Techniques Detected

(refresh)="getDataComponents()">
-
- -
-
-
-

References

-
-
-
-
- -
-
-
+
diff --git a/src/app/views/stix/detection-strategy-view/detection-strategy-view.component.html b/src/app/views/stix/detection-strategy-view/detection-strategy-view.component.html index d54a7ba50..87b8c00ff 100644 --- a/src/app/views/stix/detection-strategy-view/detection-strategy-view.component.html +++ b/src/app/views/stix/detection-strategy-view/detection-strategy-view.component.html @@ -1,4 +1,4 @@ -
+
@@ -7,86 +7,123 @@ mode: config.mode, object: config.object, }"> - +
-
-
- - -
-
- - -
-
-
-
- - @if (config.mode === 'edit') { - - } @else if (!loading) { - - } @else { -
-
- + + + + + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+ + @if (config.mode === 'edit') { + + } @else if (!loading) { + + } @else { +
+
+ +
+ analytics +
+ } +
+
+ + +
+
+ @if ( + detectionStrategy.external_references.list().length > 0 || + previous?.external_references.list().length > 0 + ) { +
+
+

References

+
+
+
+
+
- analytics
}
-
- - -
-
- - @if ( - (config.showRelationships || !config.hasOwnProperty('showRelationships')) && - !editing - ) { - + + +

Techniques

@@ -109,33 +146,14 @@

Techniques

#detectsList [config]="{ type: 'relationship', + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, sourceRef: detectionStrategy.stixID, relationshipType: 'detects', clickBehavior: 'dialog', - allowEdits: true, + allowEdits: canEdit, }">
- } - - @if ( - detectionStrategy.external_references.list().length > 0 || - previous?.external_references.list().length > 0 - ) { -
-
-

References

-
-
-
-
- -
-
- } +
diff --git a/src/app/views/stix/group-view/group-view.component.html b/src/app/views/stix/group-view/group-view.component.html index eac275116..c1c1eb66c 100644 --- a/src/app/views/stix/group-view/group-view.component.html +++ b/src/app/views/stix/group-view/group-view.component.html @@ -1,4 +1,4 @@ -
+
- +
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - + + + + + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+

References

+
+
+
+
+ +
+
+
-
- -
- + + +

Campaigns

@@ -101,15 +143,19 @@

Campaigns

#campaignList [config]="{ type: 'relationship', + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, targetRef: group.stixID, sourceType: 'campaign', relationshipType: 'attributed-to', clickBehavior: 'dialog', - allowEdits: true, + allowEdits: canEdit, }">
- +
+ +

Techniques Used

@@ -133,16 +179,19 @@

Techniques Used

#techniqueList [config]="{ type: 'relationship', + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, sourceRef: group.stixID, targetType: 'technique', relationshipType: 'uses', clickBehavior: 'dialog', - allowEdits: true, + allowEdits: canEdit, }">
+
- +

Software Used

@@ -166,35 +215,15 @@

Software Used

#softwareList [config]="{ type: 'relationship', + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, sourceRef: group.stixID, targetType: 'software', relationshipType: 'uses', clickBehavior: 'dialog', - allowEdits: true, + allowEdits: canEdit, }">
-
- -
-
-
-

References

-
-
-
-
- -
-
-
+
diff --git a/src/app/views/stix/identity-view/identity-view.component.html b/src/app/views/stix/identity-view/identity-view.component.html index fdf31b425..e1c5f26be 100644 --- a/src/app/views/stix/identity-view/identity-view.component.html +++ b/src/app/views/stix/identity-view/identity-view.component.html @@ -6,10 +6,7 @@ mode: config.mode, object: config.object, }"> - +
diff --git a/src/app/views/stix/matrix/matrix-view/matrix-view.component.html b/src/app/views/stix/matrix/matrix-view/matrix-view.component.html index c9f9d341c..c9277c456 100644 --- a/src/app/views/stix/matrix/matrix-view/matrix-view.component.html +++ b/src/app/views/stix/matrix/matrix-view/matrix-view.component.html @@ -1,4 +1,4 @@ -
+
- +
-
-
- - -
-
- - -
-
-
-
- - -
-
- -
-
- -

TACTICS

-
-
-
- -
- -
-
- + + + + + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+ +
+
+ +

TACTICS

+
+
+
+ +
+ +
+
+ +
+ tactics +
+
+ +
+
+
+

References

+
+
+
+
+ +
+
- tactics
-
- -
-
-

MATRIX

-
-
- - - -
- - Matrix View - - Side - Flat - - - - + +
+
+

MATRIX

- - +
+ + + +
+ + Matrix View + + Side + Flat + + + + +
+ + +
+ + +
- - + + - - - - - - -
-
-
-

References

-
-
-
-
- -
-
diff --git a/src/app/views/stix/mitigation-view/mitigation-view.component.html b/src/app/views/stix/mitigation-view/mitigation-view.component.html index f7edb22b4..8ee29c2f5 100644 --- a/src/app/views/stix/mitigation-view/mitigation-view.component.html +++ b/src/app/views/stix/mitigation-view/mitigation-view.component.html @@ -1,4 +1,4 @@ -
+
- +
-
-
- - -
-
- - -
-
- -
-
- -
-
- -
-
- -
-
-
-
- - + + + + + +
+
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ + +
+
+
+
+
+

References

+
+
+
+
+ +
+
+
-
- -
+ + +
-

Techniques Addressed by Mitigation

+

Techniques Addressed

@@ -103,34 +127,14 @@

Techniques Addressed by Mitigation

#mitigatesList [config]="{ type: 'relationship', + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, sourceRef: mitigation.stixID, relationshipType: 'mitigates', clickBehavior: 'dialog', - allowEdits: true, + allowEdits: canEdit, }">
-
- -
-
-
-

References

-
-
-
-
- -
-
-
+
diff --git a/src/app/views/stix/relationship-view/relationship-view.component.html b/src/app/views/stix/relationship-view/relationship-view.component.html index 839a4bf75..800cc4a82 100644 --- a/src/app/views/stix/relationship-view/relationship-view.component.html +++ b/src/app/views/stix/relationship-view/relationship-view.component.html @@ -84,10 +84,7 @@

- +

diff --git a/src/app/views/stix/relationship-view/relationship-view.component.ts b/src/app/views/stix/relationship-view/relationship-view.component.ts index d94638960..fe588de8f 100644 --- a/src/app/views/stix/relationship-view/relationship-view.component.ts +++ b/src/app/views/stix/relationship-view/relationship-view.component.ts @@ -240,9 +240,11 @@ export class RelationshipViewComponent extends StixViewPage implements OnInit { * @param {any} obj the raw STIX object */ public navigateTo(obj: any): void { - if ( - !(obj?.stix?.type && obj?.stix?.id && StixTypeToAttackType[obj.stix.type]) - ) { + if (!( + obj?.stix?.type && + obj?.stix?.id && + StixTypeToAttackType[obj.stix.type] + )) { console.warn('Invalid object passed to navigateTo:', obj); return; } diff --git a/src/app/views/stix/software-view/software-view.component.html b/src/app/views/stix/software-view/software-view.component.html index b6640b04f..09733e391 100644 --- a/src/app/views/stix/software-view/software-view.component.html +++ b/src/app/views/stix/software-view/software-view.component.html @@ -1,4 +1,4 @@ -
+
- +
-
-
- - -
-
- - -
-
-
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - + + + + + + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+
+

References

+
+
+
+
+ +
+
+
-
- -
- + + +

Techniques Used

@@ -140,16 +168,19 @@

Techniques Used

#techniquesList [config]="{ type: 'relationship', + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, sourceRef: software.stixID, targetType: 'technique', relationshipType: 'uses', clickBehavior: 'dialog', - allowEdits: true, + allowEdits: canEdit, }">
+
- +

Associated Groups

@@ -173,16 +204,19 @@

Associated Groups

#groupList [config]="{ type: 'relationship', + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, targetRef: software.stixID, sourceType: 'group', relationshipType: 'uses', clickBehavior: 'dialog', - allowEdits: true, + allowEdits: canEdit, }">
+
- +

Campaigns

@@ -206,35 +240,15 @@

Campaigns

#campaignList [config]="{ type: 'relationship', + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, targetRef: software.stixID, sourceType: 'campaign', relationshipType: 'uses', clickBehavior: 'dialog', - allowEdits: true, + allowEdits: canEdit, }">
-
- -
-
-
-

References

-
-
-
-
- -
-
-
+
diff --git a/src/app/views/stix/stix-dialog/stix-dialog.component.html b/src/app/views/stix/stix-dialog/stix-dialog.component.html index 523ab65aa..4836cfd91 100644 --- a/src/app/views/stix/stix-dialog/stix-dialog.component.html +++ b/src/app/views/stix/stix-dialog/stix-dialog.component.html @@ -99,109 +99,53 @@ } @case ('intrusion-set') { - - + } @case ('campaign') { - - + } @case ('malware') { - - + } @case ('tool') { - - + } @case ('x-mitre-matrix') { - - + } @case ('course-of-action') { - - + } @case ('attack-pattern') { - - + } @case ('x-mitre-data-source') { - - + } @case ('x-mitre-data-component') { - + } @case ('x-mitre-asset') { - - + } @case ('x-mitre-tactic') { - - + } @case ('x-mitre-collection') { - - + } @case ('x-mitre-detection-strategy') { - + } @case ('x-mitre-analytic') { - - + } }
diff --git a/src/app/views/stix/stix-dialog/stix-dialog.component.spec.ts b/src/app/views/stix/stix-dialog/stix-dialog.component.spec.ts index 398cb93d6..df1ce2919 100644 --- a/src/app/views/stix/stix-dialog/stix-dialog.component.spec.ts +++ b/src/app/views/stix/stix-dialog/stix-dialog.component.spec.ts @@ -8,6 +8,7 @@ import { of } from 'rxjs'; import { StixDialogComponent } from './stix-dialog.component'; import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { AuthenticationService } from 'src/app/services/connectors/authentication/authentication.service'; import { createAsyncObservable, createMockRestApiConnector, @@ -28,6 +29,10 @@ describe('StixDialogComponent', () => { schemas: [NO_ERRORS_SCHEMA], providers: [ { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + { + provide: AuthenticationService, + useValue: { canEdit: () => true }, + }, provideHttpClient(), provideRouter([]), { @@ -54,4 +59,14 @@ describe('StixDialogComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('keeps diff dialogs read-only even when marked editable', () => { + component._config = { + mode: 'diff', + object: [{} as any, {} as any], + editable: true, + }; + + expect(component.config.editable).toBe(false); + }); }); diff --git a/src/app/views/stix/stix-dialog/stix-dialog.component.ts b/src/app/views/stix/stix-dialog/stix-dialog.component.ts index a91a44d37..4e627aa24 100644 --- a/src/app/views/stix/stix-dialog/stix-dialog.component.ts +++ b/src/app/views/stix/stix-dialog/stix-dialog.component.ts @@ -17,11 +17,8 @@ import { ConfirmationDialogComponent } from 'src/app/components/confirmation-dia import { DeleteDialogComponent } from 'src/app/components/delete-dialog/delete-dialog.component'; import { AuthenticationService } from 'src/app/services/connectors/authentication/authentication.service'; import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { ReleaseTracksConnectorService } from 'src/app/services/connectors/rest-api/release-tracks.service'; import { EditorService } from 'src/app/services/editor/editor.service'; -import { - SidebarService, - tabOption, -} from 'src/app/services/sidebar/sidebar.service'; import { StixViewConfig } from '../stix-view-page'; import { StixTypeToClass } from 'src/app/utils/class-mappings'; @@ -36,8 +33,8 @@ export class StixDialogComponent implements OnInit { constructor( public dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public _config: StixViewConfig, - public sidebarService: SidebarService, public restApiService: RestApiConnectorService, + private releaseTracksService: ReleaseTracksConnectorService, public editorService: EditorService, private authenticationService: AuthenticationService, private dialog: MatDialog @@ -76,7 +73,12 @@ export class StixDialogComponent implements OnInit { sourceType: this._config.sourceType ? this._config.sourceType : null, targetType: this._config.targetType ? this._config.targetType : null, showRelationships: this.showRelationships, - editable: this._config.editable && this.authenticationService.canEdit(), + relationshipCreatedBefore: this._config.relationshipCreatedBefore, + relationshipAddedAfter: this._config.relationshipAddedAfter, + editable: + this._config.mode !== 'diff' && + this._config.editable && + this.authenticationService.canEdit(), is_new: this._config.is_new ? true : false, sidebarControl: this._config.sidebarControl == 'disable' ? 'disable' : 'events', @@ -144,12 +146,18 @@ export class StixDialogComponent implements OnInit { const object = Array.isArray(this.config.object) ? this.config.object[0] : this.config.object; - const subscription = object.save(this.restApiService).subscribe({ + const save: Observable = + object instanceof Relationship + ? object + .save(this.restApiService, this.releaseTracksService) + .pipe(map(() => undefined)) + : object.save(this.restApiService).pipe(map(() => undefined)); + const subscription = save.subscribe({ next: result => { this.editorService.onEditingStopped.emit(); this._config.is_new = false; - if (object.attackType == 'relationship') - this.updateRelationshipObjects(object as Relationship); // update source/target object versions + if (object instanceof Relationship) + this.updateRelationshipObjects(object); // update source/target object versions if (this.prevObject) this.revertToPreviousObject(); else if (object.attackType == 'data-component') { // view data component on save @@ -215,6 +223,9 @@ export class StixDialogComponent implements OnInit { maxWidth: '35em', disableClose: true, autoFocus: false, // disables auto focus on the dialog form field + data: { + stixId: object.stixID, + }, }); const subscription = prompt.afterClosed().subscribe({ next: confirm => { @@ -369,18 +380,9 @@ export class StixDialogComponent implements OnInit { } public sidebarOpened = false; - public currentTab: tabOption = 'history'; public toggleSidebar() { this.sidebarOpened = !this.sidebarOpened; } - public openHistory() { - this.sidebarOpened = true; - this.currentTab = 'history'; - } - public openNotes() { - this.sidebarOpened = true; - this.currentTab = 'notes'; - } public get stixType(): string { return Array.isArray(this.config.object) ? this.config.object[0].type diff --git a/src/app/views/stix/stix-page/stix-page.component.ts b/src/app/views/stix/stix-page/stix-page.component.ts index 2767971a2..d6bf0c1bb 100644 --- a/src/app/views/stix/stix-page/stix-page.component.ts +++ b/src/app/views/stix/stix-page/stix-page.component.ts @@ -144,6 +144,7 @@ export class StixPageComponent implements OnInit, OnDestroy { autoFocus: false, data: { collectionDelete: this.objectType == 'collection', + stixId: this.objects[0]?.stixID, }, }); const closeSubscription = prompt.afterClosed().subscribe({ diff --git a/src/app/views/stix/stix-view-page.ts b/src/app/views/stix/stix-view-page.ts index 59579b0d1..3d2f7b83e 100644 --- a/src/app/views/stix/stix-view-page.ts +++ b/src/app/views/stix/stix-view-page.ts @@ -17,7 +17,9 @@ export abstract class StixViewPage { return this.config.mode == 'edit'; } public get canEdit(): boolean { - return this.authenticationService.canEdit(); + return ( + this.config.editable !== false && this.authenticationService.canEdit() + ); } public get configCurrentObject(): StixObject { return Array.isArray(this.config.object) @@ -27,16 +29,6 @@ export abstract class StixViewPage { public get configPreviousObject(): StixObject | null { return this.config.mode == 'diff' ? this.config.object[1] || null : null; } - - //outputs to use if config.sidebarControl == "events" - @Output() public onOpenHistory = new EventEmitter(); - public openHistory(): void { - this.onOpenHistory.emit(); - } - @Output() public onOpenNotes = new EventEmitter(); - public openNotes(): void { - this.onOpenNotes.emit(); - } } export interface StixViewConfig { @@ -54,6 +46,10 @@ export interface StixViewConfig { targetType?: string; // the relationship target type (only relevant when creating a new relationship) /* if true or omitted, show relationships with the object on the page. If false, omit the relationships */ showRelationships?: boolean; + /** Hide relationships created after this timestamp. */ + relationshipCreatedBefore?: Date | string; + /** Mark relationships created after this timestamp as new. */ + relationshipAddedAfter?: Date | string; /* is the current page editable? * if true or omitted, include edit elements on the page such as buttons to add a relationship * if false, hide such elements diff --git a/src/app/views/stix/tactic-view/tactic-view.component.html b/src/app/views/stix/tactic-view/tactic-view.component.html index 0abba79b6..923dfa9a5 100644 --- a/src/app/views/stix/tactic-view/tactic-view.component.html +++ b/src/app/views/stix/tactic-view/tactic-view.component.html @@ -1,4 +1,4 @@ -
+
- +
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - + + + + + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+
+

References

+
+
+
+
+ +
+
+
-
+ - - -
+

Techniques

@@ -96,28 +117,5 @@

Techniques

}">
-
- - -
-
-
-

References

-
-
-
-
- -
-
-
+
diff --git a/src/app/views/stix/technique-view/technique-view.component.html b/src/app/views/stix/technique-view/technique-view.component.html index 08b8dd666..b3a9019b1 100644 --- a/src/app/views/stix/technique-view/technique-view.component.html +++ b/src/app/views/stix/technique-view/technique-view.component.html @@ -1,4 +1,4 @@ -
+
@@ -11,322 +11,345 @@ ? technique.parentTechnique : null, }"> - +
-
-
- - -
-
- -
-
- - -
-
- -
-
- -
-
- -
-
- -
- - -
- arrow_forward -
-
- -
- + + + +
+
+
+ + + }">
- -
+
+ +
+
+ + +
+
+ +
+
- -
+
+ + +
+
-
- -
-
-
- - -
-
- - -
-
- -
-
- -
-
-
-
- -
-
-
-
- - -
-
- -
-
- -
- - -
- arrow_forward -
-
- -
+ technique.supportsDomainSpecificFields || + previous?.supportsDomainSpecificFields + "> + +
+ arrow_forward +
+
+ +
+ +
+ +
+ +
+ +
+ +
+
+ +
+
+
+
- -
+
+
- -
+
+
+
- -
+
+
+
- -
- +
+
+ + +
+
+ +
+
+ + field: 'tactics', + editType: 'stixList', + disabled: + editing && + (technique.domains.length === 0 || technique.is_subtechnique), + }"> +
+ + +
+ arrow_forward +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+
+
+
+
+

References

+
+
+
+
+ +
- -
-
-
- -
-
- -
- + + +
@@ -351,8 +374,10 @@

Sub-techniques

[config]="{ type: 'relationship', targetRef: technique.stixID, + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, relationshipType: 'subtechnique-of', - allowEdits: true, + allowEdits: canEdit, clickBehavior: 'none', }">
@@ -373,16 +398,20 @@

[config]="{ type: 'relationship', targetRef: technique.parentTechnique.stixID, + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, relationshipType: 'subtechnique-of', clickBehavior: 'none', - allowEdits: true, + allowEdits: canEdit, excludeSourceRefs: [technique.stixID], }">

- + + +

Campaigns

@@ -407,14 +436,18 @@

Campaigns

[config]="{ type: 'relationship', targetRef: technique.stixID, + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, sourceType: 'campaign', relationshipType: 'uses', clickBehavior: 'dialog', - allowEdits: true, + allowEdits: canEdit, }">
- +
+ +

Mitigations

@@ -438,13 +471,17 @@

Mitigations

[config]="{ type: 'relationship', targetRef: technique.stixID, + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, relationshipType: 'mitigates', clickBehavior: 'dialog', - allowEdits: true, + allowEdits: canEdit, }">
- +
+ +

Procedure Examples

@@ -468,13 +505,17 @@

Procedure Examples

[config]="{ type: 'relationship', targetRef: technique.stixID, + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, relationshipType: 'uses', clickBehavior: 'dialog', - allowEdits: true, + allowEdits: canEdit, }">
- +
+ +
@@ -494,15 +535,19 @@

Data Sources

[config]="{ type: 'relationship', targetRef: technique.stixID, + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, relationshipType: 'detects', sourceType: 'data-component', clickBehavior: 'dialog', - allowEdits: true, + allowEdits: canEdit, }">
- +
+ +

Detection Strategies

@@ -527,14 +572,18 @@

Detection Strategies

[config]="{ type: 'relationship', targetRef: technique.stixID, + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, relationshipType: 'detects', sourceType: 'detection-strategy', clickBehavior: 'dialog', - allowEdits: true, + allowEdits: canEdit, }">
- +
+ +

Assets

@@ -558,34 +607,14 @@

Assets

[config]="{ type: 'relationship', sourceRef: technique.stixID, + relationshipCreatedBefore: config.relationshipCreatedBefore, + relationshipAddedAfter: config.relationshipAddedAfter, targetType: 'asset', relationshipType: 'targets', clickBehavior: 'dialog', - allowEdits: true, + allowEdits: canEdit, }">
-
- -
-
-
-

References

-
-
-
-
- -
-
-
+
diff --git a/src/assets/build-info.json b/src/assets/build-info.json new file mode 100644 index 000000000..7e4f83925 --- /dev/null +++ b/src/assets/build-info.json @@ -0,0 +1,6 @@ +{ + "name": "attack-workbench-frontend", + "version": "3.2.0", + "gitCommit": "unknown", + "buildDate": "unknown" +} diff --git a/src/style/colors.scss b/src/style/colors.scss index f71168e5f..00a9ce4c7 100644 --- a/src/style/colors.scss +++ b/src/style/colors.scss @@ -70,6 +70,10 @@ $colors: ( color: $mitre-dark-gray, on-color: $mitre-black, ), + mitre-light-blue: ( + color: $mitre-light-blue, + on-color: $mitre-black, + ), // snackbar colors success: ( color: #599e2f, @@ -142,6 +146,40 @@ $colors: ( // @return rgba(invert(color($name)), 0.125); } +@mixin theme-property($property, $dark-value, $light-value) { + .dark & { + #{$property}: $dark-value; + } + + .light & { + #{$property}: $light-value; + } +} + +@mixin theme-border-color { + @include theme-property( + border-color, + border-color(dark), + border-color(light) + ); +} + +@mixin theme-text-emphasis { + @include theme-property( + color, + on-color-emphasis(dark), + on-color-emphasis(light) + ); +} + +@mixin theme-text-deemphasis { + @include theme-property( + color, + on-color-deemphasis(dark), + on-color-deemphasis(light) + ); +} + // escape the color. Note param is a color and not a color name: this is not an accessor to the color map above. // replaces # with %23 in hex colors // see https://codepen.io/gunnarbittersmann/pen/BoovjR for explanation of why we have to escape # for the background image diff --git a/src/style/forms.scss b/src/style/forms.scss index 663a6e9e3..a1f24307f 100644 --- a/src/style/forms.scss +++ b/src/style/forms.scss @@ -106,23 +106,10 @@ } .button-group { - // left border removed - .mat-mdc-button:not(:first-child), - .mat-mdc-raised-button:not(:first-child), - .mat-mdc-outlined-button:not(:first-child), - .mat-mdc-unelevated-button:not(:first-child) { - border-top-left-radius: 0; - border-bottom-left-radius: 0; - border-left-width: 0px; - } - // right border radius removed - .mat-mdc-button:not(:last-child), - .mat-mdc-raised-button:not(:last-child), - .mat-mdc-outlined-button:not(:last-child), - .mat-mdc-unelevated-button:not(:last-child) { - border-top-right-radius: 0; - border-bottom-right-radius: 0; - } + display: flex; + gap: 8px; + align-items: center; + justify-content: center; } .mat-mdc-form-field-error { diff --git a/src/style/layouts/view-page.scss b/src/style/layouts/view-page.scss index 3c09ddcfb..d32e4ec42 100644 --- a/src/style/layouts/view-page.scss +++ b/src/style/layouts/view-page.scss @@ -9,7 +9,7 @@ h1 { margin: 0; } - width: 45em; + width: 55em; max-width: 100%; margin: 0 auto; padding-bottom: 3em; diff --git a/src/style/theme.scss b/src/style/theme.scss index 836476339..f03876e06 100644 --- a/src/style/theme.scss +++ b/src/style/theme.scss @@ -185,26 +185,64 @@ $tooltip-color: colors.color-alternate(dark, 4); // tooltip styling background: $tooltip-color; } + .mat-mdc-menu-panel.account-menu { + min-width: 180px; + + .mat-mdc-menu-item { + padding: 0 18px; + + .mat-icon { + color: currentColor; + } + } + + .mat-mdc-menu-item.logout-menu-item { + color: colors.color(error); + + .mat-icon { + color: colors.color(error); + } + } + } &.dark .mat-mdc-menu-panel { background: colors.color(dark); color: colors.on-color(dark); + .mat-mdc-menu-item:hover { background: colors.color-alternate(dark); } + .mat-mdc-menu-item:disabled { color: colors.on-color-deemphasis(dark); } } + &.dark .mat-mdc-menu-panel.account-menu { + border: 1px solid rgba(colors.on-color(dark), 0.14); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.45); + + .mat-mdc-menu-item.logout-menu-item:hover { + background: rgba(colors.color(error), 0.14); + } + } &.light .mat-mdc-menu-panel { background: colors.color(light); color: colors.on-color(light); + .mat-mdc-menu-item:hover { background: colors.color-alternate(light); } + .mat-mdc-menu-item:disabled { color: colors.on-color-deemphasis(light); } } + &.light .mat-mdc-menu-panel.account-menu { + border: 1px solid rgba(colors.on-color(light), 0.1); + + .mat-mdc-menu-item.logout-menu-item:hover { + background: rgba(colors.color(error), 0.08); + } + } &.dark .mat-mdc-dialog-surface { background: colors.color(dark); color: colors.on-color(dark); @@ -430,12 +468,12 @@ button.mat-mdc-outlined-button:disabled { } } -.mat-mdc-card .mat-mdc-card-header { +.mat-mdc-card .mat-mdc-card-actions { .light & { - background: colors.color-alternate(light); + border-top: 1px solid colors.color-alternate(light); } .dark & { - background: colors.color-alternate(dark); + border-top: 1px solid colors.color-alternate(dark); } } diff --git a/src/style/typography.scss b/src/style/typography.scss index 6e98787dc..490e3d59c 100644 --- a/src/style/typography.scss +++ b/src/style/typography.scss @@ -2,6 +2,10 @@ @use 'colors'; @use 'fonts'; +$mono-font: + Roboto Mono, + monospace; + .superheading { //super big display text font-size: 60px; @@ -79,6 +83,9 @@ color: rgba(255, 255, 255, 0.6); } } +.description-text { + @extend .placeholder-text; +} .mdc-label { line-height: 24px; padding: 0px !important;