diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4780a57b0..4d97879a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,6 @@ on: - next-major - beta - alpha - - adm - '*.x' # Matches branches like '1.x', '2.x' pull_request: # Run on all PRs regardless of target branch workflow_dispatch: @@ -36,9 +35,66 @@ jobs: - name: Validate all commits run: npx commitlint --from ${{ github.event.pull_request.base.sha || github.event.before }} --to ${{ github.event.pull_request.head.sha || github.sha }} --verbose - # Job 2: Semantic Release and Docker Build - release: + # Job 2a: Static Checks (Linting) + static-checks: + needs: [commitlint] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22.x' + + - name: Install dependencies + run: npm ci + + # since there are lots of lint errors, run lint with warnonly to not fail the build + - name: Run linting + run: npm run lint:warnonly + + # Job 2b: Tests + run-tests: + runs-on: ubuntu-latest needs: [commitlint] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22.x' + + - name: Install dependencies + run: npm ci + + - name: Run regression tests with code coverage + run: npm run test:coverage + + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + with: + name: workbench-frontend-coverage + path: coverage + retention-days: 7 + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: mitre-attack/attack-workbench-frontend + files: coverage/lcov.info + verbose: true + + - name: Verify integrity of dependencies + run: npm audit signatures + + # Job 3: Semantic Release and Docker Build + release: + needs: [static-checks, run-tests] # Only run on pushes (not PRs) - semantic-release will determine whether to release if: github.event_name == 'push' runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 6dd5bd968..62b926dd1 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ speed-measure-plugin*.json /.idea .project .classpath +.angular/ .c9/ *.launch .settings/ diff --git a/.releaserc b/.releaserc index 5cc138d4f..a822116d3 100644 --- a/.releaserc +++ b/.releaserc @@ -11,10 +11,6 @@ { "name": "alpha", "prerelease": true - }, - { - "name": "adm", - "prerelease": true } ], "plugins": [ diff --git a/Dockerfile b/Dockerfile index c4eea6a3f..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} @@ -36,6 +44,9 @@ LABEL org.opencontainers.image.title="ATT&CK Workbench Frontend Service" \ org.opencontainers.image.vendor="The MITRE Corporation" \ org.opencontainers.image.licenses="Apache-2.0" \ org.opencontainers.image.authors="MITRE ATT&CK" \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.created="${BUILDTIME}" \ + org.opencontainers.image.revision="${REVISION}" \ maintainer="MITRE ATT&CK" # Remove the default nginx website @@ -48,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 0734e1a67..b4225314a 100644 --- a/angular.json +++ b/angular.json @@ -73,7 +73,7 @@ { "type": "initial", "maximumWarning": "3mb", - "maximumError": "5mb" + "maximumError": "6mb" }, { "type": "anyComponentStyle", @@ -110,21 +110,7 @@ } }, "test": { - "builder": "@angular-devkit/build-angular:karma", - "options": { - "main": "src/test.ts", - "polyfills": "src/polyfills.ts", - "tsConfig": "tsconfig.spec.json", - "karmaConfig": "karma.conf.js", - "assets": [ - "src/favicon.ico", - "src/assets" - ], - "styles": [ - "src/style/theme.scss" - ], - "scripts": [] - } + "builder": "@analogjs/vitest-angular:test" }, "e2e": { "builder": "@angular-devkit/build-angular:protractor", 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/karma.conf.js b/karma.conf.js deleted file mode 100644 index c11180d94..000000000 --- a/karma.conf.js +++ /dev/null @@ -1,32 +0,0 @@ -// Karma configuration file, see link for more information -// https://karma-runner.github.io/1.0/config/configuration-file.html - -module.exports = function (config) { - config.set({ - basePath: '', - frameworks: ['jasmine', '@angular-devkit/build-angular'], - plugins: [ - require('karma-jasmine'), - require('karma-chrome-launcher'), - require('karma-jasmine-html-reporter'), - require('karma-coverage-istanbul-reporter'), - require('@angular-devkit/build-angular/plugins/karma') - ], - client: { - clearContext: false // leave Jasmine Spec Runner output visible in browser - }, - coverageIstanbulReporter: { - dir: require('path').join(__dirname, './coverage/app'), - reports: ['html', 'lcovonly', 'text-summary'], - fixWebpackSourcePaths: true - }, - reporters: ['progress', 'kjhtml'], - port: 9876, - colors: true, - logLevel: config.LOG_INFO, - autoWatch: true, - browsers: ['Chrome'], - singleRun: false, - restartOnFileChange: true - }); -}; diff --git a/package-lock.json b/package-lock.json index 5ffe83df4..cbd0160d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,13 +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.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", @@ -36,14 +36,19 @@ "zone.js": "~0.15.0" }, "devDependencies": { + "@analogjs/vite-plugin-angular": "^1.22.1", + "@analogjs/vitest-angular": "^1.22.1", "@angular-devkit/build-angular": "^19.2.27", "@angular/cli": "^19.2.27", "@angular/compiler-cli": "^19.2.25", "@codedependant/semantic-release-docker": "^5.1.1", "@commitlint/config-conventional": "^20.5.3", + "@eslint/js": "^9.39.1", "@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", @@ -53,22 +58,25 @@ "eslint-plugin-prettier": "^5.5.5", "eslint-plugin-unused-imports": "^4.1.4", "husky": "^9.1.7", - "jasmine-core": "^6.3.0", - "jasmine-spec-reporter": "^7.0.0", - "karma": "~6.4.2", - "karma-chrome-launcher": "^3.2.0", - "karma-coverage-istanbul-reporter": "~3.0.2", - "karma-jasmine": "^5.1.0", - "karma-jasmine-html-reporter": "^2.1.0", + "jsdom": "^27.0.1", "prettier": "^3.8.1", "prettier-eslint": "^16.3.0", + "sass": "^1.94.2", "semantic-release": "^25.0.3", "ts-node": "^10.9.1", - "tslint": "~6.1.0", "typescript": "^5.8.3", - "typescript-eslint": "^8.58.0" + "typescript-eslint": "^8.58.0", + "vite": "^6.4.1", + "vitest": "^3.2.4" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@actions/core": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.11.1.tgz", @@ -122,30 +130,64 @@ "node": ">=6.0.0" } }, - "node_modules/@angular-devkit/architect": { - "version": "0.1902.27", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1902.27.tgz", - "integrity": "sha512-I2YW4Zn1818toEGKPPcbv8qqglegNnWT4A4GM28LPRg4rOYwPkZzy3PAB7EIJEwZIDQgz2I+Oy0pc56BWcFVUw==", + "node_modules/@analogjs/vite-plugin-angular": { + "version": "1.22.5", + "resolved": "https://registry.npmjs.org/@analogjs/vite-plugin-angular/-/vite-plugin-angular-1.22.5.tgz", + "integrity": "sha512-N1BQD6HQSp2Imbb1fThymskWFSLq0ZF+d2fe3DgErwlBFf6SzRp++iFltddQc3wIzenTXE+5brS4fAPXO8UT9g==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.2.27", - "rxjs": "7.8.1" + "ts-morph": "^21.0.0", + "vfile": "^6.0.3" }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/brandonroberts" + }, + "peerDependencies": { + "@angular-devkit/build-angular": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0", + "@angular/build": "^18.0.0 || ^19.0.0 || ^20.0.0" + }, + "peerDependenciesMeta": { + "@angular-devkit/build-angular": { + "optional": true + }, + "@angular/build": { + "optional": true + } } }, - "node_modules/@angular-devkit/architect/node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "node_modules/@analogjs/vitest-angular": { + "version": "1.22.5", + "resolved": "https://registry.npmjs.org/@analogjs/vitest-angular/-/vitest-angular-1.22.5.tgz", + "integrity": "sha512-lYwa9f6LFClW80sPhCydTOQvKnpWPnyChFhYdgMRpUZuKYo9102PndOLZkqR5K8WlKSilbP2293whaG1vZBMyA==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/brandonroberts" + }, + "peerDependencies": { + "@analogjs/vite-plugin-angular": "*", + "@angular-devkit/architect": ">=0.1500.0 < 0.2100.0", + "vitest": "^1.3.1 || ^2.0.0 || ^3.0.0 || ^4.0.0" + } + }, + "node_modules/@angular-devkit/architect": { + "version": "0.2003.32", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2003.32.tgz", + "integrity": "sha512-V5d531w97LENARKdYk5Km0F2rt8NPEL3rXUQk7+gbo9r/jgLWn9GU3VHQ30oBWXnU7Vu00Hdp/8yFeYgpWqSow==", + "dev": true, + "license": "MIT", + "peer": true, "dependencies": { - "tslib": "^2.1.0" + "@angular-devkit/core": "20.3.32", + "rxjs": "7.8.2" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" } }, "node_modules/@angular-devkit/build-angular": { @@ -274,6 +316,50 @@ } } }, + "node_modules/@angular-devkit/build-angular/node_modules/@angular-devkit/architect": { + "version": "0.1902.27", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1902.27.tgz", + "integrity": "sha512-I2YW4Zn1818toEGKPPcbv8qqglegNnWT4A4GM28LPRg4rOYwPkZzy3PAB7EIJEwZIDQgz2I+Oy0pc56BWcFVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.27", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/@angular-devkit/core": { + "version": "19.2.27", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.27.tgz", + "integrity": "sha512-3amNzoCVSKd7ah6l6lBQL4onwwJvqvam7FMoQBILrxtW5LB5ezh8gMSPuA4zJjKjoRzf9uoWdlzqv/84I52xZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, "node_modules/@angular-devkit/build-angular/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -284,6 +370,37 @@ "tslib": "^2.1.0" } }, + "node_modules/@angular-devkit/build-angular/node_modules/sass": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.85.0.tgz", + "integrity": "sha512-3ToiC1xZ1Y8aU7+CkgCI/tqyuPXEmYGJXO7H4uqp0xkLXUqp88rQQ4j1HmP37xSJLbCJPaIiv+cT1y+grssrww==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, "node_modules/@angular-devkit/build-webpack": { "version": "0.1902.27", "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1902.27.tgz", @@ -304,17 +421,23 @@ "webpack-dev-server": "^5.0.2" } }, - "node_modules/@angular-devkit/build-webpack/node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "node_modules/@angular-devkit/build-webpack/node_modules/@angular-devkit/architect": { + "version": "0.1902.27", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1902.27.tgz", + "integrity": "sha512-I2YW4Zn1818toEGKPPcbv8qqglegNnWT4A4GM28LPRg4rOYwPkZzy3PAB7EIJEwZIDQgz2I+Oy0pc56BWcFVUw==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "tslib": "^2.1.0" + "@angular-devkit/core": "19.2.27", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" } }, - "node_modules/@angular-devkit/core": { + "node_modules/@angular-devkit/build-webpack/node_modules/@angular-devkit/core": { "version": "19.2.27", "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.27.tgz", "integrity": "sha512-3amNzoCVSKd7ah6l6lBQL4onwwJvqvam7FMoQBILrxtW5LB5ezh8gMSPuA4zJjKjoRzf9uoWdlzqv/84I52xZA==", @@ -342,7 +465,7 @@ } } }, - "node_modules/@angular-devkit/core/node_modules/rxjs": { + "node_modules/@angular-devkit/build-webpack/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", @@ -352,6 +475,45 @@ "tslib": "^2.1.0" } }, + "node_modules/@angular-devkit/build-webpack/node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@angular-devkit/core": { + "version": "20.3.32", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-20.3.32.tgz", + "integrity": "sha512-pXipSsYP/XTEAljmwqgy1u3GR/ZzSMg1FERINekGT61Th1pGhzjs02rPgbyftqz2e5/tfQ6XgTP7xYzm3+S35Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.2", + "source-map": "0.7.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, "node_modules/@angular-devkit/schematics": { "version": "19.2.27", "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.27.tgz", @@ -371,6 +533,34 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@angular-devkit/schematics/node_modules/@angular-devkit/core": { + "version": "19.2.27", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.27.tgz", + "integrity": "sha512-3amNzoCVSKd7ah6l6lBQL4onwwJvqvam7FMoQBILrxtW5LB5ezh8gMSPuA4zJjKjoRzf9uoWdlzqv/84I52xZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, "node_modules/@angular-devkit/schematics/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -381,6 +571,16 @@ "tslib": "^2.1.0" } }, + "node_modules/@angular-devkit/schematics/node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, "node_modules/@angular-eslint/builder": { "version": "19.8.1", "resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-19.8.1.tgz", @@ -396,48 +596,112 @@ "typescript": "*" } }, - "node_modules/@angular-eslint/bundled-angular-compiler": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-19.8.1.tgz", - "integrity": "sha512-WXi1YbSs7SIQo48u+fCcc5Nt14/T4QzYQPLZUnjtsUXPgQG7ZoahhcGf7PPQ+n0V3pSopHOlSHwqK+tSsYK87A==", - "dev": true, - "license": "MIT" - }, - "node_modules/@angular-eslint/eslint-plugin": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-19.8.1.tgz", - "integrity": "sha512-wZEBMPwD2TRhifG751hcj137EMIEaFmsxRB2EI+vfINCgPnFGSGGOHXqi8aInn9fXqHs7VbXkAzXYdBsvy1m4Q==", + "node_modules/@angular-eslint/builder/node_modules/@angular-devkit/architect": { + "version": "0.1902.27", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1902.27.tgz", + "integrity": "sha512-I2YW4Zn1818toEGKPPcbv8qqglegNnWT4A4GM28LPRg4rOYwPkZzy3PAB7EIJEwZIDQgz2I+Oy0pc56BWcFVUw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-eslint/bundled-angular-compiler": "19.8.1", - "@angular-eslint/utils": "19.8.1" + "@angular-devkit/core": "19.2.27", + "rxjs": "7.8.1" }, - "peerDependencies": { - "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": "*" + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" } }, - "node_modules/@angular-eslint/eslint-plugin-template": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-19.8.1.tgz", - "integrity": "sha512-0ZVQldndLrDfB0tzFe/uIwvkUcakw8qGxvkEU0l7kSbv/ngNQ/qrkRi7P64otB15inIDUNZI2jtmVat52dqSfQ==", + "node_modules/@angular-eslint/builder/node_modules/@angular-devkit/core": { + "version": "19.2.27", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.27.tgz", + "integrity": "sha512-3amNzoCVSKd7ah6l6lBQL4onwwJvqvam7FMoQBILrxtW5LB5ezh8gMSPuA4zJjKjoRzf9uoWdlzqv/84I52xZA==", "dev": true, "license": "MIT", "dependencies": { - "@angular-eslint/bundled-angular-compiler": "19.8.1", - "@angular-eslint/utils": "19.8.1", - "aria-query": "5.3.2", - "axobject-query": "4.1.0" + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" }, "peerDependencies": { - "@angular-eslint/template-parser": "19.8.1", - "@typescript-eslint/types": "^7.11.0 || ^8.0.0", - "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": "*" - } + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular-eslint/builder/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@angular-eslint/builder/node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@angular-eslint/bundled-angular-compiler": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-19.8.1.tgz", + "integrity": "sha512-WXi1YbSs7SIQo48u+fCcc5Nt14/T4QzYQPLZUnjtsUXPgQG7ZoahhcGf7PPQ+n0V3pSopHOlSHwqK+tSsYK87A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@angular-eslint/eslint-plugin": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-19.8.1.tgz", + "integrity": "sha512-wZEBMPwD2TRhifG751hcj137EMIEaFmsxRB2EI+vfINCgPnFGSGGOHXqi8aInn9fXqHs7VbXkAzXYdBsvy1m4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "19.8.1", + "@angular-eslint/utils": "19.8.1" + }, + "peerDependencies": { + "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/eslint-plugin-template": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-19.8.1.tgz", + "integrity": "sha512-0ZVQldndLrDfB0tzFe/uIwvkUcakw8qGxvkEU0l7kSbv/ngNQ/qrkRi7P64otB15inIDUNZI2jtmVat52dqSfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "19.8.1", + "@angular-eslint/utils": "19.8.1", + "aria-query": "5.3.2", + "axobject-query": "4.1.0" + }, + "peerDependencies": { + "@angular-eslint/template-parser": "19.8.1", + "@typescript-eslint/types": "^7.11.0 || ^8.0.0", + "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": "*" + } }, "node_modules/@angular-eslint/schematics": { "version": "19.8.1", @@ -455,6 +719,44 @@ "strip-json-comments": "3.1.1" } }, + "node_modules/@angular-eslint/schematics/node_modules/@angular-devkit/core": { + "version": "19.2.27", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.27.tgz", + "integrity": "sha512-3amNzoCVSKd7ah6l6lBQL4onwwJvqvam7FMoQBILrxtW5LB5ezh8gMSPuA4zJjKjoRzf9uoWdlzqv/84I52xZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular-eslint/schematics/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/@angular-eslint/schematics/node_modules/semver": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", @@ -468,6 +770,16 @@ "node": ">=10" } }, + "node_modules/@angular-eslint/schematics/node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, "node_modules/@angular-eslint/template-parser": { "version": "19.8.1", "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-19.8.1.tgz", @@ -600,6 +912,50 @@ } } }, + "node_modules/@angular/build/node_modules/@angular-devkit/architect": { + "version": "0.1902.27", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1902.27.tgz", + "integrity": "sha512-I2YW4Zn1818toEGKPPcbv8qqglegNnWT4A4GM28LPRg4rOYwPkZzy3PAB7EIJEwZIDQgz2I+Oy0pc56BWcFVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.27", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular/build/node_modules/@angular-devkit/core": { + "version": "19.2.27", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.27.tgz", + "integrity": "sha512-3amNzoCVSKd7ah6l6lBQL4onwwJvqvam7FMoQBILrxtW5LB5ezh8gMSPuA4zJjKjoRzf9uoWdlzqv/84I52xZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, "node_modules/@angular/build/node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -1042,6 +1398,47 @@ "node": ">=18" } }, + "node_modules/@angular/build/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@angular/build/node_modules/sass": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.85.0.tgz", + "integrity": "sha512-3ToiC1xZ1Y8aU7+CkgCI/tqyuPXEmYGJXO7H4uqp0xkLXUqp88rQQ4j1HmP37xSJLbCJPaIiv+cT1y+grssrww==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/@angular/build/node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, "node_modules/@angular/build/node_modules/vite": { "version": "6.4.2", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", @@ -1208,6 +1605,70 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@angular/cli/node_modules/@angular-devkit/architect": { + "version": "0.1902.27", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1902.27.tgz", + "integrity": "sha512-I2YW4Zn1818toEGKPPcbv8qqglegNnWT4A4GM28LPRg4rOYwPkZzy3PAB7EIJEwZIDQgz2I+Oy0pc56BWcFVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.27", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular/cli/node_modules/@angular-devkit/core": { + "version": "19.2.27", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.27.tgz", + "integrity": "sha512-3amNzoCVSKd7ah6l6lBQL4onwwJvqvam7FMoQBILrxtW5LB5ezh8gMSPuA4zJjKjoRzf9uoWdlzqv/84I52xZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular/cli/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@angular/cli/node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, "node_modules/@angular/common": { "version": "19.2.25", "resolved": "https://registry.npmjs.org/@angular/common/-/common-19.2.25.tgz", @@ -1450,9 +1911,64 @@ "url": "https://github.com/sponsors/antfu" } }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", @@ -3164,6 +3680,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@braintree/sanitize-url": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", @@ -3200,6 +3726,7 @@ "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", "dev": true, "license": "MIT", + "optional": true, "engines": { "node": ">=0.1.90" } @@ -3507,6 +4034,146 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "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": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "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": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "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": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@ctrl/tinycolor": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", @@ -3969,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": { @@ -4070,9 +4737,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", "dev": true, "license": "MIT", "dependencies": { @@ -4082,7 +4749,7 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", + "js-yaml": "^4.3.0", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, @@ -4159,9 +4826,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", "dev": true, "license": "MIT", "engines": { @@ -4195,6 +4862,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@fastify/busboy": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", @@ -5426,6 +6111,31 @@ "@chevrotain/types": "~11.1.2" } }, + "node_modules/@mitre-attack/attack-data-model": { + "version": "5.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@mitre-attack/attack-data-model/-/attack-data-model-5.0.0-beta.3.tgz", + "integrity": "sha512-/TBIrix6p51p9Bu9JHUmpPyN8lPnDgOd0UxIGmZdC4zzONpx5wpAvofACQV2t2A9P3EeaYxTcnufTMCh94DZIQ==", + "license": "APACHE-2.0", + "dependencies": { + "axios": "^1.9.0", + "uuid": "^10.0.0", + "zod": "^4.0.5" + } + }, + "node_modules/@mitre-attack/attack-data-model/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", @@ -6075,61 +6785,6 @@ "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/@npmcli/package-json/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@npmcli/package-json/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==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@npmcli/package-json/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@npmcli/package-json/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@npmcli/promise-spawn": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-8.0.3.tgz", @@ -6370,20 +7025,6 @@ "node": ">= 20" } }, - "node_modules/@octokit/request/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/@octokit/types": { "version": "16.0.0", "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", @@ -6395,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", @@ -6406,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" @@ -6416,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" ], @@ -6453,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" ], @@ -6474,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" ], @@ -6495,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" ], @@ -6516,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" ], @@ -6537,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" ], @@ -6558,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" ], @@ -6579,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" ], @@ -6600,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" ], @@ -6621,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" ], @@ -6642,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" ], @@ -6662,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" ], @@ -6781,8 +7400,15 @@ "node": ">=12" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ @@ -7148,6 +7774,54 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@schematics/angular/node_modules/@angular-devkit/core": { + "version": "19.2.27", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.27.tgz", + "integrity": "sha512-3amNzoCVSKd7ah6l6lBQL4onwwJvqvam7FMoQBILrxtW5LB5ezh8gMSPuA4zJjKjoRzf9uoWdlzqv/84I52xZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@schematics/angular/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@schematics/angular/node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, "node_modules/@sec-ant/readable-stream": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", @@ -7287,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": { @@ -7499,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": { @@ -7758,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" }, @@ -7790,13 +8464,68 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@socket.io/component-emitter": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", - "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "node_modules/@ts-morph/common": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.22.0.tgz", + "integrity": "sha512-HqNBuV/oIlMKdkLshXd1zKBqNQCsuPEsgQOkfFQ/eUKjRlwndXW1AjN9LVkBEIukm00gGXSRmfkl0Wv5VXLnlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "^3.3.2", + "minimatch": "^9.0.3", + "mkdirp": "^3.0.1", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@ts-morph/common/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, "license": "MIT" }, + "node_modules/@ts-morph/common/node_modules/brace-expansion": { + "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": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@ts-morph/common/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@ts-morph/common/node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -7857,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": { @@ -7903,6 +8632,17 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -7924,16 +8664,6 @@ "@types/node": "*" } }, - "node_modules/@types/cors": { - "version": "2.8.19", - "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", - "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/d3": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", @@ -8218,6 +8948,13 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -8332,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" @@ -8435,6 +9173,13 @@ "license": "MIT", "optional": true }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -8446,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" @@ -8469,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": { @@ -8500,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": { @@ -8522,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" @@ -8540,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": { @@ -8557,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" }, @@ -8582,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": { @@ -8596,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", @@ -8637,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" @@ -8661,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": { @@ -8692,9 +9437,9 @@ } }, "node_modules/@ungap/structured-clone": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.2.tgz", - "integrity": "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", "dev": true, "license": "ISC" }, @@ -8722,6 +9467,177 @@ "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.7.tgz", + "integrity": "sha512-NEGWJS2XNu2PfRLQwOO3CTKj1tTETxNBdk454vDxVBhxJYhPaA/eS0nAI0c+1El1P7a60z8+i+ZrQoGESweGKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.7", + "vitest": "3.2.7" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-3.2.7.tgz", + "integrity": "sha512-eVtcpJXGhS0GjMuHROfbXLhlxooyUcuip8GNzzjDD5jzafZqzanJH4W3VGmUxHNx4fv6qQGUGJHRpGUdj+9D6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "fflate": "^0.8.2", + "flatted": "^3.3.3", + "pathe": "^2.0.3", + "sirv": "^3.0.1", + "tinyglobby": "^0.2.14", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "3.2.7" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -8939,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": { @@ -9136,6 +10052,54 @@ "typescript-eslint": "^8.0.0" } }, + "node_modules/angular-eslint/node_modules/@angular-devkit/core": { + "version": "19.2.27", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.27.tgz", + "integrity": "sha512-3amNzoCVSKd7ah6l6lBQL4onwwJvqvam7FMoQBILrxtW5LB5ezh8gMSPuA4zJjKjoRzf9uoWdlzqv/84I52xZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/angular-eslint/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/angular-eslint/node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -9303,6 +10267,16 @@ "node": ">=8" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz", @@ -9310,8 +10284,33 @@ "dev": true, "license": "ISC" }, - "node_modules/autoprefixer": { - "version": "10.4.20", + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", + "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.20", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", "dev": true, @@ -9348,6 +10347,43 @@ "postcss": "^8.1.0" } }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -9459,20 +10495,10 @@ ], "license": "MIT" }, - "node_modules/base64id": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", - "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^4.5.0 || >= 5.9" - } - }, "node_modules/baseline-browser-mapping": { - "version": "2.10.42", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", - "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "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": { @@ -9516,6 +10542,16 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/big.js": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", @@ -9552,9 +10588,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", "dev": true, "license": "MIT", "dependencies": { @@ -9576,6 +10612,16 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/body-parser/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -9607,9 +10653,9 @@ "license": "MIT" }, "node_modules/bonjour-service": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.4.2.tgz", - "integrity": "sha512-lMskhnsW70yWHr4PhPeh2rvaIkLSaDpp+nmtbXBZaNKTXwxL73QOkW6HhbzqTImXjevn9TreGT4GACGBCGP9nQ==", + "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": { @@ -9632,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": { @@ -9658,9 +10704,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.5", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", - "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==", + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", "dev": true, "funding": [ { @@ -9678,10 +10724,10 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.42", - "caniuse-lite": "^1.0.30001800", - "electron-to-chromium": "^1.5.387", - "node-releases": "^2.0.50", + "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" }, "bin": { @@ -9729,6 +10775,7 @@ "integrity": "sha512-wxXCdllwGhI2kCC0MnvTGYTMvnVZTvqgypkiTI8Pa5tcz2i6VqsqwYGgqwXji+4RgCzms6EajE4IxiUH6HH8nQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -9759,6 +10806,16 @@ "node": ">= 0.8" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/cacache": { "version": "19.0.1", "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", @@ -9783,23 +10840,6 @@ "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/cacache/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/cacache/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==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/cacache/node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -9810,28 +10850,6 @@ "node": ">=18" } }, - "node_modules/cacache/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/cacache/node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -9839,26 +10857,10 @@ "dev": true, "license": "ISC" }, - "node_modules/cacache/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/cacache/node_modules/tar": { - "version": "7.5.19", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.19.tgz", - "integrity": "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==", + "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": { @@ -9886,7 +10888,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -9924,9 +10925,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001803", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz", - "integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==", + "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": [ { @@ -9944,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", @@ -9966,6 +10958,23 @@ "node": ">=0.8" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -10000,6 +11009,16 @@ "dev": true, "license": "MIT" }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -10473,6 +11492,13 @@ "node": ">=0.10.0" } }, + "node_modules/code-block-writer": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-12.0.0.tgz", + "integrity": "sha512-q4dMFMlXtKR3XNBHyMHt/3pwYNA69EDk00lloMOaaUMKPUXBw6lpXtbu3MMVG6/uOihGnRDOlkyqsONEUj60+w==", + "dev": true, + "license": "MIT" + }, "node_modules/codelyzer": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/codelyzer/-/codelyzer-6.0.2.tgz", @@ -10624,14 +11650,16 @@ "dev": true, "license": "MIT" }, - "node_modules/colors": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", - "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", - "dev": true, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, "engines": { - "node": ">=0.1.90" + "node": ">= 0.8" } }, "node_modules/commander": { @@ -10773,22 +11801,6 @@ "dev": true, "license": "ISC" }, - "node_modules/connect": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", - "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "finalhandler": "1.1.2", - "parseurl": "~1.3.3", - "utils-merge": "1.0.1" - }, - "engines": { - "node": ">= 0.10.0" - } - }, "node_modules/connect-history-api-fallback": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", @@ -10799,23 +11811,6 @@ "node": ">=0.8" } }, - "node_modules/connect/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/connect/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -10830,13 +11825,17 @@ } }, "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/conventional-changelog-angular": { @@ -11008,24 +12007,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/cose-base": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", @@ -11208,6 +12189,20 @@ "fastparse": "^1.1.2" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/css-what": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", @@ -11244,12 +12239,31 @@ "node": ">=4" } }, - "node_modules/custom-event": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz", - "integrity": "sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==", + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } }, "node_modules/cytoscape": { "version": "3.34.0", @@ -11822,14 +12836,28 @@ "dev": true, "license": "BSD-2-Clause" }, - "node_modules/date-format": { - "version": "4.0.14", - "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz", - "integrity": "sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==", + "node_modules/data-urls": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", "dev": true, "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" + }, "engines": { - "node": ">=4.0" + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" } }, "node_modules/dayjs": { @@ -11843,7 +12871,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -11857,6 +12884,23 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -11940,10 +12984,19 @@ "robust-predicates": "^3.0.2" } }, - "node_modules/delegate": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz", - "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==", + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/delegate": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz", + "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==", "license": "MIT", "optional": true }, @@ -11986,13 +13039,6 @@ "dev": true, "license": "MIT" }, - "node_modules/di": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/di/-/di-0.0.1.tgz", - "integrity": "sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA==", - "dev": true, - "license": "MIT" - }, "node_modules/diff": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", @@ -12065,19 +13111,6 @@ "node": ">=6.0.0" } }, - "node_modules/dom-serialize": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/dom-serialize/-/dom-serialize-2.2.1.tgz", - "integrity": "sha512-Yra4DbvoW7/Z6LBN560ZwXMjoNOSAN2wRsKFGc4iBeso+mpIA6qj1vfdf9HpMaKAqG6wXTy+1SYEzmNpKXOSsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "custom-event": "~1.0.0", - "ent": "~2.2.0", - "extend": "^3.0.0", - "void-elements": "^2.0.0" - } - }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -12123,9 +13156,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.11", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", - "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", "license": "(MPL-2.0 OR Apache-2.0)", "optional": true, "optionalDependencies": { @@ -12170,7 +13203,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -12239,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" }, @@ -12277,9 +13309,9 @@ } }, "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "dev": true, "license": "MIT", "engines": { @@ -12321,42 +13353,10 @@ "once": "^1.4.0" } }, - "node_modules/engine.io": { - "version": "6.6.9", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz", - "integrity": "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/cors": "^2.8.12", - "@types/node": ">=10.0.0", - "@types/ws": "^8.5.12", - "accepts": "~1.3.4", - "base64id": "2.0.0", - "cookie": "~0.7.2", - "cors": "~2.8.5", - "debug": "~4.4.1", - "engine.io-parser": "~5.2.1", - "ws": "~8.21.0" - }, - "engines": { - "node": ">=10.2.0" - } - }, - "node_modules/engine.io-parser": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", - "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, "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": { @@ -12367,22 +13367,6 @@ "node": ">=10.13.0" } }, - "node_modules/ent": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.2.tgz", - "integrity": "sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "punycode": "^1.4.1", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -12599,7 +13583,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -12609,16 +13592,15 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" } }, "node_modules/es-module-lexer": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", - "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true, "license": "MIT" }, @@ -12626,7 +13608,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -12635,15 +13616,31 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "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": { @@ -12807,9 +13804,9 @@ } }, "node_modules/eslint": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", - "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", "dev": true, "license": "MIT", "dependencies": { @@ -12818,8 +13815,8 @@ "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "9.39.4", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -13074,6 +14071,7 @@ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" @@ -13118,6 +14116,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -13186,6 +14194,16 @@ "dev": true, "license": "ISC" }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -13240,43 +14258,24 @@ "url": "https://opencollective.com/express" } }, - "node_modules/express/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/express/node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "node_modules/express/node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">= 0.6" } }, - "node_modules/express/node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "dev": true, "license": "MIT", "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" + "ms": "2.0.0" } }, "node_modules/express/node_modules/ms": { @@ -13296,23 +14295,6 @@ "node": ">= 0.6" } }, - "node_modules/express/node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true, - "license": "MIT" - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -13372,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": [ { @@ -13436,6 +14418,13 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, "node_modules/figures": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", @@ -13492,18 +14481,18 @@ } }, "node_modules/finalhandler": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", - "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", "dev": true, "license": "MIT", "dependencies": { "debug": "2.6.9", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", - "on-finished": "~2.3.0", + "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "statuses": "~1.5.0", + "statuses": "~2.0.2", "unpipe": "~1.0.0" }, "engines": { @@ -13527,19 +14516,6 @@ "dev": true, "license": "MIT" }, - "node_modules/finalhandler/node_modules/on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/find-cache-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-4.0.0.tgz", @@ -13629,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" }, @@ -13639,7 +14615,6 @@ "version": "1.16.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "dev": true, "funding": [ { "type": "individual", @@ -13673,6 +14648,22 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -13717,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": { @@ -13770,7 +14761,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -13826,7 +14816,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -13851,7 +14840,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -13911,22 +14899,22 @@ } }, "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" }, - "engines": { - "node": "*" + "bin": { + "glob": "dist/esm/bin.mjs" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -13977,27 +14965,29 @@ "license": "MIT" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "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": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^1.0.0" } }, "node_modules/glob/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^2.0.2" }, "engines": { - "node": "*" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/global-directory": { @@ -14074,7 +15064,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -14180,7 +15169,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -14193,7 +15181,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -14209,7 +15196,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -14307,6 +15293,19 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -14382,16 +15381,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/http-errors/node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/http-parser-js": { "version": "0.5.10", "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", @@ -14578,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": { @@ -14749,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": { @@ -14951,24 +15940,12 @@ "node": ">=0.10.0" } }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "license": "MIT" }, "node_modules/is-stream": { "version": "2.0.1", @@ -15026,19 +16003,6 @@ "dev": true, "license": "MIT" }, - "node_modules/isbinaryfile": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", - "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/gjtorikian/" - } - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -15116,88 +16080,18 @@ } }, "node_modules/istanbul-lib-source-maps": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-3.0.6.tgz", - "integrity": "sha512-R47KzMtDJH6X4/YW9XTx+jrLnZnscW4VpNN+1PViSYTejLVPWv7oov+Duf8YQSPyVRUvueQqz1TcsC6mooZTXw==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", "debug": "^4.1.1", - "istanbul-lib-coverage": "^2.0.5", - "make-dir": "^2.1.0", - "rimraf": "^2.6.3", - "source-map": "^0.6.1" + "istanbul-lib-coverage": "^3.0.0" }, "engines": { - "node": ">=6" - } - }, - "node_modules/istanbul-lib-source-maps/node_modules/istanbul-lib-coverage": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.5.tgz", - "integrity": "sha512-8aXznuEPCJvGnMSRft4udDRDtb1V3pkQkMMI5LI+6HuQz5oQ4J2UFn1H82raA3qJtyOLkkwVqICBQkjnGtn5mA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=6" - } - }, - "node_modules/istanbul-lib-source-maps/node_modules/make-dir": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", - "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pify": "^4.0.1", - "semver": "^5.6.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/istanbul-lib-source-maps/node_modules/pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/istanbul-lib-source-maps/node_modules/rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - } - }, - "node_modules/istanbul-lib-source-maps/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver" - } - }, - "node_modules/istanbul-lib-source-maps/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" + "node": ">=10" } }, "node_modules/istanbul-reports": { @@ -15230,23 +16124,6 @@ "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/jasmine-core": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-6.3.0.tgz", - "integrity": "sha512-eMm5qBovNjNoGOcgE/W207+wrcK5zrQv0Rg/rWGboUJUmZp0dFCpHTyjpuDAfCwRCqg7f9U2q2jtv/aUuzdCQg==", - "dev": true, - "license": "MIT" - }, - "node_modules/jasmine-spec-reporter": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/jasmine-spec-reporter/-/jasmine-spec-reporter-7.0.0.tgz", - "integrity": "sha512-OtC7JRasiTcjsaCBPtMO0Tl8glCejM4J4/dNuOJdA8lBjz4PmWjYQ6pzb0uzpBNAWJMDudYuj9OdXJWqM2QTJg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "colors": "1.4.0" - } - }, "node_modules/java-properties": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/java-properties/-/java-properties-1.0.2.tgz", @@ -15257,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", @@ -15343,6 +16205,72 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -15395,9 +16323,9 @@ "license": "MIT" }, "node_modules/json-with-bigint": { - "version": "3.5.8", - "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.8.tgz", - "integrity": "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==", + "version": "3.5.10", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.10.tgz", + "integrity": "sha512-Vcx+JVNEBts/xfcoCS69sKrOhOk/3TVlvlT+XzUOefVKnnrbYSCKpDCm10pohsJFtsJVYnwa/cXRZ4eElzaM6w==", "dev": true, "license": "MIT" }, @@ -15411,411 +16339,47 @@ "json5": "lib/cli.js" }, "engines": { - "node": ">=6" - } - }, - "node_modules/jsonc-parser": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", - "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/jsonparse": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", - "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", - "dev": true, - "engines": [ - "node >= 0.2.0" - ], - "license": "MIT" - }, - "node_modules/karma": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.4.tgz", - "integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@colors/colors": "1.5.0", - "body-parser": "^1.19.0", - "braces": "^3.0.2", - "chokidar": "^3.5.1", - "connect": "^3.7.0", - "di": "^0.0.1", - "dom-serialize": "^2.2.1", - "glob": "^7.1.7", - "graceful-fs": "^4.2.6", - "http-proxy": "^1.18.1", - "isbinaryfile": "^4.0.8", - "lodash": "^4.17.21", - "log4js": "^6.4.1", - "mime": "^2.5.2", - "minimatch": "^3.0.4", - "mkdirp": "^0.5.5", - "qjobs": "^1.2.0", - "range-parser": "^1.2.1", - "rimraf": "^3.0.2", - "socket.io": "^4.7.2", - "source-map": "^0.6.1", - "tmp": "^0.2.1", - "ua-parser-js": "^0.7.30", - "yargs": "^16.1.1" - }, - "bin": { - "karma": "bin/karma" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/karma-chrome-launcher": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/karma-chrome-launcher/-/karma-chrome-launcher-3.2.0.tgz", - "integrity": "sha512-rE9RkUPI7I9mAxByQWkGJFXfFD6lE4gC5nPuZdobf/QdTEJI6EU4yIay/cfU/xV4ZxlM5JiTv7zWYgA64NpS5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "which": "^1.2.1" - } - }, - "node_modules/karma-chrome-launcher/node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "which": "bin/which" - } - }, - "node_modules/karma-coverage-istanbul-reporter": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/karma-coverage-istanbul-reporter/-/karma-coverage-istanbul-reporter-3.0.3.tgz", - "integrity": "sha512-wE4VFhG/QZv2Y4CdAYWDbMmcAHeS926ZIji4z+FkB2aF/EposRb6DP6G5ncT/wXhqUfAb/d7kZrNKPonbvsATw==", - "dev": true, - "license": "MIT", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^3.0.6", - "istanbul-reports": "^3.0.2", - "minimatch": "^3.0.4" - }, - "funding": { - "url": "https://github.com/sponsors/mattlewis92" - } - }, - "node_modules/karma-coverage-istanbul-reporter/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/karma-coverage-istanbul-reporter/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/karma-coverage-istanbul-reporter/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/karma-jasmine": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/karma-jasmine/-/karma-jasmine-5.1.0.tgz", - "integrity": "sha512-i/zQLFrfEpRyQoJF9fsCdTMOF5c2dK7C7OmsuKg2D0YSsuZSfQDiLuaiktbuio6F2wiCsZSnSnieIQ0ant/uzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "jasmine-core": "^4.1.0" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "karma": "^6.0.0" - } - }, - "node_modules/karma-jasmine-html-reporter": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/karma-jasmine-html-reporter/-/karma-jasmine-html-reporter-2.2.0.tgz", - "integrity": "sha512-J0laEC43Oy2RdR5V5R3bqmdo7yRIYySq6XHKbA+e5iSAgLjhR1oICLGeSREPlJXpeyNcdJf3J17YcdhD0mRssQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "jasmine-core": "^4.0.0 || ^5.0.0 || ^6.0.0", - "karma": "^6.0.0", - "karma-jasmine": "^5.0.0" - } - }, - "node_modules/karma-jasmine/node_modules/jasmine-core": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-4.6.1.tgz", - "integrity": "sha512-VYz/BjjmC3klLJlLwA4Kw8ytk0zDSmbbDLNs794VnWmkcCB7I9aAL/D48VNQtmITyPvea2C3jdUMfc3kAoy0PQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/karma-source-map-support": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/karma-source-map-support/-/karma-source-map-support-1.4.0.tgz", - "integrity": "sha512-RsBECncGO17KAoJCYXjv+ckIz+Ii9NCi+9enk+rq6XC81ezYkb4/RHE6CTXdA7IOJqoF3wcaLfVG0CPmE5ca6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "source-map-support": "^0.5.5" - } - }, - "node_modules/karma/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/karma/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/karma/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/karma/node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/karma/node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "node_modules/karma/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/karma/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/karma/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/karma/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/karma/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/karma/node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/karma/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/karma/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/karma/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/karma/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "node": ">=6" } }, - "node_modules/karma/node_modules/yargs": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", - "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==", + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", "dev": true, "license": "MIT", "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" + "universalify": "^2.0.0" }, - "engines": { - "node": ">=10" + "optionalDependencies": { + "graceful-fs": "^4.1.6" } }, - "node_modules/karma/node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" + "engines": [ + "node >= 0.2.0" + ], + "license": "MIT" + }, + "node_modules/karma-source-map-support": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/karma-source-map-support/-/karma-source-map-support-1.4.0.tgz", + "integrity": "sha512-RsBECncGO17KAoJCYXjv+ckIz+Ii9NCi+9enk+rq6XC81ezYkb4/RHE6CTXdA7IOJqoF3wcaLfVG0CPmE5ca6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "source-map-support": "^0.5.5" } }, "node_modules/katex": { @@ -15948,20 +16512,6 @@ "node": ">=6" } }, - "node_modules/less/node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "dev": true, - "license": "MIT", - "optional": true, - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/less/node_modules/pify": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", @@ -16191,7 +16741,6 @@ "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, "license": "MIT" }, "node_modules/lodash-es": { @@ -16351,23 +16900,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/log4js": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/log4js/-/log4js-6.9.1.tgz", - "integrity": "sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "date-format": "^4.0.14", - "debug": "^4.3.4", - "flatted": "^3.2.7", - "rfdc": "^1.3.0", - "streamroller": "^3.1.5" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/loglevel": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", @@ -16463,6 +16995,13 @@ "node": ">=0.8.0" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -16483,6 +17022,18 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, "node_modules/make-asynchronous": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/make-asynchronous/-/make-asynchronous-1.1.0.tgz", @@ -16611,12 +17162,18 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -16778,23 +17335,22 @@ } }, "node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", "dev": true, "license": "MIT", "bin": { "mime": "cli.js" }, "engines": { - "node": ">=4.0.0" + "node": ">=4" } }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -16804,7 +17360,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -16865,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" @@ -17044,16 +17599,16 @@ } }, "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", "dev": true, "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, "bin": { "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/moment": { @@ -17079,7 +17634,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/msgpackr": { @@ -17153,9 +17707,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -17247,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", @@ -17394,9 +17934,9 @@ } }, "node_modules/node-gyp/node_modules/tar": { - "version": "7.5.19", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.19.tgz", - "integrity": "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==", + "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": { @@ -17437,9 +17977,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.50", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", - "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", "dev": true, "license": "MIT", "engines": { @@ -19860,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": { @@ -19944,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 }, @@ -20123,6 +20663,13 @@ "node": ">= 0.8" } }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, "node_modules/path-data-parser": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", @@ -20211,6 +20758,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/photoviewer": { "version": "3.11.1", "resolved": "https://registry.npmjs.org/photoviewer/-/photoviewer-3.11.1.tgz", @@ -20632,9 +21196,9 @@ } }, "node_modules/prettier": { - "version": "3.9.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz", - "integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==", + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, "license": "MIT", "bin": { @@ -20887,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": { @@ -21306,6 +21870,15 @@ } } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/prr": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", @@ -21326,20 +21899,13 @@ } }, "node_modules/punycode": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", - "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/qjobs": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/qjobs/-/qjobs-1.2.0.tgz", - "integrity": "sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "license": "MIT", "engines": { - "node": ">=0.9" + "node": ">=6" } }, "node_modules/qs": { @@ -21861,6 +22427,59 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rimraf/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/robust-predicates": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", @@ -22007,24 +22626,6 @@ ], "license": "MIT" }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -22033,21 +22634,21 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.85.0.tgz", - "integrity": "sha512-3ToiC1xZ1Y8aU7+CkgCI/tqyuPXEmYGJXO7H4uqp0xkLXUqp88rQQ4j1HmP37xSJLbCJPaIiv+cT1y+grssrww==", + "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": { - "chokidar": "^4.0.0", - "immutable": "^5.0.2", + "chokidar": "^5.0.0", + "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "bin": { "sass": "sass.js" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.19.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" @@ -22094,15 +22695,58 @@ } } }, + "node_modules/sass/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/sass/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "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, "engines": { - "node": ">=11.0.0" + "node": ">=11.0.0" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" } }, "node_modules/schema-utils": { @@ -22172,9 +22816,9 @@ } }, "node_modules/semantic-release": { - "version": "25.0.5", - "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.5.tgz", - "integrity": "sha512-mn61SUJwtM8ThrWn2WmgLVpwVJeG/hPSupua1psdMoufmwRIPyvRLkRkL0JDXkP67OntlLWUYnBnfVc8EDO3/g==", + "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": { @@ -22430,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" }, @@ -22457,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", @@ -22545,29 +23206,6 @@ "dev": true, "license": "MIT" }, - "node_modules/send/node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/send/node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/send/node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -22578,16 +23216,6 @@ "node": ">= 0.6" } }, - "node_modules/send/node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", @@ -22665,6 +23293,16 @@ "dev": true, "license": "MIT" }, + "node_modules/serve-index/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/serve-static": { "version": "1.16.3", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", @@ -22681,16 +23319,6 @@ "node": ">= 0.8.0" } }, - "node_modules/serve-static/node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -22735,9 +23363,9 @@ } }, "node_modules/shell-quote": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", - "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", + "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", "dev": true, "license": "MIT", "engines": { @@ -22823,6 +23451,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -22960,6 +23595,21 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/skin-tone": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz", @@ -23027,50 +23677,6 @@ "npm": ">= 3.0.0" } }, - "node_modules/socket.io": { - "version": "4.8.3", - "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", - "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "~1.3.4", - "base64id": "~2.0.0", - "cors": "~2.8.5", - "debug": "~4.4.1", - "engine.io": "~6.6.0", - "socket.io-adapter": "~2.5.2", - "socket.io-parser": "~4.2.4" - }, - "engines": { - "node": ">=10.2.0" - } - }, - "node_modules/socket.io-adapter": { - "version": "2.5.8", - "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz", - "integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "~4.4.1", - "ws": "~8.21.0" - } - }, - "node_modules/socket.io-parser": { - "version": "4.2.6", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", - "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.4.1" - }, - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/sockjs": { "version": "0.3.24", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", @@ -23125,13 +23731,14 @@ } }, "node_modules/source-map": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "engines": { - "node": ">= 8" + "node": ">= 12" } }, "node_modules/source-map-js": { @@ -23316,16 +23923,30 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stream-combiner2": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz", @@ -23370,56 +23991,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/streamroller": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz", - "integrity": "sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "date-format": "^4.0.14", - "debug": "^4.3.4", - "fs-extra": "^8.1.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/streamroller/node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/streamroller/node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/streamroller/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -23577,6 +24148,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stylis": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", @@ -23655,6 +24246,13 @@ "node": ">=0.10" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/synckit": { "version": "0.11.13", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", @@ -23780,19 +24378,6 @@ "node": ">=8" } }, - "node_modules/tar/node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/tar/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -23942,6 +24527,21 @@ "dev": true, "license": "MIT" }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -23973,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": { @@ -24070,6 +24670,13 @@ "license": "MIT", "optional": true }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", @@ -24097,16 +24704,56 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tmp": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", - "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", "dev": true, "license": "MIT", "engines": { - "node": ">=14.14" + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "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.9" + }, + "bin": { + "tldts": "bin/cli.js" } }, + "node_modules/tldts-core": { + "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" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -24130,6 +24777,42 @@ "node": ">=0.6" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/traverse": { "version": "0.6.8", "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.8.tgz", @@ -24193,6 +24876,17 @@ "node": ">=6.10" } }, + "node_modules/ts-morph": { + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-21.0.1.tgz", + "integrity": "sha512-dbDtVdEAncKctzrVZ+Nr7kHpHkv+0JDJb2MjjpBaj8bFeCkePU9rHfMklmhuLFnpeq/EJZk2IhStY6NzqgjOkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.22.0", + "code-block-writer": "^12.0.0" + } + }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", @@ -24244,12 +24938,12 @@ "license": "0BSD" }, "node_modules/tslint": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/tslint/-/tslint-6.1.3.tgz", - "integrity": "sha512-IbR4nkT96EQOvKE2PW/djGz8iGNeJ4rF2mBfiYaR/nvUWYKJhLwimoJKgjIFEIDibBtOevj7BqCRL4oHeWWUCg==", - "deprecated": "TSLint has been deprecated in favor of ESLint. Please see https://github.com/palantir/tslint/issues/4534 for more information.", + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/tslint/-/tslint-5.20.1.tgz", + "integrity": "sha512-EcMxhzCFt8k+/UP5r8waCf/lzmeSyVlqxqMEDQE7rWYiQky8KpIBz1JAoYXfROHrPZ1XXd43q8yQnULOLiBRQg==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@babel/code-frame": "^7.0.0", "builtin-modules": "^1.1.1", @@ -24259,10 +24953,10 @@ "glob": "^7.1.1", "js-yaml": "^3.13.1", "minimatch": "^3.0.4", - "mkdirp": "^0.5.3", + "mkdirp": "^0.5.1", "resolve": "^1.3.2", "semver": "^5.3.0", - "tslib": "^1.13.0", + "tslib": "^1.8.0", "tsutils": "^2.29.0" }, "bin": { @@ -24272,7 +24966,7 @@ "node": ">=4.8.0" }, "peerDependencies": { - "typescript": ">=2.3.0-dev || >=2.4.0-dev || >=2.5.0-dev || >=2.6.0-dev || >=2.7.0-dev || >=2.8.0-dev || >=2.9.0-dev || >=3.0.0-dev || >= 3.1.0-dev || >= 3.2.0-dev || >= 4.0.0-dev" + "typescript": ">=2.3.0-dev || >=2.4.0-dev || >=2.5.0-dev || >=2.6.0-dev || >=2.7.0-dev || >=2.8.0-dev || >=2.9.0-dev || >=3.0.0-dev || >= 3.1.0-dev || >= 3.2.0-dev" } }, "node_modules/tslint/node_modules/ansi-styles": { @@ -24281,6 +24975,7 @@ "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "color-convert": "^1.9.0" }, @@ -24294,6 +24989,7 @@ "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "sprintf-js": "~1.0.2" } @@ -24303,7 +24999,8 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tslint/node_modules/brace-expansion": { "version": "1.1.16", @@ -24311,6 +25008,7 @@ "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -24322,6 +25020,7 @@ "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -24337,6 +25036,7 @@ "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "color-name": "1.1.3" } @@ -24346,14 +25046,16 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tslint/node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tslint/node_modules/escape-string-regexp": { "version": "1.0.5", @@ -24361,16 +25063,41 @@ "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.8.0" } }, + "node_modules/tslint/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/tslint/node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=4" } @@ -24381,6 +25108,7 @@ "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -24395,6 +25123,7 @@ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -24402,12 +25131,27 @@ "node": "*" } }, + "node_modules/tslint/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/tslint/node_modules/semver": { "version": "5.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", "dev": true, "license": "ISC", + "peer": true, "bin": { "semver": "bin/semver" } @@ -24417,7 +25161,8 @@ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "dev": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/tslint/node_modules/supports-color": { "version": "5.5.0", @@ -24425,6 +25170,7 @@ "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "has-flag": "^3.0.0" }, @@ -24437,7 +25183,8 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "dev": true, - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsutils": { "version": "2.29.0", @@ -24445,6 +25192,7 @@ "integrity": "sha512-g5JVHCIJwzfISaXpXE1qvNalca5Jwob6FjI4AoPlqMusJ6ftFE7IkkFoMhVLRgK+4Kx3gkzb8UZK5t5yTTvEmA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tslib": "^1.8.1" }, @@ -24457,7 +25205,8 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "dev": true, - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tuf-js": { "version": "3.1.0", @@ -24549,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" @@ -24572,33 +25321,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/ua-parser-js": { - "version": "0.7.41", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.41.tgz", - "integrity": "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/ua-parser-js" - }, - { - "type": "paypal", - "url": "https://paypal.me/faisalman" - }, - { - "type": "github", - "url": "https://github.com/sponsors/faisalman" - } - ], - "license": "MIT", - "bin": { - "ua-parser-js": "script/cli.js" - }, - "engines": { - "node": "*" - } - }, "node_modules/uglify-js": { "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", @@ -24630,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": { @@ -24741,6 +25464,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universal-user-agent": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", @@ -24809,16 +25546,6 @@ "punycode": "^2.1.0" } }, - "node_modules/uri-js/node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/url-join": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.0.0.tgz", @@ -24897,13 +25624,42 @@ "node": ">= 0.8" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "6.4.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -24973,6 +25729,29 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vite/node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -24986,7 +25765,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } @@ -25004,7 +25782,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -25022,7 +25799,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -25040,7 +25816,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -25058,7 +25833,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -25076,7 +25850,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -25094,7 +25867,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -25112,7 +25884,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -25130,7 +25901,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -25148,7 +25918,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -25166,7 +25935,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -25184,7 +25952,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -25202,7 +25969,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -25220,7 +25986,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -25238,7 +26003,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -25256,7 +26020,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -25274,7 +26037,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -25292,7 +26054,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -25310,7 +26071,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -25328,7 +26088,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -25346,7 +26105,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -25364,7 +26122,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": ">=18" } @@ -25382,7 +26139,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } @@ -25400,7 +26156,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -25418,7 +26173,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -25436,7 +26190,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -25448,7 +26201,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -25484,22 +26236,92 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/vlq": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==", "license": "MIT" }, - "node_modules/void-elements": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz", - "integrity": "sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/vue-eslint-parser": { "version": "9.4.3", "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz", @@ -25560,6 +26382,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/watchpack": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", @@ -25609,6 +26444,16 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, "node_modules/webpack": { "version": "5.105.0", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", @@ -25895,6 +26740,13 @@ } } }, + "node_modules/webpack/node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/webpack/node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", @@ -25964,6 +26816,30 @@ "node": ">=0.8.0" } }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -25980,6 +26856,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wildcard": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", @@ -26174,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": { @@ -26216,6 +27109,23 @@ "node": ">=0.8" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -26351,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": { @@ -26376,6 +27286,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zone.js": { "version": "0.15.1", "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz", diff --git a/package.json b/package.json index be73fba7b..9e1d0c33a 100644 --- a/package.json +++ b/package.json @@ -22,14 +22,20 @@ "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", + "test:coverage": "vitest run --coverage", "build:analyze": "ng build --configuration analyze --stats-json", "analyze:report": "npm run build:analyze && npx esbuild-visualizer --metadata dist/app/stats.json --filename dist/app/esbuild-report.html && open dist/app/esbuild-report.html", - "test": "ng test", - "e2e": "ng e2e", "prepare": "husky || true", "lint": "npx eslint src", "lint:fix": "npm run lint -- --fix", + "lint:warnonly": "npm run lint || true", "prettier": "npx prettier src --check", "prettier:fix": "npm run prettier -- --write", "format": "npm run prettier:fix && npm run lint:fix", @@ -44,16 +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.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", @@ -63,6 +69,9 @@ "zone.js": "~0.15.0" }, "devDependencies": { + "@analogjs/vite-plugin-angular": "^1.22.1", + "@analogjs/vitest-angular": "^1.22.1", + "@eslint/js": "^9.39.1", "@angular-devkit/build-angular": "^19.2.27", "@angular/cli": "^19.2.27", "@angular/compiler-cli": "^19.2.25", @@ -71,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", @@ -80,19 +91,15 @@ "eslint-plugin-prettier": "^5.5.5", "eslint-plugin-unused-imports": "^4.1.4", "husky": "^9.1.7", - "jasmine-core": "^6.3.0", - "jasmine-spec-reporter": "^7.0.0", - "karma": "~6.4.2", - "karma-chrome-launcher": "^3.2.0", - "karma-coverage-istanbul-reporter": "~3.0.2", - "karma-jasmine": "^5.1.0", - "karma-jasmine-html-reporter": "^2.1.0", + "jsdom": "^27.0.1", "prettier": "^3.8.1", "prettier-eslint": "^16.3.0", + "sass": "^1.94.2", "semantic-release": "^25.0.3", "ts-node": "^10.9.1", - "tslint": "~6.1.0", "typescript": "^5.8.3", - "typescript-eslint": "^8.58.0" + "typescript-eslint": "^8.58.0", + "vite": "^6.4.1", + "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 bb363bb48..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,73 +22,93 @@ 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 + { + attackType: 'identity', + editable: true, + headerSection: 'more', + }, ]; 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: [ @@ -147,7 +168,7 @@ stixRoutes.push({ canActivateChild: [AuthorizationGuard], data: { breadcrumb: 'marking definitions', - headerSection: 'more', + group: 'more', }, children: [ { @@ -204,7 +225,7 @@ if (environment.integrations.collection_manager.enabled) { canActivateChild: [AuthorizationGuard], data: { breadcrumb: 'collections', - headerSection: 'more', + group: 'more', }, children: [ { @@ -301,7 +322,7 @@ stixRoutes.push( canActivateChild: [AuthorizationGuard], data: { breadcrumb: 'reference manager', - headerSection: 'more', + group: 'more', }, children: [ { @@ -321,7 +342,7 @@ stixRoutes.push( canActivateChild: [AuthorizationGuard], data: { breadcrumb: 'contributors', - headerSection: 'more', + group: 'more', }, children: [ { @@ -340,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 f974744cc..f426ceb08 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -3,18 +3,22 @@ import { Routes, RouterModule } from '@angular/router'; import { LandingPageComponent } from './views/landing-page/landing-page.component'; import { HelpPageComponent } from './views/help-page/help-page.component'; import { DashboardPageComponent } from './views/dashboard-page/dashboard-page.component'; +import { DataQualityComponent } from './views/dashboard-page/data-quality/data-quality.component'; 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: { @@ -47,35 +51,43 @@ const routes: Routes = [ children: [ { path: '', - data: { - breadcrumb: 'dashboard', - title: 'Dashboard', - }, - component: DashboardPageComponent, + pathMatch: 'full', + redirectTo: 'overview', }, { - path: 'org-settings', + path: 'overview', data: { - breadcrumb: 'organization settings', - title: 'Organization Identity', + breadcrumb: 'overview', + title: 'Knowledge Base Overview', }, - component: OrgSettingsPageComponent, - }, - { - path: 'user-accounts', - data: { - breadcrumb: 'user accounts', - title: 'User Accounts', - }, - component: UserAccountsPageComponent, + component: DashboardPageComponent, }, { - path: 'default-marking-definitions', + path: 'release-management', data: { - breadcrumb: 'default marking definitions', - title: 'Default Marking Definitions', + breadcrumb: 'release management', + title: 'Release Management', + roles: [Role.ADMIN, Role.TEAM_LEAD], }, - component: DefaultMarkingDefinitionsComponent, + 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', @@ -104,6 +116,51 @@ const routes: Routes = [ }, ], }, + { + path: 'data-quality', + data: { + breadcrumb: 'data quality', + title: 'Data Quality', + roles: [Role.ADMIN, Role.TEAM_LEAD], + }, + component: DataQualityComponent, + }, + { + path: 'org-settings', + data: { + breadcrumb: 'organization settings', + title: 'Organization Identity', + roles: [Role.ADMIN], + }, + component: OrgSettingsPageComponent, + }, + { + path: 'user-accounts', + data: { + breadcrumb: 'user accounts', + title: 'User Accounts', + roles: [Role.ADMIN], + }, + component: UserAccountsPageComponent, + }, + { + path: 'default-marking-definitions', + data: { + breadcrumb: 'default marking definitions', + title: 'Default Marking Definitions', + roles: [Role.ADMIN], + }, + component: DefaultMarkingDefinitionsComponent, + }, + { + path: 'validation-bypasses', + data: { + breadcrumb: 'validation bypasses', + title: 'ADM Validation Bypasses', + roles: [Role.ADMIN], + }, + component: ValidationBypassesComponent, + }, ], }, { @@ -115,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.spec.ts b/src/app/app.component.spec.ts index 62204e889..cbf208613 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -1,12 +1,33 @@ +import { provideHttpClient } from '@angular/common/http'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { RouterTestingModule } from '@angular/router/testing'; +import { provideRouter } from '@angular/router'; +import { LoggerModule, NgxLoggerLevel } from 'ngx-logger'; +import { vi } from 'vitest'; import { AppComponent } from './app.component'; describe('AppComponent', () => { beforeEach(async () => { + // Mock window.matchMedia + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); + await TestBed.configureTestingModule({ - imports: [RouterTestingModule], + imports: [LoggerModule.forRoot({ level: NgxLoggerLevel.OFF })], declarations: [AppComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [provideHttpClient(), provideRouter([])], }).compileComponents(); }); @@ -15,19 +36,4 @@ describe('AppComponent', () => { const app = fixture.componentInstance; expect(app).toBeTruthy(); }); - - it(`should have as title 'app'`, () => { - const fixture = TestBed.createComponent(AppComponent); - const app = fixture.componentInstance; - expect(app.title).toEqual('app'); - }); - - it('should render title', () => { - const fixture = TestBed.createComponent(AppComponent); - fixture.detectChanges(); - const compiled = fixture.nativeElement; - expect(compiled.querySelector('.content span').textContent).toContain( - 'app app is running!' - ); - }); }); 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 a80dd6e42..4ba43f170 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -45,6 +45,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatRadioModule } from '@angular/material/radio'; import { MatSelectModule } from '@angular/material/select'; import { MatSidenavModule } from '@angular/material/sidenav'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatSnackBarModule } from '@angular/material/snack-bar'; import { MatSortModule } from '@angular/material/sort'; import { MatStepperModule } from '@angular/material/stepper'; @@ -56,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 @@ -73,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'; @@ -106,10 +112,7 @@ import { TlpEditComponent } from './components/stix/tlp-property/tlp-edit/tlp-ed import { TlpPropertyComponent } from './components/stix/tlp-property/tlp-property.component'; import { TlpViewComponent } from './components/stix/tlp-property/tlp-view/tlp-view.component'; -import { AttackidDiffComponent } from './components/stix/attackid-property/attackid-diff/attackid-diff.component'; -import { AttackIDEditComponent } from './components/stix/attackid-property/attackid-edit/attackid-edit.component'; import { AttackIDPropertyComponent } from './components/stix/attackid-property/attackid-property.component'; -import { AttackIDViewComponent } from './components/stix/attackid-property/attackid-view/attackid-view.component'; import { StixIDPropertyComponent } from './components/stix/stixid-property/stixid-property.component'; @@ -147,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'; @@ -158,12 +161,16 @@ import { CitationViewComponent } from './components/stix/citation-property/citat import { HelpPageComponent } from './views/help-page/help-page.component'; import { LandingPageComponent } from './views/landing-page/landing-page.component'; import { DashboardPageComponent } from './views/dashboard-page/dashboard-page.component'; +import { DataQualityComponent } from './views/dashboard-page/data-quality/data-quality.component'; 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'; @@ -187,6 +194,7 @@ import { CampaignViewComponent } from './views/stix/campaign-view/campaign-view. import { DataComponentViewComponent } from './views/stix/data-component-view/data-component-view.component'; import { DataSourceViewComponent } from './views/stix/data-source-view/data-source-view.component'; import { GroupViewComponent } from './views/stix/group-view/group-view.component'; +import { IdentityViewComponent } from './views/stix/identity-view/identity-view.component'; import { MarkingDefinitionViewComponent } from './views/stix/marking-definition-view/marking-definition-view.component'; import { MatrixFlatComponent } from './views/stix/matrix/matrix-flat/matrix-flat.component'; import { MatrixSideComponent } from './views/stix/matrix/matrix-side/matrix-side.component'; @@ -225,6 +233,18 @@ import { AppConfigService } from './services/config/app-config.service'; import { AnalyticViewComponent } from './views/stix/analytic-view/analytic-view.component'; import { DetectionStrategyViewComponent } from './views/stix/detection-strategy-view/detection-strategy-view.component'; import { StixListPageComponent } from './views/stix/stix-list-page/stix-list-page.component'; +import { DictionaryPropertyComponent } from './components/stix/dictionary-property/dictionary-property.component'; +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(); @@ -234,6 +254,7 @@ export function initConfig(appConfigService: AppConfigService) { declarations: [ AppComponent, HeaderComponent, + NavigationComponent, FooterComponent, LoadingOverlayComponent, ToolbarComponent, @@ -244,11 +265,15 @@ export function initConfig(appConfigService: AppConfigService) { MarkdownViewDialogComponent, CollectionImportSummaryComponent, SaveDialogComponent, + WorkflowStatusDialogComponent, AddDialogComponent, DeleteDialogComponent, HistoryTimelineComponent, + MembershipSectionComponent, ReferenceSidebarComponent, ReferenceEditDialogComponent, + ReleasePreviewDialogComponent, + SnapshotDescriptionDialogComponent, MultipleChoiceDialogComponent, ValidationResultsComponent, AddRelationshipButtonComponent, @@ -269,9 +294,6 @@ export function initConfig(appConfigService: AppConfigService) { TlpViewComponent, TlpEditComponent, AttackIDPropertyComponent, - AttackIDEditComponent, - AttackIDViewComponent, - AttackidDiffComponent, StixIDPropertyComponent, ListPropertyComponent, ListEditComponent, @@ -285,11 +307,16 @@ export function initConfig(appConfigService: AppConfigService) { DatepickerPropertyComponent, IconViewComponent, LandingPageComponent, + AllObjectsPageComponent, + StixPageTabsComponent, HelpPageComponent, DashboardPageComponent, + DataQualityComponent, OrgSettingsPageComponent, UserAccountsPageComponent, DefaultMarkingDefinitionsComponent, + ValidationBypassesComponent, + ValidationBypassRuleDialogComponent, ProfilePageComponent, ReferenceManagerComponent, StixDialogComponent, @@ -328,6 +355,7 @@ export function initConfig(appConfigService: AppConfigService) { IdentityPropertyComponent, DataSourceViewComponent, DataComponentViewComponent, + IdentityViewComponent, MarkingDefinitionViewComponent, CampaignViewComponent, CitationPropertyComponent, @@ -368,11 +396,20 @@ export function initConfig(appConfigService: AppConfigService) { StixJsonDialogComponent, OutdatedContentWarningComponent, StreamProgressComponent, + DictionaryPropertyComponent, + DictionaryEditComponent, + DictionaryViewComponent, + DictionaryDiffComponent, + ReleaseManagementComponent, + NewTrackDialogComponent, + ReleaseTrackPageComponent, + StatusChipComponent, ], exports: [ MatToolbarModule, MatButtonModule, MatSidenavModule, + MatSlideToggleModule, MatIconModule, MatTableModule, MatSortModule, @@ -390,6 +427,7 @@ export function initConfig(appConfigService: AppConfigService) { MatSelectModule, MatExpansionModule, MatCheckboxModule, + MatSlideToggleModule, MatRadioModule, MatProgressSpinnerModule, MatMenuModule, @@ -409,7 +447,6 @@ export function initConfig(appConfigService: AppConfigService) { disableConsoleLogging: false, }), MtxPopoverModule, - NgxJdenticonModule, AutosizeModule, BrowserModule, AppRoutingModule, @@ -418,6 +455,7 @@ export function initConfig(appConfigService: AppConfigService) { MatToolbarModule, MatButtonModule, MatSidenavModule, + MatSlideToggleModule, MatIconModule, MatTableModule, MatSortModule, @@ -437,6 +475,7 @@ export function initConfig(appConfigService: AppConfigService) { MatSelectModule, MatExpansionModule, MatCheckboxModule, + MatSlideToggleModule, MatRadioModule, MatProgressSpinnerModule, MatMenuModule, @@ -450,6 +489,10 @@ export function initConfig(appConfigService: AppConfigService) { ClipboardModule, OverlayModule, MatAutocompleteModule, + UserAvatarComponent, + WorkbenchChipComponent, + ReleaseTrackCardComponent, + ReleaseTrackObjectCardComponent, ], providers: [ AppConfigService, @@ -457,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/external-references.ts b/src/app/classes/external-references.ts index e4d4ec9cf..cfb1f48f6 100644 --- a/src/app/classes/external-references.ts +++ b/src/app/classes/external-references.ts @@ -5,6 +5,7 @@ import { Serializable, ValidationData } from './serializable'; import { StixObject } from './stix/stix-object'; import { logger } from '../utils/logger'; import { RelatedAsset } from './stix/asset'; +import { xMitreFirstSeenCitationSchema } from '@mitre-attack/attack-data-model'; export class ExternalReferences extends Serializable { private _externalReferences = new Map(); @@ -12,6 +13,7 @@ export class ExternalReferences extends Serializable { private usedReferences: string[] = []; // array to store used references private missingReferences: string[] = []; // array to store missing references private brokenCitations: string[] = []; // array to store broken citations + private invalidCitations: string[] = []; // array to store invalid citations /** * return external references list @@ -208,38 +210,54 @@ export class ExternalReferences extends Serializable { restApiConnector: RestApiConnectorService ): Observable { const reReference = /\(Citation: (.*?)\)/gmu; - const citations = value.match(reReference); const result = new CitationParseResult({ brokenCitations: this.validateBrokenCitations(value, [ /\(Citation:([^ ].*?)\)/gmu, /\(citation:(.*?)\)/gmu, + /\(Citation [^)]+\)/gmu, ]), }); + const apiMap: { [key: string]: Observable } = {}; // Initialize API map + // Extract citations even if the value doesn't pass validation + const citations = value.match(reReference); // Extract citations using regex + // Process citations if (citations) { - // build lookup api map - const api_map = {}; for (const citation of citations) { - // Split to get source name from citation - const sourceName = citation.split('(Citation: ')[1].slice(0, -1); - api_map[sourceName] = this.checkAndAddReference( - sourceName, - restApiConnector - ); + const validateValue = xMitreFirstSeenCitationSchema.safeParse(citation); + if (validateValue.success) { + // Extract source name from citation + const sourceName = citation.split('(Citation: ')[1].slice(0, -1); + // Add API call to the map + apiMap[sourceName] = this.checkAndAddReference( + sourceName, + restApiConnector + ); + } else { + if (citation != '') { + result.invalidCitations.add(citation); + } + } } - // check/add each citation - return forkJoin(api_map).pipe( - map(api_results => { - const citation_results = api_results as any; - for (const key of Object.keys(citation_results)) { - // was the result able to be found/added? - if (citation_results[key]) result.usedCitations.add(key); - else result.missingCitations.add(key); + } + // If there are valid citations to process, use forkJoin + if (Object.keys(apiMap).length > 0) { + return forkJoin(apiMap).pipe( + map(apiResults => { + const citationResults = apiResults as any; + for (const key of Object.keys(citationResults)) { + // Check if the citation was successfully processed + if (citationResults[key]) { + result.usedCitations.add(key); + } else { + result.missingCitations.add(key); + } } return result; }) ); } else { + // If no valid citations, return the result immediately return of(result); } } @@ -497,6 +515,20 @@ export class ExternalReferences extends Serializable { field: 'external_references', //TODO set this to the actual field to improve warnings message: `Citations ${brokenCitations.join(', ')} do not match format (Citation: source name)`, }); + // invalid citations + const invalidCitations = Array.from(citationResult.invalidCitations); + if (invalidCitations.length == 1) + result.errors.push({ + result: 'error', + field: 'external_references', //TODO set this to the actual field to improve warnings + message: `Citation does not match format (Citation: source name) or (Citation: source name)(Citation: source name)`, + }); + else if (invalidCitations.length > 1) + result.errors.push({ + result: 'error', + field: 'external_references', //TODO set this to the actual field to improve warnings + message: `Citations ${invalidCitations.join(', ')} do not match format (Citation: source name) or (Citation: source name)(Citation: source name)`, + }); //missing citations const missingCitations = Array.from(citationResult.missingCitations); @@ -538,11 +570,14 @@ export class CitationParseResult { public missingCitations = new Set(); // list of broken references detected in the field public brokenCitations = new Set(); + // list of invalid references detected in the field + public invalidCitations = new Set(); constructor(initData?: { usedCitations?: Set; missingCitations?: Set; brokenCitations: Set; + invalidCitations?: Set; }) { if (initData && initData.usedCitations) this.usedCitations = initData.usedCitations; @@ -550,6 +585,8 @@ export class CitationParseResult { this.missingCitations = initData.missingCitations; if (initData && initData.brokenCitations) this.brokenCitations = initData.brokenCitations; + if (initData && initData.invalidCitations) + this.invalidCitations = initData.invalidCitations; } /** @@ -569,5 +606,9 @@ export class CitationParseResult { ...this.brokenCitations, ...that.brokenCitations, ]); + this.invalidCitations = new Set([ + ...this.invalidCitations, + ...that.invalidCitations, + ]); } } diff --git a/src/app/classes/release-tracks/api.ts b/src/app/classes/release-tracks/api.ts new file mode 100644 index 000000000..b68e5f265 --- /dev/null +++ b/src/app/classes/release-tracks/api.ts @@ -0,0 +1,208 @@ +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..29852029b --- /dev/null +++ b/src/app/classes/release-tracks/tiers.ts @@ -0,0 +1,52 @@ +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 ca25a6390..b4bdce1d8 100644 --- a/src/app/classes/serializable.ts +++ b/src/app/classes/serializable.ts @@ -1,5 +1,6 @@ import { Observable } from 'rxjs'; import { RestApiConnectorService } from '../services/connectors/rest-api/rest-api-connector.service'; +import { WorkflowStatusType } from '../utils/types'; /** * Objects which are serializable to the REST API implement this class @@ -16,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 @@ -27,7 +28,8 @@ export abstract class Serializable { */ public abstract validate( restAPIService: RestApiConnectorService, - options?: any + options?: any, + tempWorkflowState?: WorkflowStatusType ): Observable; } diff --git a/src/app/classes/stix/analytic.ts b/src/app/classes/stix/analytic.ts index dcebfe0a5..b00d1a1c0 100644 --- a/src/app/classes/stix/analytic.ts +++ b/src/app/classes/stix/analytic.ts @@ -1,9 +1,9 @@ -import { RelatedRef, StixObject } from './stix-object'; +import { EmbeddedRelationship, StixObject } from './stix-object'; import { logger } from '../../utils/logger'; -import { catchError, forkJoin, map, Observable, of, switchMap } from 'rxjs'; +import { Observable, of, switchMap } from 'rxjs'; import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; import { ValidationData } from '../serializable'; -import { StixType } from 'src/app/utils/types'; +import { WorkflowStatusType } from 'src/app/utils/types'; export class Analytic extends StixObject { public name = ''; @@ -13,7 +13,7 @@ export class Analytic extends StixObject { public mutableElements: MutableElement[] = []; // NOTE: the following fields will only be populated when this object is fetched with the `includeRefs=true` param - public relatedDetections: RelatedRef[] = []; + public relatedDetections: EmbeddedRelationship[] = []; public readonly supportsAttackID = true; protected get attackIDValidator() { @@ -72,6 +72,10 @@ export class Analytic extends StixObject { ); if (this.mutableElements?.length) rep.stix.x_mitre_mutable_elements = this.mutableElements; + + // Strip properties that are empty strs + lists + rep.stix = this.filterObject(rep.stix); + return rep; } @@ -136,22 +140,23 @@ export class Analytic extends StixObject { } else this.mutableElements = []; } - this.deserializeRelatedRefs(raw); + this.deserializeEmbeddedRelationships(raw); } - public deserializeRelatedRefs(raw: any): void { - if ('related_to' in raw) { - const relatedTo = raw.related_to as any[]; - const relatedRefs: RelatedRef[] = relatedTo.map(ref => ({ - stixId: ref.id, + public deserializeEmbeddedRelationships(raw: any): void { + if (raw.workspace?.embedded_relationships) { + const relatedTo = raw.workspace.embedded_relationships as any[]; + const relatedRefs: EmbeddedRelationship[] = relatedTo.map(ref => ({ + stixId: ref.stix_id, name: ref.name, attackId: ref.attack_id, - type: ref.type as StixType, })); - this.relatedDetections = relatedRefs.filter( - ref => ref.type === 'x-mitre-detection-strategy' - ); + function isDetectionStrategy(o: EmbeddedRelationship) { + return o.stixId.includes('x-mitre-detection-strategy'); + } + + this.relatedDetections = relatedRefs.filter(isDetectionStrategy); } } @@ -173,9 +178,10 @@ export class Analytic 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( + return this.base_validate(restAPIService, tempWorkflowState).pipe( switchMap(result => { // validate unique mutable fields if (this.mutableElements.length) { @@ -192,35 +198,6 @@ export class Analytic extends StixObject { seen.add(normalizedField); } } - - // validate unique log source references - if (this.logSourceReferences.length) { - return forkJoin( - this.logSourceReferences.map(ref => - restAPIService.getDataComponent(ref.dataComponentRef).pipe( - catchError(() => of(null)) // fallback if API fails - ) - ) - ).pipe( - map(dataComponents => { - const seen = new Set(); - this.logSourceReferences.forEach((lsr, idx) => { - const key = `${lsr.dataComponentRef}::${lsr.name}::${lsr.channel}`; - if (seen.has(key)) { - const dataComponent = dataComponents[idx]; - result.errors.push({ - field: 'logSourceReferences', - result: 'error', - message: `Duplicate log source reference found: ${dataComponent?.[0].attackID || 'unknown'}`, - }); - } - seen.add(key); - }); - return result; - }) - ); - } - return of(result); }) ); diff --git a/src/app/classes/stix/asset.ts b/src/app/classes/stix/asset.ts index 6409e103e..42daeac38 100644 --- a/src/app/classes/stix/asset.ts +++ b/src/app/classes/stix/asset.ts @@ -3,6 +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 { WorkflowStatusType } from 'src/app/utils/types'; export class Asset extends StixObject { public name = ''; @@ -72,15 +73,16 @@ export class Asset extends StixObject { contributors => contributors.map(x => x.trim()) ); + // Strip properties that are empty strs + lists + rep.stix = this.filterObject(rep.stix); + return rep; } public isRelatedAssetArray(arr: any[]): boolean { - return arr.every(a => this.instanceOfRelatedAsset(a)); - } - - public instanceOfRelatedAsset(object: any): boolean { - return 'name' in object && 'related_asset_sectors' in object; + return ( + Array.isArray(arr) && arr.every(a => typeof a === 'object' && 'name' in a) + ); } /** @@ -136,9 +138,10 @@ export class Asset 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); + return this.base_validate(restAPIService, tempWorkflowState); } /** @@ -190,6 +193,24 @@ export class Asset extends StixObject { }); return putObservable; } + + /** + * Revoke the STIX object in the database. + * @param restAPIService [RestApiConnectorService] the service to perform the revoke through + * @param revokingObject the revoking object payload + * @returns {Observable} of the revoke + */ + public revoke( + restAPIService: RestApiConnectorService, + revokingObject: { revoking: { stixId: string; modified: string } }, + preserveRelationships = false + ): Observable { + return restAPIService.revokeAsset( + this.stixID, + revokingObject, + preserveRelationships + ); + } } export interface RelatedAsset { 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 c44b825c7..e820334b2 100644 --- a/src/app/classes/stix/campaign.ts +++ b/src/app/classes/stix/campaign.ts @@ -1,8 +1,9 @@ -import { StixObject } from './stix-object'; -import { logger } from '../../utils/logger'; import { Observable } from 'rxjs'; import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { logger } from '../../utils/logger'; import { ValidationData } from '../serializable'; +import { StixObject } from './stix-object'; +import { WorkflowStatusType } from 'src/app/utils/types'; export class Campaign extends StixObject { public name = ''; @@ -10,8 +11,9 @@ export class Campaign extends StixObject { public last_seen: Date; public first_seen_citation = ''; public last_seen_citation = ''; - public aliases: string[] = ['placeholder']; // initialize field with placeholder in first index for campaign name + public aliases: string[] = []; public contributors: string[] = []; + public domains: string[] = []; public readonly supportsAttackID = true; protected get attackIDValidator() { @@ -40,12 +42,16 @@ export class Campaign extends StixObject { } rep.stix.name = this.name.trim(); - rep.stix.first_seen = this.first_seen.toISOString(); - rep.stix.last_seen = this.last_seen.toISOString(); + rep.stix.first_seen = this.first_seen?.toISOString(); + rep.stix.last_seen = this.last_seen?.toISOString(); rep.stix.x_mitre_first_seen_citation = this.first_seen_citation.trim(); 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); return rep; } @@ -147,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 = []; } } @@ -156,9 +168,10 @@ export class Campaign 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); + return this.base_validate(restAPIService, tempWorkflowState); } /** @@ -167,8 +180,6 @@ export class Campaign extends StixObject { * @returns {Observable} of the post */ public save(restAPIService: RestApiConnectorService): Observable { - // update first index of aliases field to campaign name - this.aliases[0] = this.name; const postObservable = restAPIService.postCampaign(this); const subscription = postObservable.subscribe({ next: result => { @@ -212,4 +223,22 @@ export class Campaign extends StixObject { }); return putObservable; } + + /** + * Revoke the STIX object in the database. + * @param restAPIService [RestApiConnectorService] the service to perform the revoke through + * @param revokingObject the revoking object payload + * @returns {Observable} of the revoke + */ + public revoke( + restAPIService: RestApiConnectorService, + revokingObject: { revoking: { stixId: string; modified: string } }, + preserveRelationships = false + ): Observable { + return restAPIService.revokeCampaign( + this.stixID, + revokingObject, + preserveRelationships + ); + } } 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 f2ed9382c..ce6836624 100644 --- a/src/app/classes/stix/collection.ts +++ b/src/app/classes/stix/collection.ts @@ -19,6 +19,7 @@ import { Tactic, Technique, } from '../stix'; +import { WorkflowStatusType } from 'src/app/utils/types'; /** * auto-generated changelog/report about an import @@ -496,9 +497,10 @@ export class Collection 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); + 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 a41196f6e..03a3227ee 100644 --- a/src/app/classes/stix/data-component.ts +++ b/src/app/classes/stix/data-component.ts @@ -4,6 +4,7 @@ import { ValidationData } from '../serializable'; import { StixObject } from './stix-object'; import { logger } from '../../utils/logger'; import { DataSource } from './data-source'; +import { WorkflowStatusType } from 'src/app/utils/types'; export class DataComponent extends StixObject { public name = ''; @@ -49,6 +50,9 @@ export class DataComponent extends StixObject { rep.stix.x_mitre_log_sources = this.logSources; } + // Strip properties that are empty strs + lists + rep.stix = this.filterObject(rep.stix); + return rep; } @@ -128,28 +132,10 @@ export class DataComponent 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 => { - if (!this.logSources.length) return result; - - // validate log sources array - const seen = new Set(); - for (const { name, channel } of this.logSources) { - const key = `${name}::${channel}`; - if (seen.has(key)) { - result.errors.push({ - field: 'permutations', - result: 'error', - message: `Duplicate log source found: name="${name}", channel="${channel}"`, - }); - } - seen.add(key); - } - return result; - }) - ); + return this.base_validate(restAPIService, tempWorkflowState); } /** @@ -205,6 +191,24 @@ export class DataComponent extends StixObject { }); return putObservable; } + + /** + * Revoke the STIX object in the database. + * @param restAPIService [RestApiConnectorService] the service to perform the revoke through + * @param revokingObject the revoking object payload + * @returns {Observable} of the revoke + */ + public revoke( + restAPIService: RestApiConnectorService, + revokingObject: { revoking: { stixId: string; modified: string } }, + preserveRelationships = false + ): Observable { + return restAPIService.revokeDataComponent( + this.stixID, + revokingObject, + preserveRelationships + ); + } } export interface LogSource { diff --git a/src/app/classes/stix/data-source.ts b/src/app/classes/stix/data-source.ts index ed5535bd8..be7255988 100644 --- a/src/app/classes/stix/data-source.ts +++ b/src/app/classes/stix/data-source.ts @@ -4,6 +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 { WorkflowStatusType } from 'src/app/utils/types'; export class DataSource extends StixObject { public name = ''; @@ -47,6 +48,9 @@ export class DataSource extends StixObject { 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); + return rep; } @@ -131,9 +135,10 @@ export class DataSource 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); + return this.base_validate(restAPIService, tempWorkflowState); } /** @@ -187,4 +192,22 @@ export class DataSource extends StixObject { }); return putObservable; } + + /** + * Revoke the STIX object in the database. + * @param restAPIService [RestApiConnectorService] the service to perform the revoke through + * @param revokingObject the revoking object payload + * @returns {Observable} of the revoke + */ + public revoke( + restAPIService: RestApiConnectorService, + revokingObject: { revoking: { stixId: string; modified: string } }, + preserveRelationships = false + ): Observable { + return restAPIService.revokeDataSource( + this.stixID, + revokingObject, + preserveRelationships + ); + } } diff --git a/src/app/classes/stix/detection-strategy.ts b/src/app/classes/stix/detection-strategy.ts index ef44a1280..8f7a0eee6 100644 --- a/src/app/classes/stix/detection-strategy.ts +++ b/src/app/classes/stix/detection-strategy.ts @@ -3,11 +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 { 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() { @@ -36,6 +38,10 @@ 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); return rep; } @@ -74,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 = []; } } @@ -83,9 +95,10 @@ export class DetectionStrategy 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); + return this.base_validate(restAPIService, tempWorkflowState); } /** diff --git a/src/app/classes/stix/group.ts b/src/app/classes/stix/group.ts index 541055970..1850b27d5 100644 --- a/src/app/classes/stix/group.ts +++ b/src/app/classes/stix/group.ts @@ -3,11 +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 { WorkflowStatusType } from 'src/app/utils/types'; export class Group extends StixObject { public name = ''; - public aliases: string[] = ['placeholder']; // initialize field with placeholder in first index for group name + public aliases: string[] = []; public contributors: string[] = []; + public domains: string[] = []; public readonly supportsAttackID = true; protected get attackIDValidator() { @@ -37,6 +39,10 @@ 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); return rep; } @@ -86,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 = []; } } @@ -95,9 +107,10 @@ export class Group 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); + return this.base_validate(restAPIService, tempWorkflowState); } /** @@ -106,8 +119,6 @@ export class Group extends StixObject { * @returns {Observable} of the post */ public save(restAPIService: RestApiConnectorService): Observable { - // update first index of aliases field to group name - this.aliases[0] = this.name; const postObservable = restAPIService.postGroup(this); const subscription = postObservable.subscribe({ next: result => { @@ -151,4 +162,22 @@ export class Group extends StixObject { }); return putObservable; } + + /** + * Revoke the STIX object in the database. + * @param restAPIService [RestApiConnectorService] the service to perform the revoke through + * @param revokingObject the revoking object payload + * @returns {Observable} of the revoke + */ + public revoke( + restAPIService: RestApiConnectorService, + revokingObject: { revoking: { stixId: string; modified: string } }, + preserveRelationships = false + ): Observable { + return restAPIService.revokeGroup( + this.stixID, + revokingObject, + preserveRelationships + ); + } } diff --git a/src/app/classes/stix/identity.ts b/src/app/classes/stix/identity.ts index 4b54131a1..7a3ce186b 100644 --- a/src/app/classes/stix/identity.ts +++ b/src/app/classes/stix/identity.ts @@ -1,13 +1,15 @@ -import { Observable, of } from 'rxjs'; +import { Observable } from 'rxjs'; import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { logger } from '../../utils/logger'; import { ValidationData } from '../serializable'; import { StixObject } from './stix-object'; -import { logger } from '../../utils/logger'; +import { WorkflowStatusType } from 'src/app/utils/types'; export class Identity extends StixObject { public name: string; // identity name public identity_class: string; // type of entity this identity describes - public roles?: string[]; // list of roles this identity performs + public roles: string[] = []; // list of roles this identity performs + public sectors: string[] = []; // list of sectors this identity belongs to public contact?: string; // contact information for this identity public readonly supportsAttackID = false; // Identity does not support ATT&CK IDs @@ -15,11 +17,15 @@ export class Identity extends StixObject { return null; } // identities do not have an ATT&CK ID + // override StixObject excludedFields + protected excludedFields = ['x_mitre_version']; + constructor(sdo?: any) { super(sdo, 'identity'); if (sdo) { this.deserialize(sdo); } + this.workflow = undefined; } /** @@ -33,8 +39,12 @@ export class Identity extends StixObject { rep.stix.name = this.name; rep.stix.identity_class = this.identity_class; if (this.roles) rep.stix.roles = this.roles; + if (this.sectors?.length) rep.stix.sectors = this.sectors; if (this.contact) rep.stix.contact_information = this.contact; + // Strip properties that are empty strs + lists + rep.stix = this.filterObject(rep.stix); + return rep; } @@ -75,6 +85,15 @@ export class Identity extends StixObject { if ('roles' in sdo) { if (this.isStringArray(sdo.roles)) this.roles = sdo.roles; else logger.error('TypeError: roles field is not a string array.'); + } else { + this.roles = []; + } + + if ('sectors' in sdo) { + if (this.isStringArray(sdo.sectors)) this.sectors = sdo.sectors; + else logger.error('TypeError: sectors field is not a string array.'); + } else { + this.sectors = []; } if ('contact_information' in sdo) { @@ -98,8 +117,10 @@ export class Identity extends StixObject { * @returns {Observable} the validation warnings and errors once validation is complete. */ public validate( - restAPIService: RestApiConnectorService + restAPIService: RestApiConnectorService, + _tempWorkflowState?: WorkflowStatusType ): Observable { + void _tempWorkflowState; return this.base_validate(restAPIService); } @@ -122,9 +143,14 @@ export class Identity extends StixObject { return postObservable; } - public delete(_restAPIService: RestApiConnectorService): Observable { - // deletion is not supported on Identity objects - return of({}); + public delete(restAPIService: RestApiConnectorService): Observable { + const deleteObservable = restAPIService.deleteIdentity(this.stixID); + const subscription = deleteObservable.subscribe({ + complete: () => { + subscription.unsubscribe(); + }, + }); + return deleteObservable; } /** diff --git a/src/app/classes/stix/marking-definition.ts b/src/app/classes/stix/marking-definition.ts index a2b23e28c..dc4cd1902 100644 --- a/src/app/classes/stix/marking-definition.ts +++ b/src/app/classes/stix/marking-definition.ts @@ -4,6 +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 { WorkflowStatusType } from 'src/app/utils/types'; export class MarkingDefinition extends StixObject { public name = ''; @@ -15,6 +16,9 @@ export class MarkingDefinition extends StixObject { return null; } //marking-defs do not have ATT&CK IDs + // override StixObject excludedFields + protected excludedFields = ['x_mitre_version', 'x_mitre_deprecated']; + constructor(sdo?: any) { super(sdo, 'marking-definition'); if (sdo) { @@ -35,6 +39,9 @@ export class MarkingDefinition extends StixObject { rep.stix.definition = {}; rep.stix.definition[this.definition_type] = this.definition_string; + // Strip properties that are empty strs + lists + rep.stix = this.filterObject(rep.stix); + return rep; } @@ -96,9 +103,10 @@ export class MarkingDefinition 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( + return this.base_validate(restAPIService, tempWorkflowState).pipe( map(result => { // presence of statement if (!this.definition_string) { diff --git a/src/app/classes/stix/matrix.ts b/src/app/classes/stix/matrix.ts index 00831abfc..f9264053f 100644 --- a/src/app/classes/stix/matrix.ts +++ b/src/app/classes/stix/matrix.ts @@ -4,10 +4,12 @@ import { ValidationData } from '../serializable'; import { StixObject } from './stix-object'; import { logger } from '../../utils/logger'; import { Tactic } from './tactic'; +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[] = []; @@ -39,6 +41,10 @@ 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); return rep; } @@ -76,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 = []; } } @@ -85,10 +97,11 @@ export class Matrix extends StixObject { * @returns {Observable} the validation warnings and errors once validation is complete. */ public validate( - restAPIService: RestApiConnectorService + restAPIService: RestApiConnectorService, + tempWorkflowState?: WorkflowStatusType ): Observable { // TODO verify all tactics exist - return this.base_validate(restAPIService); + return this.base_validate(restAPIService, tempWorkflowState); } /** @@ -111,6 +124,7 @@ export class Matrix extends StixObject { public delete(_restAPIService: RestApiConnectorService): Observable { // deletion is not supported on Matrix objects + void _restAPIService; return of({}); } @@ -131,4 +145,22 @@ export class Matrix extends StixObject { }); return putObservable; } + + /** + * Revoke the STIX object in the database. + * @param restAPIService [RestApiConnectorService] the service to perform the revoke through + * @param revokingObject the revoking object payload + * @returns {Observable} of the revoke + */ + public revoke( + restAPIService: RestApiConnectorService, + revokingObject: { revoking: { stixId: string; modified: string } }, + preserveRelationships = false + ): Observable { + return restAPIService.revokeMatrix( + this.stixID, + revokingObject, + preserveRelationships + ); + } } diff --git a/src/app/classes/stix/mitigation.ts b/src/app/classes/stix/mitigation.ts index bf1f72ae4..acf0a3580 100644 --- a/src/app/classes/stix/mitigation.ts +++ b/src/app/classes/stix/mitigation.ts @@ -3,6 +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 { WorkflowStatusType } from 'src/app/utils/types'; export class Mitigation extends StixObject { public name = ''; @@ -46,6 +47,9 @@ export class Mitigation extends StixObject { rep.stix.labels = this.securityControls; } + // Strip properties that are empty strs + lists + rep.stix = this.filterObject(rep.stix); + return rep; } @@ -89,9 +93,10 @@ export class Mitigation 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); + return this.base_validate(restAPIService, tempWorkflowState); } /** @@ -145,4 +150,22 @@ export class Mitigation extends StixObject { }); return putObservable; } + + /** + * Revoke the STIX object in the database. + * @param restAPIService [RestApiConnectorService] the service to perform the revoke through + * @param revokingObject the revoking object payload + * @returns {Observable} of the revoke + */ + public revoke( + restAPIService: RestApiConnectorService, + revokingObject: { revoking: { stixId: string; modified: string } }, + preserveRelationships = false + ): Observable { + return restAPIService.revokeMitigation( + this.stixID, + revokingObject, + preserveRelationships + ); + } } diff --git a/src/app/classes/stix/note.ts b/src/app/classes/stix/note.ts index 4ff26279c..b9a44ca68 100644 --- a/src/app/classes/stix/note.ts +++ b/src/app/classes/stix/note.ts @@ -3,6 +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 { WorkflowStatusType } from 'src/app/utils/types'; export class Note extends StixObject { public title = ''; @@ -86,9 +87,10 @@ export class Note 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); + 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 5d4fa6053..91ec7b110 100644 --- a/src/app/classes/stix/relationship.ts +++ b/src/app/classes/stix/relationship.ts @@ -1,22 +1,24 @@ -import { Observable, of } from 'rxjs'; -import { map, switchMap } from 'rxjs/operators'; -import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; -import { ValidationData } from '../serializable'; -import { StixObject } from './stix-object'; -import { logger } from '../../utils/logger'; +import { concat, defer, Observable, of } from 'rxjs'; +import { last, map, switchMap } from 'rxjs/operators'; import { Asset, Campaign, DataComponent, DataSource, + DetectionStrategy, Group, Matrix, Mitigation, Software, Tactic, Technique, - DetectionStrategy, } 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 { WorkflowStatus, WorkflowStatusType } from 'src/app/utils/types'; export class Relationship extends StixObject { public source_ref = ''; @@ -44,6 +46,9 @@ export class Relationship extends StixObject { return null; } // relationships have no ATT&CK ID + // override StixObject excludedFields + protected excludedFields = ['x_mitre_version']; + /** * Creates and returns the deserialized object * @param type the stix type of the object @@ -379,6 +384,9 @@ export class Relationship extends StixObject { rep.stix.source_ref = this.source_ref; rep.stix.target_ref = this.target_ref; + // Strip properties that are empty strs + lists + rep.stix = this.filterObject(rep.stix); + return rep; } @@ -478,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 => { @@ -611,33 +620,38 @@ export class Relationship extends StixObject { * @returns {Observable} of the post */ public save( - restAPIService: RestApiConnectorService + restAPIService: RestApiConnectorService, + _releaseTracksService?: ReleaseTracksConnectorService ): Observable { if (!this.workflow) { // Initialize the workflow object if it doesn't exist - this.workflow = { state: '' }; + this.workflow = { state: WorkflowStatus.WorkInProgress }; } - this.workflow.state = 'work-in-progress'; - const postObservable = restAPIService.postRelationship(this); - const subscription = postObservable.subscribe({ - next: result => { + 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) + ); + }) + ); } /** @@ -675,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 */ @@ -686,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: '' }; + 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 c4b4539ab..009998d4c 100644 --- a/src/app/classes/stix/software.ts +++ b/src/app/classes/stix/software.ts @@ -3,12 +3,13 @@ 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 { WorkflowStatusType } from 'src/app/utils/types'; type type_software = 'malware' | 'tool'; export class Software extends StixObject { public name = ''; public description: string; - public aliases: string[] = ['placeholder']; // initialize field with placeholder in first index for software name + public aliases: string[] = []; public platforms: string[] = []; public type: string; public contributors: string[] = []; @@ -48,6 +49,9 @@ export class Software extends StixObject { rep.stix.x_mitre_contributors = this.contributors.map(x => x.trim()); if (this.type == 'malware') rep.stix.is_family = true; // add is_family to malware type SDOs + // Strip properties that are empty strs + lists + rep.stix = this.filterObject(rep.stix); + return rep; } @@ -137,9 +141,10 @@ export class Software 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); + return this.base_validate(restAPIService, tempWorkflowState); } /** @@ -148,8 +153,6 @@ export class Software extends StixObject { * @returns {Observable} of the post */ public save(restAPIService: RestApiConnectorService): Observable { - // update first index of aliases field to software name - this.aliases[0] = this.name; const postObservable = restAPIService.postSoftware(this); const subscription = postObservable.subscribe({ next: result => { @@ -193,4 +196,22 @@ export class Software extends StixObject { }); return putObservable; } + + /** + * Revoke the STIX object in the database. + * @param restAPIService [RestApiConnectorService] the service to perform the revoke through + * @param revokingObject the revoking object payload + * @returns {Observable} of the revoke + */ + public revoke( + restAPIService: RestApiConnectorService, + revokingObject: { revoking: { stixId: string; modified: string } }, + preserveRelationships = false + ): Observable { + return restAPIService.revokeSoftware( + this.stixID, + revokingObject, + preserveRelationships + ); + } } diff --git a/src/app/classes/stix/stix-object.ts b/src/app/classes/stix/stix-object.ts index 6e96e7dbb..97dad7225 100644 --- a/src/app/classes/stix/stix-object.ts +++ b/src/app/classes/stix/stix-object.ts @@ -1,3 +1,7 @@ +import { + createAttackIdSchema, + StixTypesWithAttackIds, +} from '@mitre-attack/attack-data-model'; import { forkJoin, Observable, of } from 'rxjs'; import { map, switchMap } from 'rxjs/operators'; import { @@ -8,19 +12,14 @@ import { AttackTypeToRoute, StixTypeToAttackType, } from 'src/app/utils/type-mappings'; -import { StixType } 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'; 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 @@ -32,16 +31,21 @@ export abstract class StixObject extends Serializable { public created_by?: any; public modified_by_ref: string; //embedded relationship public modified_by?: any; + public created_by_user_account?: UserAccount; public firstInitialized: boolean; // boolean to track if it is a newly created object 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: WorkflowStatusType; protected abstract get attackIDValidator(): { regex: string; // regex to validate the ID format: string; // format to display to user }; + // fields to omit. By default, do not omit any fields + protected excludedFields: string[] = []; + protected buildAttackExternalReference(): object | null { if (this.attackID && AttackTypeToRoute[this.attackType]) { return { @@ -55,29 +59,15 @@ export abstract class StixObject extends Serializable { private defaultMarkingDefinitionsLoaded = false; // avoid overloading of default marking definitions - public get routes(): any[] { - // route to view the object - return [ - { - label: 'view', - route: '', - }, - { - label: 'edit', - route: '', - query: { editing: true }, - }, - ]; - } - public created: Date; // object created date public modified: Date; // object modified date 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? @@ -98,9 +88,9 @@ export abstract class StixObject extends Serializable { this.version = new VersionNumber('0.1'); this.attackID = ''; this.external_references = new ExternalReferences(); - if (this.type !== 'x-mitre-collection') { + if (this.type !== 'x-mitre-collection' && this.type !== 'relationship') { this.workflow = { - state: 'work-in-progress', + state: WorkflowStatus.WorkInProgress, }; } this.description = ''; @@ -123,25 +113,29 @@ export abstract class StixObject extends Serializable { serialized_external_references.unshift(attackExtRef); } - const stix: any = { + const stix = this.filterObject({ type: this.type, id: this.stixID, created: this.created ? this.created.toISOString() : new Date().toISOString(), - x_mitre_version: this.version.toString(), - external_references: serialized_external_references, + modified: + this.type !== 'marking-definition' + ? new Date().toISOString() + : undefined, + x_mitre_version: this.version?.toString(), x_mitre_deprecated: this.deprecated, revoked: this.revoked, - object_marking_refs: this.object_marking_refs, spec_version: '2.1', - }; - if (this.description) stix.description = this.description; - // Add modified date if type is not marking-definition - if (this.type != 'marking-definition') - stix['modified'] = new Date().toISOString(); - if (this.created_by_ref) stix.created_by_ref = this.created_by_ref; - // do not set modified by ref since we don't know who we are, but the REST API knows + description: this.description, + created_by_ref: this.created_by_ref, + object_marking_refs: this.object_marking_refs, + external_references: serialized_external_references, + }); + + for (const field of this.excludedFields) { + delete stix[field]; + } return { workspace: { @@ -361,10 +355,16 @@ export abstract class StixObject extends Serializable { "ObjectError: 'stix' field does not exist in modified_by_identity object" ); } + if ('created_by_user_account' in raw && raw.created_by_user_account) { + this.created_by_user_account = new UserAccount( + raw.created_by_user_account + ); + } 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; @@ -383,11 +383,14 @@ export abstract class StixObject extends Serializable { * @returns true if the ATT&CK ID is valid, false otherwise */ public isValidAttackId(): boolean { - const idRegex = new RegExp( - '^([A-Z]+-)?' + this.attackIDValidator.regex + '$' - ); - const attackIDValid = idRegex.test(this.attackID); - return attackIDValid; + if (this.type in StixTypeToAttackType) { + const attackIDSchema = createAttackIdSchema( + this.type as StixTypesWithAttackIds + ); + const attackIDValid = attackIDSchema.safeParse(this.attackID); + return attackIDValid.success; + } + return false; } /** @@ -397,22 +400,55 @@ export abstract class StixObject extends Serializable { * @returns {Observable} the validation warnings and errors once validation is complete. */ public base_validate( - restAPIService: RestApiConnectorService + restAPIService: RestApiConnectorService, + tempWorkflowState? ): Observable { - const validation = new ValidationData(); - - // test version number format - if (!this.version.valid()) { - validation.errors.push({ - result: 'error', - field: 'version', - message: 'version number is not formatted properly', - }); + if (tempWorkflowState) { + this.workflow = { state: tempWorkflowState }; } // check any asynchronous validators - return of(validation).pipe( - // check if the name is unique if it has a name - switchMap(result => { + const result = new ValidationData(); + // Throws user friendly error when name is missing in the stix object + // Need to exclude analytics from this check because user cannot set names + // for analytics and those are automatically generated + if (this.hasOwnProperty('name') && this.type != 'x-mitre-analytic') { + if ('name' in this) { + const name = this.name; + if (typeof name !== 'string' || name.trim() === '') { + result.errors.push({ + result: 'error', + field: 'name', + message: 'name is required', + }); + return of(result); + } + } + } + const validator = restAPIService.validateStixObject(); + + return validator(this).pipe( + switchMap(validatorResult => { + // Process validation errors from API (backend now handles error-to-warning conversion) + (validatorResult.errors || []).forEach((err: any) => { + const errorMessage = `${err.path.join('.')}: ${err.message}`; + result.errors.push({ + result: 'error', + field: 'temp', + message: errorMessage, + }); + }); + + // Process validation warnings from API + (validatorResult.warnings || []).forEach((warning: any) => { + const warningMessage = + warning.message || `${warning.path.join('.')}: ${warning.message}`; + result.warnings.push({ + result: 'warning', + field: warning.path[warning.path.length - 1] || 'temp', + message: warningMessage, + }); + }); + // check if the name is unique if it has a name //do not check name or attackID for relationships or marking definitions if ( this.attackType == 'relationship' || @@ -456,13 +492,7 @@ export abstract class StixObject extends Serializable { map(objects => { // check name if (this.hasOwnProperty('name')) { - if (this['name'] == '') { - result.errors.push({ - result: 'error', - field: 'name', - message: 'object has no name', - }); - } else if ( + if ( objects.data.some( x => x['name'].toLowerCase() == this['name'].toLowerCase() && @@ -482,98 +512,6 @@ export abstract class StixObject extends Serializable { }); } } - // check ATT&CK ID, ignoring collections and matrices - if ( - this.attackType !== 'matrix' && - this.hasOwnProperty('supportsAttackID') && - this.supportsAttackID - ) { - if (this.attackID == '') { - if (this.attackType === 'analytic') { - result.errors.push({ - result: 'error', - field: 'attackID', - message: 'object does not have ATT&CK ID', - }); - } else { - result.warnings.push({ - result: 'warning', - field: 'attackID', - message: 'object does not have ATT&CK ID', - }); - } - } else { - if ( - objects.data.some( - x => x.attackID == this.attackID && x.stixID != this.stixID - ) - ) { - result.errors.push({ - result: 'error', - field: 'attackID', - message: 'ATT&CK ID is not unique', - }); - } else { - result.successes.push({ - result: 'success', - field: 'attackID', - message: 'ATT&CK ID is unique', - }); - } - if (!this.isValidAttackId()) { - result.errors.push({ - result: 'error', - field: 'attackID', - message: `ATT&CK ID does not match the format ${this.attackIDValidator.format}`, - }); - } - } - } - // check required first/last seen fields for campaigns - if (this.attackType == 'campaign') { - if ( - !this.hasOwnProperty('first_seen') || - this['first_seen'] == null - ) { - result.errors.push({ - result: 'error', - field: 'first_seen', - message: 'object does not have a first seen date', - }); - } - if ( - !this.hasOwnProperty('first_seen_citation') || - this['first_seen_citation'] == '' - ) { - result.errors.push({ - result: 'error', - field: 'first_seen_citation', - message: - 'object is missing a citation for the first seen date', - }); - } - if ( - !this.hasOwnProperty('last_seen') || - this['last_seen'] == null - ) { - result.errors.push({ - result: 'error', - field: 'last_seen', - message: 'object does not have a last seen date', - }); - } - if ( - !this.hasOwnProperty('last_seen_citation') || - this['last_seen_citation'] == '' - ) { - result.errors.push({ - result: 'error', - field: 'last_seen_citation', - message: - 'object is missing a citation for the last seen date', - }); - } - } return result; }) ); @@ -750,6 +688,48 @@ export abstract class StixObject extends Serializable { return result; } + /** + * Checks if the provided field has a valid value. + * Returns undefined or the value of the field if it is valid + * @param {*} field - The value to validate. + * @returns {value} - Value if the field has a valid value, undefined otherwise. + */ + public clean(value) { + if (value == null) return undefined; // null or undefined + if (typeof value === 'string' && value.trim() === '') return undefined; + if (typeof value === 'number' && Number.isNaN(value)) return undefined; + if (Array.isArray(value)) { + if (value.length === 0) { + return undefined; + } else { + const arr = value.map(v => this.clean(v)).filter(v => v !== undefined); + return arr.length ? arr : undefined; + } + } + + if (typeof value === 'object') { + const obj = this.filterObject(value); + return Object.keys(obj).length ? obj : undefined; + } + + return value; + } + + /** + * Filters the properties of an object, returning a new object containing only + * those entries whose values pass the validity check. + * @param {Object} obj - The object to filter. + * @returns {Object} - A new object with only the valid entries. + */ + public filterObject(obj) { + const out = {}; + for (const [key, value] of Object.entries(obj)) { + const cleaned = this.clean(value); + if (cleaned !== undefined) out[key] = cleaned; + } + return out; + } + /** * Check if the given array is a list of strings * @param arr the array to check @@ -823,6 +803,18 @@ export abstract class StixObject extends Serializable { */ abstract update(restAPIService: RestApiConnectorService): Observable; + /** + * Revoke the STIX object in the database. + * @param restAPIService [RestApiConnectorService] the service to perform the revoke through + * @param revokingObject the revoking object payload + * @returns {Observable} of the revoke + */ + public revoke?( + restAPIService: RestApiConnectorService, + revokingObject: { revoking: { stixId: string; modified: string } }, + preserveRelationships?: boolean + ): Observable; + /** * Updates the object's marking definitions with the default the first time an object is created * @param restAPIService [RestApiConnectorService] the service to perform the POST/PUT through @@ -843,169 +835,6 @@ export abstract class StixObject extends Serializable { }, }); } - - public generateAttackId( - apiService: RestApiConnectorService, - existingPrefix?: string - ): Observable { - this.attackID = '(generating ID)'; - return apiService.getOrganizationNamespace().pipe( - switchMap(namespace => { - const accessor = this.getApiAccessor( - apiService, - this.attackType, - true, - true - ); - if (!accessor) return of('(unsupported attack type)'); - - const typePrefix = this.getAttackIdPrefix(); // ex: "TA" for tactics - // org prefix (ex: "ORG"), use existing prefix if defined - const orgPrefix = existingPrefix - ? existingPrefix - : (namespace.prefix ?? ''); - // family prefix: orgPrefix + typePrefix (ex: "ORG-TA" for tactics) - const familyPrefix = orgPrefix - ? orgPrefix + '-' + typePrefix - : typePrefix; - - return accessor.pipe( - switchMap(objects => { - if ('is_subtechnique' in this && this['is_subtechnique']) { - return this.getNextSubtechniqueAttackId( - apiService, - familyPrefix, - typePrefix - ); - } else { - return this.getNextObjectAttackId( - objects, - familyPrefix, - typePrefix, - namespace.range_start - ); - } - }), - map(generatedId => this.formatWithPrefix(generatedId, orgPrefix)) - ); - }) - ); - } - - private getApiAccessor( - apiService: RestApiConnectorService, - attackType: string, - includeDeprecated?: boolean, - includeRevoked?: boolean - ): Observable> { - const options = { - includeDeprecated: includeDeprecated ?? false, - includeRevoked: includeRevoked ?? false, - }; - if (attackType == 'group') return apiService.getAllGroups(options); - else if (attackType == 'campaign') - return apiService.getAllCampaigns(options); - else if (attackType == 'mitigation') - return apiService.getAllMitigations(options); - else if (attackType == 'software') - return apiService.getAllSoftware(options); - else if (attackType == 'tactic') return apiService.getAllTactics(options); - else if (attackType == 'technique') - return apiService.getAllTechniques(options); - else if (attackType == 'data-source') - return apiService.getAllDataSources(options); - else if (attackType == 'data-component') - return apiService.getAllDataComponents(options); - else if (attackType == 'asset') return apiService.getAllAssets(options); - else if (attackType == 'matrix') return apiService.getAllMatrices(options); - else if (attackType == 'detection-strategy') - return apiService.getAllDetectionStrategies(options); - else if (attackType == 'analytic') - return apiService.getAllAnalytics(options); - else return null; - } - - private getAttackIdPrefix(): string { - return this.attackIDValidator.format.includes('#') - ? this.attackIDValidator.format.split('#')[0] - : ''; - } - - private getNextSubtechniqueAttackId( - apiService: RestApiConnectorService, - orgPrefix: string, - typePrefix: string - ): Observable { - if (!('parentTechnique' in this && this['parentTechnique'])) { - return of('(parent technique missing)'); - } - - // get 4-digit ID of parent technique - const parent = this['parentTechnique'] as StixObject; - const found = parent.attackID.match(/[0-9]{4}/g); - if (!found?.length) return of('(invalid parent id)'); - orgPrefix += found[0]; - - return apiService.getTechnique(parent.stixID, null, 'latest', true).pipe( - map(technique => { - const children = technique[0]?.subTechniques ?? []; - let count = 1; - - if (children.length > 0) { - const childIds = children - .filter(obj => obj.attackID.startsWith(orgPrefix)) - .map(obj => obj.attackID.match(/[^.]([0-9]*)$/g)?.[0]) - .filter(Boolean) - .map(Number); - - // get next available subtechnique number - if (childIds.length > 0) { - count = Math.max(...childIds) + 1; - } - } - - // construct new id (e.g. T1234.001) - return `${typePrefix}${found[0]}.${count.toString().padStart(3, '0')}`; - }) - ); - } - - private getNextObjectAttackId( - objects: Paginated, - orgPrefix: string, - typePrefix: string, - rangeStart: string - ): Observable { - // get ids of existing objects that have the same prefix - const currIds = objects.data.reduce((ids, obj) => { - if (obj.attackID.startsWith(orgPrefix)) { - // remove non-digits and decimals - ids.push(obj.attackID.replace(orgPrefix, '').replace(/[.](\d{3})/, '')); - } - return ids; - }, [] as string[]); - - // get next available ID from existing IDs - const next = currIds.length > 0 ? Number(currIds.sort().pop()) + 1 : 1; - - let newId = next; - if (this.firstInitialized && rangeStart) { - // if creating new & range start is defined, use range start if larger than next available ID - newId = +rangeStart > next ? +rangeStart : next; - } - - // construct new id (e.g. G0999) - return of(typePrefix + newId.toString().padStart(4, '0')); - } - - public formatWithPrefix(attackId: string, orgPrefix: string): string { - const prefix = orgPrefix ? orgPrefix + '-' : ''; - const withPrefix = attackId.startsWith(prefix) - ? attackId - : prefix + attackId; - // matrix IDs are case sensitive, all others uppercase - return this.attackType === 'matrix' ? withPrefix : withPrefix.toUpperCase(); - } } /** @@ -1035,9 +864,8 @@ export class LinkByIdParseResult { } } -export interface RelatedRef { +export interface EmbeddedRelationship { stixId: string; name: string; attackId: string; - type: StixType; } diff --git a/src/app/classes/stix/tactic.ts b/src/app/classes/stix/tactic.ts index 77a0f623d..f1880e255 100644 --- a/src/app/classes/stix/tactic.ts +++ b/src/app/classes/stix/tactic.ts @@ -4,6 +4,7 @@ import { ValidationData } from '../serializable'; import { StixObject } from './stix-object'; import { logger } from '../../utils/logger'; import { Technique } from './technique'; +import { WorkflowStatusType } from 'src/app/utils/types'; export class Tactic extends StixObject { public name = ''; @@ -47,6 +48,10 @@ export class Tactic extends StixObject { rep.stix.x_mitre_domains = this.domains; rep.stix.x_mitre_shortname = this.shortname; rep.stix.x_mitre_contributors = this.contributors.map(x => x.trim()); + rep.stix = this.filterObject(rep.stix); + + // Strip properties that are empty strs + lists + rep.stix = this.filterObject(rep.stix); return rep; } @@ -99,9 +104,10 @@ export class Tactic 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); + return this.base_validate(restAPIService, tempWorkflowState); } /** @@ -144,4 +150,22 @@ export class Tactic extends StixObject { }); return putObservable; } + + /** + * Revoke the STIX object in the database. + * @param restAPIService [RestApiConnectorService] the service to perform the revoke through + * @param revokingObject the revoking object payload + * @returns {Observable} of the revoke + */ + public revoke( + restAPIService: RestApiConnectorService, + revokingObject: { revoking: { stixId: string; modified: string } }, + preserveRelationships = false + ): Observable { + return restAPIService.revokeTactic( + this.stixID, + revokingObject, + preserveRelationships + ); + } } diff --git a/src/app/classes/stix/technique.ts b/src/app/classes/stix/technique.ts index 840bdcd72..7ae6dbc45 100644 --- a/src/app/classes/stix/technique.ts +++ b/src/app/classes/stix/technique.ts @@ -1,10 +1,11 @@ import { forkJoin, Observable, of } from 'rxjs'; import { concatMap, map, shareReplay, switchMap } from 'rxjs/operators'; import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; -import { ValidationData } from '../serializable'; -import { StixObject } from './stix-object'; import { logger } from '../../utils/logger'; +import { ValidationData } from '../serializable'; import { Relationship } from './relationship'; +import { StixObject } from './stix-object'; +import { WorkflowStatusType } from 'src/app/utils/types'; export class Technique extends StixObject { public name = ''; @@ -243,6 +244,10 @@ export class Technique extends StixObject { } } } + + // Strip properties that are empty strs + lists + rep.stix = this.filterObject(rep.stix); + return rep; } @@ -447,9 +452,10 @@ export class Technique 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( + return this.base_validate(restAPIService, tempWorkflowState).pipe( map(result => { // validate technique has at least one tactic if (this.attackID && this.tactics.length == 0) { @@ -774,4 +780,22 @@ export class Technique extends StixObject { }); return putObservable; } + + /** + * Revoke the STIX object in the database. + * @param restAPIService [RestApiConnectorService] the service to perform the revoke through + * @param revokingObject the revoking object payload + * @returns {Observable} of the revoke + */ + public revoke( + restAPIService: RestApiConnectorService, + revokingObject: { revoking: { stixId: string; modified: string } }, + preserveRelationships = false + ): Observable { + return restAPIService.revokeTechnique( + this.stixID, + revokingObject, + preserveRelationships + ); + } } diff --git a/src/app/classes/version-number.ts b/src/app/classes/version-number.ts index 35e73b4fb..b5affe23c 100644 --- a/src/app/classes/version-number.ts +++ b/src/app/classes/version-number.ts @@ -1,4 +1,4 @@ -import { ValidatorFn, AbstractControl } from '@angular/forms'; +import { AbstractControl, ValidatorFn } from '@angular/forms'; import { logger } from '../utils/logger'; export class VersionNumber { @@ -115,14 +115,6 @@ export class VersionNumber { } return timesIncremented > 1; } - - /** - * Is this version number formatted correctly? - * @returns {boolean} true if valid - */ - public valid(): boolean { - return /^(\d+\.)*\d+$/.test(this.toString()); - } } /** diff --git a/src/app/components/add-dialog/add-dialog.component.html b/src/app/components/add-dialog/add-dialog.component.html index e8c8ac543..874d0bc74 100644 --- a/src/app/components/add-dialog/add-dialog.component.html +++ b/src/app/components/add-dialog/add-dialog.component.html @@ -1,29 +1,33 @@

{{ config.title }}

-
+
+ Preserve relationships - If set to true, relationships referencing the + revoked object are cloned to point to the revoking object before + deprecation. + + [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.spec.ts b/src/app/components/confirmation-dialog/confirmation-dialog.component.spec.ts index c25f48869..d52eb7b55 100644 --- a/src/app/components/confirmation-dialog/confirmation-dialog.component.spec.ts +++ b/src/app/components/confirmation-dialog/confirmation-dialog.component.spec.ts @@ -1,4 +1,6 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { ConfirmationDialogComponent } from './confirmation-dialog.component'; @@ -9,6 +11,16 @@ describe('ConfirmationDialogComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [ConfirmationDialogComponent], + providers: [ + { provide: MatDialogRef, useValue: {} }, + { + provide: MAT_DIALOG_DATA, + useValue: { + message: 'Test message', + }, + }, + ], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); diff --git a/src/app/components/confirmation-dialog/confirmation-dialog.component.ts b/src/app/components/confirmation-dialog/confirmation-dialog.component.ts index 5ef4e5efd..4e02dc030 100644 --- a/src/app/components/confirmation-dialog/confirmation-dialog.component.ts +++ b/src/app/components/confirmation-dialog/confirmation-dialog.component.ts @@ -13,13 +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/contributor-edit-dialog/contributor-edit-dialog.component.spec.ts b/src/app/components/contributor-edit-dialog/contributor-edit-dialog.component.spec.ts index cf18701bf..7d587fff4 100644 --- a/src/app/components/contributor-edit-dialog/contributor-edit-dialog.component.spec.ts +++ b/src/app/components/contributor-edit-dialog/contributor-edit-dialog.component.spec.ts @@ -1,14 +1,28 @@ +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 { ContributorEditDialogComponent } from './contributor-edit-dialog.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { createMockRestApiConnector } from 'src/app/testing/mocks/rest-api-connector.mock'; describe('ContributorEditDialogComponent', () => { let component: ContributorEditDialogComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({}); + await TestBed.configureTestingModule({ declarations: [ContributorEditDialogComponent], + providers: [ + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + { provide: MatDialogRef, useValue: {} }, + { provide: MAT_DIALOG_DATA, useValue: {} }, + provideHttpClient(), + ], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); fixture = TestBed.createComponent(ContributorEditDialogComponent); diff --git a/src/app/components/create-new-dialog/create-new-dialog.component.spec.ts b/src/app/components/create-new-dialog/create-new-dialog.component.spec.ts index 87fcd909b..ba1cc1b95 100644 --- a/src/app/components/create-new-dialog/create-new-dialog.component.spec.ts +++ b/src/app/components/create-new-dialog/create-new-dialog.component.spec.ts @@ -1,4 +1,6 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { CreateNewDialogComponent } from './create-new-dialog.component'; @@ -9,6 +11,19 @@ describe('CreateNewDialogComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [CreateNewDialogComponent], + providers: [ + { provide: MatDialogRef, useValue: {} }, + { + provide: MAT_DIALOG_DATA, + useValue: { + config: { + objectName: 'test', + formObjects: [], + }, + }, + }, + ], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); 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 65c84a935..5eb4522db 100644 --- a/src/app/components/delete-dialog/delete-dialog.component.spec.ts +++ b/src/app/components/delete-dialog/delete-dialog.component.spec.ts @@ -1,4 +1,6 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { DeleteDialogComponent } from './delete-dialog.component'; @@ -9,6 +11,14 @@ describe('DeleteDialogComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [DeleteDialogComponent], + providers: [ + { provide: MatDialogRef, useValue: {} }, + { + provide: MAT_DIALOG_DATA, + useValue: { objectType: 'test', objectName: 'test' }, + }, + ], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); @@ -21,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/empty-list-marker/empty-list-marker.component.spec.ts b/src/app/components/empty-list-marker/empty-list-marker.component.spec.ts index 5724b5432..e9cd883b2 100644 --- a/src/app/components/empty-list-marker/empty-list-marker.component.spec.ts +++ b/src/app/components/empty-list-marker/empty-list-marker.component.spec.ts @@ -1,3 +1,4 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { EmptyListMarkerComponent } from './empty-list-marker.component'; @@ -9,6 +10,7 @@ describe('EmptyListMarkerComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [EmptyListMarkerComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); 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 c3507d1df..f6e5b6497 100644 --- a/src/app/components/footer/footer.component.spec.ts +++ b/src/app/components/footer/footer.component.spec.ts @@ -1,14 +1,39 @@ +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(); })); @@ -21,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 8bdd32836..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) !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 27130aff1..9e8f69493 100644 --- a/src/app/components/header/header.component.spec.ts +++ b/src/app/components/header/header.component.spec.ts @@ -1,14 +1,54 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideRouter } from '@angular/router'; +import { MatToolbarModule } from '@angular/material/toolbar'; +import { MatMenuModule } from '@angular/material/menu'; +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 { 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, + createMockUserAccount, +} from 'src/app/testing/mocks/authentication-service.mock'; describe('HeaderComponent', () => { let component: HeaderComponent; let fixture: ComponentFixture; beforeEach(waitForAsync(() => { + const mockRestApiConnector = createMockRestApiConnector({}); + const mockAuthService = createMockAuthenticationService({ + currentUser: createMockUserAccount(), + isLoggedIn: true, + }); + TestBed.configureTestingModule({ declarations: [HeaderComponent], + imports: [ + UserAvatarComponent, + MatToolbarModule, + MatMenuModule, + MatButtonModule, + MatIconModule, + MatTooltipModule, + NoopAnimationsModule, + ], + providers: [ + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + { provide: AuthenticationService, useValue: mockAuthService }, + provideRouter([]), + provideHttpClient(), + ], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); @@ -21,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/icon-view/icon-view.component.spec.ts b/src/app/components/icon-view/icon-view.component.spec.ts index fc12eba9b..27d763dac 100644 --- a/src/app/components/icon-view/icon-view.component.spec.ts +++ b/src/app/components/icon-view/icon-view.component.spec.ts @@ -1,3 +1,4 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { IconViewComponent } from './icon-view.component'; @@ -9,12 +10,18 @@ describe('WorkflowPropertyComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [IconViewComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(IconViewComponent); component = fixture.componentInstance; + // Set required config input + component.config = { + field: 'test', + object: {} as any, + }; fixture.detectChanges(); }); diff --git a/src/app/components/loading-overlay/loading-overlay.component.html b/src/app/components/loading-overlay/loading-overlay.component.html index d93de2774..00f62c5da 100644 --- a/src/app/components/loading-overlay/loading-overlay.component.html +++ b/src/app/components/loading-overlay/loading-overlay.component.html @@ -1,6 +1,35 @@
-
+
+ + +
+ + +
{{ progress }}%
+
+ + +
+
+
+ {{ phase.label }} +
+
+ + +
{{ phase.progress }}%
+
+
+
+
{{ message }}
diff --git a/src/app/components/loading-overlay/loading-overlay.component.scss b/src/app/components/loading-overlay/loading-overlay.component.scss index 68d0e5207..c1465f977 100644 --- a/src/app/components/loading-overlay/loading-overlay.component.scss +++ b/src/app/components/loading-overlay/loading-overlay.component.scss @@ -6,6 +6,50 @@ justify-content: center; align-items: center; } + .progress-wrapper { + width: 100%; + max-width: 400px; + margin: 0 auto; + .progress-text { + margin-top: 8px; + font-size: 14px; + font-weight: 500; + color: rgba(0, 0, 0, 0.6); + } + } + .multi-phase-progress { + width: 100%; + max-width: 500px; + margin: 0 auto; + .phase-progress-item { + margin-bottom: 20px; + &:last-child { + margin-bottom: 0; + } + .phase-label { + font-size: 14px; + font-weight: 500; + color: rgba(0, 0, 0, 0.4); + margin-bottom: 6px; + text-align: left; + &.active { + color: rgba(0, 0, 0, 0.87); + font-weight: 600; + } + } + .phase-bar-container { + position: relative; + .phase-progress-text { + position: absolute; + right: 0; + top: -20px; + font-size: 12px; + font-weight: 500; + color: rgba(0, 0, 0, 0.6); + } + } + } + } text-align: center; .text-label { margin-top: 12px; diff --git a/src/app/components/loading-overlay/loading-overlay.component.spec.ts b/src/app/components/loading-overlay/loading-overlay.component.spec.ts index ca7b534dc..e9a92d5a9 100644 --- a/src/app/components/loading-overlay/loading-overlay.component.spec.ts +++ b/src/app/components/loading-overlay/loading-overlay.component.spec.ts @@ -1,3 +1,4 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { LoadingOverlayComponent } from './loading-overlay.component'; @@ -9,6 +10,7 @@ describe('LoadingOverlayComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ declarations: [LoadingOverlayComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); diff --git a/src/app/components/loading-overlay/loading-overlay.component.ts b/src/app/components/loading-overlay/loading-overlay.component.ts index b930feb06..787f3a2f0 100644 --- a/src/app/components/loading-overlay/loading-overlay.component.ts +++ b/src/app/components/loading-overlay/loading-overlay.component.ts @@ -1,5 +1,12 @@ import { Component, Input, OnInit, ViewEncapsulation } from '@angular/core'; +export interface PhaseProgress { + phase: string; + label: string; + progress: number; + active: boolean; +} + @Component({ selector: 'app-loading-overlay', templateUrl: './loading-overlay.component.html', @@ -9,6 +16,9 @@ import { Component, Input, OnInit, ViewEncapsulation } from '@angular/core'; }) export class LoadingOverlayComponent implements OnInit { @Input() message = ''; + @Input() showProgress = false; + @Input() progress = 0; // 0-100 (single progress bar) + @Input() phaseProgress: PhaseProgress[] = []; // Multiple progress bars constructor() { // intentionally left blank diff --git a/src/app/components/markdown-view-dialog/markdown-view-dialog.component.spec.ts b/src/app/components/markdown-view-dialog/markdown-view-dialog.component.spec.ts index 053d91e33..1772778b5 100644 --- a/src/app/components/markdown-view-dialog/markdown-view-dialog.component.spec.ts +++ b/src/app/components/markdown-view-dialog/markdown-view-dialog.component.spec.ts @@ -1,4 +1,6 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { MarkdownViewDialogComponent } from './markdown-view-dialog.component'; @@ -9,6 +11,16 @@ describe('MarkdownViewDialogComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [MarkdownViewDialogComponent], + providers: [ + { provide: MatDialogRef, useValue: {} }, + { + provide: MAT_DIALOG_DATA, + useValue: { + markdown: 'Test markdown', + }, + }, + ], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); diff --git a/src/app/components/matrix/tactic-cell/tactic-cell.component.spec.ts b/src/app/components/matrix/tactic-cell/tactic-cell.component.spec.ts index 14232b208..1b56b407a 100644 --- a/src/app/components/matrix/tactic-cell/tactic-cell.component.spec.ts +++ b/src/app/components/matrix/tactic-cell/tactic-cell.component.spec.ts @@ -1,4 +1,6 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { Tactic } from 'src/app/classes/stix/tactic'; import { TacticCellComponent } from './tactic-cell.component'; @@ -9,12 +11,20 @@ describe('TacticCellComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ declarations: [TacticCellComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(TacticCellComponent); component = fixture.componentInstance; + + // Initialize the required tactic input with a mock + const mockTactic = new Tactic(); + mockTactic.attackID = 'TA0001'; + mockTactic.name = 'Test Tactic'; + component.tactic = mockTactic; + fixture.detectChanges(); }); diff --git a/src/app/components/matrix/technique-cell/technique-cell.component.spec.ts b/src/app/components/matrix/technique-cell/technique-cell.component.spec.ts index 64871ce85..6400f38bc 100644 --- a/src/app/components/matrix/technique-cell/technique-cell.component.spec.ts +++ b/src/app/components/matrix/technique-cell/technique-cell.component.spec.ts @@ -1,6 +1,8 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { TechniqueCellComponent } from './technique-cell.component'; +import { Technique } from 'src/app/classes/stix/technique'; describe('TechniqueCellComponent', () => { let component: TechniqueCellComponent; @@ -9,12 +11,21 @@ describe('TechniqueCellComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ declarations: [TechniqueCellComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(TechniqueCellComponent); component = fixture.componentInstance; + + // Initialize the required technique input with a mock + const mockTechnique = new Technique(); + mockTechnique.attackID = 'T1234'; + mockTechnique.name = 'Test Technique'; + mockTechnique.subTechniques = []; + component.technique = mockTechnique; + fixture.detectChanges(); }); 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.spec.ts b/src/app/components/multiple-choice-dialog/multiple-choice-dialog.component.spec.ts index 34467ddd1..d910560d9 100644 --- a/src/app/components/multiple-choice-dialog/multiple-choice-dialog.component.spec.ts +++ b/src/app/components/multiple-choice-dialog/multiple-choice-dialog.component.spec.ts @@ -1,4 +1,6 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { MultipleChoiceDialogComponent } from './multiple-choice-dialog.component'; @@ -9,6 +11,17 @@ describe('MultipleChoiceDialogComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [MultipleChoiceDialogComponent], + providers: [ + { provide: MatDialogRef, useValue: {} }, + { + provide: MAT_DIALOG_DATA, + useValue: { + title: 'Test Title', + choices: [{ label: 'Choice 1' }], + }, + }, + ], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); 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.html b/src/app/components/object-status/object-status.component.html deleted file mode 100644 index 00a42798e..000000000 --- a/src/app/components/object-status/object-status.component.html +++ /dev/null @@ -1,51 +0,0 @@ - - - -
- - Workflow Status - - {{ workflow[1] }} - - - -
- Revoke - Deprecate -
-
- - - -
diff --git a/src/app/components/object-status/object-status.component.spec.ts b/src/app/components/object-status/object-status.component.spec.ts index fdfac4712..38d934ca1 100644 --- a/src/app/components/object-status/object-status.component.spec.ts +++ b/src/app/components/object-status/object-status.component.spec.ts @@ -1,14 +1,71 @@ +import { DragDropModule } from '@angular/cdk/drag-drop'; +import { provideHttpClient } from '@angular/common/http'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatSelectModule } from '@angular/material/select'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { ActivatedRoute, Router } from '@angular/router'; +import { MtxPopoverModule } from '@ng-matero/extensions/popover'; +import { of } from 'rxjs'; import { ObjectStatusComponent } from './object-status.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('ObjectStatusComponent', () => { let component: ObjectStatusComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({ + getAllTechniques: () => + createAsyncObservable(createPaginatedResponse([])), + getRelatedTo: () => createAsyncObservable(createPaginatedResponse([])), + }); await TestBed.configureTestingModule({ declarations: [ObjectStatusComponent], + imports: [ + MtxPopoverModule, + ReactiveFormsModule, + FormsModule, + MatFormFieldModule, + MatSelectModule, + MatCheckboxModule, + MatIconModule, + MatButtonModule, + MatTooltipModule, + DragDropModule, + BrowserAnimationsModule, + ], + providers: [ + { + provide: ActivatedRoute, + useValue: { + params: of({}), + queryParams: of({}), + }, + }, + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + provideHttpClient(), + { + provide: Router, + useValue: { + url: '/technique/mock-stix-id?param=value', + events: of({}), + }, + }, + ], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); diff --git a/src/app/components/object-status/object-status.component.ts b/src/app/components/object-status/object-status.component.ts index 8392eb054..eb5e03889 100644 --- a/src/app/components/object-status/object-status.component.ts +++ b/src/app/components/object-status/object-status.component.ts @@ -1,6 +1,5 @@ import { SelectionModel } from '@angular/cdk/collections'; import { Component, OnInit, ViewEncapsulation } from '@angular/core'; -import { FormControl } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; import { Relationship } from 'src/app/classes/stix/relationship'; import { StixObject } from 'src/app/classes/stix/stix-object'; @@ -9,19 +8,18 @@ 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 { WorkflowStates } from 'src/app/utils/types'; +import { WorkflowStatusMap } from 'src/app/utils/types'; @Component({ selector: 'app-object-status', - templateUrl: './object-status.component.html', + template: '', encapsulation: ViewEncapsulation.None, standalone: false, }) export class ObjectStatusComponent implements OnInit { public loaded = false; - public statusControl: FormControl; public select: SelectionModel; - public workflows = Object.entries(WorkflowStates); + public workflows = Object.entries(WorkflowStatusMap); public objects: StixObject[]; public object: StixObject; public relationships = []; @@ -34,6 +32,24 @@ export class ObjectStatusComponent implements OnInit { ); } + public get revokeDisabled(): boolean { + return this.disabled || this.deprecated || !this.objects; + } + + public get revokeTooltip(): string { + return this.object?.revoked || this.revoked ? 'already revoked' : 'revoke'; + } + + public get deprecateDisabled(): boolean { + return this.disabled || this.revoked || !this.objects; + } + + public get deprecateTooltip(): string { + return this.object?.deprecated || this.deprecated + ? 'already deprecated' + : 'deprecate'; + } + constructor( public editorService: EditorService, private restAPIService: RestApiConnectorService, @@ -41,10 +57,14 @@ export class ObjectStatusComponent implements OnInit { ) {} ngOnInit(): void { - this.statusControl = new FormControl(); + this.loadData(); } public loadData() { + if (!this.editorService.stixId || this.editorService.stixId == 'new') + return; + if (this.loaded && this.object && this.objects) return; + let data$; const options = { includeRevoked: true, @@ -86,9 +106,6 @@ export class ObjectStatusComponent implements OnInit { object => object.stixID === this.editorService.stixId ); if (this.object) { - if (this.object.workflow?.state) { - this.statusControl.setValue(this.object.workflow.state); - } this.revoked = this.object.revoked; this.deprecated = this.object.deprecated; } @@ -139,48 +156,46 @@ export class ObjectStatusComponent implements OnInit { }); } - /** - * Handle workflow state change - * @param event workflow state selection - */ - public workflowChange(event) { - if (event.isUserInput) { - if (event.source.value == 'none') this.object.workflow = undefined; - else this.object.workflow = { state: event.source.value }; - this.save(); - } + public revoke() { + if (!this.loaded || !this.object || !this.objects) return; + if (this.revokeDisabled) return; + this.setRevoke(!this.revoked); } - /** - * Handle the selection for revoking or un-revoking an object - * @param event revoke selection - */ - public revoke(event) { - if (event.checked) { + public toggleDeprecated() { + if (!this.loaded || !this.object || !this.objects) return; + if (this.deprecateDisabled) return; + this.setDeprecated(!this.deprecated); + } + + private setRevoke(revoked: boolean) { + this.revoked = revoked; + if (revoked) { // revoke object // prompt for revoking object this.select = new SelectionModel(); + const revokeDialogData = { + selectableObjects: this.objects.filter(object => { + return object.stixID !== this.editorService.stixId; + }), + type: this.editorService.type, + select: this.select, + selectionType: 'one', + title: 'Select the revoking object', + buttonLabel: 'revoke', + showPreserveRelationshipsOption: true, + preserveRelationships: false, + }; const revokedDialog = this.dialog.open(AddDialogComponent, { maxWidth: '70em', maxHeight: '70em', - data: { - selectableObjects: this.objects.filter(object => { - return object.stixID !== this.editorService.stixId; - }), - type: this.editorService.type, - select: this.select, - selectionType: 'one', - title: 'Select the revoking object', - buttonLabel: 'revoke', - }, + data: revokeDialogData, autoFocus: false, // prevents auto focus on toolbar buttons }); const revokedSubscription = revokedDialog.afterClosed().subscribe({ next: result => { if (result && this.select.selected.length) { - // target object selected - const target_id = this.select.selected[0]; - this.deprecateObjects(true, target_id); + this.revokeObject(revokeDialogData.preserveRelationships); } else { // user cancelled or no object selected this.revoked = false; @@ -202,17 +217,15 @@ export class ObjectStatusComponent implements OnInit { revokedRelationship.deprecated = true; revokedRelationship.save(this.restAPIService); } + this.revoked = false; this.object.revoked = false; this.save(); } } - /** - * Handle the selection for deprecating or un-deprecating an object - * @param event deprecate selection - */ - public deprecate(event) { - if (event.checked) { + private setDeprecated(deprecated: boolean) { + this.deprecated = deprecated; + if (deprecated) { this.deprecateObjects(false); } else { this.object.deprecated = false; @@ -220,6 +233,45 @@ export class ObjectStatusComponent implements OnInit { } } + private revokeObject(preserveRelationships = false) { + const revokingObjectId = this.select.selected[0]; + const revokingObject = this.objects.find( + object => object.stixID === revokingObjectId + ); + + if (!revokingObject?.modified) { + this.revoked = false; + return; + } + + const revokePayload = { + revoking: { + stixId: revokingObject.stixID, + modified: revokingObject.modified.toISOString(), + }, + }; + + const revoke = this.object.revoke?.( + this.restAPIService, + revokePayload, + preserveRelationships + ); + if (!revoke) { + this.revoked = false; + return; + } + + const revokeSubscription = revoke.subscribe({ + complete: () => { + this.editorService.onReload.emit(); + revokeSubscription.unsubscribe(); + }, + error: () => { + this.revoked = false; + }, + }); + } + /** * Deprecates or revokes the object and deprecates all relationships with this object, * with the exception of 'subtechnique-of' relationships diff --git a/src/app/components/outdated-content-warning/outdated-content-warning.component.spec.ts b/src/app/components/outdated-content-warning/outdated-content-warning.component.spec.ts index 2c2b21546..2c95de1f2 100644 --- a/src/app/components/outdated-content-warning/outdated-content-warning.component.spec.ts +++ b/src/app/components/outdated-content-warning/outdated-content-warning.component.spec.ts @@ -1,3 +1,4 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { OutdatedContentWarningComponent } from './outdated-content-warning.component'; @@ -9,6 +10,7 @@ describe('OutdatedContentWarningComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [OutdatedContentWarningComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); fixture = TestBed.createComponent(OutdatedContentWarningComponent); diff --git a/src/app/components/property-diff/property-diff.component.spec.ts b/src/app/components/property-diff/property-diff.component.spec.ts index cbb81e887..cb50cfaaa 100644 --- a/src/app/components/property-diff/property-diff.component.spec.ts +++ b/src/app/components/property-diff/property-diff.component.spec.ts @@ -1,3 +1,4 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { PropertyDiffComponent } from './property-diff.component'; @@ -9,10 +10,16 @@ describe('PropertyDiffComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [PropertyDiffComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); fixture = TestBed.createComponent(PropertyDiffComponent); component = fixture.componentInstance; + + // Initialize required inputs to prevent null errors + component.before = 'old value'; + component.after = 'new value'; + fixture.detectChanges(); }); diff --git a/src/app/components/recent-activity/recent-activity.component.spec.ts b/src/app/components/recent-activity/recent-activity.component.spec.ts index 8ccb2ad49..31f370179 100644 --- a/src/app/components/recent-activity/recent-activity.component.spec.ts +++ b/src/app/components/recent-activity/recent-activity.component.spec.ts @@ -1,4 +1,6 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; import { RecentActivityComponent } from './recent-activity.component'; @@ -9,12 +11,15 @@ describe('RecentActivityComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [RecentActivityComponent], + providers: [provideHttpClient()], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(RecentActivityComponent); component = fixture.componentInstance; + component.identities = []; fixture.detectChanges(); }); 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/reference-edit-dialog/reference-edit-dialog.component.spec.ts b/src/app/components/reference-edit-dialog/reference-edit-dialog.component.spec.ts index d7917dbc6..2c17ef7d3 100644 --- a/src/app/components/reference-edit-dialog/reference-edit-dialog.component.spec.ts +++ b/src/app/components/reference-edit-dialog/reference-edit-dialog.component.spec.ts @@ -1,14 +1,48 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { provideHttpClient } from '@angular/common/http'; import { ReferenceEditDialogComponent } from './reference-edit-dialog.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('ReferenceEditDialogComponent', () => { let component: ReferenceEditDialogComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({ + getAllReferences: () => + createAsyncObservable(createPaginatedResponse([])), + getRelatedTo: () => createAsyncObservable(createPaginatedResponse([])), + putReference: () => createAsyncObservable({}), + postReference: () => createAsyncObservable({}), + }); + await TestBed.configureTestingModule({ declarations: [ReferenceEditDialogComponent], + providers: [ + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + { + provide: MAT_DIALOG_DATA, + useValue: { + mode: 'view', + }, + }, + { + provide: MatDialogRef, + useValue: { + close: () => {}, + }, + }, + provideHttpClient(), + ], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); 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..4305206bd --- /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.stats?.components ?? 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..eaa5a4ecf --- /dev/null +++ b/src/app/components/release-track-object-card/release-track-object-card.component.scss @@ -0,0 +1,211 @@ +@use 'sass:color'; +@use '../../../style/colors' as colors; + +.release-track-object-card { + display: flex; + flex-direction: column; + gap: 0; + cursor: pointer; + 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/history-timeline/history-timeline.component.spec.ts b/src/app/components/resources-drawer/history-timeline/history-timeline.component.spec.ts deleted file mode 100644 index e87e71181..000000000 --- a/src/app/components/resources-drawer/history-timeline/history-timeline.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { HistoryTimelineComponent } from './history-timeline.component'; - -describe('HistoryTimelineComponent', () => { - let component: HistoryTimelineComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - declarations: [HistoryTimelineComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(HistoryTimelineComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/components/resources-drawer/notes-editor/notes-editor.component.spec.ts b/src/app/components/resources-drawer/notes-editor/notes-editor.component.spec.ts deleted file mode 100644 index a0d313898..000000000 --- a/src/app/components/resources-drawer/notes-editor/notes-editor.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { NotesEditorComponent } from './notes-editor.component'; - -describe('NotesEditorComponent', () => { - let component: NotesEditorComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - declarations: [NotesEditorComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(NotesEditorComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/components/resources-drawer/reference-sidebar/reference-sidebar.component.spec.ts b/src/app/components/resources-drawer/reference-sidebar/reference-sidebar.component.spec.ts index 19b9fa31c..0db36e5a5 100644 --- a/src/app/components/resources-drawer/reference-sidebar/reference-sidebar.component.spec.ts +++ b/src/app/components/resources-drawer/reference-sidebar/reference-sidebar.component.spec.ts @@ -1,14 +1,32 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; import { ReferenceSidebarComponent } from './reference-sidebar.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('ReferenceManagerComponent', () => { let component: ReferenceSidebarComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({ + getAllReferences: () => + createAsyncObservable(createPaginatedResponse([])), + }); + await TestBed.configureTestingModule({ declarations: [ReferenceSidebarComponent], + providers: [ + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + provideHttpClient(), + ], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); 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 94b7befe4..7914c8e9d 100644 --- a/src/app/components/resources-drawer/resources-drawer.component.spec.ts +++ b/src/app/components/resources-drawer/resources-drawer.component.spec.ts @@ -1,14 +1,25 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; import { ResourcesDrawerComponent } from './resources-drawer.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { createMockRestApiConnector } from 'src/app/testing/mocks/rest-api-connector.mock'; describe('ResourcesDrawerComponent', () => { let component: ResourcesDrawerComponent; let fixture: ComponentFixture; beforeEach(waitForAsync(() => { + const mockRestApiConnector = createMockRestApiConnector({}); + TestBed.configureTestingModule({ declarations: [ResourcesDrawerComponent], + providers: [ + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + provideHttpClient(), + ], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); @@ -21,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/resources-drawer/search/search.component.spec.ts b/src/app/components/resources-drawer/search/search.component.spec.ts index bb8443bfa..c73665fb7 100644 --- a/src/app/components/resources-drawer/search/search.component.spec.ts +++ b/src/app/components/resources-drawer/search/search.component.spec.ts @@ -1,14 +1,40 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; import { SearchComponent } from './search.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('SearchComponent', () => { let component: SearchComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({ + getAllObjects: () => createAsyncObservable(createPaginatedResponse([])), + }); + await TestBed.configureTestingModule({ declarations: [SearchComponent], + providers: [ + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + provideHttpClient(), + { + provide: ActivatedRoute, + useValue: { + params: of({}), + queryParams: of({}), + }, + }, + ], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); fixture = TestBed.createComponent(SearchComponent); diff --git a/src/app/components/save-dialog/save-dialog.component.html b/src/app/components/save-dialog/save-dialog.component.html index a64a2d839..69b8cb898 100644 --- a/src/app/components/save-dialog/save-dialog.component.html +++ b/src/app/components/save-dialog/save-dialog.component.html @@ -1,62 +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

@@ -99,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 e7c2a0f1b..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,14 +1,140 @@ +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 { 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 { 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 () => { + 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', + 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({ + 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: { close: vi.fn() } }, + { + provide: MAT_DIALOG_DATA, + useValue: { + object: mockObject, + versionAlreadyIncremented: false, + }, + }, + { + provide: RestApiConnectorService, + useValue: createMockRestApiConnector(), + }, + { + provide: ReleaseTracksConnectorService, + useValue: mockReleaseTracksService, + }, + provideHttpClient(), + ], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); @@ -21,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 c9f9d5feb..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,24 @@ 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({ selector: 'app-save-dialog', @@ -22,45 +34,82 @@ 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; - }, - complete: () => { - subscription.unsubscribe(); - }, - }); } ngOnInit(): void { - this.newState = 'work-in-progress'; + this.newState = this.workflowEnabled + ? this.config.initialWorkflowState || WorkflowStatus.WorkInProgress + : undefined; + if (this.config.object.attackType === 'relationship') { + this.newState = undefined; + } if (this.config.object.attackType === 'detection-strategy') { const det = this.config.object as DetectionStrategy; const newAnalytics = new Set(det.analytics); @@ -82,6 +131,64 @@ export class SaveDialogComponent implements OnInit { for (const a of newAnalytics) this.analyticsToPatch.add(a); } } + this.validateObject(); + this.loadTrackRows(); + } + + public get selectedVersion(): string { + switch (this.versionChoice) { + case 'major': + return this.nextMajorVersion; + case 'minor': + return this.nextMinorVersion; + default: + return this.currentVersion; + } + } + + 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 = ''; } /** @@ -192,9 +299,7 @@ export class SaveDialogComponent implements OnInit { * Save the object with the current version and check for patches */ public saveCurrentVersion() { - this.config.object.workflow = this.newState - ? { state: this.newState } - : undefined; + this.applyWorkflowState(); if (this.config.patchId || this.config.patchAnalytics) this.parse_patches(); else this.save(); } @@ -204,9 +309,7 @@ export class SaveDialogComponent implements OnInit { */ public saveNextMinorVersion() { this.config.object.version = new VersionNumber(this.nextMinorVersion); - this.config.object.workflow = this.newState - ? { state: this.newState } - : undefined; + this.applyWorkflowState(); if (this.config.patchId || this.config.patchAnalytics) this.parse_patches(); else this.save(); } @@ -216,9 +319,7 @@ export class SaveDialogComponent implements OnInit { */ public saveNextMajorVersion() { this.config.object.version = new VersionNumber(this.nextMajorVersion); - this.config.object.workflow = this.newState - ? { state: this.newState } - : undefined; + this.applyWorkflowState(); if (this.config.patchId || this.config.patchAnalytics) this.parse_patches(); else this.save(); } @@ -227,6 +328,13 @@ export class SaveDialogComponent implements OnInit { return this.config.object.save(this.restApiService); // save this object } + private applyWorkflowState(): void { + this.config.object.workflow = + this.workflowEnabled && this.newState + ? { state: this.newState } + : undefined; + } + /** * Save the object without patching other objects */ @@ -234,13 +342,349 @@ export class SaveDialogComponent implements OnInit { if (!this.saveEnabled) { return; } - const sub = this.saveObject().subscribe({ - next: () => { - this.dialogRef.close(true); - }, - complete: () => sub.unsubscribe(), - }); + this.applyWorkflowState(); + 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 { @@ -248,4 +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?: 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-json-dialog/stix-json-dialog.component.spec.ts b/src/app/components/stix-json-dialog/stix-json-dialog.component.spec.ts index ebea396ea..1f0b3ccfb 100644 --- a/src/app/components/stix-json-dialog/stix-json-dialog.component.spec.ts +++ b/src/app/components/stix-json-dialog/stix-json-dialog.component.spec.ts @@ -1,4 +1,8 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { vi } from 'vitest'; import { StixJsonDialogComponent } from './stix-json-dialog.component'; @@ -7,8 +11,23 @@ describe('StixJsonDialogComponent', () => { let fixture: ComponentFixture; beforeEach(async () => { + const mockStixObject = { + name: 'Test Object', + attackID: 'T1234', + serialize: vi.fn().mockReturnValue({ + name: 'Test Object', + id: 'test-id', + }), + }; + await TestBed.configureTestingModule({ declarations: [StixJsonDialogComponent], + providers: [ + { provide: MatDialogRef, useValue: {} }, + { provide: MAT_DIALOG_DATA, useValue: { stixObject: mockStixObject } }, + { provide: MatSnackBar, useValue: {} }, + ], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); fixture = TestBed.createComponent(StixJsonDialogComponent); diff --git a/src/app/components/stix/alias-property/alias-diff/alias-diff.component.spec.ts b/src/app/components/stix/alias-property/alias-diff/alias-diff.component.spec.ts index 84ce5122c..1cbaa4942 100644 --- a/src/app/components/stix/alias-property/alias-diff/alias-diff.component.spec.ts +++ b/src/app/components/stix/alias-property/alias-diff/alias-diff.component.spec.ts @@ -1,4 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { AliasDiffComponent } from './alias-diff.component'; @@ -9,11 +10,12 @@ describe('AliasDiffComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [AliasDiffComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); fixture = TestBed.createComponent(AliasDiffComponent); component = fixture.componentInstance; - fixture.detectChanges(); + component.config = { mode: 'view', object: [{}, {}] as any }; }); it('should create', () => { diff --git a/src/app/components/stix/alias-property/alias-edit/alias-edit-dialog/alias-edit-dialog.component.spec.ts b/src/app/components/stix/alias-property/alias-edit/alias-edit-dialog/alias-edit-dialog.component.spec.ts index e2075eec0..665469e3e 100644 --- a/src/app/components/stix/alias-property/alias-edit/alias-edit-dialog/alias-edit-dialog.component.spec.ts +++ b/src/app/components/stix/alias-property/alias-edit/alias-edit-dialog/alias-edit-dialog.component.spec.ts @@ -1,4 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { AliasEditDialogComponent } from './alias-edit-dialog.component'; @@ -9,13 +11,17 @@ describe('AliasAddDialogComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [AliasEditDialogComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + { provide: MatDialogRef, useValue: {} }, + { provide: MAT_DIALOG_DATA, useValue: {} }, + ], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(AliasEditDialogComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create', () => { diff --git a/src/app/components/stix/alias-property/alias-edit/alias-edit.component.html b/src/app/components/stix/alias-property/alias-edit/alias-edit.component.html index ef165eb27..b511e4092 100644 --- a/src/app/components/stix/alias-property/alias-edit/alias-edit.component.html +++ b/src/app/components/stix/alias-property/alias-edit/alias-edit.component.html @@ -2,7 +2,7 @@
diff --git a/src/app/components/stix/alias-property/alias-edit/alias-edit.component.spec.ts b/src/app/components/stix/alias-property/alias-edit/alias-edit.component.spec.ts index 05cf1faae..30aa4d883 100644 --- a/src/app/components/stix/alias-property/alias-edit/alias-edit.component.spec.ts +++ b/src/app/components/stix/alias-property/alias-edit/alias-edit.component.spec.ts @@ -1,4 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { AliasEditComponent } from './alias-edit.component'; @@ -9,13 +10,14 @@ describe('AliasEditComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [AliasEditComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(AliasEditComponent); component = fixture.componentInstance; - fixture.detectChanges(); + component.config = { mode: 'edit', object: {} as any }; }); it('should create', () => { diff --git a/src/app/components/stix/alias-property/alias-edit/alias-edit.component.ts b/src/app/components/stix/alias-property/alias-edit/alias-edit.component.ts index 9221c385a..5f2e68903 100644 --- a/src/app/components/stix/alias-property/alias-edit/alias-edit.component.ts +++ b/src/app/components/stix/alias-property/alias-edit/alias-edit.component.ts @@ -56,4 +56,12 @@ export class AliasEditComponent implements OnInit { }); obj.external_references.deserialize(references); } + + // When displaying aliases for software, groups and campaigns, filter out the object's name from the aliases list + public get visibleAliases(): string[] { + const object = this.config.object as StixObject; + const aliases = object?.[this.config.field] ?? []; + const name = 'name' in object ? object.name : undefined; + return aliases.filter(alias => alias !== name); + } } diff --git a/src/app/components/stix/alias-property/alias-property.component.spec.ts b/src/app/components/stix/alias-property/alias-property.component.spec.ts index 226a5b667..013567167 100644 --- a/src/app/components/stix/alias-property/alias-property.component.spec.ts +++ b/src/app/components/stix/alias-property/alias-property.component.spec.ts @@ -1,6 +1,8 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { AliasPropertyComponent } from './alias-property.component'; +import { StixObject } from 'src/app/classes/stix/stix-object'; describe('AliasPropertyComponent', () => { let component: AliasPropertyComponent; @@ -9,12 +11,20 @@ describe('AliasPropertyComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [AliasPropertyComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(AliasPropertyComponent); component = fixture.componentInstance; + // Set required config input + component.config = { + mode: 'view', + object: {} as StixObject, + field: 'aliases', + label: 'Aliases', + }; fixture.detectChanges(); }); diff --git a/src/app/components/stix/alias-property/alias-view/alias-view.component.spec.ts b/src/app/components/stix/alias-property/alias-view/alias-view.component.spec.ts index 1f5906aa0..bd315b09b 100644 --- a/src/app/components/stix/alias-property/alias-view/alias-view.component.spec.ts +++ b/src/app/components/stix/alias-property/alias-view/alias-view.component.spec.ts @@ -1,4 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { AliasViewComponent } from './alias-view.component'; @@ -9,13 +10,14 @@ describe('AliasViewComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [AliasViewComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(AliasViewComponent); component = fixture.componentInstance; - fixture.detectChanges(); + component.config = { mode: 'view', field: 'name', object: {} as any }; }); it('should create', () => { diff --git a/src/app/components/stix/attackid-property/attackid-diff/attackid-diff.component.html b/src/app/components/stix/attackid-property/attackid-diff/attackid-diff.component.html deleted file mode 100644 index e42dde022..000000000 --- a/src/app/components/stix/attackid-property/attackid-diff/attackid-diff.component.html +++ /dev/null @@ -1,8 +0,0 @@ -
-
- -
- ATT&CK ID -
diff --git a/src/app/components/stix/attackid-property/attackid-diff/attackid-diff.component.spec.ts b/src/app/components/stix/attackid-property/attackid-diff/attackid-diff.component.spec.ts deleted file mode 100644 index 037458ed7..000000000 --- a/src/app/components/stix/attackid-property/attackid-diff/attackid-diff.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { AttackidDiffComponent } from './attackid-diff.component'; - -describe('AttackidDiffComponent', () => { - let component: AttackidDiffComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - declarations: [AttackidDiffComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(AttackidDiffComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/components/stix/attackid-property/attackid-diff/attackid-diff.component.ts b/src/app/components/stix/attackid-property/attackid-diff/attackid-diff.component.ts deleted file mode 100644 index 8cc53ea51..000000000 --- a/src/app/components/stix/attackid-property/attackid-diff/attackid-diff.component.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Component, Input } from '@angular/core'; -import { AttackIDPropertyConfig } from '../attackid-property.component'; - -@Component({ - selector: 'app-attackid-diff', - templateUrl: './attackid-diff.component.html', - standalone: false, -}) -export class AttackidDiffComponent { - @Input() public config: AttackIDPropertyConfig; - - public get current(): string { - return this.config.object[0]?.['attackID'] || ''; - } - public get previous(): string { - return this.config.object[1]?.['attackID'] || ''; - } -} diff --git a/src/app/components/stix/attackid-property/attackid-edit/attackid-edit.component.html b/src/app/components/stix/attackid-property/attackid-edit/attackid-edit.component.html deleted file mode 100644 index ee7bbce5a..000000000 --- a/src/app/components/stix/attackid-property/attackid-edit/attackid-edit.component.html +++ /dev/null @@ -1,23 +0,0 @@ -
- - ID - - - -
diff --git a/src/app/components/stix/attackid-property/attackid-edit/attackid-edit.component.scss b/src/app/components/stix/attackid-property/attackid-edit/attackid-edit.component.scss deleted file mode 100644 index ef633d38a..000000000 --- a/src/app/components/stix/attackid-property/attackid-edit/attackid-edit.component.scss +++ /dev/null @@ -1,8 +0,0 @@ -.attackid-edit { - .mat-mdc-form-field { - width: 100%; - } - .mat-mdc-form-field-subscript-wrapper { - display: none; - } -} diff --git a/src/app/components/stix/attackid-property/attackid-edit/attackid-edit.component.spec.ts b/src/app/components/stix/attackid-property/attackid-edit/attackid-edit.component.spec.ts deleted file mode 100644 index e7ac86189..000000000 --- a/src/app/components/stix/attackid-property/attackid-edit/attackid-edit.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { AttackidEditComponent } from './attackid-edit.component'; - -describe('AttackidEditComponent', () => { - let component: AttackidEditComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - declarations: [AttackidEditComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(AttackidEditComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/components/stix/attackid-property/attackid-edit/attackid-edit.component.ts b/src/app/components/stix/attackid-property/attackid-edit/attackid-edit.component.ts deleted file mode 100644 index 42f62d589..000000000 --- a/src/app/components/stix/attackid-property/attackid-edit/attackid-edit.component.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { - Component, - Input, - OnInit, - Output, - ViewEncapsulation, - EventEmitter, -} from '@angular/core'; -import { AttackIDPropertyConfig } from '../attackid-property.component'; -import { RestApiConnectorService } from '../../../../services/connectors/rest-api/rest-api-connector.service'; -import { StixObject } from '../../../../classes/stix/stix-object'; - -@Component({ - selector: 'app-attackid-edit', - templateUrl: './attackid-edit.component.html', - styleUrls: ['./attackid-edit.component.scss'], - encapsulation: ViewEncapsulation.None, - standalone: false, -}) -export class AttackIDEditComponent implements OnInit { - @Input() public config: AttackIDPropertyConfig; - @Output() public attackIdGenerated = new EventEmitter(); - public prefix = ''; - - constructor(public restApiConnector: RestApiConnectorService) {} - - ngOnInit(): void { - // Get namespace settings and prepend, if creating a new object - if ((this.config.object as StixObject).firstInitialized) { - const namespaceSub = this.restApiConnector - .getOrganizationNamespace() - .subscribe({ - next: namespaceSettings => { - this.prefix = namespaceSettings.prefix ?? ''; - }, - complete: () => namespaceSub.unsubscribe(), - }); - } else { - // Otherwise extract existing prefix, if any - const found = (this.config.object as StixObject).attackID.match( - /[A-Z]+-/g - ); - if (found) { - this.prefix = found[0].replace(/-$/, ''); - } - } - } - - public handleGenerateClick(): void { - if ((this.config.object as StixObject).supportsAttackID) { - const sub = (this.config.object as StixObject) - .generateAttackId(this.restApiConnector, this.prefix) - .subscribe({ - next: val => { - (this.config.object as StixObject).attackID = val; - }, - complete: () => { - this.attackIdChanged(); - sub.unsubscribe(); - }, - }); - } - } - - public attackIdChanged(): void { - this.attackIdGenerated.emit(); - } - - public formatAttackId(): void { - // handle user set attack id - const object = this.config.object as StixObject; - const withPrefix = object.formatWithPrefix(object.attackID, this.prefix); - (this.config.object as StixObject).attackID = withPrefix; - } -} diff --git a/src/app/components/stix/attackid-property/attackid-property.component.html b/src/app/components/stix/attackid-property/attackid-property.component.html index 729863f4c..ede1e805c 100644 --- a/src/app/components/stix/attackid-property/attackid-property.component.html +++ b/src/app/components/stix/attackid-property/attackid-property.component.html @@ -1,32 +1,37 @@
-
-
- - + @if (config.mode === 'diff') { +
+
+ +
+ ATT&CK ID
- ATT&CK ID -
- - + } @else { +
+
+ {{ config.object['attackID'] }} + @if (config.object['attackID']) { + + } @else { + Auto-generated on save. + } +
+ ATT&CK ID +
+ }
diff --git a/src/app/components/stix/attackid-property/attackid-property.component.spec.ts b/src/app/components/stix/attackid-property/attackid-property.component.spec.ts index c8537d42f..51209f7f4 100644 --- a/src/app/components/stix/attackid-property/attackid-property.component.spec.ts +++ b/src/app/components/stix/attackid-property/attackid-property.component.spec.ts @@ -1,20 +1,27 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { AttackidPropertyComponent } from './attackid-property.component'; +import { AttackIDPropertyComponent } from './attackid-property.component'; -describe('AttackidPropertyComponent', () => { - let component: AttackidPropertyComponent; - let fixture: ComponentFixture; +describe('AttackIDPropertyComponent', () => { + let component: AttackIDPropertyComponent; + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [AttackidPropertyComponent], + declarations: [AttackIDPropertyComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); beforeEach(() => { - fixture = TestBed.createComponent(AttackidPropertyComponent); + fixture = TestBed.createComponent(AttackIDPropertyComponent); component = fixture.componentInstance; + // Set required config input + component.config = { + mode: 'view', + object: {} as any, + }; fixture.detectChanges(); }); diff --git a/src/app/components/stix/attackid-property/attackid-property.component.ts b/src/app/components/stix/attackid-property/attackid-property.component.ts index f688495bc..59d3ef0df 100644 --- a/src/app/components/stix/attackid-property/attackid-property.component.ts +++ b/src/app/components/stix/attackid-property/attackid-property.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Component, Input } from '@angular/core'; import { MatSnackBar } from '@angular/material/snack-bar'; import { StixObject } from 'src/app/classes/stix/stix-object'; @@ -10,7 +10,14 @@ import { StixObject } from 'src/app/classes/stix/stix-object'; }) export class AttackIDPropertyComponent { @Input() public config: AttackIDPropertyConfig; - @Output() public attackIdGenerated = new EventEmitter(); + + public get currentId(): string { + return this.config.object[0]?.['attackID'] || ''; + } + + public get previousId(): string { + return this.config.object[1]?.['attackID'] || ''; + } public get linkById(): string { return `(LinkById: ${this.config.object['attackID']})`; @@ -19,21 +26,15 @@ export class AttackIDPropertyComponent { constructor(public snackbar: MatSnackBar) { // intentionally left blank } - - public onAttackIdGenerated(): void { - this.attackIdGenerated.emit(); - } } export interface AttackIDPropertyConfig { /* What is the current mode? Default: 'view - * view: viewing the list property - * edit: editing the list property + * view: viewing the attack id property * diff: displaying the diff between two STIX objects. If this mode is selected, two StixObjects must be specified in the objects field */ - mode?: 'view' | 'edit' | 'diff'; + mode?: 'view' | 'diff'; /* The object to show the field of * Note: if mode is diff, pass an array of two objects to diff */ object: StixObject | [StixObject, StixObject]; - required?: boolean; // default false } diff --git a/src/app/components/stix/attackid-property/attackid-view/attackid-view.component.html b/src/app/components/stix/attackid-property/attackid-view/attackid-view.component.html deleted file mode 100644 index 17e8dadff..000000000 --- a/src/app/components/stix/attackid-property/attackid-view/attackid-view.component.html +++ /dev/null @@ -1,3 +0,0 @@ -
- {{ config.object['attackID'] }} -
diff --git a/src/app/components/stix/attackid-property/attackid-view/attackid-view.component.spec.ts b/src/app/components/stix/attackid-property/attackid-view/attackid-view.component.spec.ts deleted file mode 100644 index e411777de..000000000 --- a/src/app/components/stix/attackid-property/attackid-view/attackid-view.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { AttackIDViewComponent } from './attackid-view.component'; - -describe('AttackidViewComponent', () => { - let component: AttackIDViewComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - declarations: [AttackIDViewComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(AttackIDViewComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/components/stix/attackid-property/attackid-view/attackid-view.component.ts b/src/app/components/stix/attackid-property/attackid-view/attackid-view.component.ts deleted file mode 100644 index e7e8c6767..000000000 --- a/src/app/components/stix/attackid-property/attackid-view/attackid-view.component.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Component, Input, OnInit } from '@angular/core'; -import { AttackIDPropertyConfig } from '../attackid-property.component'; - -@Component({ - selector: 'app-attackid-view', - templateUrl: './attackid-view.component.html', - styleUrls: ['./attackid-view.component.scss'], - standalone: false, -}) -export class AttackIDViewComponent implements OnInit { - @Input() public config: AttackIDPropertyConfig; - - constructor() { - // intentionally left blank - } - - ngOnInit(): void { - // intentionally left blank - } -} diff --git a/src/app/components/stix/boolean-property/boolean-property.component.spec.ts b/src/app/components/stix/boolean-property/boolean-property.component.spec.ts index 5642203e8..0aafa26cd 100644 --- a/src/app/components/stix/boolean-property/boolean-property.component.spec.ts +++ b/src/app/components/stix/boolean-property/boolean-property.component.spec.ts @@ -1,6 +1,8 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { BooleanPropertyComponent } from './boolean-property.component'; +import { StixObject } from 'src/app/classes/stix'; describe('BooleanPropertyComponent', () => { let component: BooleanPropertyComponent; @@ -9,10 +11,18 @@ describe('BooleanPropertyComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [BooleanPropertyComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); fixture = TestBed.createComponent(BooleanPropertyComponent); component = fixture.componentInstance; + // Set required config input + component.config = { + mode: 'view', + object: {} as StixObject, + field: 'test', + label: 'Test', + }; fixture.detectChanges(); }); diff --git a/src/app/components/stix/citation-property/citation-diff/citation-diff.component.spec.ts b/src/app/components/stix/citation-property/citation-diff/citation-diff.component.spec.ts index 24668a21e..8ac4c42ca 100644 --- a/src/app/components/stix/citation-property/citation-diff/citation-diff.component.spec.ts +++ b/src/app/components/stix/citation-property/citation-diff/citation-diff.component.spec.ts @@ -1,6 +1,8 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { CitationDiffComponent } from './citation-diff.component'; +import { StixObject } from 'src/app/classes/stix/stix-object'; describe('CitationDiffComponent', () => { let component: CitationDiffComponent; @@ -9,10 +11,19 @@ describe('CitationDiffComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [CitationDiffComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); fixture = TestBed.createComponent(CitationDiffComponent); component = fixture.componentInstance; + // Set required config input + component.config = { + mode: 'diff', + object: [{} as StixObject, {} as StixObject], + field: 'test', + referencesField: 'external_references', + label: 'Test', + }; fixture.detectChanges(); }); diff --git a/src/app/components/stix/citation-property/citation-edit/citation-edit.component.spec.ts b/src/app/components/stix/citation-property/citation-edit/citation-edit.component.spec.ts index feb698f5d..49b41fe11 100644 --- a/src/app/components/stix/citation-property/citation-edit/citation-edit.component.spec.ts +++ b/src/app/components/stix/citation-property/citation-edit/citation-edit.component.spec.ts @@ -1,21 +1,47 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideRouter } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { of } from 'rxjs'; import { CitationEditComponent } from './citation-edit.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { createMockRestApiConnector } from 'src/app/testing/mocks/rest-api-connector.mock'; describe('CitationEditComponent', () => { let component: CitationEditComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({}); + await TestBed.configureTestingModule({ declarations: [CitationEditComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + provideHttpClient(), + provideRouter([]), + { + provide: ActivatedRoute, + useValue: { + params: of({}), + queryParams: of({}), + }, + }, + ], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(CitationEditComponent); component = fixture.componentInstance; - fixture.detectChanges(); + component.config = { + mode: 'edit', + object: {} as any, + field: 'external_references', + } as any; }); it('should create', () => { diff --git a/src/app/components/stix/citation-property/citation-property.component.spec.ts b/src/app/components/stix/citation-property/citation-property.component.spec.ts index d8ce1719d..dd5e23fa4 100644 --- a/src/app/components/stix/citation-property/citation-property.component.spec.ts +++ b/src/app/components/stix/citation-property/citation-property.component.spec.ts @@ -1,3 +1,4 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { CitationPropertyComponent } from './citation-property.component'; @@ -9,12 +10,20 @@ describe('CitationPropertyComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [CitationPropertyComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(CitationPropertyComponent); component = fixture.componentInstance; + component.config = { + mode: 'view', + object: {} as any, + field: 'description', + referencesField: 'external_references', + label: 'Description', + }; fixture.detectChanges(); }); diff --git a/src/app/components/stix/citation-property/citation-view/citation-view.component.spec.ts b/src/app/components/stix/citation-property/citation-view/citation-view.component.spec.ts index 055bcc256..04bdb576c 100644 --- a/src/app/components/stix/citation-property/citation-view/citation-view.component.spec.ts +++ b/src/app/components/stix/citation-property/citation-view/citation-view.component.spec.ts @@ -1,4 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { CitationViewComponent } from './citation-view.component'; @@ -9,13 +11,21 @@ describe('CitationViewComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [CitationViewComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [provideHttpClient()], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(CitationViewComponent); component = fixture.componentInstance; - fixture.detectChanges(); + component.config = { + mode: 'view', + object: {} as any, + field: 'external_references', + referencesField: 'external_references', + label: 'External References', + }; }); it('should create', () => { diff --git a/src/app/components/stix/datepicker-property/datepicker-property.component.spec.ts b/src/app/components/stix/datepicker-property/datepicker-property.component.spec.ts index 472fc1b4b..1997116a6 100644 --- a/src/app/components/stix/datepicker-property/datepicker-property.component.spec.ts +++ b/src/app/components/stix/datepicker-property/datepicker-property.component.spec.ts @@ -1,4 +1,6 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; import { DatepickerPropertyComponent } from './datepicker-property.component'; @@ -9,12 +11,19 @@ describe('DatepickerPropertyComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [DatepickerPropertyComponent], + providers: [provideHttpClient()], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(DatepickerPropertyComponent); component = fixture.componentInstance; + // Set required config input + component.config = { + object: {} as any, + field: 'date', + }; fixture.detectChanges(); }); diff --git a/src/app/components/stix/descriptive-property/descriptive-diff/descriptive-diff.component.spec.ts b/src/app/components/stix/descriptive-property/descriptive-diff/descriptive-diff.component.spec.ts index cc4002356..39597f648 100644 --- a/src/app/components/stix/descriptive-property/descriptive-diff/descriptive-diff.component.spec.ts +++ b/src/app/components/stix/descriptive-property/descriptive-diff/descriptive-diff.component.spec.ts @@ -1,4 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { DescriptiveDiffComponent } from './descriptive-diff.component'; @@ -9,11 +10,18 @@ describe('DescriptiveDiffComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [DescriptiveDiffComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); + }); + beforeEach(() => { fixture = TestBed.createComponent(DescriptiveDiffComponent); component = fixture.componentInstance; - fixture.detectChanges(); + component.config = { + mode: 'diff', + object: [{} as any, {} as any], + field: 'description', + }; }); it('should create', () => { diff --git a/src/app/components/stix/descriptive-property/descriptive-edit/descriptive-edit.component.spec.ts b/src/app/components/stix/descriptive-property/descriptive-edit/descriptive-edit.component.spec.ts index 9f25b5f5d..09ea78456 100644 --- a/src/app/components/stix/descriptive-property/descriptive-edit/descriptive-edit.component.spec.ts +++ b/src/app/components/stix/descriptive-property/descriptive-edit/descriptive-edit.component.spec.ts @@ -1,21 +1,47 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideRouter } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { of } from 'rxjs'; import { DescriptiveEditComponent } from './descriptive-edit.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { createMockRestApiConnector } from 'src/app/testing/mocks/rest-api-connector.mock'; describe('DescriptiveEditComponent', () => { let component: DescriptiveEditComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({}); + await TestBed.configureTestingModule({ declarations: [DescriptiveEditComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + provideHttpClient(), + provideRouter([]), + { + provide: ActivatedRoute, + useValue: { + params: of({}), + queryParams: of({}), + }, + }, + ], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(DescriptiveEditComponent); component = fixture.componentInstance; - fixture.detectChanges(); + component.config = { + mode: 'edit', + object: {} as any, + field: 'description', + }; }); it('should create', () => { diff --git a/src/app/components/stix/descriptive-property/descriptive-property.component.spec.ts b/src/app/components/stix/descriptive-property/descriptive-property.component.spec.ts index bbb0d1c37..4ae599875 100644 --- a/src/app/components/stix/descriptive-property/descriptive-property.component.spec.ts +++ b/src/app/components/stix/descriptive-property/descriptive-property.component.spec.ts @@ -1,3 +1,4 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { DescriptivePropertyComponent } from './descriptive-property.component'; @@ -9,12 +10,19 @@ describe('DescriptivePropertyComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [DescriptivePropertyComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(DescriptivePropertyComponent); component = fixture.componentInstance; + component.config = { + mode: 'view', + object: {} as any, + field: 'description', + label: 'Description', + }; fixture.detectChanges(); }); diff --git a/src/app/components/stix/descriptive-property/descriptive-view/descriptive-view.component.spec.ts b/src/app/components/stix/descriptive-property/descriptive-view/descriptive-view.component.spec.ts index 218d1d91c..91bfc0220 100644 --- a/src/app/components/stix/descriptive-property/descriptive-view/descriptive-view.component.spec.ts +++ b/src/app/components/stix/descriptive-property/descriptive-view/descriptive-view.component.spec.ts @@ -1,20 +1,35 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { DescriptiveViewComponent } from './descriptive-view.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { createMockRestApiConnector } from 'src/app/testing/mocks/rest-api-connector.mock'; describe('DescriptiveViewComponent', () => { let component: DescriptiveViewComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({}); + await TestBed.configureTestingModule({ declarations: [DescriptiveViewComponent], + providers: [ + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + ], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(DescriptiveViewComponent); component = fixture.componentInstance; + // Set required config input + component.config = { + mode: 'view', + object: {} as any, + field: 'description', + }; fixture.detectChanges(); }); diff --git a/src/app/components/stix/dictionary-property/dictionary-diff/dictionary-diff.component.html b/src/app/components/stix/dictionary-property/dictionary-diff/dictionary-diff.component.html new file mode 100644 index 000000000..c58972939 --- /dev/null +++ b/src/app/components/stix/dictionary-property/dictionary-diff/dictionary-diff.component.html @@ -0,0 +1,23 @@ +
+
+ + + + + + + + +
+ {{ column.label || column.name | titlecase }} + + + +
+
+ {{ config.label }} +
diff --git a/src/app/components/stix/attackid-property/attackid-view/attackid-view.component.scss b/src/app/components/stix/dictionary-property/dictionary-diff/dictionary-diff.component.scss similarity index 100% rename from src/app/components/stix/attackid-property/attackid-view/attackid-view.component.scss rename to src/app/components/stix/dictionary-property/dictionary-diff/dictionary-diff.component.scss diff --git a/src/app/components/stix/dictionary-property/dictionary-diff/dictionary-diff.component.ts b/src/app/components/stix/dictionary-property/dictionary-diff/dictionary-diff.component.ts new file mode 100644 index 000000000..89d9c2be8 --- /dev/null +++ b/src/app/components/stix/dictionary-property/dictionary-diff/dictionary-diff.component.ts @@ -0,0 +1,86 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { DictionaryPropertyConfig } from '../dictionary-property.component'; + +@Component({ + selector: 'app-dictionary-diff', + standalone: false, + templateUrl: './dictionary-diff.component.html', + styleUrl: './dictionary-diff.component.scss', +}) +export class DictionaryDiffComponent implements OnInit { + @Input() public config: DictionaryPropertyConfig; + + public get current() { + return this.config.object?.[0]?.[this.config.field] || []; + } + + public get previous() { + return this.config.object?.[1]?.[this.config.field] || []; + } + + public detailTable = []; + + public get columns() { + return this.config.columns; + } + + public get columnNames(): string[] { + return this.config.columns.map(c => c.name); + } + + public get columnsLabels(): string[] { + return this.config.columns.map(c => (c.label ? c.label : c.name)); + } + + ngOnInit(): void { + this.detailTable = this.mergeTable(); + } + + private mergeTable(): any[] { + const merged = new Map(); + + const prevCounts = new Map(); + const currCounts = new Map(); + + // add before state to map + for (const item of this.previous) { + const sig = this.rowSignature(item); + const n = (prevCounts.get(sig) ?? 0) + 1; + prevCounts.set(sig, n); + + const key = `${sig}__#${n}`; + merged.set(key, { before: item, after: null }); + } + + // add after state to map + for (const item of this.current) { + const sig = this.rowSignature(item); + const n = (currCounts.get(sig) ?? 0) + 1; + currCounts.set(sig, n); + + const key = `${sig}__#${n}`; + if (merged.has(key)) { + merged.get(key)!.after = item; + } else { + merged.set(key, { before: null, after: item }); + } + } + + return Array.from(merged.values()); + } + + private rowSignature(item): string { + return this.columnNames.map(col => this.normalize(item?.[col])).join('::'); + } + + private normalize(v: any): string { + if (v == null || v == undefined) return ''; + return String(v).trim().toLowerCase(); + } + + public valueToString(item, columnName) { + if (!item?.[columnName]) return ''; + if (Array.isArray(item[columnName])) return item[columnName].join('; '); + return String(item[columnName]); + } +} diff --git a/src/app/components/stix/dictionary-property/dictionary-edit/dictionary-edit.component.html b/src/app/components/stix/dictionary-property/dictionary-edit/dictionary-edit.component.html new file mode 100644 index 000000000..a76a7fd36 --- /dev/null +++ b/src/app/components/stix/dictionary-property/dictionary-edit/dictionary-edit.component.html @@ -0,0 +1,81 @@ +
+
+
+ +
+ + @if (config.object[config.field].length > 0) { + + + + + + + + + + + + + + +
+ {{ column.label || column.name }} + +
+
+ {{ row[column.name] }} +
+ @if (isEditing(rowIndex, column.name)) { +
+ + + +
+ } +
+
+
+ +
+
+ } +
+ {{ config.label }} +
diff --git a/src/app/components/stix/dictionary-property/dictionary-edit/dictionary-edit.component.scss b/src/app/components/stix/dictionary-property/dictionary-edit/dictionary-edit.component.scss new file mode 100644 index 000000000..38d420afc --- /dev/null +++ b/src/app/components/stix/dictionary-property/dictionary-edit/dictionary-edit.component.scss @@ -0,0 +1,131 @@ +@use '../../../../../style/globals'; +@use '../../../../../style/colors.scss'; + +.dictionary-edit { + @extend .labelled-box; + + .mat-divider { + margin: 16px 8px; + } + + .mat-mdc-form-field-subscript-wrapper { + display: none; // remove bottom padding used for hint + } + .mat-mdc-form-field-infix { + padding-bottom: 8px !important; + padding-top: 8px !important; + min-height: unset !important; + } + .mat-mdc-text-field-wrapper { + height: unset !important; + padding: 0 6px !important; + } + + table.property-table { + width: 100%; + table-layout: fixed; + } + td.mat-mdc-cell, + td.mat-cell, + .cell { + position: relative; + overflow: visible; + } + .cell { + cursor: text; + padding: 6px; + &:hover { + .light & { + background: colors.color-alternate(light); + } + .dark & { + background: colors.color-alternate(dark); + } + } + } + th.mat-column-actions, + td.mat-column-actions { + width: 44px; + min-width: 44px; + max-width: 44px; + text-align: center; + padding-left: 0; + padding-right: 0; + } + .action-cell { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + } + th.mat-column-name, + td.mat-column-name { + width: 30%; + max-width: 30%; + } + th.mat-mdc-header-cell, + td.mat-mdc-cell { + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; + // vertical-align: top; + vertical-align: middle; + } + .table-value { + white-space: normal; + overflow-wrap: anywhere; + + flex: 1; + height: 100%; + display: flex; + align-items: center; + word-break: break-word; + // min-height: 28px; + } + .cell-inner { + position: relative; + min-height: 40px; // give single-line edits room for the input field + display: flex; + align-items: center; + } + .table-value.hidden { + visibility: hidden; + } + .editor { + position: absolute; + inset: 0; + display: flex; + // align-items: flex-start; + align-items: center; + z-index: 2; + } + .editor-field { + width: 100%; + height: 100%; + } + .editor-field .mat-mdc-text-field-wrapper { + padding: 0 6px !important; + height: 100% !important; + display: flex; + align-items: center; + } + .editor-field .mat-mdc-form-field-infix { + padding-top: 0 !important; + padding-bottom: 0 !important; + min-height: unset !important; + + display: flex; + align-items: center; + } + .editor-field input.mat-mdc-input-element { + height: 100%; + line-height: normal; + } + .editor-field .mat-mdc-form-field-subscript-wrapper { + display: none; + } + .content { + overflow-x: auto; + display: block !important; + } +} diff --git a/src/app/components/stix/dictionary-property/dictionary-edit/dictionary-edit.component.ts b/src/app/components/stix/dictionary-property/dictionary-edit/dictionary-edit.component.ts new file mode 100644 index 000000000..be130770f --- /dev/null +++ b/src/app/components/stix/dictionary-property/dictionary-edit/dictionary-edit.component.ts @@ -0,0 +1,114 @@ +import { + Component, + Input, + OnInit, + ViewEncapsulation, + ViewChild, + ElementRef, + OnChanges, + SimpleChanges, +} from '@angular/core'; +import { DictionaryPropertyConfig } from '../dictionary-property.component'; +import { MatTable } from '@angular/material/table'; +import { EditorService } from 'src/app/services/editor/editor.service'; + +@Component({ + selector: 'app-dictionary-edit', + standalone: false, + templateUrl: './dictionary-edit.component.html', + styleUrls: ['./dictionary-edit.component.scss'], + encapsulation: ViewEncapsulation.None, +}) +export class DictionaryEditComponent implements OnInit, OnChanges { + @Input() public config: DictionaryPropertyConfig; + @ViewChild('editInput', { static: false }) editInput: ElementRef; + @ViewChild(MatTable) table!: MatTable; + + public displayedColumns: string[] = []; + public editingIdx: number | undefined; + public editingCol: string | undefined; + + private originalCellValue: any; + private originalTableValue: any; + + constructor(private editorService: EditorService) {} + + ngOnInit() { + this.originalTableValue = JSON.parse( + JSON.stringify(this.config.object[this.config.field]) + ); + + this.displayedColumns = [ + ...this.config.columns.map(c => c.name), + 'actions', + ]; + + this.sortRows(); + + this.editorService.onEditingStopped.subscribe(discard => { + if (discard) { + this.config.object[this.config.field] = this.originalTableValue; + } + }); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['config']) this.sortRows(); + } + + public sortRows(): void { + const sortColumn = this.config.columns.find(c => c.sort)?.name; + if (!sortColumn) return; + + this.config.object[this.config.field].sort((a, b) => { + const aValue = a[sortColumn] ?? ''; + const bValue = b[sortColumn] ?? ''; + return String(aValue).localeCompare(String(bValue), undefined, { + sensitivity: 'base', + }); + }); + } + + public isEditing(index: number, column: string): boolean { + return index === this.editingIdx && column === this.editingCol; + } + + public stopEditing(): void { + this.editingIdx = undefined; + this.editingCol = undefined; + this.originalCellValue = undefined; + } + + public cancelEditing(): void { + if (this.editingIdx === undefined || !this.editingCol) return; + const row = this.config.object[this.config.field]?.[this.editingIdx]; + if (row) row[this.editingCol] = this.originalCellValue; + this.stopEditing(); + } + + public addRow(): void { + const newRow: any = {}; + this.config.columns.forEach(c => (newRow[c.name] = '')); + this.config.object[this.config.field].unshift(newRow); + this.table?.renderRows(); + this.editCell(0, this.config.columns[0]?.name); + } + + public editCell(index: number, column: string): void { + if (this.isEditing(index, column)) return; + + this.editingIdx = index; + this.editingCol = column; + + const row = this.config.object[this.config.field]?.[index]; + this.originalCellValue = row ? row[column] : undefined; + + setTimeout(() => this.editInput?.nativeElement?.focus()); + } + + public deleteRow(index: number): void { + this.config.object[this.config.field]?.splice(index, 1); + this.table?.renderRows(); + this.stopEditing(); + } +} diff --git a/src/app/components/stix/dictionary-property/dictionary-property.component.html b/src/app/components/stix/dictionary-property/dictionary-property.component.html new file mode 100644 index 000000000..8a2b98f72 --- /dev/null +++ b/src/app/components/stix/dictionary-property/dictionary-property.component.html @@ -0,0 +1,11 @@ +
+ + + +
diff --git a/src/app/components/stix/dictionary-property/dictionary-property.component.scss b/src/app/components/stix/dictionary-property/dictionary-property.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/components/stix/dictionary-property/dictionary-property.component.ts b/src/app/components/stix/dictionary-property/dictionary-property.component.ts new file mode 100644 index 000000000..bf04b9ecf --- /dev/null +++ b/src/app/components/stix/dictionary-property/dictionary-property.component.ts @@ -0,0 +1,36 @@ +import { Component, Input } from '@angular/core'; +import { StixObject } from 'src/app/classes/stix'; + +@Component({ + selector: 'app-dictionary-property', + standalone: false, + templateUrl: './dictionary-property.component.html', + styleUrls: ['./dictionary-property.component.scss'], +}) +export class DictionaryPropertyComponent { + @Input() public config: DictionaryPropertyConfig; +} + +export interface DictionaryPropertyConfig { + /* What is the current mode? Default: 'view' */ + mode?: 'view' | 'edit' | 'diff'; + /* The fields to display as columns in the table */ + columns: DictionaryColumn[]; + /* The label for the table */ + label: string; + /* Label for the add button */ + buttonLabel?: string; + /* The tooltip for the add button/edit dialog */ + tooltip?: string; + /** The object to show the field of */ + object: StixObject | [StixObject, StixObject]; + field: string; +} + +interface DictionaryColumn { + name: string; + label?: string; // default name + editType: 'string' | 'description' | 'select' | 'autocomplete'; + required?: boolean; // default false + sort?: boolean; // default false +} diff --git a/src/app/components/stix/dictionary-property/dictionary-view/dictionary-view.component.html b/src/app/components/stix/dictionary-property/dictionary-view/dictionary-view.component.html new file mode 100644 index 000000000..47296bb4c --- /dev/null +++ b/src/app/components/stix/dictionary-property/dictionary-view/dictionary-view.component.html @@ -0,0 +1,20 @@ +
+
+ + + + + + + + +
+ {{ column.label || column.name }} + + {{ dataSource[idx][column.name] }} +
+
+ {{ config.label }} +
diff --git a/src/app/components/stix/dictionary-property/dictionary-view/dictionary-view.component.scss b/src/app/components/stix/dictionary-property/dictionary-view/dictionary-view.component.scss new file mode 100644 index 000000000..badd39b9a --- /dev/null +++ b/src/app/components/stix/dictionary-property/dictionary-view/dictionary-view.component.scss @@ -0,0 +1,9 @@ +@use '../../../../../style/globals'; + +.dictionary-view { + @extend .labelled-box; + + .content { + overflow-x: auto; + } +} diff --git a/src/app/components/stix/dictionary-property/dictionary-view/dictionary-view.component.ts b/src/app/components/stix/dictionary-property/dictionary-view/dictionary-view.component.ts new file mode 100644 index 000000000..41f3e2c60 --- /dev/null +++ b/src/app/components/stix/dictionary-property/dictionary-view/dictionary-view.component.ts @@ -0,0 +1,40 @@ +import { + Component, + Input, + ViewChild, + ViewEncapsulation, + OnInit, +} from '@angular/core'; +import { MatSort } from '@angular/material/sort'; +import { DictionaryPropertyConfig } from '../dictionary-property.component'; + +@Component({ + selector: 'app-dictionary-view', + standalone: false, + templateUrl: './dictionary-view.component.html', + styleUrls: ['./dictionary-view.component.scss'], + encapsulation: ViewEncapsulation.None, +}) +export class DictionaryViewComponent implements OnInit { + @Input() public config: DictionaryPropertyConfig; + @ViewChild(MatSort) sort: MatSort; + + public dataSource: any[] = []; + public displayedColumns: string[] = []; + + ngOnInit() { + this.dataSource = this.config.object[this.config.field]; + this.displayedColumns = this.config.columns.map(c => c.name); + + const sortColumn = this.config.columns.find(c => c.sort).name; + if (sortColumn) { + this.dataSource.sort((a, b) => { + const aValue = a[sortColumn] ?? ''; + const bValue = b[sortColumn] ?? ''; + return String(aValue).localeCompare(String(bValue), undefined, { + sensitivity: 'base', + }); + }); + } + } +} diff --git a/src/app/components/stix/external-references-property/external-references-diff/external-references-diff.component.spec.ts b/src/app/components/stix/external-references-property/external-references-diff/external-references-diff.component.spec.ts index 94ba4731a..7c2b490b6 100644 --- a/src/app/components/stix/external-references-property/external-references-diff/external-references-diff.component.spec.ts +++ b/src/app/components/stix/external-references-property/external-references-diff/external-references-diff.component.spec.ts @@ -1,4 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ExternalReferencesDiffComponent } from './external-references-diff.component'; @@ -9,11 +10,19 @@ describe('ExternalReferencesDiffComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [ExternalReferencesDiffComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); fixture = TestBed.createComponent(ExternalReferencesDiffComponent); component = fixture.componentInstance; - fixture.detectChanges(); + component.config = { + mode: 'diff', + object: [ + { external_references: [] } as any, + { external_references: [] } as any, + ], + referencesField: 'external_references', + }; }); it('should create', () => { diff --git a/src/app/components/stix/external-references-property/external-references-property.component.spec.ts b/src/app/components/stix/external-references-property/external-references-property.component.spec.ts index 3708be928..d23914e90 100644 --- a/src/app/components/stix/external-references-property/external-references-property.component.spec.ts +++ b/src/app/components/stix/external-references-property/external-references-property.component.spec.ts @@ -1,3 +1,4 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ExternalReferencesPropertyComponent } from './external-references-property.component'; @@ -9,12 +10,18 @@ describe('ExternalReferencesPropertyComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [ExternalReferencesPropertyComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(ExternalReferencesPropertyComponent); component = fixture.componentInstance; + component.config = { + mode: 'view', + object: {} as any, + referencesField: 'external_references', + }; fixture.detectChanges(); }); diff --git a/src/app/components/stix/external-references-property/external-references-view/external-references-view.component.spec.ts b/src/app/components/stix/external-references-property/external-references-view/external-references-view.component.spec.ts index 0a90d53db..cfb1f30e8 100644 --- a/src/app/components/stix/external-references-property/external-references-view/external-references-view.component.spec.ts +++ b/src/app/components/stix/external-references-property/external-references-view/external-references-view.component.spec.ts @@ -1,4 +1,9 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideRouter } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { of } from 'rxjs'; import { ExternalReferencesViewComponent } from './external-references-view.component'; @@ -9,13 +14,29 @@ describe('ExternalReferencesViewComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [ExternalReferencesViewComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + provideHttpClient(), + provideRouter([]), + { + provide: ActivatedRoute, + useValue: { + params: of({}), + queryParams: of({}), + }, + }, + ], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(ExternalReferencesViewComponent); component = fixture.componentInstance; - fixture.detectChanges(); + component.config = { + mode: 'view', + object: {} as any, + referencesField: 'external_references', + }; }); it('should create', () => { 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 53c579e57..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 @@ -1,20 +1,44 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; 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, + createAsyncObservable, + createPaginatedResponse, +} from 'src/app/testing/mocks/rest-api-connector.mock'; describe('IdentityPropertyComponent', () => { let component: IdentityPropertyComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({ + getAllIdentities: () => createAsyncObservable(createPaginatedResponse()), + }); + await TestBed.configureTestingModule({ declarations: [IdentityPropertyComponent], + imports: [UserAvatarComponent], + providers: [ + provideHttpClient(), + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + ], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(IdentityPropertyComponent); component = fixture.componentInstance; + // Set required config input + component.config = { + object: {} as any, + field: 'identity', + }; fixture.detectChanges(); }); diff --git a/src/app/components/stix/identity-property/identity-property.component.ts b/src/app/components/stix/identity-property/identity-property.component.ts index 5c3f234d5..eda6c9252 100644 --- a/src/app/components/stix/identity-property/identity-property.component.ts +++ b/src/app/components/stix/identity-property/identity-property.component.ts @@ -2,8 +2,6 @@ import { Component, Input, OnInit, ViewEncapsulation } from '@angular/core'; import { Identity } from 'src/app/classes/stix/identity'; import { StixObject } from 'src/app/classes/stix/stix-object'; import { AuthenticationService } from '../../../services/connectors/authentication/authentication.service'; -import { RestApiConnectorService } from '../../../services/connectors/rest-api/rest-api-connector.service'; -import { Subscription } from 'rxjs'; import { UserAccount } from 'src/app/classes/authn/user-account'; @Component({ @@ -17,12 +15,8 @@ export class IdentityPropertyComponent implements OnInit { @Input() public config: IdentityPropertyConfig; public identity: Identity; - private userSubscription$: Subscription; - constructor( - private authenticationService: AuthenticationService, - private restAPIConnector: RestApiConnectorService - ) {} + constructor(private authenticationService: AuthenticationService) {} ngOnInit(): void { const object = Array.isArray(this.config.object) @@ -36,19 +30,11 @@ export class IdentityPropertyComponent implements OnInit { if ( this.authenticationService.isLoggedIn && this.config.field.includes('modified') && - object?.['workflow']?.['created_by_user_account'] + object?.['created_by_user_account'] ) { - const userID = object.workflow.created_by_user_account; - this.userSubscription$ = this.restAPIConnector - .getUserAccount(userID) - .subscribe({ - next: account => { - const user = new UserAccount(account); - if (!this.identity) this.identity = new Identity(); - this.identity.name = user.displayName; - }, - complete: () => this.userSubscription$.unsubscribe(), - }); + const user = new UserAccount(object.created_by_user_account); + if (!this.identity) this.identity = new Identity(); + this.identity.name = user.displayName; } } } diff --git a/src/app/components/stix/list-property/list-diff/list-diff.component.spec.ts b/src/app/components/stix/list-property/list-diff/list-diff.component.spec.ts index 13c6d7148..7f05899cd 100644 --- a/src/app/components/stix/list-property/list-diff/list-diff.component.spec.ts +++ b/src/app/components/stix/list-property/list-diff/list-diff.component.spec.ts @@ -1,4 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ListDiffComponent } from './list-diff.component'; @@ -9,11 +10,16 @@ describe('ListDiffComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [ListDiffComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); fixture = TestBed.createComponent(ListDiffComponent); component = fixture.componentInstance; - fixture.detectChanges(); + component.config = { + mode: 'diff', + object: [{} as any, {} as any], + field: 'platforms', + } as any; }); it('should create', () => { diff --git a/src/app/components/stix/list-property/list-edit/list-edit.component.spec.ts b/src/app/components/stix/list-property/list-edit/list-edit.component.spec.ts index 5ea8fa04a..67253eac1 100644 --- a/src/app/components/stix/list-property/list-edit/list-edit.component.spec.ts +++ b/src/app/components/stix/list-property/list-edit/list-edit.component.spec.ts @@ -1,21 +1,32 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ListEditComponent } from './list-edit.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { createMockRestApiConnector } from 'src/app/testing/mocks/rest-api-connector.mock'; describe('ListEditComponent', () => { let component: ListEditComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({}); + await TestBed.configureTestingModule({ declarations: [ListEditComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + provideHttpClient(), + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + ], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(ListEditComponent); component = fixture.componentInstance; - fixture.detectChanges(); + component.config = { mode: 'edit', object: {} as any, field: 'platforms' }; }); it('should create', () => { diff --git a/src/app/components/stix/list-property/list-edit/list-edit.component.ts b/src/app/components/stix/list-property/list-edit/list-edit.component.ts index b80a46a6c..ab96806d7 100644 --- a/src/app/components/stix/list-property/list-edit/list-edit.component.ts +++ b/src/app/components/stix/list-property/list-edit/list-edit.component.ts @@ -63,7 +63,7 @@ export class ListEditComponent implements OnInit, AfterContentChecked { permissions_required: 'x_mitre_permissions_required', collection_layers: 'x_mitre_collection_layers', data_sources: 'x_mitre_data_sources', - sectors: 'x_mitre_sectors', + sectors: 'sectors', }; public domains = ['enterprise-attack', 'mobile-attack', 'ics-attack']; @@ -299,7 +299,7 @@ export class ListEditComponent implements OnInit, AfterContentChecked { // filter values const values = new Set(); const property = this.allAllowedValues.properties.find(p => { - return p.propertyName == this.fieldToStix[this.config.field]; + return p.propertyName == this.allowedValuesPropertyName(); }); if (!property) { // property not found @@ -337,6 +337,14 @@ export class ListEditComponent implements OnInit, AfterContentChecked { return values; } + private allowedValuesPropertyName(): string { + const object = this.config.object as StixObject; + if (object.attackType === 'asset' && this.config.field === 'sectors') { + return 'x_mitre_sectors'; + } + return this.fieldToStix[this.config.field]; + } + /** Add value to object property list */ public add(event: MatChipInputEvent): void { if (event.value?.trim()) { diff --git a/src/app/components/stix/list-property/list-property.component.spec.ts b/src/app/components/stix/list-property/list-property.component.spec.ts index a9d161f6a..9d45e9fef 100644 --- a/src/app/components/stix/list-property/list-property.component.spec.ts +++ b/src/app/components/stix/list-property/list-property.component.spec.ts @@ -1,20 +1,36 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; import { ListPropertyComponent } from './list-property.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { createMockRestApiConnector } from 'src/app/testing/mocks/rest-api-connector.mock'; describe('ListPropertyComponent', () => { let component: ListPropertyComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({}); + await TestBed.configureTestingModule({ declarations: [ListPropertyComponent], + providers: [ + provideHttpClient(), + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + ], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(ListPropertyComponent); component = fixture.componentInstance; + // Set required config input + component.config = { + field: 'test', + object: {} as any, + }; fixture.detectChanges(); }); 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 3c07cee4c..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 @@ -1,3 +1,4 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ListViewComponent } from './list-view.component'; @@ -9,16 +10,49 @@ describe('ListViewComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [ListViewComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(ListViewComponent); component = fixture.componentInstance; + // Set required config input + component.config = { + mode: 'view', + object: { test: [] } as any, + field: 'test', + label: 'Test', + }; fixture.detectChanges(); }); 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 357e951c8..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 @@ -1,7 +1,7 @@ import { Component, Input, ViewEncapsulation } from '@angular/core'; import { ListPropertyConfig } from '../list-property.component'; import { StixTypeToAttackType } from 'src/app/utils/type-mappings'; -import { RelatedRef } from 'src/app/classes/stix/stix-object'; +import { EmbeddedRelationship } from 'src/app/classes/stix/stix-object'; @Component({ selector: 'app-list-view', @@ -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' @@ -49,7 +50,7 @@ export class ListViewComponent { return arr; } - public getHTML(val: string | RelatedRef) { + public getHTML(val: string | EmbeddedRelationship) { if (this.config.objectProperty && typeof val === 'object') { if (this.showLink) { return `${val[this.config.objectProperty]}`; @@ -59,8 +60,9 @@ export class ListViewComponent { return val; } - public internalLink(item: RelatedRef): string { - const attackType = StixTypeToAttackType[item.type]; + public internalLink(item: EmbeddedRelationship): string { + const stixType = item.stixId.split('--')?.[0]; + const attackType = StixTypeToAttackType[stixType]; return `/${attackType}/${item.stixId}`; } } diff --git a/src/app/components/stix/log-source-reference-property/log-source-reference-dialog/log-source-reference-dialog.component.spec.ts b/src/app/components/stix/log-source-reference-property/log-source-reference-dialog/log-source-reference-dialog.component.spec.ts index aa5e3bf99..406f885a2 100644 --- a/src/app/components/stix/log-source-reference-property/log-source-reference-dialog/log-source-reference-dialog.component.spec.ts +++ b/src/app/components/stix/log-source-reference-property/log-source-reference-dialog/log-source-reference-dialog.component.spec.ts @@ -1,19 +1,45 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { LogSourceReferenceDialogComponent } from './log-source-reference-dialog.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('LogSourceReferenceDialogComponent', () => { let component: LogSourceReferenceDialogComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({ + getAllDataComponents: () => + createAsyncObservable(createPaginatedResponse([])), + getDataComponent: () => createAsyncObservable([]), + }); + await TestBed.configureTestingModule({ declarations: [LogSourceReferenceDialogComponent], + imports: [MatAutocompleteModule], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + { provide: MatDialogRef, useValue: {} }, + { + provide: MAT_DIALOG_DATA, + useValue: { object: { logSourceReferences: [] } }, + }, + ], }).compileComponents(); + }); + beforeEach(() => { fixture = TestBed.createComponent(LogSourceReferenceDialogComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create', () => { diff --git a/src/app/components/stix/log-source-reference-property/log-source-reference-diff/log-source-reference-diff.component.spec.ts b/src/app/components/stix/log-source-reference-property/log-source-reference-diff/log-source-reference-diff.component.spec.ts index 11fe45bc9..e2e6c6e30 100644 --- a/src/app/components/stix/log-source-reference-property/log-source-reference-diff/log-source-reference-diff.component.spec.ts +++ b/src/app/components/stix/log-source-reference-property/log-source-reference-diff/log-source-reference-diff.component.spec.ts @@ -1,19 +1,37 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { LogSourceReferenceDiffComponent } from './log-source-reference-diff.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { createMockRestApiConnector } from 'src/app/testing/mocks/rest-api-connector.mock'; describe('LogSourceReferenceDiffComponent', () => { let component: LogSourceReferenceDiffComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({}); + await TestBed.configureTestingModule({ declarations: [LogSourceReferenceDiffComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + ], }).compileComponents(); + }); + beforeEach(() => { fixture = TestBed.createComponent(LogSourceReferenceDiffComponent); component = fixture.componentInstance; - fixture.detectChanges(); + component.config = { + mode: 'diff', + object: [ + { logSourceReferences: [] } as any, + { logSourceReferences: [] } as any, + ], + field: 'logSourceReferences', + } as any; }); it('should create', () => { diff --git a/src/app/components/stix/log-source-reference-property/log-source-reference-edit/log-source-reference-edit.component.spec.ts b/src/app/components/stix/log-source-reference-property/log-source-reference-edit/log-source-reference-edit.component.spec.ts index 9cd78b2e4..d517351b9 100644 --- a/src/app/components/stix/log-source-reference-property/log-source-reference-edit/log-source-reference-edit.component.spec.ts +++ b/src/app/components/stix/log-source-reference-property/log-source-reference-edit/log-source-reference-edit.component.spec.ts @@ -1,18 +1,39 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { LogSourceReferenceEditComponent } from './log-source-reference-edit.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('LogSourceReferenceEditComponent', () => { let component: LogSourceReferenceEditComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({ + getAllDataComponents: () => + createAsyncObservable(createPaginatedResponse([])), + }); + await TestBed.configureTestingModule({ declarations: [LogSourceReferenceEditComponent], + providers: [ + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + ], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); fixture = TestBed.createComponent(LogSourceReferenceEditComponent); component = fixture.componentInstance; + component.config = { + mode: 'edit', + object: { logSourceReferences: [] } as any, + field: 'logSourceReferences', + } as any; fixture.detectChanges(); }); diff --git a/src/app/components/stix/log-source-reference-property/log-source-reference-property.component.spec.ts b/src/app/components/stix/log-source-reference-property/log-source-reference-property.component.spec.ts index 51c2a2138..c9b373693 100644 --- a/src/app/components/stix/log-source-reference-property/log-source-reference-property.component.spec.ts +++ b/src/app/components/stix/log-source-reference-property/log-source-reference-property.component.spec.ts @@ -1,3 +1,4 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { LogSourceReferencePropertyComponent } from './log-source-reference-property.component'; @@ -9,10 +10,15 @@ describe('LogSourceReferencePropertyComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [LogSourceReferencePropertyComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); fixture = TestBed.createComponent(LogSourceReferencePropertyComponent); component = fixture.componentInstance; + component.config = { + mode: 'view', + object: {} as any, + }; fixture.detectChanges(); }); diff --git a/src/app/components/stix/log-source-reference-property/log-source-reference-view/log-source-reference-view.component.spec.ts b/src/app/components/stix/log-source-reference-property/log-source-reference-view/log-source-reference-view.component.spec.ts index ea7fdaef4..bbcefb312 100644 --- a/src/app/components/stix/log-source-reference-property/log-source-reference-view/log-source-reference-view.component.spec.ts +++ b/src/app/components/stix/log-source-reference-property/log-source-reference-view/log-source-reference-view.component.spec.ts @@ -1,19 +1,34 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { LogSourceReferenceViewComponent } from './log-source-reference-view.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { createMockRestApiConnector } from 'src/app/testing/mocks/rest-api-connector.mock'; describe('LogSourceReferenceViewComponent', () => { let component: LogSourceReferenceViewComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({}); + await TestBed.configureTestingModule({ declarations: [LogSourceReferenceViewComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + ], }).compileComponents(); + }); + beforeEach(() => { fixture = TestBed.createComponent(LogSourceReferenceViewComponent); component = fixture.componentInstance; - fixture.detectChanges(); + component.config = { + mode: 'view', + object: { logSourceReferences: [] } as any, + field: 'logSourceReferences', + } as any; }); it('should create', () => { 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 be3a2e811..576c3accd 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,9 +9,100 @@

>:  {{ config.object[field] }} -

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

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; + } + + 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 98b87926a..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,295 @@ +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 { 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: { + params: of({}), + queryParams: of({}), + }, + }, + provideHttpClient(), + ], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(NamePropertyComponent); component = fixture.componentInstance; + // Set required config input + component.config = { + mode: 'view', + object: {} as any, + }; fixture.detectChanges(); }); 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 baeb6ed57..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,8 +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 { 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', @@ -11,15 +44,46 @@ import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/re 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 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'; } + public get object(): any { + return Array.isArray(this.config.object) + ? this.config.object[0] + : this.config.object; + } + + public get showWorkflowControl(): boolean { + return ( + this.config.mode === 'view' && + this.object instanceof StixObject && + !['collection', 'identity'].includes(this.object.attackType) + ); + } + + public get hasTracks(): boolean { + return this.trackStatuses.length > 0; + } + /** * retrieve the internal link to the parent technique */ @@ -40,12 +104,24 @@ export class NamePropertyComponent implements OnInit { return this.previous?.[this.field] || ''; } - constructor(private restAPIService: RestApiConnectorService) {} + constructor( + private dialog: MatDialog, + private editorService: EditorService, + 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 = Array.isArray(this.config.object) - ? this.config.object[0] - : this.config.object; + const object = this.object; + this.syncStatusControl(); + this.loadTrackStatuses(); if (this.config.mode !== 'diff' && object.revoked) { // retrieve revoking object const data$ = this.restAPIService.getRelatedTo({ @@ -70,6 +146,330 @@ export class NamePropertyComponent implements OnInit { else if (obj?.['deprecated']) return 'deprecated'; return ''; } + + public openStatusDialog( + targetStatus: WorkflowStatusType, + menuTrigger: MatMenuTrigger | undefined, + track: ReleaseTrackStatus + ): void { + menuTrigger?.closeMenu(); + const previousWorkflowState = + 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; + }, + }); + } + + 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; + }); + } + + 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), []); + } + + 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 { @@ -92,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/ordered-list-property/ordered-list-diff/ordered-list-diff.component.spec.ts b/src/app/components/stix/ordered-list-property/ordered-list-diff/ordered-list-diff.component.spec.ts index 3c4a05a8c..9bef3c4d9 100644 --- a/src/app/components/stix/ordered-list-property/ordered-list-diff/ordered-list-diff.component.spec.ts +++ b/src/app/components/stix/ordered-list-property/ordered-list-diff/ordered-list-diff.component.spec.ts @@ -1,3 +1,4 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { OrderedListDiffComponent } from './ordered-list-diff.component'; @@ -9,10 +10,20 @@ describe('OrderedListDiffComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [OrderedListDiffComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); fixture = TestBed.createComponent(OrderedListDiffComponent); component = fixture.componentInstance; + // Set required config input + component.config = { + mode: 'diff', + object: [{}, {}] as any, + objectOrderedListField: 'test', + field: 'test', + globalObjects: [], + label: 'Test', + }; fixture.detectChanges(); }); diff --git a/src/app/components/stix/ordered-list-property/ordered-list-edit/ordered-list-edit.component.spec.ts b/src/app/components/stix/ordered-list-property/ordered-list-edit/ordered-list-edit.component.spec.ts index 800107f89..eae8266cf 100644 --- a/src/app/components/stix/ordered-list-property/ordered-list-edit/ordered-list-edit.component.spec.ts +++ b/src/app/components/stix/ordered-list-property/ordered-list-edit/ordered-list-edit.component.spec.ts @@ -1,4 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { DragDropModule } from '@angular/cdk/drag-drop'; import { OrderedListEditComponent } from './ordered-list-edit.component'; @@ -9,13 +12,22 @@ describe('OrderedListEditComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [OrderedListEditComponent], + imports: [DragDropModule], + schemas: [NO_ERRORS_SCHEMA], + providers: [provideHttpClient()], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(OrderedListEditComponent); component = fixture.componentInstance; - fixture.detectChanges(); + component.config = { + mode: 'edit', + object: {} as any, + field: 'x_mitre_contributors', + objectOrderedListField: 'x_mitre_contributors', + globalObjects: [], + }; }); it('should create', () => { diff --git a/src/app/components/stix/ordered-list-property/ordered-list-property.component.spec.ts b/src/app/components/stix/ordered-list-property/ordered-list-property.component.spec.ts index 06fbf5680..a75d56f77 100644 --- a/src/app/components/stix/ordered-list-property/ordered-list-property.component.spec.ts +++ b/src/app/components/stix/ordered-list-property/ordered-list-property.component.spec.ts @@ -1,3 +1,4 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { OrderedListPropertyComponent } from './ordered-list-property.component'; @@ -9,12 +10,20 @@ describe('OrderedListPropertyComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [OrderedListPropertyComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(OrderedListPropertyComponent); component = fixture.componentInstance; + component.config = { + mode: 'view', + field: 'name', + object: {} as any, + objectOrderedListField: 'items', + globalObjects: [], + }; fixture.detectChanges(); }); diff --git a/src/app/components/stix/ordered-list-property/ordered-list-view/ordered-list-view.component.spec.ts b/src/app/components/stix/ordered-list-property/ordered-list-view/ordered-list-view.component.spec.ts index 50ed21dd1..4bbf78f7e 100644 --- a/src/app/components/stix/ordered-list-property/ordered-list-view/ordered-list-view.component.spec.ts +++ b/src/app/components/stix/ordered-list-property/ordered-list-view/ordered-list-view.component.spec.ts @@ -1,3 +1,4 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { OrderedListViewComponent } from './ordered-list-view.component'; @@ -9,12 +10,22 @@ describe('OrderedListViewComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [OrderedListViewComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(OrderedListViewComponent); component = fixture.componentInstance; + // Set required config input + component.config = { + mode: 'view', + object: {} as any, + objectOrderedListField: 'test', + objectField: 'test', + label: 'Test', + globalObjects: [], + }; fixture.detectChanges(); }); diff --git a/src/app/components/stix/statement-property/statement-diff/statement-diff.component.spec.ts b/src/app/components/stix/statement-property/statement-diff/statement-diff.component.spec.ts index de497d570..3039e9302 100644 --- a/src/app/components/stix/statement-property/statement-diff/statement-diff.component.spec.ts +++ b/src/app/components/stix/statement-property/statement-diff/statement-diff.component.spec.ts @@ -1,4 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { MtxPopoverModule } from '@ng-matero/extensions/popover'; import { StatementDiffComponent } from './statement-diff.component'; @@ -9,11 +11,19 @@ describe('StatementDiffComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [StatementDiffComponent], + imports: [MtxPopoverModule], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); + }); + beforeEach(() => { fixture = TestBed.createComponent(StatementDiffComponent); component = fixture.componentInstance; - fixture.detectChanges(); + component.config = { + mode: 'diff', + object: [{} as any, {} as any], + field: 'statement', + } as any; }); it('should create', () => { diff --git a/src/app/components/stix/statement-property/statement-edit/statement-edit.component.spec.ts b/src/app/components/stix/statement-property/statement-edit/statement-edit.component.spec.ts index cefd1e3f2..06813d017 100644 --- a/src/app/components/stix/statement-property/statement-edit/statement-edit.component.spec.ts +++ b/src/app/components/stix/statement-property/statement-edit/statement-edit.component.spec.ts @@ -1,4 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { MtxPopoverModule } from '@ng-matero/extensions/popover'; import { StatementEditComponent } from './statement-edit.component'; @@ -9,13 +12,20 @@ describe('StatementEditComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [StatementEditComponent], + imports: [MtxPopoverModule], + schemas: [NO_ERRORS_SCHEMA], + providers: [provideHttpClient()], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(StatementEditComponent); component = fixture.componentInstance; - fixture.detectChanges(); + component.config = { + mode: 'edit', + object: {} as any, + field: 'statement', + } as any; }); it('should create', () => { diff --git a/src/app/components/stix/statement-property/statement-property.component.spec.ts b/src/app/components/stix/statement-property/statement-property.component.spec.ts index 80b37dd20..1a83977a1 100644 --- a/src/app/components/stix/statement-property/statement-property.component.spec.ts +++ b/src/app/components/stix/statement-property/statement-property.component.spec.ts @@ -1,14 +1,30 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { StatementPropertyComponent } from './statement-property.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { + createMockRestApiConnector, + createAsyncObservable, + createPaginatedResponse, +} from 'src/app/testing/mocks/rest-api-connector.mock'; describe('StatementPropertyComponent', () => { let component: StatementPropertyComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({ + getAllMarkingDefinitions: () => + createAsyncObservable(createPaginatedResponse([])), + }); + await TestBed.configureTestingModule({ declarations: [StatementPropertyComponent], + providers: [ + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + ], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); diff --git a/src/app/components/stix/statement-property/statement-view/statement-view.component.spec.ts b/src/app/components/stix/statement-property/statement-view/statement-view.component.spec.ts index c80134621..9bd573f9c 100644 --- a/src/app/components/stix/statement-property/statement-view/statement-view.component.spec.ts +++ b/src/app/components/stix/statement-property/statement-view/statement-view.component.spec.ts @@ -1,4 +1,6 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MtxPopoverModule } from '@ng-matero/extensions/popover'; import { StatementViewComponent } from './statement-view.component'; @@ -9,12 +11,20 @@ describe('StatementViewComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [StatementViewComponent], + imports: [MtxPopoverModule], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(StatementViewComponent); component = fixture.componentInstance; + // Set required config input + component.config = { + mode: 'view', + object: {} as any, + field: 'test', + } as any; fixture.detectChanges(); }); 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 130ace12c..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)"> @@ -266,119 +266,84 @@
+ *ngIf="isCollectionType() && expandedElement === element">
- -
- -
-
- - - - - -
+ + + +
+ -
-
-
+ {{ route.label }} + +
+
+
- - Version - {{ - collection.version - }} - + + Version + {{ + collection.version + }} + - - Released - {{ - collection.release - }} - + + Released + {{ + collection.release + }} + - - Description - - {{ - collection.description - }} - - + + Description + + {{ + collection.description + }} + + - - -
-
+ + +
+ [class.relationship-added]="isNewRelationship(element)" + [class.relationship-changed]="isChangedRelationship(element)" + [class.expanded]=" + isCollectionType() && expandedElement === element + "> { let component: StixListComponent; let fixture: ComponentFixture; + const defaultConfig: StixListConfig = { + type: 'technique', + }; + beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ declarations: [StixListComponent], + 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(); })); beforeEach(() => { fixture = TestBed.createComponent(StixListComponent); component = fixture.componentInstance; + component.config = { ...defaultConfig }; fixture.detectChanges(); }); 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 0e177ec96..8efe40625 100644 --- a/src/app/components/stix/stix-list/stix-list.component.ts +++ b/src/app/components/stix/stix-list/stix-list.component.ts @@ -15,7 +15,6 @@ import { MatPaginator } from '@angular/material/paginator'; import { SelectionModel } from '@angular/cdk/collections'; import { MatDialog } from '@angular/material/dialog'; import { Router } from '@angular/router'; - import { fromEvent, Observable, of, Subscription } from 'rxjs'; import { debounceTime, @@ -23,16 +22,16 @@ import { filter, tap, } from 'rxjs/operators'; - -import { RelatedRef, StixObject } from 'src/app/classes/stix/stix-object'; +import { + EmbeddedRelationship, + StixObject, +} from 'src/app/classes/stix/stix-object'; import { StixDialogComponent } from 'src/app/views/stix/stix-dialog/stix-dialog.component'; import { Paginated, 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 { MatSelect } from '@angular/material/select'; import { AddDialogComponent } from '../../add-dialog/add-dialog.component'; import { Collection } from 'src/app/classes/stix/collection'; import { logger } from 'src/app/utils/logger'; @@ -81,27 +80,21 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { @ViewChild(MatPaginator) paginator: MatPaginator; @ViewChild('search') search: ElementRef; - @ViewChild(MatSelect) matSelect: MatSelect; // search query public searchQuery = ''; private searchSubscription: Subscription; // objects to render - public objects$: Observable; public data$: Observable>; public totalObjectCount = 0; - // view mode - public mode = 'cards'; - // options provided to the user for grouping and filtering public filterOptions: FilterGroup[] = []; public showControls = true; // current grouping and filtering selections public filter: string[] = []; - public groupBy: string[] = []; public userIdsUsedInSearch = []; // TABLE STUFF @@ -116,7 +109,7 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { // Selection stuff public selection: SelectionModel; - // all possible each type of filter/groupBy + // all possible each type of filter private platformSubscription: Subscription; private platformMap = new Map>(); private domains: FilterValue[] = [ @@ -167,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 { @@ -207,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), @@ -225,7 +217,7 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { /** * Build the stix list table to display */ - private buildTable(): void { + protected buildTable(): void { // filter options this.filterOptions = []; if (!('showFilters' in this.config)) this.config.showFilters = true; @@ -234,14 +226,28 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { const sticky_allowed = !( this.config.rowAction && this.config.rowAction.position == 'start' ); + // Column presets take precedence + if (this.config.columnsPreset === 'id-name') { + this.addColumn('ID', 'attackID', 'plain', false); + this.addColumn('name', 'name', 'plain', sticky_allowed, ['name']); + 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, '-')) { case 'collection': case 'collection-created': - this.addColumn('name', 'name', 'plain', sticky_allowed, ['name']); + this.addNameColumn(sticky_allowed); this.addColumn('latest version', 'version', 'version'); - this.addColumn('created', 'created', 'timestamp'); this.addColumn('modified', 'modified', 'timestamp'); this.tableDetail = [ { @@ -257,7 +263,7 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { ]; break; case 'collection-imported': - this.addColumn('name', 'name', 'plain', sticky_allowed, ['name']); + this.addNameColumn(sticky_allowed); this.addColumn('latest version', 'version', 'version'); this.addColumn('imported', 'imported', 'timestamp'); this.addColumn('modified', 'modified', 'timestamp'); @@ -276,48 +282,24 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { case 'mitigation': case 'tactic': case 'data-component': - this.addColumn('', 'workflow', 'icon'); - this.addColumn('', 'state', 'icon'); - this.addColumn('ID', 'attackID', 'plain', false); - this.addColumn('name', 'name', 'plain', sticky_allowed, ['name']); - this.addColumn('domain', 'domains', 'list'); - this.addVersionsAndDatesColumns(); - this.tableDetail = [ - { - field: 'description', - display: 'descriptive', - }, - ]; + this.addStateColumnOnly(); + this.addIdAndNameColumns(sticky_allowed); + this.addDomainColumn(); + this.addColumn('modified', 'modified', 'timestamp'); break; case 'matrix': - this.addColumn('', 'workflow', 'icon'); - this.addColumn('', 'state', 'icon'); - this.addColumn('name', 'name', 'plain', sticky_allowed, ['name']); - this.addVersionsAndDatesColumns(); - this.tableDetail = [ - { - field: 'description', - display: 'descriptive', - }, - ]; + this.addStateColumnOnly(); + this.addNameColumn(sticky_allowed); + this.addColumn('modified', 'modified', 'timestamp'); break; case 'detection-strategy': case 'campaign': - this.addColumn('', 'workflow', 'icon'); - this.addColumn('', 'state', 'icon'); - this.addColumn('ID', 'attackID', 'plain', false); - this.addColumn('name', 'name', 'plain', sticky_allowed, ['name']); - this.addVersionsAndDatesColumns(); - this.tableDetail = [ - { - field: 'description', - display: 'descriptive', - }, - ]; + this.addStateColumnOnly(); + this.addIdAndNameColumns(sticky_allowed); + this.addColumn('modified', 'modified', 'timestamp'); break; case 'analytic': - this.addColumn('', 'workflow', 'icon'); - this.addColumn('', 'state', 'icon'); + this.addStateColumnOnly(); this.addColumn('ID', 'attackID', 'plain', false); this.addColumn( 'related detection strategy', @@ -328,150 +310,146 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { 'name' ); this.addColumn('platform', 'platform', 'plain'); - this.addColumn('domain', 'domains', 'list'); - this.addVersionsAndDatesColumns(); - this.tableDetail = [ - { - field: 'description', - display: 'descriptive', - }, - ]; + this.addDomainColumn(); + this.addColumn('modified', 'modified', 'timestamp'); break; case 'group': - this.addColumn('', 'workflow', 'icon'); - this.addColumn('', 'state', 'icon'); - this.addColumn('ID', 'attackID', 'plain', false); - this.addColumn('name', 'name', 'plain', sticky_allowed, ['name']); + this.addStateColumnOnly(); + this.addIdAndNameColumns(sticky_allowed); this.addColumn('associated groups', 'aliases', 'list'); - this.addVersionsAndDatesColumns(); - this.tableDetail = [ - { - field: 'description', - display: 'descriptive', - }, - ]; + this.addColumn('modified', 'modified', 'timestamp'); break; case 'software': - this.addColumn('', 'workflow', 'icon'); - this.addColumn('', 'state', 'icon'); - this.addColumn('ID', 'attackID', 'plain', false); - this.addColumn('name', 'name', 'plain', sticky_allowed, ['name']); + this.addStateColumnOnly(); + this.addIdAndNameColumns(sticky_allowed); this.addColumn('type', 'type', 'plain'); - this.addColumn('domain', 'domains', 'list'); - this.addVersionsAndDatesColumns(); - this.tableDetail = [ - { - field: 'description', - display: 'descriptive', - }, - ]; + this.addDomainColumn(); + this.addColumn('modified', 'modified', 'timestamp'); + break; + case 'identity': + 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.addColumn('', 'workflow', 'icon'); - this.addColumn('', 'state', 'icon'); - this.addColumn('ID', 'attackID', 'plain', false); - this.addColumn('name', 'name', 'plain', sticky_allowed, ['name']); - this.addColumn('platforms', 'platforms', 'list'); - this.addColumn('domain', 'domains', 'list'); - this.addVersionsAndDatesColumns(); - this.tableDetail = [ - { - field: 'description', - display: 'descriptive', - }, - ]; + this.addStateColumnOnly(); + this.addIdAndNameColumns(sticky_allowed); + this.addDomainColumn(); + this.addPlatformsColumn(); + this.addColumn('modified', 'modified', 'timestamp'); break; case 'asset': - this.addColumn('', 'workflow', 'icon'); - this.addColumn('', 'state', 'icon'); - this.addColumn('ID', 'attackID', 'plain', false); - this.addColumn('name', 'name', 'plain', sticky_allowed, ['name']); - this.addColumn('platforms', 'platforms', 'list'); + this.addStateColumnOnly(); + this.addIdAndNameColumns(sticky_allowed); + this.addPlatformsColumn(); this.addColumn('sectors', 'sectors', 'list'); - this.addVersionsAndDatesColumns(); - this.tableDetail = [ - { - field: 'description', - display: 'descriptive', - }, - ]; + this.addColumn('modified', 'modified', 'timestamp'); break; case 'relationship': - this.addColumn('', 'state', 'icon'); - if ( - this.config.relationshipType && - this.config.relationshipType !== 'detects' - ) { + if (this.config.compactRelationshipColumns) { + // Only show source ID, type, target ID, and description this.addColumn('source', 'source_ID', 'plain'); + this.addColumn('type', 'relationship_type', 'plain', false, [ + 'text-deemphasis', + 'relationship-joiner', + ]); + this.addColumn('target', 'target_ID', 'plain'); + this.addColumn('description', 'description', 'descriptive', false); + this.addColumn('created', 'created', 'timestamp', false); + this.addColumn('modified', 'modified', 'timestamp', false); + } else { + this.addStateColumnOnly(); + if ( + this.config.relationshipType && + this.config.relationshipType !== 'detects' + ) { + this.addColumn('source', 'source_ID', 'plain'); + this.addColumn( + '', + 'source_name', + 'plain', + this.config.targetRef ? sticky_allowed : false, + ['relationship-name'] + ); + } else + this.addColumn( + 'source', + 'source_name', + 'plain', + this.config.targetRef ? sticky_allowed : false, + ['relationship-name'] + ); + this.addColumn('type', 'relationship_type', 'plain', false, [ + 'text-deemphasis', + 'relationship-joiner', + ]); + this.addColumn('target', 'target_ID', 'plain'); this.addColumn( '', - 'source_name', - 'plain', - this.config.targetRef ? sticky_allowed : false, - ['relationship-name'] - ); - } else - this.addColumn( - 'source', - 'source_name', + 'target_name', 'plain', - this.config.targetRef ? sticky_allowed : false, + this.config.sourceRef ? sticky_allowed : false, ['relationship-name'] ); - this.addColumn('type', 'relationship_type', 'plain', false, [ - 'text-deemphasis', - 'relationship-joiner', - ]); - this.addColumn('target', 'target_ID', 'plain'); - this.addColumn( - '', - 'target_name', - 'plain', - 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', 'descriptive', false); + this.addColumn( + 'description', + 'description', + 'descriptive', + false + ); + } break; case 'marking-definition': this.addColumn('definition type', 'definition_type', 'plain'); this.addColumn('created', 'created', 'timestamp'); this.addColumn('definition', 'definition_string', 'descriptive'); - this.tableDetail = [ - { - field: 'definition_string', - display: 'descriptive', - }, - ]; break; case 'note': this.addColumn('title', 'title', 'plain'); this.addColumn('content', 'content', 'plain'); this.addColumn('modified', 'modified', 'timestamp'); - this.addColumn('created', 'created', 'timestamp'); break; default: this.addColumn('type', 'attackType', 'plain'); this.addColumn('modified', 'modified', 'timestamp'); - this.addColumn('created', 'created', 'timestamp'); } } else { - this.groupBy = ['type']; - this.addColumn('', 'state', 'icon'); + this.addStateColumnOnly(); this.addColumn('type', 'attackType', 'plain'); - this.addColumn('ID', 'attackID', 'plain', false); - this.addColumn('name', 'name', 'plain', true, ['name']); + this.addIdAndNameColumns(true); this.addColumn('modified', 'modified', 'timestamp'); - this.addColumn('created', 'created', 'timestamp'); } } + private addStateColumnOnly(): void { + this.addColumn('', 'state', 'icon'); + } + + private addIdAndNameColumns(stickyAllowed: boolean): void { + this.addColumn('ID', 'attackID', 'plain', false); + this.addColumn('name', 'name', 'plain', stickyAllowed, ['name']); + } + + private addNameColumn(stickyAllowed: boolean): void { + this.addColumn('name', 'name', 'plain', stickyAllowed, ['name']); + } + + private addDomainColumn(): void { + this.addColumn('domain', 'domains', 'list'); + } + + private addPlatformsColumn(type: column_types = 'list'): void { + this.addColumn('platforms', 'platforms', type); + } + /** * Set up controls, including control columns and filters */ @@ -524,7 +502,6 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { if (filterList.includes('workflow_status')) { this.filterOptions.push({ name: 'workflow status', - disabled: 'status' in this.config, values: this.statuses, }); } @@ -540,19 +517,18 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { if (filterList.includes('state')) { this.filterOptions.push({ name: 'state', - disabled: 'status' in this.config, values: this.states, }); } if (filterList.includes('state_exclusive')) { this.filterOptions.push({ name: 'state (exclusive)', - disabled: 'status' in this.config, values: this.statesExclusive, }); } const filterByDomain: boolean = this.config.type ? [ + 'data-component', 'data-source', 'mitigation', 'software', @@ -567,7 +543,6 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { if (filterByDomain) { this.filterOptions.push({ name: 'domain', - disabled: 'status' in this.config, values: this.domains, }); } @@ -579,7 +554,6 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { if (platforms.length) { this.filterOptions.push({ name: 'platform', - disabled: 'status' in this.config, values: platforms, }); } @@ -604,21 +578,13 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { * @param {boolean} [sticky] is the column sticky? If true, the column will be static in the X scrolling of the view * @param {string[]} [classes] list of css classes to apply to the cell */ - private addColumn( + protected addColumn( label: string, field: string, - display: - | 'version' - | 'list' - | 'plain' - | 'timestamp' - | 'descriptive' - | 'relationship_name' - | 'icon' - | 'related_ref_list', + display: column_types, sticky?: boolean, classes?: string[], - relatedRefProperty?: keyof RelatedRef + relatedRefProperty?: keyof EmbeddedRelationship ) { this.tableColumns.push(field); this.tableColumns_settings.set(field, { @@ -630,15 +596,6 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { }); } - /** - * Add version, modified, and created columns to the table - */ - private addVersionsAndDatesColumns() { - this.addColumn('version', 'version', 'version'); - this.addColumn('modified', 'modified', 'timestamp'); - this.addColumn('created', 'created', 'timestamp'); - } - public openUserSelectModal(): void { const select = new SelectionModel(true); @@ -676,10 +633,9 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { * @param {StixObject} object of the row that was clicked */ public rowClick(element: StixObject) { - if (this.config.clickBehavior && this.config.clickBehavior == 'none') - return; - if (this.config.clickBehavior && this.config.clickBehavior == 'dialog') { - //open modal + if (this.config.clickBehavior == 'none') return; + + if (this.config.clickBehavior == 'dialog') { const prompt = this.dialog.open(StixDialogComponent, { data: { object: element, @@ -689,10 +645,11 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { maxHeight: '75vh', autoFocus: false, // prevents auto focus on toolbar buttons }); + const subscription = prompt.afterClosed().subscribe({ - next: result => { + next: () => { if (prompt.componentInstance.dirty) { - //re-fetch values since an edit occurred + // re-fetch values since an edit occurred this.applyControls(); this.refresh.emit(); } @@ -701,42 +658,31 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { subscription.unsubscribe(); }, }); - } else if ( - this.config.clickBehavior && - this.config.clickBehavior == 'linkToObjectPage' - ) { - this.onRowAction.emit(); // close any open dialogs - this.router.navigateByUrl( - '/' + element.attackType + '/' + element.stixID - ); - } else if ( - this.config.clickBehavior && - this.config.clickBehavior == 'linkToSourceRef' - ) { + return; + } + + if (this.config.clickBehavior == 'linkToSourceRef') { const source_ref = element['source_ref']; // Get type to navigate from source_ref const type = StixTypeToAttackType[source_ref.split('--')[0]]; this.router.navigateByUrl(`/${type}/${source_ref}`); - } else if ( - this.config.clickBehavior && - this.config.clickBehavior == 'linkToTargetRef' - ) { + return; + } + + if (this.config.clickBehavior == 'linkToTargetRef') { const target_ref = element['target_ref']; // Get type to navigate from target_ref const type = StixTypeToAttackType[target_ref.split('--')[0]]; this.router.navigateByUrl(`/${type}/${target_ref}`); - } else if ( - this.config.clickBehavior && - this.config.clickBehavior == 'linkToObjectRef' - ) { + return; + } + + if (this.config.clickBehavior == 'linkToObjectRef') { // technically a note can be linked to many objects, we will select the first object const object_ref = element['object_refs'][0]; // 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') { @@ -754,42 +700,68 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { } else { this.router.navigateByUrl(url); } - } else { - //expand + return; + } + + if (this.isCollectionType()) { this.expandedElement = this.expandedElement === element ? null : element; + return; + } + + // by default, link to the object page + if ( + this.config.clickBehavior == 'linkToObjectPage' || + !this.config.clickBehavior + ) { + this.onRowAction.emit(); // close any open dialogs + this.router.navigateByUrl( + '/' + element.attackType + '/' + element.stixID + ); + return; } } - // AUTHENTICATION FUNCTIONS + 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 getAccessibleRoutes(attackType: string, routes: any[]) { - return routes.filter( - route => this.canAccess(attackType, route) && this.canEdit(route) + public isCollectionType(): boolean { + return ['collection', 'collection-created', 'collection-imported'].includes( + this.config.type ); } - public routeTo(url, queryParams): void { - this.router.navigate([url], { queryParams: queryParams }); + // AUTHENTICATION FUNCTIONS + + public getAccessibleCollectionRoutes(collection) { + return collection.routes.filter(route => + this.canShowCollectionRoute(collection.attackType, route) + ); } - private canAccess(attackType: string, route: any) { - if ( - route.label && - route.label == 'edit' && - !this.authenticationService.canEdit(attackType) - ) { - // user not authorized - return false; + private canShowCollectionRoute(attackType: string, route): boolean { + if (route.label == 'edit') { + return ( + this.authenticationService.canEdit(attackType) && + !this.config.uneditableObject + ); } - // user authorized return true; } - private canEdit(route: any) { - if (route.label && route.label == 'edit' && this.config.uneditableObject) { - return false; - } - return true; + public routeTo(url, queryParams): void { + this.router.navigate([url], { queryParams: queryParams }); } /** @@ -908,11 +880,233 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { } } + private hasLocalStixObjects(): boolean { + return ( + 'stixObjects' in this.config && this.config.stixObjects !== undefined + ); + } + /** * Apply all controls and fetch objects from the back-end if configured */ - public applyControls() { - const { + public applyControls(): void { + const filterStates = this.getFilterObjectStates(); + + if (this.hasLocalStixObjects()) { + this.applyControlsToLocalData(filterStates); + } else { + this.applyControlsToBackendData(filterStates); + } + } + + private applyControlsToLocalData(filterStates): void { + if (this.config.stixObjects instanceof Observable) { + // pull objects out of observable + return; + } + // filter on STIX objects specified in the config + let filtered = this.filterExcludedAttackTypes(this.config.stixObjects); + filtered = this.filterLocalObjects(filtered, filterStates); + filtered = this.sortObjects(filtered); + + if (this.paginator) this.totalObjectCount = filtered.length; + + // filter to only ones within the correct index range + const { startIndex, endIndex } = this.getPaginationRange(); + const paged = filtered.slice(startIndex, endIndex); + + this.data$ = of({ + data: paged, + pagination: { + total: this.config.stixObjects.length, + offset: startIndex, + limit: this.paginator ? this.paginator.pageSize : 0, + }, + }); + + this.emitDetectsHasData(paged.length > 0); + } + + private applyControlsToBackendData(filterStates): void { + // fetch objects from backend + const limit = this.paginator ? this.paginator.pageSize : 10; + const offset = this.paginator ? this.paginator.pageIndex * limit : 0; + + const options = { + limit: limit, + offset: offset, + excludeIDs: this.config.excludeIDs, + search: this.searchQuery, + state: filterStates.state, + includeRevoked: filterStates.revoked, + includeDeprecated: filterStates.deprecated, + platforms: filterStates.platforms, + domains: filterStates.domains, + lastUpdatedBy: this.userIdsUsedInSearch, + }; + if (this.config.type == 'software') + this.data$ = this.restAPIConnectorService.getAllSoftware(options); + else if (this.config.type == 'campaign') + this.data$ = this.restAPIConnectorService.getAllCampaigns(options); + else if (this.config.type == 'group') + this.data$ = this.restAPIConnectorService.getAllGroups(options); + else if (this.config.type == 'matrix') + this.data$ = this.restAPIConnectorService.getAllMatrices(options); + else if (this.config.type == 'mitigation') + this.data$ = this.restAPIConnectorService.getAllMitigations(options); + else if (this.config.type == 'tactic') + 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')) + this.data$ = this.restAPIConnectorService.getAllCollections({ + search: this.searchQuery, + versions: 'all', + }); + else if (this.config.type == 'relationship') + this.data$ = this.restAPIConnectorService.getRelatedTo({ + sourceRef: this.config.sourceRef, + targetRef: this.config.targetRef, + sourceType: this.config.sourceType, + targetType: this.config.targetType, + relationshipType: this.config.relationshipType, + excludeSourceRefs: this.config.excludeSourceRefs, + excludeTargetRefs: this.config.excludeTargetRefs, + limit: limit, + offset: offset, + includeDeprecated: filterStates.deprecated, + }); + else if (this.config.type == 'detection-strategy') + this.data$ = + this.restAPIConnectorService.getAllDetectionStrategies(options); + else if (this.config.type == 'analytic') + this.data$ = this.restAPIConnectorService.getAllAnalytics({ + ...options, + includeRefs: true, + }); + else if (this.config.type == 'data-source') + this.data$ = this.restAPIConnectorService.getAllDataSources(options); + else if (this.config.type == 'data-component') + this.data$ = this.restAPIConnectorService.getAllDataComponents(options); + else if (this.config.type == 'asset') + this.data$ = this.restAPIConnectorService.getAllAssets(options); + else if (this.config.type == 'marking-definition') + this.data$ = + this.restAPIConnectorService.getAllMarkingDefinitions(options); + else if (this.config.type == 'identity') + this.data$ = this.restAPIConnectorService.getAllIdentities(options); + else if (this.config.type == 'note') + this.data$ = this.restAPIConnectorService.getAllNotes(options); + 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); + }, + complete: () => { + if (subscription) subscription.unsubscribe(); + }, + }); + } + + /** + * 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[], + { deprecated, revoked, state, @@ -920,189 +1114,115 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { domains, exclusiveDeprecated, exclusiveRevoked, - } = this.getFilterObjectStates(); - if ('stixObjects' in this.config && this.config.stixObjects !== undefined) { - if (this.config.stixObjects instanceof Observable) { - // pull objects out of observable - } else { - // filter on STIX objects specified in the config - let filtered = this.config.stixObjects; - - //filter by domains - if (Array.isArray(domains) && domains.length > 0) { - filtered = filtered.filter((obj: any) => - obj.domains.some((object_domain: any) => - domains.includes(object_domain) - ) - ); - } + }: ReturnType + ): StixObject[] { + let filtered = objects; + //filter by domains + if (Array.isArray(domains) && domains.length > 0) { + filtered = filtered.filter((obj: any) => + this.getArrayField(obj, 'domains').some((object_domain: any) => + domains.includes(object_domain) + ) + ); + } - //filter by platforms - if (Array.isArray(platforms) && platforms.length > 0) { - filtered = filtered.filter((obj: any) => - obj.platforms.some((object_platform: any) => - platforms.includes(object_platform) - ) - ); - } + //filter by platforms + if (Array.isArray(platforms) && platforms.length > 0) { + filtered = filtered.filter((obj: any) => + this.getArrayField(obj, 'platforms').some((object_platform: any) => + platforms.includes(object_platform) + ) + ); + } - // filter by workflow status - if (state) { - filtered = filtered.filter( - (obj: any) => obj.workflow && obj.workflow.state == state - ); - } + // filter by workflow status + if (state) { + filtered = filtered.filter( + (obj: any) => obj.workflow && obj.workflow.state == state + ); + } - // filter by deprecation status - if (exclusiveDeprecated) { - filtered = filtered.filter((obj: any) => obj.deprecated); - } else if (deprecated || this.config.includeDeprecatedObjects) { - filtered = filtered.filter((obj: any) => obj || obj.deprecated); - } else { - filtered = filtered.filter((obj: any) => !obj.deprecated); - } + // filter by deprecation status + if (exclusiveDeprecated) { + filtered = filtered.filter((obj: any) => obj.deprecated); + } else if (deprecated || this.config.includeDeprecatedObjects) { + filtered = filtered.filter((obj: any) => obj || obj.deprecated); + } else { + filtered = filtered.filter((obj: any) => !obj.deprecated); + } - // filter by revocation status - if (exclusiveRevoked) { - filtered = filtered.filter((obj: any) => obj.revoked); - } + // filter by revocation status + if (exclusiveRevoked) { + filtered = filtered.filter((obj: any) => obj.revoked); + } - // filter by users - if ( - Array.isArray(this.userIdsUsedInSearch) && - this.userIdsUsedInSearch.length > 0 - ) { - filtered = filtered.filter( - (obj: any) => - obj.workflow && - this.userIdsUsedInSearch.includes( - obj.workflow.created_by_user_account - ) - ); - } + // filter by users + if ( + Array.isArray(this.userIdsUsedInSearch) && + this.userIdsUsedInSearch.length > 0 + ) { + filtered = filtered.filter( + (obj: any) => + obj.workflow && + this.userIdsUsedInSearch.includes( + obj.workflow.created_by_user_account + ) + ); + } - // filter to objects matching searchString - filtered = this.filterObjects(this.searchQuery, filtered); - // sort - filtered = filtered.sort((a, b) => { - const x = a as any; - const y = b as any; - return x.hasOwnProperty('name') && y.hasOwnProperty('name') - ? x.name.localeCompare(y.name) - : x.stixID.localeCompare(y.stixID); - }); - if (this.paginator) this.totalObjectCount = filtered.length; - - // filter to only ones within the correct index range - const startIndex = this.paginator - ? this.paginator.pageIndex * this.paginator.pageSize - : 0; - const endIndex = this.paginator - ? startIndex + this.paginator.pageSize - : 10; - filtered = filtered.slice(startIndex, endIndex); - this.data$ = of({ - data: filtered, - pagination: { - total: this.config.stixObjects.length, - offset: startIndex, - limit: this.paginator ? this.paginator.pageSize : 0, - }, - }); - // used to conditionally hide data component relationships with techniques - if ( - this.config.type === 'relationship' && - this.config.relationshipType === 'detects' - ) { - this.detectsHasData.emit(filtered.length > 0); - } - } - } else { - // fetch objects from backend - const limit = this.paginator ? this.paginator.pageSize : 10; - const offset = this.paginator ? this.paginator.pageIndex * limit : 0; + // filter to objects matching searchString + filtered = this.filterObjects(this.searchQuery, filtered); + return filtered; + } - const options = { - limit: limit, - offset: offset, - excludeIDs: this.config.excludeIDs, - search: this.searchQuery, - state: state, - includeRevoked: revoked, - includeDeprecated: deprecated, - platforms: platforms, - domains: domains, - lastUpdatedBy: this.userIdsUsedInSearch, - }; - if (this.config.type == 'software') - this.data$ = this.restAPIConnectorService.getAllSoftware(options); - else if (this.config.type == 'campaign') - this.data$ = this.restAPIConnectorService.getAllCampaigns(options); - else if (this.config.type == 'group') - this.data$ = this.restAPIConnectorService.getAllGroups(options); - else if (this.config.type == 'matrix') - this.data$ = this.restAPIConnectorService.getAllMatrices(options); - else if (this.config.type == 'mitigation') - this.data$ = this.restAPIConnectorService.getAllMitigations(options); - else if (this.config.type == 'tactic') - 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')) - this.data$ = this.restAPIConnectorService.getAllCollections({ - search: this.searchQuery, - versions: 'all', - }); - else if (this.config.type == 'relationship') - this.data$ = this.restAPIConnectorService.getRelatedTo({ - sourceRef: this.config.sourceRef, - targetRef: this.config.targetRef, - sourceType: this.config.sourceType, - targetType: this.config.targetType, - relationshipType: this.config.relationshipType, - excludeSourceRefs: this.config.excludeSourceRefs, - excludeTargetRefs: this.config.excludeTargetRefs, - limit: limit, - offset: offset, - includeDeprecated: deprecated, - }); - else if (this.config.type == 'detection-strategy') - this.data$ = - this.restAPIConnectorService.getAllDetectionStrategies(options); - else if (this.config.type == 'analytic') - this.data$ = this.restAPIConnectorService.getAllAnalytics({ - ...options, - includeRefs: true, - }); - else if (this.config.type == 'data-source') - this.data$ = this.restAPIConnectorService.getAllDataSources(options); - else if (this.config.type == 'data-component') - this.data$ = this.restAPIConnectorService.getAllDataComponents(options); - else if (this.config.type == 'asset') - this.data$ = this.restAPIConnectorService.getAllAssets(options); - else if (this.config.type == 'marking-definition') - this.data$ = - this.restAPIConnectorService.getAllMarkingDefinitions(options); - else if (this.config.type == 'note') - this.data$ = this.restAPIConnectorService.getAllNotes(options); - const subscription = this.data$.subscribe({ - next: data => { - this.totalObjectCount = data.pagination.total; - // used to conditionally hide data component relationships with techniques - if ( - this.config.type === 'relationship' && - this.config.relationshipType === 'detects' - ) { - this.detectsHasData.emit(data.data.length > 0); - } - }, - complete: () => { - if (subscription) subscription.unsubscribe(); - }, - }); + 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; + const y = b as any; + return x.hasOwnProperty('name') && y.hasOwnProperty('name') + ? x.name.localeCompare(y.name) + : x.stixID.localeCompare(y.stixID); + }); + } + + private getPaginationRange(): { + startIndex: number; + endIndex: number; + } { + const startIndex = this.paginator + ? this.paginator.pageIndex * this.paginator.pageSize + : 0; + const endIndex = this.paginator ? startIndex + this.paginator.pageSize : 10; + + return { startIndex, endIndex }; + } + + private emitDetectsHasData(hasData: boolean) { + // used to conditionally hide data component relationships with techniques + if ( + this.config.type === 'relationship' && + this.config.relationshipType === 'detects' + ) { + this.detectsHasData.emit(hasData); } } + 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'); @@ -1204,7 +1324,16 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { } type selection_types = 'one' | 'many' | 'disabled'; -type filter_types = 'state' | 'workflow_status'; +type filter_types = 'state' | 'workflow_status' | 'state_exclusive'; +type column_types = + | 'version' + | 'list' + | 'plain' + | 'timestamp' + | 'descriptive' + | 'relationship_name' + | 'icon' + | 'related_ref_list'; export interface StixListConfig { /* if specified, shows the given STIX objects in the table instead of loading from the back-end based on other configurations. */ stixObjects?: Observable | StixObject[]; @@ -1217,11 +1346,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'; - /** force the list to show only objects matching this query */ - query?: any; + /** 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 @@ -1234,12 +1367,15 @@ 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 */ showFilters?: boolean; /** default true, if false hides all search/filter/control options */ showControls?: boolean; + /** Optional preset to override default columns */ + 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 @@ -1295,6 +1431,11 @@ export interface StixListConfig { * Map of collections by stixID */ collectionMap?: Map; + + /** + * If true, collapse relationship columns to source, type, target, description only + */ + compactRelationshipColumns?: boolean; } export interface FilterValue { 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/stix/stix-page-tabs/history-timeline/history-timeline.component.spec.ts b/src/app/components/stix/stix-page-tabs/history-timeline/history-timeline.component.spec.ts new file mode 100644 index 000000000..5bf72472c --- /dev/null +++ b/src/app/components/stix/stix-page-tabs/history-timeline/history-timeline.component.spec.ts @@ -0,0 +1,72 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { ActivatedRoute, Router } from '@angular/router'; +import { of } from 'rxjs'; + +import { HistoryTimelineComponent } from './history-timeline.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('HistoryTimelineComponent', () => { + let component: HistoryTimelineComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({ + getSoftware: () => createAsyncObservable([]), + getGroup: () => createAsyncObservable([]), + getMatrix: () => createAsyncObservable([]), + getMitigation: () => createAsyncObservable([]), + getTactic: () => createAsyncObservable([]), + getCampaign: () => createAsyncObservable([]), + getTechnique: () => createAsyncObservable([]), + getCollection: () => createAsyncObservable([]), + getDataSource: () => createAsyncObservable([]), + getDataComponent: () => createAsyncObservable([]), + getAsset: () => createAsyncObservable([]), + getAnalytic: () => createAsyncObservable([]), + getDetectionStrategy: () => createAsyncObservable([]), + getRelatedTo: () => createAsyncObservable(createPaginatedResponse([])), + getAllCollections: () => + createAsyncObservable(createPaginatedResponse([])), + }); + + await TestBed.configureTestingModule({ + declarations: [HistoryTimelineComponent], + providers: [ + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + provideHttpClient(), + { + provide: ActivatedRoute, + useValue: { + params: of({}), + queryParams: of({}), + }, + }, + { + provide: Router, + useValue: { + url: '/technique/mock-stix-id?param=value', + events: of({}), + }, + }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(HistoryTimelineComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); 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/stix/stix-page-tabs/notes-editor/notes-editor.component.spec.ts b/src/app/components/stix/stix-page-tabs/notes-editor/notes-editor.component.spec.ts new file mode 100644 index 000000000..4db95f7d6 --- /dev/null +++ b/src/app/components/stix/stix-page-tabs/notes-editor/notes-editor.component.spec.ts @@ -0,0 +1,65 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { Router } from '@angular/router'; + +import { NotesEditorComponent } from './notes-editor.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { + createMockRestApiConnector, + createAsyncObservable, + createPaginatedResponse, +} from 'src/app/testing/mocks/rest-api-connector.mock'; + +describe('NotesEditorComponent', () => { + let component: NotesEditorComponent; + let fixture: ComponentFixture; + let mockRestApiConnector: any; + let mockRouter: any; + + beforeEach(async () => { + mockRestApiConnector = createMockRestApiConnector({ + getAllNotes: vi.fn(() => + createAsyncObservable(createPaginatedResponse()) + ), + }); + mockRouter = { + url: '/test/mock-id?param=value', + }; + + await TestBed.configureTestingModule({ + declarations: [NotesEditorComponent], + providers: [ + provideHttpClient(), + { + provide: Router, + useValue: mockRouter, + }, + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(NotesEditorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + 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/stix/stixid-property/stixid-property.component.spec.ts b/src/app/components/stix/stixid-property/stixid-property.component.spec.ts index 0026b45c2..74949ce3e 100644 --- a/src/app/components/stix/stixid-property/stixid-property.component.spec.ts +++ b/src/app/components/stix/stixid-property/stixid-property.component.spec.ts @@ -1,4 +1,9 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatIconModule } from '@angular/material/icon'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { StixObject } from 'src/app/classes/stix/stix-object'; import { StixIDPropertyComponent } from './stixid-property.component'; @@ -9,12 +14,18 @@ describe('StixidPropertyComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [StixIDPropertyComponent], + imports: [MatSnackBarModule, MatIconModule, NoopAnimationsModule], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(StixIDPropertyComponent); component = fixture.componentInstance; + component.config = { + mode: 'view', + object: { stixID: 'test-stix-id' } as StixObject, + }; fixture.detectChanges(); }); diff --git a/src/app/components/stix/string-property/string-property.component.spec.ts b/src/app/components/stix/string-property/string-property.component.spec.ts index 8186708f9..4cece281a 100644 --- a/src/app/components/stix/string-property/string-property.component.spec.ts +++ b/src/app/components/stix/string-property/string-property.component.spec.ts @@ -1,20 +1,35 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { StixObject } from 'src/app/classes/stix/stix-object'; import { StringPropertyComponent } from './string-property.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { createMockRestApiConnector } from 'src/app/testing/mocks/rest-api-connector.mock'; describe('StringPropertyComponent', () => { let component: StringPropertyComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({}); + await TestBed.configureTestingModule({ declarations: [StringPropertyComponent], + providers: [ + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + ], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(StringPropertyComponent); component = fixture.componentInstance; + component.config = { + mode: 'view', + object: { stixID: 'test-stix-id' } as StixObject, + field: 'name', + }; fixture.detectChanges(); }); diff --git a/src/app/components/stix/string-property/string-property.component.ts b/src/app/components/stix/string-property/string-property.component.ts index 763ceba04..79f334e8a 100644 --- a/src/app/components/stix/string-property/string-property.component.ts +++ b/src/app/components/stix/string-property/string-property.component.ts @@ -27,6 +27,10 @@ export class StringPropertyComponent implements OnInit, OnChanges { public allowedValues: any; public channels: Set; public loading = false; + private fieldToAllowedValuesProperty = { + identity_class: 'identity_class', + platform: 'x_mitre_platforms', + }; // prevent async issues private subscription: Subscription = new Subscription(); @@ -47,7 +51,7 @@ export class StringPropertyComponent implements OnInit, OnChanges { disabled: this.config.disabled ?? false, }); - if (this.config.field === 'platform') { + if (this.fieldToAllowedValuesProperty[this.config.field]) { this.loading = true; const data$ = this.apiService.getAllAllowedValues(); this.subscription = data$.subscribe({ @@ -79,16 +83,21 @@ export class StringPropertyComponent implements OnInit, OnChanges { public getOptions(): Set { const options = new Set(); if (this.loading) return options; - if (this.config.field === 'platform') { + const allowedValuesProperty = + this.fieldToAllowedValuesProperty[this.config.field]; + if (allowedValuesProperty) { const obj = this.config.object as any; - const properties = this.allowedValues.properties.find(p => { - return p.propertyName == 'x_mitre_platforms'; + const properties = this.allowedValues?.properties?.find(p => { + return p.propertyName == allowedValuesProperty; }); properties?.domains?.forEach(d => { - if (obj?.domains?.includes(d.domainName)) { + if (!obj?.domains || obj.domains.includes(d.domainName)) { d.allowedValues.forEach(options.add, options); } }); + if (this.config.object[this.config.field]) { + options.add(this.config.object[this.config.field]); + } } return options; } diff --git a/src/app/components/stix/subtype-property/subtype-dialog/subtype-dialog.component.spec.ts b/src/app/components/stix/subtype-property/subtype-dialog/subtype-dialog.component.spec.ts index f44cccc23..295d609bc 100644 --- a/src/app/components/stix/subtype-property/subtype-dialog/subtype-dialog.component.spec.ts +++ b/src/app/components/stix/subtype-property/subtype-dialog/subtype-dialog.component.spec.ts @@ -1,21 +1,57 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideRouter } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { of } from 'rxjs'; import { SubtypeDialogComponent } from './subtype-dialog.component'; +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'; describe('SubtypeDialogComponent', () => { let component: SubtypeDialogComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({ + getAllAllowedValues: () => createAsyncObservable([]), + }); + await TestBed.configureTestingModule({ declarations: [SubtypeDialogComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + provideHttpClient(), + provideRouter([]), + { + provide: ActivatedRoute, + useValue: { + params: of({}), + queryParams: of({}), + }, + }, + { provide: MatDialogRef, useValue: {} }, + { + provide: MAT_DIALOG_DATA, + useValue: { + subtypeFields: [], + object: { attackType: 'test-type' }, + field: 'testField', + }, + }, + ], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(SubtypeDialogComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create', () => { diff --git a/src/app/components/stix/subtype-property/subtype-diff/subtype-diff.component.spec.ts b/src/app/components/stix/subtype-property/subtype-diff/subtype-diff.component.spec.ts index 976f93724..5c8536b09 100644 --- a/src/app/components/stix/subtype-property/subtype-diff/subtype-diff.component.spec.ts +++ b/src/app/components/stix/subtype-property/subtype-diff/subtype-diff.component.spec.ts @@ -1,4 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { SubtypeDiffComponent } from './subtype-diff.component'; @@ -9,11 +10,18 @@ describe('SubtypeDiffComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [SubtypeDiffComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); + }); + beforeEach(() => { fixture = TestBed.createComponent(SubtypeDiffComponent); component = fixture.componentInstance; - fixture.detectChanges(); + component.config = { + mode: 'diff', + object: [{}, {}], + field: 'x_mitre_attack_spec_version', + }; }); it('should create', () => { diff --git a/src/app/components/stix/subtype-property/subtype-edit/subtype-edit.component.spec.ts b/src/app/components/stix/subtype-property/subtype-edit/subtype-edit.component.spec.ts index 6488a9abf..446d6f034 100644 --- a/src/app/components/stix/subtype-property/subtype-edit/subtype-edit.component.spec.ts +++ b/src/app/components/stix/subtype-property/subtype-edit/subtype-edit.component.spec.ts @@ -1,4 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { SubtypeEditComponent } from './subtype-edit.component'; @@ -9,13 +10,19 @@ describe('SubtypeEditComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [SubtypeEditComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(SubtypeEditComponent); component = fixture.componentInstance; - fixture.detectChanges(); + component.config = { + mode: 'edit', + object: {}, + field: 'x_mitre_platforms', + subtypeFields: [{ key: 'name' }], + }; }); it('should create', () => { diff --git a/src/app/components/stix/subtype-property/subtype-property.component.spec.ts b/src/app/components/stix/subtype-property/subtype-property.component.spec.ts index df7849ad1..d8039ca54 100644 --- a/src/app/components/stix/subtype-property/subtype-property.component.spec.ts +++ b/src/app/components/stix/subtype-property/subtype-property.component.spec.ts @@ -1,6 +1,8 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { SubtypePropertyComponent } from './subtype-property.component'; +import { StixObject } from 'src/app/classes/stix'; describe('SubtypePropertyComponent', () => { let component: SubtypePropertyComponent; @@ -9,12 +11,22 @@ describe('SubtypePropertyComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [SubtypePropertyComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(SubtypePropertyComponent); component = fixture.componentInstance; + // Set required config input + component.config = { + mode: 'view', + object: {} as StixObject, + field: 'test', + label: 'Test', + subtypeFields: [], + tooltip: 'Test tooltip', + }; fixture.detectChanges(); }); diff --git a/src/app/components/stix/subtype-property/subtype-view/subtype-view.component.html b/src/app/components/stix/subtype-property/subtype-view/subtype-view.component.html index 89c2f96c9..ff5680bc3 100644 --- a/src/app/components/stix/subtype-property/subtype-view/subtype-view.component.html +++ b/src/app/components/stix/subtype-property/subtype-view/subtype-view.component.html @@ -5,7 +5,7 @@ *ngIf="detailTable.length" mat-table [dataSource]="detailTable" - class="subtype-table"> + class="property-table"> diff --git a/src/app/components/stix/subtype-property/subtype-view/subtype-view.component.scss b/src/app/components/stix/subtype-property/subtype-view/subtype-view.component.scss index 418fad1cd..00d5875a2 100644 --- a/src/app/components/stix/subtype-property/subtype-view/subtype-view.component.scss +++ b/src/app/components/stix/subtype-property/subtype-view/subtype-view.component.scss @@ -1,5 +1,4 @@ @use '../../../../../style/globals'; -@use '../../../../../style/colors'; .subtype-view { @extend .labelled-box; @@ -7,29 +6,7 @@ .content { overflow-x: auto; } - .subtype-table { - width: 100%; - padding: 8px; - .light & { - background-color: colors.color(light); - } - .dark & { - background-color: colors.color(dark); - } - tr.mat-mdc-header-row { - height: 25px; - } - .mat-mdc-cell, - .mat-mdc-header-cell { - padding: 0 10px; - .light & { - border-color: colors.border-color(light); - } - .dark & { - border-color: colors.border-color(dark); - } - } - } + .subtype-markdown { p { padding: 0; diff --git a/src/app/components/stix/subtype-property/subtype-view/subtype-view.component.spec.ts b/src/app/components/stix/subtype-property/subtype-view/subtype-view.component.spec.ts index f3053202a..c33f999ea 100644 --- a/src/app/components/stix/subtype-property/subtype-view/subtype-view.component.spec.ts +++ b/src/app/components/stix/subtype-property/subtype-view/subtype-view.component.spec.ts @@ -1,3 +1,4 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { SubtypeViewComponent } from './subtype-view.component'; @@ -9,12 +10,22 @@ describe('SubtypeViewComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [SubtypeViewComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(SubtypeViewComponent); component = fixture.componentInstance; + // Set required config input + component.config = { + mode: 'view', + object: { test: {} } as any, + field: 'test', + label: 'Test', + subtypeFields: [], + tooltip: 'Test', + }; fixture.detectChanges(); }); diff --git a/src/app/components/stix/timestamp-property/timestamp-property.component.spec.ts b/src/app/components/stix/timestamp-property/timestamp-property.component.spec.ts index 07da9c4c4..e308d6035 100644 --- a/src/app/components/stix/timestamp-property/timestamp-property.component.spec.ts +++ b/src/app/components/stix/timestamp-property/timestamp-property.component.spec.ts @@ -1,6 +1,8 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TimestampPropertyComponent } from './timestamp-property.component'; +import { StixObject } from 'src/app/classes/stix/stix-object'; describe('TimestampPropertyComponent', () => { let component: TimestampPropertyComponent; @@ -9,12 +11,19 @@ describe('TimestampPropertyComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [TimestampPropertyComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(TimestampPropertyComponent); component = fixture.componentInstance; + // Set required config input + component.config = { + mode: 'view', + object: {} as StixObject, + field: 'created', + }; fixture.detectChanges(); }); diff --git a/src/app/components/stix/timestamp-property/timestamp-property.component.ts b/src/app/components/stix/timestamp-property/timestamp-property.component.ts index a16240cad..6b3ac950e 100644 --- a/src/app/components/stix/timestamp-property/timestamp-property.component.ts +++ b/src/app/components/stix/timestamp-property/timestamp-property.component.ts @@ -23,9 +23,8 @@ export class TimestampPropertyComponent implements OnInit { return ( this.config.field.includes('modified') && this.config.object && - 'workflow' in this.config.object && - this.config.object.workflow && - 'created_by_user_account' in this.config.object.workflow + 'created_by_user_account' in this.config.object && + !!this.config.object.created_by_user_account ); } } diff --git a/src/app/components/stix/timestamp-property/timestamp-view/timestamp-view.component.spec.ts b/src/app/components/stix/timestamp-property/timestamp-view/timestamp-view.component.spec.ts index 7ab2cd898..869ee4e42 100644 --- a/src/app/components/stix/timestamp-property/timestamp-view/timestamp-view.component.spec.ts +++ b/src/app/components/stix/timestamp-property/timestamp-view/timestamp-view.component.spec.ts @@ -1,21 +1,30 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { TimestampViewComponent } from './timestamp-view.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { createMockRestApiConnector } from 'src/app/testing/mocks/rest-api-connector.mock'; describe('TimestampViewComponent', () => { let component: TimestampViewComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({}); + await TestBed.configureTestingModule({ declarations: [TimestampViewComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + ], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(TimestampViewComponent); component = fixture.componentInstance; - fixture.detectChanges(); + component.config = { mode: 'view', object: {} as any, field: 'created' }; }); it('should create', () => { diff --git a/src/app/components/stix/timestamp-property/timestamp-view/timestamp-view.component.ts b/src/app/components/stix/timestamp-property/timestamp-view/timestamp-view.component.ts index ecceb0d23..47dbc5fcd 100644 --- a/src/app/components/stix/timestamp-property/timestamp-view/timestamp-view.component.ts +++ b/src/app/components/stix/timestamp-property/timestamp-view/timestamp-view.component.ts @@ -1,8 +1,6 @@ import { Component, Input, OnInit } from '@angular/core'; import { TimestampPropertyConfig } from '../timestamp-property.component'; import moment from 'moment'; -import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; -import { Subscription } from 'rxjs'; import { UserAccount } from 'src/app/classes/authn/user-account'; @Component({ @@ -15,10 +13,9 @@ export class TimestampViewComponent implements OnInit { @Input() public config: TimestampPropertyConfig; private _humanized: string = null; - private userSubscription$: Subscription; displayName = ''; - constructor(private restAPIConnector: RestApiConnectorService) { + constructor() { // intentionally left blank } @@ -27,20 +24,11 @@ export class TimestampViewComponent implements OnInit { const object = Array.isArray(this.config.object) ? this.config.object[0] : this.config.object; - const createdByAccountId = object.workflow.created_by_user_account; - if (!createdByAccountId) { - // createdByAccountId does not exist + if (!object?.created_by_user_account) { return; } - this.userSubscription$ = this.restAPIConnector - .getUserAccount(createdByAccountId) - .subscribe({ - next: response => { - const user = new UserAccount(response); - this.displayName = user.displayName; - }, - complete: () => this.userSubscription$.unsubscribe(), - }); + const user = new UserAccount(object.created_by_user_account); + this.displayName = user.displayName; } } diff --git a/src/app/components/stix/tlp-property/tlp-diff/tlp-diff.component.spec.ts b/src/app/components/stix/tlp-property/tlp-diff/tlp-diff.component.spec.ts index 49d627122..599087a9d 100644 --- a/src/app/components/stix/tlp-property/tlp-diff/tlp-diff.component.spec.ts +++ b/src/app/components/stix/tlp-property/tlp-diff/tlp-diff.component.spec.ts @@ -1,3 +1,4 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TlpDiffComponent } from './tlp-diff.component'; @@ -9,10 +10,16 @@ describe('TlpDiffComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [TlpDiffComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); fixture = TestBed.createComponent(TlpDiffComponent); component = fixture.componentInstance; + // Set required config input + component.config = { + mode: 'diff', + object: [{} as any, {} as any], + }; fixture.detectChanges(); }); diff --git a/src/app/components/stix/tlp-property/tlp-edit/tlp-edit.component.spec.ts b/src/app/components/stix/tlp-property/tlp-edit/tlp-edit.component.spec.ts index 9391a4d0d..ffa372f46 100644 --- a/src/app/components/stix/tlp-property/tlp-edit/tlp-edit.component.spec.ts +++ b/src/app/components/stix/tlp-property/tlp-edit/tlp-edit.component.spec.ts @@ -1,3 +1,4 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TlpEditComponent } from './tlp-edit.component'; @@ -9,6 +10,7 @@ describe('TlpEditComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [TlpEditComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); diff --git a/src/app/components/stix/tlp-property/tlp-property.component.spec.ts b/src/app/components/stix/tlp-property/tlp-property.component.spec.ts index c400ea245..816030df6 100644 --- a/src/app/components/stix/tlp-property/tlp-property.component.spec.ts +++ b/src/app/components/stix/tlp-property/tlp-property.component.spec.ts @@ -1,20 +1,41 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { StixObject } from 'src/app/classes/stix/stix-object'; import { TlpPropertyComponent } from './tlp-property.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { + createMockRestApiConnector, + createAsyncObservable, + createPaginatedResponse, +} from 'src/app/testing/mocks/rest-api-connector.mock'; describe('TlpPropertyComponent', () => { let component: TlpPropertyComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({ + getAllMarkingDefinitions: () => + createAsyncObservable(createPaginatedResponse([])), + }); + await TestBed.configureTestingModule({ declarations: [TlpPropertyComponent], + providers: [ + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + ], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(TlpPropertyComponent); component = fixture.componentInstance; + component.config = { + mode: 'view', + object: { stixID: 'test-stix-id' } as StixObject, + }; fixture.detectChanges(); }); diff --git a/src/app/components/stix/tlp-property/tlp-view/tlp-view.component.spec.ts b/src/app/components/stix/tlp-property/tlp-view/tlp-view.component.spec.ts index 292a6f6ac..a1d11632b 100644 --- a/src/app/components/stix/tlp-property/tlp-view/tlp-view.component.spec.ts +++ b/src/app/components/stix/tlp-property/tlp-view/tlp-view.component.spec.ts @@ -1,3 +1,4 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TlpViewComponent } from './tlp-view.component'; @@ -9,6 +10,7 @@ describe('TlpViewComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [TlpViewComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); diff --git a/src/app/components/stix/version-property/version-diff/version-diff.component.spec.ts b/src/app/components/stix/version-property/version-diff/version-diff.component.spec.ts index 8b06f6db2..96c30b762 100644 --- a/src/app/components/stix/version-property/version-diff/version-diff.component.spec.ts +++ b/src/app/components/stix/version-property/version-diff/version-diff.component.spec.ts @@ -1,3 +1,4 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { VersionDiffComponent } from './version-diff.component'; @@ -9,10 +10,16 @@ describe('VersionDiffComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [VersionDiffComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); fixture = TestBed.createComponent(VersionDiffComponent); component = fixture.componentInstance; + // Set required config input + component.config = { + mode: 'diff', + object: [{} as any, {} as any], + }; fixture.detectChanges(); }); diff --git a/src/app/components/stix/version-property/version-edit/version-edit.component.spec.ts b/src/app/components/stix/version-property/version-edit/version-edit.component.spec.ts index b6ad8de63..c63435896 100644 --- a/src/app/components/stix/version-property/version-edit/version-edit.component.spec.ts +++ b/src/app/components/stix/version-property/version-edit/version-edit.component.spec.ts @@ -1,4 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { VersionEditComponent } from './version-edit.component'; @@ -9,13 +10,19 @@ describe('VersionEditComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [VersionEditComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(VersionEditComponent); component = fixture.componentInstance; - fixture.detectChanges(); + component.config = { + mode: 'edit', + object: {} as any, + field: 'x_mitre_version', + }; + component.config.object[component.field] = { version: '1.0' }; }); it('should create', () => { diff --git a/src/app/components/stix/version-property/version-property.component.spec.ts b/src/app/components/stix/version-property/version-property.component.spec.ts index 312586226..dbfeae72d 100644 --- a/src/app/components/stix/version-property/version-property.component.spec.ts +++ b/src/app/components/stix/version-property/version-property.component.spec.ts @@ -1,6 +1,9 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { VersionPropertyComponent } from './version-property.component'; +import { StixObject } from 'src/app/classes/stix/stix-object'; describe('VersionPropertyComponent', () => { let component: VersionPropertyComponent; @@ -9,13 +12,19 @@ describe('VersionPropertyComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [VersionPropertyComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [provideHttpClient()], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(VersionPropertyComponent); component = fixture.componentInstance; - fixture.detectChanges(); + // Set required config input + component.config = { + mode: 'view', + object: {} as StixObject, + }; }); it('should create', () => { diff --git a/src/app/components/stix/version-property/version-view/version-view.component.spec.ts b/src/app/components/stix/version-property/version-view/version-view.component.spec.ts index a8fa7d8f3..a04cb50b6 100644 --- a/src/app/components/stix/version-property/version-view/version-view.component.spec.ts +++ b/src/app/components/stix/version-property/version-view/version-view.component.spec.ts @@ -1,6 +1,9 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { VersionViewComponent } from './version-view.component'; +import { Technique } from 'src/app/classes/stix/technique'; +import { VersionNumber } from 'src/app/classes/version-number'; describe('VersionViewComponent', () => { let component: VersionViewComponent; @@ -9,12 +12,22 @@ describe('VersionViewComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [VersionViewComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(VersionViewComponent); component = fixture.componentInstance; + + // Initialize the required config input with a mock + const mockObject = new Technique(); + mockObject.version = new VersionNumber('1.0'); + component.config = { + object: mockObject, + mode: 'view', + }; + fixture.detectChanges(); }); 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 6730d1006..f8d9d67b2 100644 --- a/src/app/components/subheading/subheading.component.spec.ts +++ b/src/app/components/subheading/subheading.component.spec.ts @@ -1,6 +1,12 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; +import { of } from 'rxjs'; +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; @@ -9,16 +15,53 @@ describe('SubheadingComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [SubheadingComponent], + imports: [MtxPopoverModule], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + { + provide: ActivatedRoute, + useValue: { + params: of({}), + queryParams: of({}), + }, + }, + provideHttpClient(), + ], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(SubheadingComponent); component = fixture.componentInstance; + component.config = { object: {} }; fixture.detectChanges(); }); 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.html b/src/app/components/toolbar/toolbar.component.html index 8675c7879..5d23f1c1b 100644 --- a/src/app/components/toolbar/toolbar.component.html +++ b/src/app/components/toolbar/toolbar.component.html @@ -74,7 +74,45 @@ delete
- + + +
{ beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ declarations: [ToolbarComponent], + providers: [ + { + provide: ActivatedRoute, + useValue: { + params: of({}), + queryParams: of({}), + }, + }, + provideHttpClient(), + ], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); diff --git a/src/app/components/toolbar/toolbar.component.ts b/src/app/components/toolbar/toolbar.component.ts index d1b7afc12..b476b087c 100644 --- a/src/app/components/toolbar/toolbar.component.ts +++ b/src/app/components/toolbar/toolbar.component.ts @@ -4,6 +4,7 @@ import { Output, EventEmitter, Input, + ViewChild, } from '@angular/core'; import { Router } from '@angular/router'; import { ValidationData } from 'src/app/classes/serializable'; @@ -11,6 +12,7 @@ import { AuthenticationService } from 'src/app/services/connectors/authenticatio import { EditorService } from 'src/app/services/editor/editor.service'; import { SidebarService } from 'src/app/services/sidebar/sidebar.service'; import { WebsiteIntegrationService } from 'src/app/services/website-integration/website-integration.service'; +import { ObjectStatusComponent } from '../object-status/object-status.component'; @Component({ selector: 'app-toolbar', @@ -25,6 +27,8 @@ export class ToolbarComponent { @Output() public onToggleSidebar = new EventEmitter(); @Output() public onScrollTop = new EventEmitter(); + @ViewChild(ObjectStatusComponent) public objectStatus?: ObjectStatusComponent; + public validationData: ValidationData = null; public get editing(): boolean { @@ -115,4 +119,11 @@ export class ToolbarComponent { `/collection/new?editing=true&groupId=${this.editorService.stixId}` ); } + public toggleRevoked() { + this.objectStatus?.revoke(); + } + + public toggleDeprecated() { + this.objectStatus?.toggleDeprecated(); + } } 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 c1a3566f7..bcf7e8295 100644 --- a/src/app/components/users-list/users-list.component.spec.ts +++ b/src/app/components/users-list/users-list.component.spec.ts @@ -1,24 +1,79 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; 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 { + createMockRestApiConnector, + createAsyncObservable, + createPaginatedResponse, +} from 'src/app/testing/mocks/rest-api-connector.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 () => { + mockRestApiConnector = createMockRestApiConnector({ + getAllUserAccounts: () => + createAsyncObservable(createPaginatedResponse()), + getTeamsByUserId: () => createAsyncObservable([]), + putUserAccount: vi.fn(user => createAsyncObservable(user)), + }); + const mockAuthService = createMockAuthenticationService({}); + await TestBed.configureTestingModule({ declarations: [UsersListComponent], + providers: [ + provideHttpClient(), + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + { provide: AuthenticationService, useValue: mockAuthService }, + ], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(UsersListComponent); component = fixture.componentInstance; + userAccountEvents = TestBed.inject(UserAccountEventsService); + component.config = { + mode: 'view', + columnsToDisplay: [], + team: null, + showSearch: false, + showFilters: false, + selection: null, + } as any; fixture.detectChanges(); }); 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/validation-results/validation-results.component.spec.ts b/src/app/components/validation-results/validation-results.component.spec.ts index 69d1ecb5e..c7727eb12 100644 --- a/src/app/components/validation-results/validation-results.component.spec.ts +++ b/src/app/components/validation-results/validation-results.component.spec.ts @@ -1,4 +1,6 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ValidationData } from 'src/app/classes/serializable'; import { ValidationResultsComponent } from './validation-results.component'; @@ -9,12 +11,17 @@ describe('ValidationResultsComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [ValidationResultsComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(ValidationResultsComponent); component = fixture.componentInstance; + + // Initialize required validation input + component.validation = new ValidationData(); + fixture.detectChanges(); }); diff --git a/src/app/components/version-popover/version-popover.component.spec.ts b/src/app/components/version-popover/version-popover.component.spec.ts index 9b111a550..e68f97e0c 100644 --- a/src/app/components/version-popover/version-popover.component.spec.ts +++ b/src/app/components/version-popover/version-popover.component.spec.ts @@ -1,6 +1,9 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MtxPopoverModule } from '@ng-matero/extensions/popover'; import { VersionPopoverComponent } from './version-popover.component'; +import { StixObject } from 'src/app/classes/stix/stix-object'; describe('VersionPopoverComponent', () => { let component: VersionPopoverComponent; @@ -9,12 +12,18 @@ describe('VersionPopoverComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [VersionPopoverComponent], + imports: [MtxPopoverModule], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(VersionPopoverComponent); component = fixture.componentInstance; + // Set required config input + component.config = { + object: {} as StixObject, + }; fixture.detectChanges(); }); 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..7ba927cd7 --- /dev/null +++ b/src/app/components/workbench-chip/workbench-chip.component.ts @@ -0,0 +1,47 @@ +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/api-connector.ts b/src/app/services/connectors/api-connector.ts index a9670dd9e..8cac55fc0 100644 --- a/src/app/services/connectors/api-connector.ts +++ b/src/app/services/connectors/api-connector.ts @@ -14,7 +14,9 @@ export abstract class ApiConnector { */ private errorSnack(error: any) { // show error field if it's a string (for some error's it's a string, for some it's an Object) - if ('error' in error && typeof error.error == 'string') + if ('error' in error && typeof error.error?.message == 'string') + this.snack(error.error.message, 'warn'); + else if ('error' in error && typeof error.error == 'string') this.snack(error.error, 'warn'); // otherwise, try showing the message else if ('message' in error) this.snack(error.message, 'warn'); @@ -63,7 +65,7 @@ export abstract class ApiConnector { */ private snack(message: string, snackType?: 'warn' | 'success'): void { this.theSnackbar.open(message, 'dismiss', { - duration: 2000, + duration: snackType === 'warn' ? 6000 : 2000, panelClass: snackType, }); } diff --git a/src/app/services/connectors/authentication/authentication.service.spec.ts b/src/app/services/connectors/authentication/authentication.service.spec.ts index 5c9886764..d19246ffd 100644 --- a/src/app/services/connectors/authentication/authentication.service.spec.ts +++ b/src/app/services/connectors/authentication/authentication.service.spec.ts @@ -1,4 +1,6 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; import { AuthenticationService } from './authentication.service'; @@ -6,7 +8,10 @@ describe('AuthenticationService', () => { let service: AuthenticationService; beforeEach(() => { - TestBed.configureTestingModule({}); + TestBed.configureTestingModule({ + providers: [provideHttpClient()], + schemas: [NO_ERRORS_SCHEMA], + }); service = TestBed.inject(AuthenticationService); }); 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.spec.ts b/src/app/services/connectors/rest-api/rest-api-connector.service.spec.ts index 0218e463b..2ce69e90f 100644 --- a/src/app/services/connectors/rest-api/rest-api-connector.service.spec.ts +++ b/src/app/services/connectors/rest-api/rest-api-connector.service.spec.ts @@ -1,4 +1,6 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; import { RestApiConnectorService } from './rest-api-connector.service'; @@ -6,7 +8,10 @@ describe('RestApiConnectorService', () => { let service: RestApiConnectorService; beforeEach(() => { - TestBed.configureTestingModule({}); + TestBed.configureTestingModule({ + providers: [provideHttpClient()], + schemas: [NO_ERRORS_SCHEMA], + }); service = TestBed.inject(RestApiConnectorService); }); 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 58d226a86..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,24 @@ 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; +} + @Injectable({ providedIn: 'root', }) @@ -513,6 +532,7 @@ export class RestApiConnectorService extends ApiConnector { revoked?: boolean; deprecated?: boolean; deserialize?: boolean; + versions?: 'all' | 'latest'; lastUpdatedBy?: string[]; search?: string; }) { @@ -536,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 @@ -1103,8 +1124,23 @@ export class RestApiConnectorService extends ApiConnector { const plural = AttackTypeToPlural[attackType]; return function

(object: P): Observable

{ const url = `${this.apiUrl}/${plural}`; + let params = new HttpParams(); + + // add parentTechniqueId for sub-techniques + if (attackType == 'technique') { + const technique = object as StixObject as Technique; + if (technique.is_subtechnique && technique.parentTechnique) { + params = params.set( + 'parentTechniqueId', + technique.parentTechnique.attackID + ); + } + } return this.http - .post(url, object.serialize(), { headers: this.headers }) + .post(url, object.serialize(), { + headers: this.headers, + params: params, + }) .pipe( tap(this.handleSuccess(`${this.getObjectName(object)} saved`)), map(result => { @@ -1119,6 +1155,68 @@ export class RestApiConnectorService extends ApiConnector { }; } + /** + * Factory to create a STIX object validator via dryRun POST. + * + * Posts the serialized object to its type-specific endpoint with ?dryRun=true, + * which runs the full create pipeline (compose, validate) without persisting. + * Normalizes the response into { errors, warnings } for the caller. + * + * @template T the type to validate + * @returns validator function + */ + public validateStixObject() { + return

(object: P): Observable => { + const plural = AttackTypeToPlural[object.attackType]; + const url = `${this.apiUrl}/${plural}`; + let params = new HttpParams().set('dryRun', 'true'); + + // add parentTechniqueId for sub-techniques + if (object.attackType == 'technique') { + const technique = object as StixObject as Technique; + if (technique.is_subtechnique && technique.parentTechnique) { + params = params.set( + 'parentTechniqueId', + technique.parentTechnique.attackID + ); + } + } + + return this.http.post(url, object.serialize(), { params }).pipe( + // Success (200): validation passed, extract warnings only + map(result => ({ + errors: [], + warnings: (result as any).warnings || [], + })), + catchError((error: any) => { + // Validation failure (400): normalize errors and warnings into expected shape + if (error.status === 400 && error.error?.details) { + return of({ + errors: error.error.details, + warnings: error.error.warnings || [], + }); + } + if (error.status === 400) { + return of({ + errors: [ + { + path: ['request'], + message: + typeof error.error === 'string' + ? error.error + : error.error?.message || 'Validation failed.', + }, + ], + warnings: [], + }); + } + // Non-validation errors: re-raise + return throwError(error); + }), + share() + ); + }; + } /** * POST (create) a new technique * @param {Technique} object the object to create @@ -1431,6 +1529,23 @@ export class RestApiConnectorService extends ApiConnector { }; } + private revokeStixObjectFactory(attackType: AttackType) { + const plural = AttackTypeToPlural[attackType]; + return function ( + id: string, + revokingObject: { revoking: { stixId: string; modified: string } }, + preserveRelationships = false + ): Observable<{}> { + const url = `${this.apiUrl}/${plural}/${id}/revoke`; + const params = { preserveRelationships }; + return this.http.post(url, revokingObject, { params }).pipe( + tap(this.handleSuccess(`${attackType} revoked`)), + catchError(this.handleError_raise()), + share() // multicast so that multiple subscribers don't trigger the call twice. THIS MUST BE THE LAST LINE OF THE PIPE + ); + }; + } + /** * DELETE a technique * @param {string} id the STIX ID of the object to delete @@ -1527,6 +1642,94 @@ export class RestApiConnectorService extends ApiConnector { public get deleteMatrix() { return this.deleteStixObjectFactory('matrix'); } + /** + * DELETE an identity + * @param {string} id the STIX ID of the object to delete + * @returns {Observable<{}>} observable of the response body + */ + public get deleteIdentity() { + return this.deleteStixObjectFactory('identity'); + } + /** + * REVOKE a technique + * @param {string} id the STIX ID of the object to revoke + * @returns {Observable<{}>} observable of the response body + */ + public get revokeTechnique() { + return this.revokeStixObjectFactory('technique'); + } + /** + * REVOKE a tactic + * @param {string} id the STIX ID of the object to revoke + * @returns {Observable<{}>} observable of the response body + */ + public get revokeTactic() { + return this.revokeStixObjectFactory('tactic'); + } + /** + * REVOKE a group + * @param {string} id the STIX ID of the object to revoke + * @returns {Observable<{}>} observable of the response body + */ + public get revokeGroup() { + return this.revokeStixObjectFactory('group'); + } + /** + * REVOKE a matrix + * @param {string} id the STIX ID of the object to revoke + * @returns {Observable<{}>} observable of the response body + */ + public get revokeMatrix() { + return this.revokeStixObjectFactory('matrix'); + } + /** + * REVOKE a campaign + * @param {string} id the STIX ID of the object to revoke + * @returns {Observable<{}>} observable of the response body + */ + public get revokeCampaign() { + return this.revokeStixObjectFactory('campaign'); + } + /** + * REVOKE an asset + * @param {string} id the STIX ID of the object to revoke + * @returns {Observable<{}>} observable of the response body + */ + public get revokeAsset() { + return this.revokeStixObjectFactory('asset'); + } + /** + * REVOKE a software + * @param {string} id the STIX ID of the object to revoke + * @returns {Observable<{}>} observable of the response body + */ + public get revokeSoftware() { + return this.revokeStixObjectFactory('software'); + } + /** + * REVOKE a mitigation + * @param {string} id the STIX ID of the object to revoke + * @returns {Observable<{}>} observable of the response body + */ + public get revokeMitigation() { + return this.revokeStixObjectFactory('mitigation'); + } + /** + * REVOKE a data source + * @param {string} id the STIX ID of the object to revoke + * @returns {Observable<{}>} observable of the response body + */ + public get revokeDataSource() { + return this.revokeStixObjectFactory('data-source'); + } + /** + * REVOKE a data component + * @param {string} id the STIX ID of the object to revoke + * @returns {Observable<{}>} observable of the response body + */ + public get revokeDataComponent() { + return this.revokeStixObjectFactory('data-component'); + } /** * DELETE a collection * @param {string} id the STIX ID of the object to delete @@ -1980,6 +2183,101 @@ export class RestApiConnectorService extends ApiConnector { ); } + /** + * Stream collection bundle import with progress updates using Server-Sent Events + * @param collectionBundle the STIX bundle to import + * @param force whether to force import despite warnings + * @returns Observable that emits progress events and final collection + */ + public streamCollectionBundleImport( + collectionBundle: any, + force = false + ): Observable<{ type: string; data: any }> { + return new Observable(observer => { + let query = new HttpParams(); + query = query.set('stream', 'true'); + if (force) query = query.set('forceImport', 'all'); + + const url = `${this.apiUrl}/collection-bundles?${query.toString()}`; + + // Note: EventSource doesn't support POST with body, so we need to use fetch + // with SSE streaming instead + fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream', + }, + credentials: 'include', + body: JSON.stringify(collectionBundle), + }) + .then(async response => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const reader = response.body?.getReader(); + const decoder = new TextDecoder(); + + if (!reader) { + throw new Error('No response body'); + } + + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + + if (done) { + observer.complete(); + break; + } + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.trim() === '' || line.startsWith(':')) continue; + + const eventMatch = line.match(/^event: (\w+)\n/); + const dataMatch = line.match(/data: (.+)$/m); + + if (dataMatch) { + try { + const data = JSON.parse(dataMatch[1]); + const eventType = eventMatch ? eventMatch[1] : 'message'; + + if (eventType === 'error') { + observer.error(data); + return; + } + + observer.next({ type: eventType, data }); + + if (eventType === 'complete') { + observer.complete(); + return; + } + } catch (e) { + logger.error('Error parsing SSE data:', e); + } + } + } + } + }) + .catch(err => { + logger.error('Stream error:', err); + observer.error(err); + }); + + // Cleanup + return () => { + // EventSource cleanup if needed + }; + }); + } + /** * Preview a collection bundle. * POST the collection bundle to the back end to retrieve a preview of the import results. A second POST @@ -2287,6 +2585,21 @@ export class RestApiConnectorService extends ApiConnector { ); } + /** + * Set the organization identity to an existing identity object. + * @param identityId the STIX ID of the identity to use as the organization identity + * @returns {Observable} the update response + */ + public setOrganizationIdentityRef(identityId: string): Observable { + return this.http + .post(`${this.apiUrl}/config/organization-identity`, { id: identityId }) + .pipe( + tap(this.handleSuccess('Organization Identity Updated')), + catchError(this.handleError_raise()), + share() + ); + } + /** * Get the organization namespace configurations * @returns {Observable} the organization namespace configurations @@ -2330,6 +2643,152 @@ export class RestApiConnectorService extends ApiConnector { ); } + /** + * Get whether protected MITRE identity writes are enabled. + * @returns {Observable} the MITRE identity write setting + */ + public getMitreIdentityWrites(): Observable { + return this.http + .get(`${this.apiUrl}/config/mitre-identity-writes`) + .pipe( + tap(() => logger.log('retrieved MITRE identity write setting')), + catchError( + this.handleError_continue({ enabled: false }) + ), + share() + ); + } + + /** + * Set whether protected MITRE identity writes are enabled. + * @param enabled true if protected MITRE identity writes should be enabled + * @returns {Observable} the update response + */ + public setMitreIdentityWrites(enabled: boolean): Observable { + return this.http + .post(`${this.apiUrl}/config/mitre-identity-writes`, { enabled }) + .pipe( + tap(this.handleSuccess('MITRE Identity Write Setting Updated')), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * 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') + * @param {string} [parentRef] optional parent technique STIX ID for subtechniques + * @returns {Observable} the next available ATT&CK ID + */ + public getNextAttackId( + stixType: string, + parentRef?: string + ): Observable { + let params = new HttpParams().set('type', stixType); + if (parentRef) { + params = params.set('parentRef', parentRef); + } + + return this.http + .get<{ + attack_id: string; + }>(`${this.apiUrl}/attack-objects/attack-id/next`, { params }) + .pipe( + tap(_ => logger.log(`retrieved next ATT&CK ID for ${stixType}`)), + map(result => result.attack_id), + catchError(this.handleError_continue()), + share() + ); + } + // _ _ ___ ___ ___ _ ___ ___ ___ _ _ _ _ _____ _ ___ ___ ___ // | | | / __| __| _ \ /_\ / __/ __/ _ \| | | | \| |_ _| /_\ | _ \_ _/ __| // | |_| \__ \ _|| / / _ \ (_| (_| (_) | |_| | .` | | | / _ \| _/| |\__ \ @@ -2592,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; @@ -2662,6 +3121,39 @@ export class RestApiConnectorService extends ApiConnector { }); return getter; } + + // ___ _ + // | _ \___ _ __ ___ _ _ __| |_ ___ + // | / -_) '_ \/ _ \ '_/ _| _(_-< + // |_|_\___| .__/\___/_| \__|\__/__/ + // |_| + + /** + * Retrieve objects which have unresolved link-by-id references + */ + public getMissingLinkById(): Observable { + const url = `${this.apiUrl}/reports/link-by-id/missing`; + return this.http.get(url).pipe( + tap(results => + logger.log('retrieved missing link-by-id report', results) + ), + catchError(this.handleError_continue([])), // on error, trigger the error notification and continue operation without crashing (returns empty item) + share() // multicast so that multiple subscribers don't trigger the call twice. THIS MUST BE THE LAST LINE OF THE PIPE + ); + } + /** + * Retrieve groups of parallel relationships between the same source/target/type + */ + public getParallelRelationships(): Observable { + const url = `${this.apiUrl}/reports/parallel-relationships`; + return this.http.get(url).pipe( + tap(results => + logger.log('retrieved parallel relationships report', results) + ), + catchError(this.handleError_continue([])), // on error, trigger the error notification and continue operation without crashing (returns empty item) + share() // multicast so that multiple subscribers don't trigger the call twice. THIS MUST BE THE LAST LINE OF THE PIPE + ); + } } class CustomEncoder implements HttpParameterCodec { diff --git a/src/app/services/editor/editor.service.spec.ts b/src/app/services/editor/editor.service.spec.ts index cbead93a6..02bcb393f 100644 --- a/src/app/services/editor/editor.service.spec.ts +++ b/src/app/services/editor/editor.service.spec.ts @@ -1,4 +1,8 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; +import { of } from 'rxjs'; import { EditorService } from './editor.service'; @@ -6,7 +10,19 @@ describe('EditorService', () => { let service: EditorService; beforeEach(() => { - TestBed.configureTestingModule({}); + TestBed.configureTestingModule({ + providers: [ + { + provide: ActivatedRoute, + useValue: { + params: of({}), + queryParams: of({}), + }, + }, + provideHttpClient(), + ], + schemas: [NO_ERRORS_SCHEMA], + }); service = TestBed.inject(EditorService); }); diff --git a/src/app/services/editor/editor.service.ts b/src/app/services/editor/editor.service.ts index e7e5c8290..3d27f3da0 100644 --- a/src/app/services/editor/editor.service.ts +++ b/src/app/services/editor/editor.service.ts @@ -3,12 +3,15 @@ import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { SidebarService } from '../sidebar/sidebar.service'; import { ConfirmationDialogComponent } from 'src/app/components/confirmation-dialog/confirmation-dialog.component'; import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; import { AuthenticationService } from '../connectors/authentication/authentication.service'; import { RestApiConnectorService } from '../connectors/rest-api/rest-api-connector.service'; import { map } from 'rxjs/operators'; -import { forkJoin, Observable } from 'rxjs'; +import { forkJoin, Observable, of } from 'rxjs'; import { Relationship } from 'src/app/classes/stix/relationship'; +const MITRE_IDENTITY_STIX_ID = 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5'; + @Injectable({ providedIn: 'root', }) @@ -47,7 +50,8 @@ export class EditorService { private sidebarService: SidebarService, private authenticationService: AuthenticationService, private apiService: RestApiConnectorService, - private dialog: MatDialog + private dialog: MatDialog, + private snackbar: MatSnackBar ) { this.router.events.subscribe(event => { if (event instanceof NavigationEnd) { @@ -62,17 +66,9 @@ export class EditorService { editable.length > 0 && editable.every(x => x) && this.authenticationService.canEdit(attackType); - this.hasWorkflow = attackType !== 'home'; + 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'; @@ -104,13 +100,50 @@ export class EditorService { } }); this.route.queryParams.subscribe(params => { - this.editing = params['editing'] && this.authenticationService.canEdit(); + const editingRequested = + params['editing'] && this.authenticationService.canEdit(); + if (!editingRequested) { + this.editing = false; + return; + } + + this.canEditCurrentObject().subscribe(canEdit => { + this.editing = canEdit; + if (!canEdit) this.blockProtectedMitreIdentityEdit(); + }); }); } public startEditing() { - if (this.editable) - this.router.navigate([], { queryParams: { editing: true } }); + if (this.editable) { + this.canEditCurrentObject().subscribe(canEdit => { + if (canEdit) + this.router.navigate([], { queryParams: { editing: true } }); + else this.blockProtectedMitreIdentityEdit(); + }); + } + } + + private canEditCurrentObject(): Observable { + if (!this.isProtectedMitreIdentityRoute()) return of(true); + + return this.apiService + .getMitreIdentityWrites() + .pipe(map(config => config.enabled)); + } + + private isProtectedMitreIdentityRoute(): boolean { + return this.type === 'identity' && this.stixId === MITRE_IDENTITY_STIX_ID; + } + + private blockProtectedMitreIdentityEdit(): void { + this.editing = false; + this.router.navigate([], { queryParams: {}, replaceUrl: true }); + this.snackbar.open( + 'MITRE identity edits are disabled by system configuration.', + 'dismiss', + { duration: 4000, panelClass: 'warn' } + ); } public stopEditing() { @@ -128,7 +161,7 @@ export class EditorService { if (!this.router.url.includes('/new')) this.router.navigate([], { queryParams: {} }); else this.router.navigate(['..'], { queryParams: {} }); - this.onEditingStopped.emit(); + this.onEditingStopped.emit(true); } }, complete: () => { diff --git a/src/app/services/helpers/auth.interceptor.spec.ts b/src/app/services/helpers/auth.interceptor.spec.ts index 8e5ddfed3..1967c840b 100644 --- a/src/app/services/helpers/auth.interceptor.spec.ts +++ b/src/app/services/helpers/auth.interceptor.spec.ts @@ -1,3 +1,4 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { AuthInterceptor } from './auth.interceptor'; @@ -6,6 +7,7 @@ describe('AuthInterceptor', () => { beforeEach(() => TestBed.configureTestingModule({ providers: [AuthInterceptor], + schemas: [NO_ERRORS_SCHEMA], }) ); diff --git a/src/app/services/helpers/authorization.guard.spec.ts b/src/app/services/helpers/authorization.guard.spec.ts index 74f6f6976..dfeaa95ce 100644 --- a/src/app/services/helpers/authorization.guard.spec.ts +++ b/src/app/services/helpers/authorization.guard.spec.ts @@ -1,4 +1,6 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; import { AuthorizationGuard } from './authorization.guard'; @@ -6,7 +8,10 @@ describe('AuthorizationGuard', () => { let guard: AuthorizationGuard; beforeEach(() => { - TestBed.configureTestingModule({}); + TestBed.configureTestingModule({ + providers: [provideHttpClient()], + schemas: [NO_ERRORS_SCHEMA], + }); guard = TestBed.inject(AuthorizationGuard); }); diff --git a/src/app/services/helpers/breadcrumb.service.spec.ts b/src/app/services/helpers/breadcrumb.service.spec.ts index 472f6db0e..fb6e6e2d3 100644 --- a/src/app/services/helpers/breadcrumb.service.spec.ts +++ b/src/app/services/helpers/breadcrumb.service.spec.ts @@ -1,3 +1,4 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { BreadcrumbService } from './breadcrumb.service'; @@ -6,7 +7,9 @@ describe('BreadcrumbService', () => { let service: BreadcrumbService; beforeEach(() => { - TestBed.configureTestingModule({}); + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + }); service = TestBed.inject(BreadcrumbService); }); diff --git a/src/app/services/sidebar/sidebar.service.spec.ts b/src/app/services/sidebar/sidebar.service.spec.ts index aba44f7e7..7b2bac58f 100644 --- a/src/app/services/sidebar/sidebar.service.spec.ts +++ b/src/app/services/sidebar/sidebar.service.spec.ts @@ -1,12 +1,15 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { SidebarService } from './sidebar.service'; -describe('SidebarManagerService', () => { - let service: SidebarManagerService; +describe('SidebarService', () => { + let service: SidebarService; beforeEach(() => { - TestBed.configureTestingModule({}); + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + }); service = TestBed.inject(SidebarService); }); 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/title/title.service.spec.ts b/src/app/services/title/title.service.spec.ts index 39294f082..dd293dc4b 100644 --- a/src/app/services/title/title.service.spec.ts +++ b/src/app/services/title/title.service.spec.ts @@ -1,4 +1,7 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; import { TitleService } from './title.service'; @@ -6,7 +9,19 @@ describe('TitleService', () => { let service: TitleService; beforeEach(() => { - TestBed.configureTestingModule({}); + TestBed.configureTestingModule({ + providers: [ + { + provide: ActivatedRoute, + useValue: { + params: of({}), + queryParams: of({}), + snapshot: { params: {}, queryParams: {} }, + }, + }, + ], + schemas: [NO_ERRORS_SCHEMA], + }); service = TestBed.inject(TitleService); }); 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/services/website-integration/website-integration.service.spec.ts b/src/app/services/website-integration/website-integration.service.spec.ts index a9ea589ac..c1a69988f 100644 --- a/src/app/services/website-integration/website-integration.service.spec.ts +++ b/src/app/services/website-integration/website-integration.service.spec.ts @@ -1,4 +1,8 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; +import { of } from 'rxjs'; import { WebsiteIntegrationService } from './website-integration.service'; @@ -6,7 +10,19 @@ describe('WebsiteIntegrationService', () => { let service: WebsiteIntegrationService; beforeEach(() => { - TestBed.configureTestingModule({}); + TestBed.configureTestingModule({ + providers: [ + { + provide: ActivatedRoute, + useValue: { + params: of({}), + queryParams: of({}), + }, + }, + provideHttpClient(), + ], + schemas: [NO_ERRORS_SCHEMA], + }); service = TestBed.inject(WebsiteIntegrationService); }); diff --git a/src/app/testing/mocks/authentication-service.mock.ts b/src/app/testing/mocks/authentication-service.mock.ts new file mode 100644 index 000000000..9546536ef --- /dev/null +++ b/src/app/testing/mocks/authentication-service.mock.ts @@ -0,0 +1,145 @@ +import { EventEmitter } from '@angular/core'; +import { Observable } from 'rxjs'; +import { Role } from 'src/app/classes/authn/role'; +import { Status } from 'src/app/classes/authn/status'; +import { UserAccount } from 'src/app/classes/authn/user-account'; +import { createAsyncObservable } from './rest-api-connector.mock'; + +/** + * Creates a mock UserAccount for testing purposes. + * + * @param overrides - Partial UserAccount properties to override defaults + * @returns A UserAccount object with sensible test defaults + * + * @example + * const user = createMockUserAccount({ role: Role.ADMIN }); + */ +export function createMockUserAccount( + overrides?: Partial +): UserAccount { + const mockUser = new UserAccount({ + id: 'mock-user-id', + username: 'testuser', + email: 'test@example.com', + displayName: 'Test User', + status: Status.ACTIVE, + role: Role.VISITOR, + created: new Date().toISOString(), + modified: new Date().toISOString(), + }); + + // Apply overrides if provided + if (overrides) { + Object.assign(mockUser, overrides); + } + + return mockUser; +} + +/** + * Creates a mock AuthenticationService with configurable method implementations. + * + * @param config - Partial object with method implementations to override + * @returns A mock object that can be used in Angular tests + * + * @example + * // Basic mock with logged-out user + * const mock = createMockAuthenticationService({}); + * + * @example + * // Mock with logged-in admin user + * const mock = createMockAuthenticationService({ + * currentUser: createMockUserAccount({ role: Role.ADMIN }), + * isLoggedIn: true + * }); + * + * @example + * // Mock with custom authorization logic + * const mock = createMockAuthenticationService({ + * isAuthorized: (roles: Role[]) => roles.includes(Role.EDITOR) + * }); + */ +export function createMockAuthenticationService(config?: any): any { + // Default mock user (not logged in) + const defaultUser = new UserAccount({ + id: '', + username: '', + email: '', + displayName: '', + status: Status.PENDING, + role: Role.NONE, + created: new Date().toISOString(), + modified: new Date().toISOString(), + }); + + const defaults = { + // Properties + currentUser: defaultUser, + activeRoles: [Role.ADMIN, Role.EDITOR, Role.TEAM_LEAD, Role.VISITOR], + inactiveRoles: [Role.NONE], + onLogin: new EventEmitter(), + + // Computed property + get isLoggedIn(): boolean { + return this.currentUser && this.currentUser.status === Status.ACTIVE; + }, + + // Methods + isAuthorized: (roles: Role[]): boolean => { + const mock = config?.currentUser || defaultUser; + if (!mock || mock.status !== Status.ACTIVE) return false; + return roles.indexOf(mock.role) > -1; + }, + + canEdit: (attackType?: string): boolean => { + const mock = config?.currentUser || defaultUser; + if (!mock || mock.status !== Status.ACTIVE) return false; + + if ( + attackType && + (attackType.includes('collection') || + attackType.includes('marking-definition')) + ) { + return mock.role === Role.ADMIN; + } + return [Role.EDITOR, Role.TEAM_LEAD, Role.ADMIN].indexOf(mock.role) > -1; + }, + + canDelete: (): boolean => { + const mock = config?.currentUser || defaultUser; + if (!mock || mock.status !== Status.ACTIVE) return false; + return mock.role === Role.ADMIN; + }, + + login: (): Observable => { + return createAsyncObservable(config?.currentUser || defaultUser); + }, + + logout: (): Observable => { + return createAsyncObservable({}); + }, + + register: (): Observable => { + return createAsyncObservable({}); + }, + + handleRegisterRedirect: (): Observable => { + return createAsyncObservable({}); + }, + + getSession: (): Observable => { + return createAsyncObservable(config?.currentUser || defaultUser); + }, + + getAuthType: (): Observable => { + return createAsyncObservable('anonymous'); + }, + + success: (): void => { + // Mock success method + }, + }; + + // Merge config with defaults + return { ...defaults, ...config }; +} diff --git a/src/app/testing/mocks/rest-api-connector.mock.ts b/src/app/testing/mocks/rest-api-connector.mock.ts new file mode 100644 index 000000000..a70a1bf5c --- /dev/null +++ b/src/app/testing/mocks/rest-api-connector.mock.ts @@ -0,0 +1,66 @@ +import { Observable } from 'rxjs'; +import { Paginated } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; + +/** + * Creates an async Observable that emits the provided data after a setTimeout(0). + * This prevents subscription initialization bugs in Angular tests. + * + * @param data - The data to emit from the Observable + * @returns An Observable that emits the data asynchronously + */ +export function createAsyncObservable(data: T): Observable { + return new Observable(subscriber => { + setTimeout(() => { + subscriber.next(data); + subscriber.complete(); + }, 0); + }); +} + +/** + * Creates a standard paginated response structure with empty data by default. + * + * @param data - Optional array of data items (defaults to empty array) + * @returns A Paginated response object with the provided data + */ +export function createPaginatedResponse(data: T[] = []): Paginated { + return { + data, + pagination: { + total: 0, + limit: -1, + offset: -1, + }, + }; +} + +/** + * Creates a mock RestApiConnectorService with configurable method implementations. + * + * @param config - Partial object with method implementations to override + * @returns A mock object that can be used in Angular tests + * + * @example + * // Simple paginated method mock + * const mock = createMockRestApiConnector({ + * getAllNotes: () => createAsyncObservable(createPaginatedResponse()) + * }); + * + * @example + * // Custom response mock + * const mock = createMockRestApiConnector({ + * getOrganizationIdentity: () => createAsyncObservable({ name: 'Test Org' }) + * }); + */ +export function createMockRestApiConnector(config?: any): any { + const defaults = { + // Default implementation for getAllAllowedValues to prevent HTTP errors in tests + getAllAllowedValues: () => createAsyncObservable([]), + }; + + 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 66d8258fb..89aa535e6 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,22 +50,51 @@ 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 = { - '': 'none', - '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, }; /** @@ -71,3 +106,18 @@ export type ChangelogCategory = | '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/collection-manager/collection-manager.component.spec.ts b/src/app/views/collection-manager/collection-manager.component.spec.ts index 24324f60f..fdcdc5418 100644 --- a/src/app/views/collection-manager/collection-manager.component.spec.ts +++ b/src/app/views/collection-manager/collection-manager.component.spec.ts @@ -1,14 +1,32 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; import { CollectionManagerComponent } from './collection-manager.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('CollectionManagerComponent', () => { let component: CollectionManagerComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({ + getAllCollections: () => + createAsyncObservable(createPaginatedResponse([])), + }); + await TestBed.configureTestingModule({ declarations: [CollectionManagerComponent], + providers: [ + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + provideHttpClient(), + ], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); diff --git a/src/app/views/contributors-page/contributors-page.component.spec.ts b/src/app/views/contributors-page/contributors-page.component.spec.ts index 304094d84..8a443d94c 100644 --- a/src/app/views/contributors-page/contributors-page.component.spec.ts +++ b/src/app/views/contributors-page/contributors-page.component.spec.ts @@ -1,14 +1,33 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { MtxPopoverModule } from '@ng-matero/extensions/popover'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ContributorsPageComponent } from './contributors-page.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { + createMockRestApiConnector, + createAsyncObservable, + createPaginatedResponse, +} from 'src/app/testing/mocks/rest-api-connector.mock'; describe('ContributorsPageComponent', () => { let component: ContributorsPageComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({ + getAllObjects: () => createAsyncObservable(createPaginatedResponse()), + }); + await TestBed.configureTestingModule({ declarations: [ContributorsPageComponent], + imports: [MtxPopoverModule], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + provideHttpClient(), + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + ], }).compileComponents(); fixture = TestBed.createComponent(ContributorsPageComponent); diff --git a/src/app/views/dashboard-page/dashboard-page.component.html b/src/app/views/dashboard-page/dashboard-page.component.html index 5c256ca6c..6a09e4a22 100644 --- a/src/app/views/dashboard-page/dashboard-page.component.html +++ b/src/app/views/dashboard-page/dashboard-page.component.html @@ -1,53 +1,440 @@ -
-
-
-

Dashboard

+
+
+
+

Knowledge Base Overview

-
-
-
-
- - - -
+
+ + Include revoked + + + Include deprecated + + +
-
-
-
- - - -
-
+ + + +
+ {{ 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 42457a886..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,18 +1,94 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; import { DashboardPageComponent } from './dashboard-page.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; 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 () => { + mockRestApiConnector = { + getAllObjects: vi.fn(() => of(attackObjects)), + }; + await TestBed.configureTestingModule({ declarations: [DashboardPageComponent], + providers: [ + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + ], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); - }); - beforeEach(() => { fixture = TestBed.createComponent(DashboardPageComponent); component = fixture.componentInstance; fixture.detectChanges(); @@ -21,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 555eecbe7..dd853f611 100644 --- a/src/app/views/dashboard-page/dashboard-page.component.ts +++ b/src/app/views/dashboard-page/dashboard-page.component.ts @@ -1,5 +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 { + 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', @@ -9,19 +175,941 @@ import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/re 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) {} 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)); + }, + error: () => { + this.loadError = 'Unable to load knowledge base dashboard data.'; + this.resetDashboard(); }, - complete: () => userSubscription.unsubscribe(), }); } + + 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); + }); + } + + 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); + } + + 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/data-quality/data-quality.component.html b/src/app/views/dashboard-page/data-quality/data-quality.component.html new file mode 100644 index 000000000..d5ef084c0 --- /dev/null +++ b/src/app/views/dashboard-page/data-quality/data-quality.component.html @@ -0,0 +1,79 @@ +
+

Duplicate Relationships

+ +
{{ parallelError }}
+ + + + + + + + + {{ first.source_object.stix.external_references[0].external_id }} + + arrow_forward + {{ group.relationshipType }} + arrow_forward + + {{ first.target_object.stix.external_references[0].external_id }} + + + + + + +
+ + + Selecting one relationship will mark the remaining + {{ group.toDeprecate.length }} for deprecation + +
+
+
+
+ No parallel relationships found. +
+ + +

Missing LinkByIds Status

+ +
{{ error }}
+ + + + + Missing Links by ID ({{ missingLinks.length }}) + + + + + +
+ No missing link by ids found. +
+
diff --git a/src/app/views/dashboard-page/data-quality/data-quality.component.scss b/src/app/views/dashboard-page/data-quality/data-quality.component.scss new file mode 100644 index 000000000..8915dce81 --- /dev/null +++ b/src/app/views/dashboard-page/data-quality/data-quality.component.scss @@ -0,0 +1,52 @@ +@use '../../../../style/globals'; + +.data-quality-subheader { + @extend .text-label; + @extend .text-deemphasis; + text-align: center; + text-transform: uppercase; + margin-bottom: 20px; +} + +.dq-accordion { + .mat-expansion-panel-header-title { + font-family: Roboto, Arial, sans-serif; + font-weight: 500; + font-size: 1.2rem; /* larger header text */ + line-height: 1.3; + } + .dq-rel-table { + width: 100%; + } + .dq-actions { + display: flex; + align-items: center; + gap: 12px; + margin: 8px 0 16px; + } + .dq-note { + font-size: 0.9rem; + opacity: 0.8; + } +} + +.missing-list { + .missing-id { + font-family: Roboto, Arial, sans-serif; + font-size: 1rem; + word-break: break-all; + } + .missing-item { + padding: 8px 0; + border-bottom: 1px solid #eee; + } + .missing-rel-header { + font-weight: 600; + font-size: 1.05rem; + } + .missing-rel-description, + .missing-description { + margin-top: 4px; + font-size: 0.95rem; + } +} diff --git a/src/app/views/dashboard-page/data-quality/data-quality.component.spec.ts b/src/app/views/dashboard-page/data-quality/data-quality.component.spec.ts new file mode 100644 index 000000000..3bf741845 --- /dev/null +++ b/src/app/views/dashboard-page/data-quality/data-quality.component.spec.ts @@ -0,0 +1,31 @@ +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { DataQualityComponent } from './data-quality.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; + +describe('DataQualityComponent', () => { + let component: DataQualityComponent; + let fixture: ComponentFixture; + + const mockReportService = { + getMissingLinkById: () => of([]), + getParallelRelationships: () => of({}), + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [DataQualityComponent], + providers: [ + { provide: RestApiConnectorService, useValue: mockReportService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(DataQualityComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/views/dashboard-page/data-quality/data-quality.component.ts b/src/app/views/dashboard-page/data-quality/data-quality.component.ts new file mode 100644 index 000000000..4d983adda --- /dev/null +++ b/src/app/views/dashboard-page/data-quality/data-quality.component.ts @@ -0,0 +1,285 @@ +import { Component, OnInit, ViewEncapsulation, ViewChild } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { StixObject } from 'src/app/classes/stix/stix-object'; +import { StixListConfig } from 'src/app/components/stix/stix-list/stix-list.component'; +import { StixTypeToAttackType } from 'src/app/utils/type-mappings'; +import { StixListComponent } from 'src/app/components/stix/stix-list/stix-list.component'; +import { SelectionModel } from '@angular/cdk/collections'; +import { forkJoin } from 'rxjs'; +import { Relationship } from 'src/app/classes/stix/relationship'; +import { ConfirmationDialogComponent } from 'src/app/components/confirmation-dialog/confirmation-dialog.component'; + +interface ParallelRelationshipGroup { + key: string; + sourceRef: string; + targetRef: string; + relationshipType: string; + count: number; + relationships: any[]; + stixObjects?: StixObject[]; + selectedRelationship: string; + toDeprecate: string[]; + selection?: SelectionModel; +} + +@Component({ + selector: 'app-data-quality', + templateUrl: './data-quality.component.html', + styleUrls: ['./data-quality.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false, +}) +export class DataQualityComponent implements OnInit { + constructor( + private reportService: RestApiConnectorService, + private dialog: MatDialog + ) {} + + missingLinks: any[] = []; + missingLinkRows: { id: string; name: string }[] = []; + loadingMissingLinks = false; + error?: string; + + parallelRelationships: ParallelRelationshipGroup[] = []; + loadingParallel = false; + parallelError?: string; + + stixRelationshipConfig: StixListConfig = { + type: 'relationship', + stixObjects: [], + clickBehavior: 'none', + showFilters: false, + showDeprecatedFilter: true, + compactRelationshipColumns: true, + }; + + @ViewChild('relationshipList') relationshipList: StixListComponent; + + ngOnInit(): void { + this.loadParallelRelationships(); + this.loadMissingLinks(); + } + + private transformParallelRelationships( + raw: Record + ): ParallelRelationshipGroup[] { + return Object.entries(raw).map(([key, relationships]) => { + const byNewest = [...relationships].sort((a, b) => { + const ac = a.stix?.created; + const bc = b.stix?.created; + return bc - ac; + }); + const selectedRelationship = + byNewest[0].stix.id ?? relationships[0].stix.id; + const toDeprecate = relationships + .map(r => r.stix.id) + .filter(id => id !== selectedRelationship); + const selection = new SelectionModel(false); + if (selectedRelationship) selection.toggle(selectedRelationship); + + const first = relationships[0].stix; + return { + key, + sourceRef: first?.source_ref, + targetRef: first?.target_ref, + relationshipType: first?.relationship_type, + count: relationships.length, + relationships, + selectedRelationship, + toDeprecate, + selection, + }; + }); + } + + // Build a link to the workbench object page using STIX->ATT&CK mapping + objectLink(item: any): any[] { + const id = item.stix.id; + const stixType = item.stix.type as keyof typeof StixTypeToAttackType; + const attackType = StixTypeToAttackType[stixType]; + return ['/', attackType, id]; + } + + loadMissingLinks(): void { + this.loadingMissingLinks = true; + this.reportService.getMissingLinkById().subscribe({ + next: data => { + // update later to also display relationship missing link by ids + this.missingLinks = Array.isArray(data) + ? data.filter(item => { + const type = item.stix.type; + return type !== 'relationship'; + }) + : []; + this.missingLinkRows = this.buildMissingLinkRows(); + this.loadingMissingLinks = false; + }, + error: err => { + this.error = 'Failed to load report'; + this.loadingMissingLinks = false; + console.error(err); + }, + }); + } + + loadParallelRelationships(): void { + this.loadingParallel = true; + + this.reportService.getParallelRelationships().subscribe({ + next: rawData => { + this.parallelRelationships = + this.transformParallelRelationships(rawData); + + this.parallelRelationships.forEach(group => { + group.stixObjects = this.buildStixObjectsForGroup(group); + }); + this.loadingParallel = false; + this.parallelError = undefined; + }, + error: err => { + this.parallelError = 'Failed to load parallel relationships report'; + this.loadingParallel = false; + console.error(err); + }, + }); + } + + // Build a StixList-compatible array for a given group + buildStixObjectsForGroup(group: ParallelRelationshipGroup): StixObject[] { + if (!group || !Array.isArray(group.relationships)) return [] as any; + return group.relationships.map(r => this.mapRelationshipToRow(r)) as any; + } + + // Use existing config and override only the stixObjects and hide controls in panels + stixConfigForGroup(group: ParallelRelationshipGroup): StixListConfig { + return { + ...this.stixRelationshipConfig, + stixObjects: group.stixObjects || this.buildStixObjectsForGroup(group), + showControls: false, + select: 'one', + selectionModel: group.selection, + }; + } + + // Build stix objects for missing links + buildMissingLinkObjects(): StixObject[] { + return (this.missingLinks || []).map(item => { + const s = item?.stix || item; + return { + stixID: s.id, + attackType: StixTypeToAttackType[s.type], + type: s.type, + attackID: s.external_references?.[0]?.external_id || '', + name: s.name || s.external_references?.[0]?.external_id || s.id, + } as any; + }); + } + + // Use stix-list for missing links with a 2 columns - id and name + stixConfigForMissingLinks(): StixListConfig { + return { + type: 'relationship', // use relationship so stix-list table styling matches + stixObjects: this.buildMissingLinkObjects(), + columnsPreset: 'id-name', + showControls: false, + showFilters: false, + showDeprecatedFilter: false, + clickBehavior: 'linkToObjectPage', + }; + } + + private mapRelationshipToRow(r: any): StixObject { + const stix = r.stix; + return { + stixID: stix.id, + type: 'relationship', + source_ref: stix.source_ref || '', + target_ref: stix.target_ref || '', + source_name: r.source_object.stix.name || '', + target_name: r.target_object.stix.name || '', + source_ID: r.source_object.stix.external_references[0].external_id || '', + target_ID: r.target_object.stix.external_references[0].external_id || '', + relationship_type: stix.relationship_type, + description: stix.description || '', + created: stix.created || undefined, + modified: stix.modified || undefined, + revoked: r?.revoked, + deprecated: r?.x_mitre_deprecated, + } as any; + } + // Build rows for missing links table and display id and name + buildMissingLinkRows(): { id: string; name: string }[] { + return (this.missingLinks || []).map(item => { + const id = + item?.stix.external_references?.[0]?.external_id || item?.stix.id || ''; + const name = item?.stix.name || ''; + return { id, name }; + }); + } + + // Handle selection change from stix-list radios + onGroupSelect(element: StixObject, group: ParallelRelationshipGroup): void { + if (!group || !group.selection) return; + const stixId = element.stixID; + if (!stixId) return; + group.selection.clear(); + group.selection.select(stixId); + group.selectedRelationship = stixId; + group.toDeprecate = group.relationships + .map(r => r.stix.id) + .filter(id => id && id !== group.selectedRelationship); + group.stixObjects = this.buildStixObjectsForGroup(group); + } + + // Deprecate non-selected relationships for a group + deprecateOthers(group: ParallelRelationshipGroup): void { + if (!group || !group.toDeprecate?.length) return; + + const confirmationPrompt = this.dialog.open(ConfirmationDialogComponent, { + maxWidth: '35em', + data: { + message: + 'All selected relationships in this group will be deprecated. Do you want to continue?', + }, + autoFocus: false, // prevents auto focus on toolbar buttons + }); + + const confirmationSub = confirmationPrompt.afterClosed().subscribe({ + next: result => { + if (!result) return; // user cancelled + + const tasks = group.relationships + .filter( + r => + r.stix.id !== group.selectedRelationship && + !r?.x_mitre_deprecated && + !['subtechnique-of', 'revoked-by'].includes( + r.stix?.relationship_type + ) + ) + .map(r => { + const rel = new Relationship(r); + rel.deprecated = true; + return this.reportService.putRelationship(rel); + }); + + const sub = forkJoin(tasks).subscribe({ + next: () => { + group.relationships = group.relationships.filter( + r => r.stix?.id === group.selectedRelationship + ); + group.toDeprecate = []; + group.stixObjects = this.buildStixObjectsForGroup(group); + window.location.reload(); + }, + error: err => { + console.error(err); + }, + complete: () => sub.unsubscribe(), + }); + }, + complete: () => confirmationSub.unsubscribe(), + }); + } +} diff --git a/src/app/views/dashboard-page/default-marking-definitions/default-marking-definitions.component.spec.ts b/src/app/views/dashboard-page/default-marking-definitions/default-marking-definitions.component.spec.ts index c1012537c..c700b0ff9 100644 --- a/src/app/views/dashboard-page/default-marking-definitions/default-marking-definitions.component.spec.ts +++ b/src/app/views/dashboard-page/default-marking-definitions/default-marking-definitions.component.spec.ts @@ -1,21 +1,51 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideRouter } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { of } from 'rxjs'; +import { MtxPopoverModule } from '@ng-matero/extensions/popover'; import { DefaultMarkingDefinitionsComponent } from './default-marking-definitions.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('DefaultMarkingDefinitionsComponent', () => { let component: DefaultMarkingDefinitionsComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({ + getAllMarkingDefinitions: () => + createAsyncObservable(createPaginatedResponse([])), + }); + await TestBed.configureTestingModule({ declarations: [DefaultMarkingDefinitionsComponent], + imports: [MtxPopoverModule], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + provideHttpClient(), + provideRouter([]), + { + provide: ActivatedRoute, + useValue: { + params: of({}), + queryParams: of({}), + }, + }, + ], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(DefaultMarkingDefinitionsComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create', () => { 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 56ca33fd1..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,41 +28,45 @@

Organization Identity


- organization name - - -
-
-
-
- - organization description - - this field can be left blank if desired + organization identity + + + {{ identity.name || identity.stixID }} + + + Select an existing identity object
+
@@ -149,6 +153,40 @@

Namespace Settings

+ +
+
+
+

MITRE Identity Writes

+
+
+
+
+
+

+ Enable this only when this Workbench instance is allowed to create or + update the protected MITRE Corporation identity object. +

+
+ + {{ mitreIdentityWrites.enabled ? 'enabled' : 'disabled' }} + + +
+
+
+
@@ -157,4 +195,8 @@

Namespace Settings

+ + +
diff --git a/src/app/views/dashboard-page/org-settings-page/org-settings-page.component.scss b/src/app/views/dashboard-page/org-settings-page/org-settings-page.component.scss index 97c2ae3bd..7bb5761fc 100644 --- a/src/app/views/dashboard-page/org-settings-page/org-settings-page.component.scss +++ b/src/app/views/dashboard-page/org-settings-page/org-settings-page.component.scss @@ -9,3 +9,57 @@ hr { margin-bottom: 30px; } + +.mitre-identity-write-setting { + align-items: center; + display: flex; + gap: 24px; + justify-content: space-between; + + p { + margin: 0; + max-width: 760px; + } +} + +.mitre-identity-write-controls { + align-items: center; + display: flex; + flex-shrink: 0; + gap: 16px; +} + +.mitre-identity-write-controls .mat-mdc-slide-toggle.toggle-enabled { + --mdc-switch-selected-focus-handle-color: #ffffff; + --mdc-switch-selected-focus-state-layer-color: #2e7d32; + --mdc-switch-selected-focus-track-color: #2e7d32; + --mdc-switch-selected-handle-color: #ffffff; + --mdc-switch-selected-hover-handle-color: #ffffff; + --mdc-switch-selected-hover-state-layer-color: #2e7d32; + --mdc-switch-selected-hover-track-color: #2e7d32; + --mdc-switch-selected-pressed-handle-color: #ffffff; + --mdc-switch-selected-pressed-state-layer-color: #2e7d32; + --mdc-switch-selected-pressed-track-color: #2e7d32; + --mdc-switch-selected-track-color: #2e7d32; +} + +.mitre-identity-write-controls .mat-mdc-slide-toggle.toggle-disabled { + --mdc-switch-unselected-focus-handle-color: #ffffff; + --mdc-switch-unselected-focus-state-layer-color: #c62828; + --mdc-switch-unselected-focus-track-color: #c62828; + --mdc-switch-unselected-handle-color: #ffffff; + --mdc-switch-unselected-hover-handle-color: #ffffff; + --mdc-switch-unselected-hover-state-layer-color: #c62828; + --mdc-switch-unselected-hover-track-color: #c62828; + --mdc-switch-unselected-pressed-handle-color: #ffffff; + --mdc-switch-unselected-pressed-state-layer-color: #c62828; + --mdc-switch-unselected-pressed-track-color: #c62828; + --mdc-switch-unselected-track-color: #c62828; +} + +@media (max-width: 760px) { + .mitre-identity-write-setting { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/src/app/views/dashboard-page/org-settings-page/org-settings-page.component.spec.ts b/src/app/views/dashboard-page/org-settings-page/org-settings-page.component.spec.ts index 4f511fbb2..e253e2b76 100644 --- a/src/app/views/dashboard-page/org-settings-page/org-settings-page.component.spec.ts +++ b/src/app/views/dashboard-page/org-settings-page/org-settings-page.component.spec.ts @@ -1,14 +1,34 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; import { OrgSettingsPageComponent } from './org-settings-page.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { + createMockRestApiConnector, + createAsyncObservable, + createPaginatedResponse, +} from 'src/app/testing/mocks/rest-api-connector.mock'; describe('OrgSettingsPageComponent', () => { let component: OrgSettingsPageComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({ + getOrganizationIdentity: () => createAsyncObservable({}), + getAllIdentities: () => createAsyncObservable(createPaginatedResponse()), + getOrganizationNamespace: () => + createAsyncObservable({ prefix: '', range_start: undefined }), + }); + await TestBed.configureTestingModule({ declarations: [OrgSettingsPageComponent], + providers: [ + provideHttpClient(), + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + ], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); diff --git a/src/app/views/dashboard-page/org-settings-page/org-settings-page.component.ts b/src/app/views/dashboard-page/org-settings-page/org-settings-page.component.ts index b491d081e..4f91e8550 100644 --- a/src/app/views/dashboard-page/org-settings-page/org-settings-page.component.ts +++ b/src/app/views/dashboard-page/org-settings-page/org-settings-page.component.ts @@ -1,10 +1,14 @@ import { Component, OnInit } from '@angular/core'; +import { forkJoin } from 'rxjs'; import { Identity } from 'src/app/classes/stix/identity'; import { + MitreIdentityWrites, Namespace, RestApiConnectorService, } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +const MITRE_IDENTITY_STIX_ID = 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5'; + @Component({ selector: 'app-org-settings-page', templateUrl: './org-settings-page.component.html', @@ -13,7 +17,11 @@ import { }) export class OrgSettingsPageComponent implements OnInit { public organizationIdentity: Identity; + public organizationIdentities: Identity[]; + public selectedOrganizationIdentityId: string; public organizationNamespace: Namespace; + public mitreIdentityWrites: MitreIdentityWrites; + private savedMitreIdentityWritesEnabled: boolean; public idRegex = `^([A-Za-z])*$`; public rangeRegex = `^([0-9]){1,4}$`; @@ -29,11 +37,58 @@ export class OrgSettingsPageComponent implements OnInit { ); } + public get selectedOrganizationIdentity(): Identity { + return this.organizationIdentities?.find( + identity => identity.stixID === this.selectedOrganizationIdentityId + ); + } + + public get isIdentityUnchanged(): boolean { + return ( + !this.selectedOrganizationIdentityId || + this.selectedOrganizationIdentityId === this.organizationIdentity?.stixID + ); + } + + public get isMitreIdentityWritesUnchanged(): boolean { + return ( + !this.mitreIdentityWrites || + this.mitreIdentityWrites.enabled === this.savedMitreIdentityWritesEnabled + ); + } + + public get hasMitreIdentity(): boolean { + return ( + this.organizationIdentities?.some( + identity => identity.stixID === MITRE_IDENTITY_STIX_ID + ) ?? false + ); + } + constructor(private restAPIConnector: RestApiConnectorService) {} ngOnInit(): void { - const idSub = this.restAPIConnector.getOrganizationIdentity().subscribe({ - next: identity => (this.organizationIdentity = identity), + const idSub = forkJoin({ + identity: this.restAPIConnector.getOrganizationIdentity(), + identities: this.restAPIConnector.getAllIdentities(), + }).subscribe({ + next: ({ identity, identities }) => { + this.organizationIdentity = identity; + this.organizationIdentities = identities.data as Identity[]; + if ( + !this.organizationIdentities.some( + organizationIdentity => + organizationIdentity.stixID === identity.stixID + ) + ) { + this.organizationIdentities.push(identity); + } + this.organizationIdentities.sort((a, b) => + (a.name || a.stixID).localeCompare(b.name || b.stixID) + ); + this.selectedOrganizationIdentityId = identity.stixID; + if (this.hasMitreIdentity) this.loadMitreIdentityWrites(); + }, complete: () => idSub.unsubscribe(), }); @@ -52,6 +107,18 @@ export class OrgSettingsPageComponent implements OnInit { }); } + private loadMitreIdentityWrites(): void { + const mitreIdentityWritesSub = this.restAPIConnector + .getMitreIdentityWrites() + .subscribe({ + next: mitreIdentityWrites => { + this.mitreIdentityWrites = mitreIdentityWrites; + this.savedMitreIdentityWritesEnabled = mitreIdentityWrites.enabled; + }, + complete: () => mitreIdentityWritesSub.unsubscribe(), + }); + } + onBlur(): void { if (!this.isNOU(this.organizationNamespace.range_start)) { this.organizationNamespace.range_start = @@ -61,9 +128,10 @@ export class OrgSettingsPageComponent implements OnInit { saveIdentity(): void { const subscription = this.restAPIConnector - .postIdentity(this.organizationIdentity) + .setOrganizationIdentityRef(this.selectedOrganizationIdentityId) .subscribe({ - next: identity => (this.organizationIdentity = identity), + next: () => + (this.organizationIdentity = this.selectedOrganizationIdentity), complete: () => subscription.unsubscribe(), }); } @@ -76,4 +144,15 @@ export class OrgSettingsPageComponent implements OnInit { complete: () => subscription.unsubscribe(), }); } + + saveMitreIdentityWrites(): void { + const subscription = this.restAPIConnector + .setMitreIdentityWrites(this.mitreIdentityWrites.enabled) + .subscribe({ + next: () => + (this.savedMitreIdentityWritesEnabled = + this.mitreIdentityWrites.enabled), + complete: () => subscription.unsubscribe(), + }); + } } 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.spec.ts b/src/app/views/dashboard-page/release-management/release-management.component.spec.ts new file mode 100644 index 000000000..e9eabee80 --- /dev/null +++ b/src/app/views/dashboard-page/release-management/release-management.component.spec.ts @@ -0,0 +1,77 @@ +import { of } from 'rxjs'; +import { ReleaseManagementComponent } from './release-management.component'; + +describe('ReleaseManagementComponent', () => { + it('should populate virtual card counts from the release track summary', () => { + const connector = { + listReleaseTracks: vi.fn().mockReturnValue( + of({ + data: [ + { + track_id: 'release-track--virtual', + type: 'virtual', + summary: { + members_count: 24, + quarantine_count: 3, + component_tracks_count: 2, + }, + }, + ], + }) + ), + } as any; + const component = new ReleaseManagementComponent( + connector, + { navigate: vi.fn() } as any, + { open: vi.fn() } as any + ); + + component.ngOnInit(); + + expect(component.virtualTracks[0].stats).toEqual( + expect.objectContaining({ + members: 24, + quarantined: 3, + components: 2, + }) + ); + }); + + it('should fall back to included arrays when summary counts are absent', () => { + const connector = { + listReleaseTracks: vi.fn().mockReturnValue( + of({ + data: [ + { + track_id: 'release-track--virtual', + type: 'virtual', + members: [{ object_ref: 'attack-pattern--one' }], + quarantine: [{ object_ref: 'attack-pattern--two' }], + composition: { + component_tracks: [ + { track_id: 'release-track--standard-one' }, + { track_id: 'release-track--standard-two' }, + ], + }, + }, + ], + }) + ), + } as any; + const component = new ReleaseManagementComponent( + connector, + { navigate: vi.fn() } as any, + { open: vi.fn() } as any + ); + + component.ngOnInit(); + + expect(component.virtualTracks[0].stats).toEqual( + expect.objectContaining({ + members: 1, + quarantined: 1, + components: 2, + }) + ); + }); +}); 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..b5c68a29d --- /dev/null +++ b/src/app/views/dashboard-page/release-management/release-management.component.ts @@ -0,0 +1,150 @@ +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 ?? + track.latest_snapshot?.summary ?? + track.statistics ?? + {}; + const componentTracks = track.composition?.component_tracks; + const members = track.members; + const quarantine = track.quarantine; + + 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 ?? + (Array.isArray(members) ? members.length : 0), + quarantined: + summary.quarantine_count ?? + summary.quarantined_count ?? + (Array.isArray(quarantine) ? quarantine.length : 0), + components: + summary.component_tracks_count ?? + summary.components_count ?? + (Array.isArray(componentTracks) ? componentTracks.length : 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..f0922ab12 --- /dev/null +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html @@ -0,0 +1,1677 @@ +
+
+

+ {{ 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 }} +
+
+ +
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+

Description

+ +
+ +

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

+ +
+ + + +
+ + +
+
+
+ +
+
+ + {{ virtualComponentTracks.length }} + +
+ Components + standard tracks +
+
+ +
+ + {{ members.length }} + +
+ Members + resolved objects +
+
+ +
+ + {{ quarantineObjects.length }} + +
+ Quarantined + resolution conflicts +
+
+
+
+
+ +
+
+
+

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) }}

+

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

+
+ +
+
+
+
+ +
+
+
+

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

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

{{ getVirtualObjectTitle(item) }}

+

{{ getVirtualObjectSubtitle(item) }}

+

+ Source: + {{ item.source_track_name || item.source_track_id }} + + ({{ getVirtualSourceVersion(item) }}) + +

+

+ {{ item.conflict_reason }} +

+
+ +
+
+
+
+
+
+
+
+ + + + + +
+ + + +
+
+
+ + +
+
+ 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..fdb53df1d --- /dev/null +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.spec.ts @@ -0,0 +1,2350 @@ +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' + ); + expect( + component.getVirtualSourceVersion({ source_snapshot_version: '1.2' }) + ).toBe('v1.2'); + expect( + component.getVirtualSourceVersion({ source_snapshot_version: 'v2.0' }) + ).toBe('v2.0'); + }); + + 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..8b1c86423 --- /dev/null +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts @@ -0,0 +1,3300 @@ +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?.object_ref || ''; + } + + public getVirtualSourceVersion(item: any): string { + const version = String(item?.source_snapshot_version || ''); + if (!version) return ''; + return version.startsWith('v') ? version : `v${version}`; + } + + 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/teams/teams-list-page/teams-list-page.component.spec.ts b/src/app/views/dashboard-page/teams/teams-list-page/teams-list-page.component.spec.ts index ff9001e45..55132134d 100644 --- a/src/app/views/dashboard-page/teams/teams-list-page/teams-list-page.component.spec.ts +++ b/src/app/views/dashboard-page/teams/teams-list-page/teams-list-page.component.spec.ts @@ -1,21 +1,48 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideRouter } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { of } from 'rxjs'; import { TeamsListPageComponent } from './teams-list-page.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { + createMockRestApiConnector, + createAsyncObservable, + createPaginatedResponse, +} from 'src/app/testing/mocks/rest-api-connector.mock'; describe('TeamsListPageComponent', () => { let component: TeamsListPageComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({ + getAllTeams: () => createAsyncObservable(createPaginatedResponse()), + }); + await TestBed.configureTestingModule({ declarations: [TeamsListPageComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + provideHttpClient(), + provideRouter([]), + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + { + provide: ActivatedRoute, + useValue: { + params: of({}), + queryParams: of({}), + }, + }, + ], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(TeamsListPageComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create', () => { diff --git a/src/app/views/dashboard-page/teams/teams-view-page/teams-view-page.component.spec.ts b/src/app/views/dashboard-page/teams/teams-view-page/teams-view-page.component.spec.ts index eaa0fcac6..8aa77bda8 100644 --- a/src/app/views/dashboard-page/teams/teams-view-page/teams-view-page.component.spec.ts +++ b/src/app/views/dashboard-page/teams/teams-view-page/teams-view-page.component.spec.ts @@ -1,20 +1,51 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideRouter } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { of } from 'rxjs'; + import { TeamsViewPageComponent } from './teams-view-page.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { + createMockRestApiConnector, + createAsyncObservable, + createPaginatedResponse, +} from 'src/app/testing/mocks/rest-api-connector.mock'; describe('TeamsViewPageComponent', () => { let component: TeamsViewPageComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({ + getTeamById: () => + createAsyncObservable({ id: 'test', name: 'Test Team' }), + getAllUserAccounts: () => + createAsyncObservable(createPaginatedResponse()), + }); + await TestBed.configureTestingModule({ declarations: [TeamsViewPageComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + provideHttpClient(), + provideRouter([]), + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + { + provide: ActivatedRoute, + useValue: { + params: of({}), + queryParams: of({}), + }, + }, + ], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(TeamsViewPageComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create', () => { diff --git a/src/app/views/dashboard-page/user-accounts-page/user-accounts-page.component.spec.ts b/src/app/views/dashboard-page/user-accounts-page/user-accounts-page.component.spec.ts index d449146f4..149b8e93a 100644 --- a/src/app/views/dashboard-page/user-accounts-page/user-accounts-page.component.spec.ts +++ b/src/app/views/dashboard-page/user-accounts-page/user-accounts-page.component.spec.ts @@ -1,14 +1,33 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; import { UserAccountsPageComponent } from './user-accounts-page.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { + createMockRestApiConnector, + createAsyncObservable, + createPaginatedResponse, +} from 'src/app/testing/mocks/rest-api-connector.mock'; describe('UserAccountsPageComponent', () => { let component: UserAccountsPageComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({ + getAllUserAccounts: () => + createAsyncObservable(createPaginatedResponse()), + getAllTeams: () => createAsyncObservable(createPaginatedResponse()), + }); + await TestBed.configureTestingModule({ declarations: [UserAccountsPageComponent], + providers: [ + provideHttpClient(), + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + ], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); 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/help-page/help-page.component.spec.ts b/src/app/views/help-page/help-page.component.spec.ts index 36b4997d3..83ffe986b 100644 --- a/src/app/views/help-page/help-page.component.spec.ts +++ b/src/app/views/help-page/help-page.component.spec.ts @@ -1,4 +1,9 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { MarkdownModule, MarkdownService } from 'ngx-markdown'; +import { ActivatedRoute } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; +import { of } from 'rxjs'; import { HelpPageComponent } from './help-page.component'; @@ -9,6 +14,23 @@ describe('HelpPageComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ declarations: [HelpPageComponent], + imports: [MarkdownModule.forRoot()], + providers: [ + provideHttpClient(), + MarkdownService, + { + provide: ActivatedRoute, + useValue: { + params: of({}), + queryParams: of({}), + data: of({}), + snapshot: { + data: { markdown: '' }, + }, + }, + }, + ], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); 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.spec.ts b/src/app/views/landing-page/landing-page.component.spec.ts index fbca60c46..81413205e 100644 --- a/src/app/views/landing-page/landing-page.component.spec.ts +++ b/src/app/views/landing-page/landing-page.component.spec.ts @@ -1,4 +1,7 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideRouter } from '@angular/router'; import { LandingPageComponent } from './landing-page.component'; @@ -9,6 +12,8 @@ describe('LandingPageComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ declarations: [LandingPageComponent], + providers: [provideHttpClient(), provideRouter([])], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); diff --git a/src/app/views/landing-page/landing-page.component.ts b/src/app/views/landing-page/landing-page.component.ts index c588dc8da..04d6c7072 100644 --- a/src/app/views/landing-page/landing-page.component.ts +++ b/src/app/views/landing-page/landing-page.component.ts @@ -16,8 +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 placeholderIdentityReminderKey = + 'attack-workbench.placeholder-organization-identity-reminder-dismissed'; private loginSubscription: Subscription; - public pendingUsers; public routes: any[] = []; constructor( @@ -27,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 ); } @@ -45,41 +48,26 @@ 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') { + return; + } + if (this.authenticationService.isAuthorized([Role.ADMIN])) { const subscription = this.restApiConnector .getOrganizationIdentity() .subscribe({ next: identity => { - if ( - identity && - identity.name == 'Placeholder Organization Identity' - ) { + if (identity && identity.name == this.placeholderIdentityName) { const prompt = this.dialog.open(ConfirmationDialogComponent, { maxWidth: '35em', data: { @@ -87,13 +75,23 @@ export class LandingPageComponent implements OnInit, OnDestroy { '### Your organization identity has not yet been set.\n\nYour organization identity is used for attribution of edits you make to objects in the knowledge base and is attached to published collections. Currently, a placeholder is being used.\n\nUpdate your organization identity now?', yes_suffix: 'edit my identity now', no_suffix: 'edit my identity later', + alternate_label: 'No, and stop reminding me', + alternate_value: 'dismiss', }, autoFocus: false, // prevents auto focus on buttons }); const prompt_subscription = prompt.afterClosed().subscribe({ next: prompt_result => { - if (prompt_result) - this.router.navigate(['/dashboard/org-settings']); + if (prompt_result === true) { + this.router.navigate(['/identity', identity.stixID], { + queryParams: { editing: true }, + }); + } else if (prompt_result === 'dismiss') { + localStorage.setItem( + this.placeholderIdentityReminderKey, + 'true' + ); + } }, complete: () => { prompt_subscription.unsubscribe(); diff --git a/src/app/views/notes-page/notes-page.component.spec.ts b/src/app/views/notes-page/notes-page.component.spec.ts index 84f1c061a..f4b4599a0 100644 --- a/src/app/views/notes-page/notes-page.component.spec.ts +++ b/src/app/views/notes-page/notes-page.component.spec.ts @@ -1,4 +1,6 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MtxPopoverModule } from '@ng-matero/extensions/popover'; import { NotesPageComponent } from './notes-page.component'; @@ -9,6 +11,8 @@ describe('NotesPageComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [NotesPageComponent], + imports: [MtxPopoverModule], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); 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 24da7917a..dd16e8025 100644 --- a/src/app/views/profile-page/profile-page.component.spec.ts +++ b/src/app/views/profile-page/profile-page.component.spec.ts @@ -1,14 +1,61 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { AuthenticationService } from 'src/app/services/connectors/authentication/authentication.service'; +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; let fixture: ComponentFixture; beforeEach(async () => { + const mockAuthService = createMockAuthenticationService({ + currentUser: createMockUserAccount({ + id: 'mock-user-id', + username: 'testuser', + email: 'test@example.com', + }), + canEdit: () => false, + }); + const mockRestApiConnector = createMockRestApiConnector({ + getTeamsByUserId: () => createAsyncObservable([]), + }); + await TestBed.configureTestingModule({ declarations: [ProfilePageComponent], + imports: [UserAvatarComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + provideHttpClient(), + { + provide: ActivatedRoute, + useValue: { + params: of({}), + queryParams: of({}), + }, + }, + { + provide: AuthenticationService, + useValue: mockAuthService, + }, + { + provide: RestApiConnectorService, + useValue: mockRestApiConnector, + }, + ], }).compileComponents(); }); diff --git a/src/app/views/reference-manager/reference-manager.component.spec.ts b/src/app/views/reference-manager/reference-manager.component.spec.ts index dc0895538..33bc9fbc6 100644 --- a/src/app/views/reference-manager/reference-manager.component.spec.ts +++ b/src/app/views/reference-manager/reference-manager.component.spec.ts @@ -1,14 +1,32 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; import { ReferenceManagerComponent } from './reference-manager.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { + createMockRestApiConnector, + createAsyncObservable, + createPaginatedResponse, +} from 'src/app/testing/mocks/rest-api-connector.mock'; describe('ReferenceManagerComponent', () => { let component: ReferenceManagerComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({ + getAllObjects: () => createAsyncObservable(createPaginatedResponse()), + getAllReferences: () => createAsyncObservable(createPaginatedResponse()), + }); + await TestBed.configureTestingModule({ declarations: [ReferenceManagerComponent], + providers: [ + provideHttpClient(), + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + ], + schemas: [NO_ERRORS_SCHEMA], }).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 549ae115b..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,129 +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/analytic-view/analytic-view.component.spec.ts b/src/app/views/stix/analytic-view/analytic-view.component.spec.ts index 8b09ab397..8a0ded251 100644 --- a/src/app/views/stix/analytic-view/analytic-view.component.spec.ts +++ b/src/app/views/stix/analytic-view/analytic-view.component.spec.ts @@ -1,19 +1,36 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { AnalyticViewComponent } from './analytic-view.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { + createMockRestApiConnector, + createAsyncObservable, +} from 'src/app/testing/mocks/rest-api-connector.mock'; describe('AnalyticViewComponent', () => { let component: AnalyticViewComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({ + getDefaultMarkingDefinitions: () => createAsyncObservable([]), + getNextAttackId: () => createAsyncObservable('ANA-0001'), + }); + await TestBed.configureTestingModule({ declarations: [AnalyticViewComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + provideHttpClient(), + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + ], }).compileComponents(); fixture = TestBed.createComponent(AnalyticViewComponent); component = fixture.componentInstance; - fixture.detectChanges(); + component.config = { mode: 'view', object: {} as any }; }); it('should create', () => { diff --git a/src/app/views/stix/analytic-view/analytic-view.component.ts b/src/app/views/stix/analytic-view/analytic-view.component.ts index 3f01916b7..a505b6eee 100644 --- a/src/app/views/stix/analytic-view/analytic-view.component.ts +++ b/src/app/views/stix/analytic-view/analytic-view.component.ts @@ -28,27 +28,6 @@ export class AnalyticViewComponent extends StixViewPage implements OnInit { ngOnInit(): void { if (this.analytic.firstInitialized) { this.analytic.setDefaultMarkingDefinitions(this.apiService); - this.generateAttackId(); - } - } - - private generateAttackId(): void { - const sub = this.analytic.generateAttackId(this.apiService).subscribe({ - next: val => { - this.analytic.attackID = val; - this.setNameFromAttackId(); - }, - complete: () => sub.unsubscribe(), - }); - } - - public setNameFromAttackId(): void { - if (this.analytic.attackID) { - const regex = /\d+$/; - const match = regex.exec(this.analytic.attackID); - if (match) { - this.analytic.name = `Analytic ${match[0]}`; - } } } } 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/asset-view/asset-view.component.spec.ts b/src/app/views/stix/asset-view/asset-view.component.spec.ts index 735cc3384..bc2a2cd8a 100644 --- a/src/app/views/stix/asset-view/asset-view.component.spec.ts +++ b/src/app/views/stix/asset-view/asset-view.component.spec.ts @@ -1,21 +1,37 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { AssetViewComponent } from './asset-view.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { + createMockRestApiConnector, + createAsyncObservable, +} from 'src/app/testing/mocks/rest-api-connector.mock'; describe('AssetViewComponent', () => { let component: AssetViewComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({ + getDefaultMarkingDefinitions: () => createAsyncObservable([]), + }); + await TestBed.configureTestingModule({ declarations: [AssetViewComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + provideHttpClient(), + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + ], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(AssetViewComponent); component = fixture.componentInstance; - fixture.detectChanges(); + component.config = { mode: 'view', object: {} as any }; }); it('should create', () => { 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 0877f923e..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,131 +7,146 @@ mode: config.mode, object: config.object, }"> - +
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - + + + + + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
-
- -
- + + +

Groups

@@ -155,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

@@ -188,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

@@ -221,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/campaign-view/campaign-view.component.spec.ts b/src/app/views/stix/campaign-view/campaign-view.component.spec.ts index 8dbf8f66d..8f3003893 100644 --- a/src/app/views/stix/campaign-view/campaign-view.component.spec.ts +++ b/src/app/views/stix/campaign-view/campaign-view.component.spec.ts @@ -1,21 +1,37 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { CampaignViewComponent } from './campaign-view.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { + createMockRestApiConnector, + createAsyncObservable, +} from 'src/app/testing/mocks/rest-api-connector.mock'; describe('CampaignViewComponent', () => { let component: CampaignViewComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({ + getDefaultMarkingDefinitions: () => createAsyncObservable([]), + }); + await TestBed.configureTestingModule({ declarations: [CampaignViewComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + provideHttpClient(), + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + ], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(CampaignViewComponent); component = fixture.componentInstance; - fixture.detectChanges(); + component.config = { mode: 'view', object: {} as any }; }); it('should create', () => { 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-import/collection-import-error/collection-import-error.component.spec.ts b/src/app/views/stix/collection/collection-import/collection-import-error/collection-import-error.component.spec.ts index 4ddaf87b7..32521beac 100644 --- a/src/app/views/stix/collection/collection-import/collection-import-error/collection-import-error.component.spec.ts +++ b/src/app/views/stix/collection/collection-import/collection-import-error/collection-import-error.component.spec.ts @@ -1,3 +1,4 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { CollectionImportErrorComponent } from './collection-import-error.component'; @@ -9,12 +10,27 @@ describe('CollectionImportErrorComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [CollectionImportErrorComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(CollectionImportErrorComponent); component = fixture.componentInstance; + component.error = { + bundleErrors: { + duplicateCollection: false, + noCollection: false, + moreThanOneCollection: false, + badlyFormattedCollection: false, + }, + objectErrors: { + summary: { + invalidAttackSpecVersionCount: 0, + duplicateObjectInBundleCount: 0, + }, + }, + }; fixture.detectChanges(); }); diff --git a/src/app/views/stix/collection/collection-import/collection-import-review/collection-import-review.component.spec.ts b/src/app/views/stix/collection/collection-import/collection-import-review/collection-import-review.component.spec.ts index 849455bf1..771d6d8b7 100644 --- a/src/app/views/stix/collection/collection-import/collection-import-review/collection-import-review.component.spec.ts +++ b/src/app/views/stix/collection/collection-import/collection-import-review/collection-import-review.component.spec.ts @@ -1,21 +1,50 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideRouter } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { of } from 'rxjs'; import { CollectionImportReviewComponent } from './collection-import-review.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { + createMockRestApiConnector, + createAsyncObservable, + createPaginatedResponse, +} from 'src/app/testing/mocks/rest-api-connector.mock'; describe('CollectionImportReviewComponent', () => { let component: CollectionImportReviewComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({ + getAllCollections: () => createAsyncObservable(createPaginatedResponse()), + getAllMarkingDefinitions: () => + createAsyncObservable(createPaginatedResponse()), + }); + await TestBed.configureTestingModule({ declarations: [CollectionImportReviewComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + provideHttpClient(), + provideRouter([]), + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + { + provide: ActivatedRoute, + useValue: { + params: of({}), + queryParams: of({}), + }, + }, + ], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(CollectionImportReviewComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create', () => { diff --git a/src/app/views/stix/collection/collection-import/collection-import-workflow/collection-import.component.html b/src/app/views/stix/collection/collection-import/collection-import-workflow/collection-import.component.html index db8f3aab1..9e29dbfbd 100644 --- a/src/app/views/stix/collection/collection-import/collection-import-workflow/collection-import.component.html +++ b/src/app/views/stix/collection/collection-import/collection-import-workflow/collection-import.component.html @@ -112,7 +112,7 @@ + [phaseProgress]="phaseProgress"> diff --git a/src/app/views/stix/collection/collection-import/collection-import-workflow/collection-import.component.spec.ts b/src/app/views/stix/collection/collection-import/collection-import-workflow/collection-import.component.spec.ts index 38189342f..e3472f06b 100644 --- a/src/app/views/stix/collection/collection-import/collection-import-workflow/collection-import.component.spec.ts +++ b/src/app/views/stix/collection/collection-import/collection-import-workflow/collection-import.component.spec.ts @@ -1,21 +1,50 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideRouter } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { of } from 'rxjs'; import { CollectionImportComponent } from './collection-import.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { + createMockRestApiConnector, + createAsyncObservable, + createPaginatedResponse, +} from 'src/app/testing/mocks/rest-api-connector.mock'; describe('CollectionImportComponent', () => { let component: CollectionImportComponent; let fixture: ComponentFixture; beforeEach(waitForAsync(() => { + const mockRestApiConnector = createMockRestApiConnector({ + getAllCollections: () => createAsyncObservable(createPaginatedResponse()), + getAllMarkingDefinitions: () => + createAsyncObservable(createPaginatedResponse()), + }); + TestBed.configureTestingModule({ declarations: [CollectionImportComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + provideHttpClient(), + provideRouter([]), + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + { + provide: ActivatedRoute, + useValue: { + params: of({}), + queryParams: of({}), + }, + }, + ], }).compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(CollectionImportComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create', () => { diff --git a/src/app/views/stix/collection/collection-import/collection-import-workflow/collection-import.component.ts b/src/app/views/stix/collection/collection-import/collection-import-workflow/collection-import.component.ts index b4a16223f..5d66923b1 100644 --- a/src/app/views/stix/collection/collection-import/collection-import-workflow/collection-import.component.ts +++ b/src/app/views/stix/collection/collection-import/collection-import-workflow/collection-import.component.ts @@ -36,6 +36,7 @@ import { AuthenticationService } from 'src/app/services/connectors/authenticatio import { UserAccount } from 'src/app/classes/authn/user-account'; import { logger } from '../../../../../utils/logger'; import { v4 as uuid } from 'uuid'; +import { PhaseProgress } from 'src/app/components/loading-overlay/loading-overlay.component'; import type * as XLSXNS from 'xlsx'; @Component({ @@ -52,6 +53,8 @@ export class CollectionImportComponent implements OnInit { public url = ''; public loadingStep1 = false; public loadingStep2 = false; + public importProgress = 0; // 0-100 (legacy) + public phaseProgress: PhaseProgress[] = []; // Multi-phase progress public select: SelectionModel; // ids of objects which have changed (object-version not already in knowledge base) public changed_ids: string[] = []; @@ -822,27 +825,106 @@ export class CollectionImportComponent implements OnInit { } newBundle.objects = objects; const force = this.import_errors ? true : false; // force import if the collection bundle has errors + + // Use streaming import for progress updates + this.importProgress = 0; + // Initialize multi-phase progress tracking + this.phaseProgress = [ + { + phase: 'processing', + label: 'Processing Objects', + progress: 0, + active: false, + }, + { + phase: 'references', + label: 'Importing References', + progress: 0, + active: false, + }, + { + phase: 'saving', + label: 'Saving Collection', + progress: 0, + active: false, + }, + ]; + logger.log('Starting streaming import...'); const subscription = this.restAPIConnectorService - .postCollectionBundle(newBundle, false, force) + .streamCollectionBundleImport(newBundle, force) .subscribe({ - next: results => { - if (results.import_categories.errors.length > 0) { - logger.warn( - 'Collection import completed with errors:', - results.import_categories.errors + next: event => { + logger.log('SSE Event received:', event.type, event.data); + if (event.type === 'progress') { + // Update phase progress + const phase = event.data.phase; + const phasePercentage = event.data.phasePercentage || 0; + + // Find and update the appropriate phase + const phaseIndex = this.phaseProgress.findIndex( + p => p.phase === phase ); + if (phaseIndex !== -1) { + // Mark all previous phases as complete + for (let i = 0; i < phaseIndex; i++) { + this.phaseProgress[i].progress = 100; + this.phaseProgress[i].active = false; + } + // Update current phase + this.phaseProgress[phaseIndex].progress = phasePercentage; + this.phaseProgress[phaseIndex].active = true; + // Keep future phases at 0 + for ( + let i = phaseIndex + 1; + i < this.phaseProgress.length; + i++ + ) { + this.phaseProgress[i].progress = 0; + this.phaseProgress[i].active = false; + } + } + + logger.log( + 'Phase progress updated:', + phase, + phasePercentage + ); + } else if (event.type === 'complete') { + // Import complete - mark all phases as 100% + this.phaseProgress.forEach(p => { + p.progress = 100; + p.active = false; + }); + logger.log( + 'Import complete, waiting 1 second before transitioning to done' + ); + // Wait 1 second to show all progress bars at 100% before transitioning + setTimeout(() => { + this.handleImportSuccess(new Collection(event.data)); + }, 1000); } - this.save_errors = results.import_categories.errors; - const save_error_ids = new Set( - this.save_errors.map(err => err['object_ref']) - ); - for (const category in results.import_categories) { - if (category == 'errors') continue; - for (const id of results.import_categories[category]) - if (!save_error_ids.has(id)) - this.successfully_saved.add(id); + }, + error: error => { + // Check if it's a timeout error (504 Gateway Timeout or network timeout) + if (error.status === 504 || error.status === 0) { + logger.warn( + 'Import request timed out, but import may still be processing. Starting polling...' + ); + // Import is likely still running - poll for completion + this.pollForImportCompletion( + newBundle.objects[0]?.id || newBundle.collection?.id + ); + } else { + // Real error - show it and stop loading + this.loadingStep2 = false; + this.importProgress = 0; + logger.error('Import failed with error:', error); + this.snackbar.open( + `Import failed: ${error.error?.message || error.message || 'Unknown error'}`, + 'Dismiss', + { duration: 10000 } + ); } - this.stepper.next(); }, complete: () => { subscription.unsubscribe(); @@ -857,6 +939,119 @@ export class CollectionImportComponent implements OnInit { }); } + /** + * Handle successful import results + */ + private handleImportSuccess(results: Collection): void { + if (results.import_categories.errors.length > 0) { + logger.warn( + 'Collection import completed with errors:', + results.import_categories.errors + ); + } + this.save_errors = results.import_categories.errors; + const save_error_ids = new Set( + this.save_errors.map(err => err['object_ref']) + ); + for (const category in results.import_categories) { + if (category == 'errors') continue; + for (const id of results.import_categories[category]) + if (!save_error_ids.has(id)) this.successfully_saved.add(id); + } + this.loadingStep2 = false; + this.stepper.next(); + } + + /** + * Poll for import completion when the initial request times out + */ + private pollForImportCompletion(_expectedCollectionId: string): void { + // Show informative message to user + this.snackbar.open( + 'Large import detected. Processing may take several minutes. Checking for completion...', + 'Dismiss', + { duration: 8000 } + ); + + // Poll every 5 seconds for up to 15 minutes + const maxAttempts = 180; // 15 minutes (180 * 5 seconds) + let attempts = 0; + + const pollInterval = setInterval(() => { + attempts++; + + // Get all collections and find the one that was just imported + this.restAPIConnectorService.getAllCollections().subscribe({ + next: result => { + // Look for a recently modified collection that matches our import + // Since we don't have the exact collection ID before import, we look for + // the most recently modified collection + if (result && result.data && result.data.length > 0) { + // Cast to Collection array since getAllCollections returns collections + const collections = result.data as Collection[]; + + // Sort by modified date descending + const sortedCollections = [...collections].sort( + (a: Collection, b: Collection) => { + const dateA = new Date(a.modified).getTime(); + const dateB = new Date(b.modified).getTime(); + return dateB - dateA; + } + ); + + // Check if the most recent collection was modified within the last few minutes + const mostRecent = sortedCollections[0]; + const modifiedTime = new Date(mostRecent.modified).getTime(); + const now = Date.now(); + const timeDiff = now - modifiedTime; + + // If modified within last 5 minutes, assume it's our import + if (timeDiff < 300000) { + // 5 minutes in milliseconds + clearInterval(pollInterval); + logger.log( + 'Import detected as complete. Collection:', + mostRecent + ); + + // The collection from getAllCollections includes import_categories + // so we can use it directly + this.snackbar.open('Import completed successfully!', 'Dismiss', { + duration: 5000, + }); + this.handleImportSuccess(mostRecent); + } + } + + // Check if we've exceeded max attempts + if (attempts >= maxAttempts) { + clearInterval(pollInterval); + this.loadingStep2 = false; + this.snackbar.open( + 'Import status check timed out after 15 minutes. The import may still be processing. Please check the collections page.', + 'Dismiss', + { duration: 15000 } + ); + } + }, + error: () => { + // Only log errors, don't stop polling unless max attempts reached + logger.warn('Error polling for import completion'); + + if (attempts >= maxAttempts) { + clearInterval(pollInterval); + this.loadingStep2 = false; + this.snackbar.open( + 'Unable to verify import completion. Please check the collections page.', + 'Dismiss', + { duration: 10000 } + ); + } + }, + }); + }, 5000); // Poll every 5 seconds + } + /** * Cancel the collection import and revert to previous step */ diff --git a/src/app/views/stix/collection/collection-index/collection-index-import/collection-index-import.component.spec.ts b/src/app/views/stix/collection/collection-index/collection-index-import/collection-index-import.component.spec.ts index 63ef6e62f..117c24b31 100644 --- a/src/app/views/stix/collection/collection-index/collection-index-import/collection-index-import.component.spec.ts +++ b/src/app/views/stix/collection/collection-index/collection-index-import/collection-index-import.component.spec.ts @@ -1,21 +1,50 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideRouter } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { of } from 'rxjs'; import { CollectionIndexImportComponent } from './collection-index-import.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { + createMockRestApiConnector, + createAsyncObservable, + createPaginatedResponse, +} from 'src/app/testing/mocks/rest-api-connector.mock'; describe('CollectionIndexImportComponent', () => { let component: CollectionIndexImportComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({ + getAllCollectionIndexes: () => + createAsyncObservable(createPaginatedResponse()), + getAllCollections: () => createAsyncObservable(createPaginatedResponse()), + }); + await TestBed.configureTestingModule({ declarations: [CollectionIndexImportComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + provideHttpClient(), + provideRouter([]), + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + { + provide: ActivatedRoute, + useValue: { + params: of({}), + queryParams: of({}), + }, + }, + ], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(CollectionIndexImportComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create', () => { diff --git a/src/app/views/stix/collection/collection-index/collection-index-list/collection-index-list.component.spec.ts b/src/app/views/stix/collection/collection-index/collection-index-list/collection-index-list.component.spec.ts index 3b0ced4a7..ac73b399e 100644 --- a/src/app/views/stix/collection/collection-index/collection-index-list/collection-index-list.component.spec.ts +++ b/src/app/views/stix/collection/collection-index/collection-index-list/collection-index-list.component.spec.ts @@ -1,4 +1,9 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideRouter } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { of } from 'rxjs'; import { CollectionIndexListComponent } from './collection-index-list.component'; @@ -9,13 +14,24 @@ describe('CollectionIndexListComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [CollectionIndexListComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + provideHttpClient(), + provideRouter([]), + { + provide: ActivatedRoute, + useValue: { + params: of({}), + queryParams: of({}), + }, + }, + ], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(CollectionIndexListComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create', () => { diff --git a/src/app/views/stix/collection/collection-index/collection-index-view/collection-index-view.component.spec.ts b/src/app/views/stix/collection/collection-index/collection-index-view/collection-index-view.component.spec.ts index ff41356c7..4c112eb7a 100644 --- a/src/app/views/stix/collection/collection-index/collection-index-view/collection-index-view.component.spec.ts +++ b/src/app/views/stix/collection/collection-index/collection-index-view/collection-index-view.component.spec.ts @@ -1,21 +1,43 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideRouter } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { of } from 'rxjs'; import { CollectionIndexViewComponent } from './collection-index-view.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { createMockRestApiConnector } from 'src/app/testing/mocks/rest-api-connector.mock'; describe('CollectionIndexViewComponent', () => { let component: CollectionIndexViewComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({}); + await TestBed.configureTestingModule({ declarations: [CollectionIndexViewComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + provideHttpClient(), + provideRouter([]), + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + { + provide: ActivatedRoute, + useValue: { + params: of({}), + queryParams: of({}), + }, + }, + ], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(CollectionIndexViewComponent); component = fixture.componentInstance; - fixture.detectChanges(); + component.config = { mode: 'view', object: {} as any } as any; }); it('should create', () => { diff --git a/src/app/views/stix/collection/collection-list/collection-list.component.spec.ts b/src/app/views/stix/collection/collection-list/collection-list.component.spec.ts index 16cdb37a3..da7ada611 100644 --- a/src/app/views/stix/collection/collection-list/collection-list.component.spec.ts +++ b/src/app/views/stix/collection/collection-list/collection-list.component.spec.ts @@ -1,3 +1,4 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { CollectionListComponent } from './collection-list.component'; @@ -9,13 +10,18 @@ describe('CollectionListComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ declarations: [CollectionListComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(CollectionListComponent); component = fixture.componentInstance; - fixture.detectChanges(); + // Set required config input + component.config = { + mode: 'created', + collections: [], + }; }); it('should create', () => { 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/collection/collection-view/collection-view.component.spec.ts b/src/app/views/stix/collection/collection-view/collection-view.component.spec.ts index 06209109e..b70bf325f 100644 --- a/src/app/views/stix/collection/collection-view/collection-view.component.spec.ts +++ b/src/app/views/stix/collection/collection-view/collection-view.component.spec.ts @@ -1,21 +1,44 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideRouter } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { of } from 'rxjs'; import { CollectionViewComponent } from './collection-view.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { createMockRestApiConnector } from 'src/app/testing/mocks/rest-api-connector.mock'; describe('CollectionViewComponent', () => { let component: CollectionViewComponent; let fixture: ComponentFixture; beforeEach(waitForAsync(() => { + const mockRestApiConnector = createMockRestApiConnector({}); + TestBed.configureTestingModule({ declarations: [CollectionViewComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + provideHttpClient(), + provideRouter([]), + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + { + provide: ActivatedRoute, + useValue: { + params: of({}), + queryParams: of({}), + snapshot: { data: {}, queryParams: {} }, + }, + }, + ], }).compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(CollectionViewComponent); component = fixture.componentInstance; - fixture.detectChanges(); + component.config = { mode: 'view', object: {} as any }; }); it('should create', () => { 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 d24907f13..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-component-view/data-component-view.component.spec.ts b/src/app/views/stix/data-component-view/data-component-view.component.spec.ts index 88f2c431c..c600159ff 100644 --- a/src/app/views/stix/data-component-view/data-component-view.component.spec.ts +++ b/src/app/views/stix/data-component-view/data-component-view.component.spec.ts @@ -1,21 +1,37 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { DataComponentViewComponent } from './data-component-view.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { + createMockRestApiConnector, + createAsyncObservable, +} from 'src/app/testing/mocks/rest-api-connector.mock'; describe('DataComponentViewComponent', () => { let component: DataComponentViewComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({ + getDefaultMarkingDefinitions: () => createAsyncObservable([]), + }); + await TestBed.configureTestingModule({ declarations: [DataComponentViewComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + provideHttpClient(), + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + ], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(DataComponentViewComponent); component = fixture.componentInstance; - fixture.detectChanges(); + component.config = { mode: 'view', object: {} as any }; }); it('should create', () => { 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/data-source-view/data-source-view.component.spec.ts b/src/app/views/stix/data-source-view/data-source-view.component.spec.ts index a11ff8d44..0f8b7aeb0 100644 --- a/src/app/views/stix/data-source-view/data-source-view.component.spec.ts +++ b/src/app/views/stix/data-source-view/data-source-view.component.spec.ts @@ -1,20 +1,39 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { DataSourceViewComponent } from './data-source-view.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { + createMockRestApiConnector, + createAsyncObservable, + createPaginatedResponse, +} from 'src/app/testing/mocks/rest-api-connector.mock'; describe('DataSourceViewComponent', () => { let component: DataSourceViewComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({ + getAllDataComponents: () => + createAsyncObservable(createPaginatedResponse()), + }); + await TestBed.configureTestingModule({ declarations: [DataSourceViewComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + provideHttpClient(), + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + ], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(DataSourceViewComponent); component = fixture.componentInstance; + component.config = { mode: 'view', object: {} as any }; fixture.detectChanges(); }); 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/detection-strategy-view/detection-strategy-view.component.spec.ts b/src/app/views/stix/detection-strategy-view/detection-strategy-view.component.spec.ts index a871514bc..18833e3b0 100644 --- a/src/app/views/stix/detection-strategy-view/detection-strategy-view.component.spec.ts +++ b/src/app/views/stix/detection-strategy-view/detection-strategy-view.component.spec.ts @@ -1,19 +1,37 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { DetectionStrategyViewComponent } from './detection-strategy-view.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { + createMockRestApiConnector, + createAsyncObservable, + createPaginatedResponse, +} from 'src/app/testing/mocks/rest-api-connector.mock'; describe('DetectionStrategyViewComponent', () => { let component: DetectionStrategyViewComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({ + getDefaultMarkingDefinitions: () => createAsyncObservable([]), + getAllAnalytics: () => createAsyncObservable(createPaginatedResponse()), + }); + await TestBed.configureTestingModule({ declarations: [DetectionStrategyViewComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + provideHttpClient(), + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + ], }).compileComponents(); fixture = TestBed.createComponent(DetectionStrategyViewComponent); component = fixture.componentInstance; - fixture.detectChanges(); + component.config = { mode: 'view', object: {} as any }; }); it('should create', () => { 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/group-view/group-view.component.spec.ts b/src/app/views/stix/group-view/group-view.component.spec.ts index e38410583..f40077ff6 100644 --- a/src/app/views/stix/group-view/group-view.component.spec.ts +++ b/src/app/views/stix/group-view/group-view.component.spec.ts @@ -1,21 +1,37 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { GroupViewComponent } from './group-view.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { + createMockRestApiConnector, + createAsyncObservable, +} from 'src/app/testing/mocks/rest-api-connector.mock'; describe('GroupViewComponent', () => { let component: GroupViewComponent; let fixture: ComponentFixture; beforeEach(waitForAsync(() => { + const mockRestApiConnector = createMockRestApiConnector({ + getDefaultMarkingDefinitions: () => createAsyncObservable([]), + }); + TestBed.configureTestingModule({ declarations: [GroupViewComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + provideHttpClient(), + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + ], }).compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(GroupViewComponent); component = fixture.componentInstance; - fixture.detectChanges(); + component.config = { mode: 'view', object: {} as any }; }); it('should create', () => { diff --git a/src/app/views/stix/identity-view/identity-view.component.html b/src/app/views/stix/identity-view/identity-view.component.html new file mode 100644 index 000000000..e1c5f26be --- /dev/null +++ b/src/app/views/stix/identity-view/identity-view.component.html @@ -0,0 +1,95 @@ +
+
+
+ + +
+
+
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+

References

+
+
+
+
+ +
+
+
+
diff --git a/src/app/views/stix/identity-view/identity-view.component.ts b/src/app/views/stix/identity-view/identity-view.component.ts new file mode 100644 index 000000000..f27c732d1 --- /dev/null +++ b/src/app/views/stix/identity-view/identity-view.component.ts @@ -0,0 +1,32 @@ +import { Component, OnInit } from '@angular/core'; +import { Identity } from 'src/app/classes/stix'; +import { AuthenticationService } from 'src/app/services/connectors/authentication/authentication.service'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { StixViewPage } from '../stix-view-page'; + +@Component({ + selector: 'app-identity-view', + templateUrl: './identity-view.component.html', + standalone: false, +}) +export class IdentityViewComponent extends StixViewPage implements OnInit { + public get identity(): Identity { + return this.configCurrentObject as Identity; + } + public get previous(): Identity { + return this.configPreviousObject as Identity; + } + + constructor( + authenticationService: AuthenticationService, + private restApiConnector: RestApiConnectorService + ) { + super(authenticationService); + } + + ngOnInit(): void { + if (this.identity.firstInitialized) { + this.identity.setDefaultMarkingDefinitions(this.restApiConnector); + } + } +} diff --git a/src/app/views/stix/marking-definition-view/marking-definition-view.component.spec.ts b/src/app/views/stix/marking-definition-view/marking-definition-view.component.spec.ts index bf8e653fd..9cbfb44a7 100644 --- a/src/app/views/stix/marking-definition-view/marking-definition-view.component.spec.ts +++ b/src/app/views/stix/marking-definition-view/marking-definition-view.component.spec.ts @@ -1,20 +1,32 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { MarkingDefinitionViewComponent } from './marking-definition-view.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { createMockRestApiConnector } from 'src/app/testing/mocks/rest-api-connector.mock'; describe('MarkingDefinitionViewComponent', () => { let component: MarkingDefinitionViewComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({}); + await TestBed.configureTestingModule({ declarations: [MarkingDefinitionViewComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + provideHttpClient(), + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + ], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(MarkingDefinitionViewComponent); component = fixture.componentInstance; + component.config = { mode: 'view', object: {} as any }; fixture.detectChanges(); }); diff --git a/src/app/views/stix/matrix/matrix-flat/matrix-flat.component.spec.ts b/src/app/views/stix/matrix/matrix-flat/matrix-flat.component.spec.ts index fe71b729c..ab0b4f6cc 100644 --- a/src/app/views/stix/matrix/matrix-flat/matrix-flat.component.spec.ts +++ b/src/app/views/stix/matrix/matrix-flat/matrix-flat.component.spec.ts @@ -1,3 +1,4 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { MatrixFlatComponent } from './matrix-flat.component'; @@ -9,6 +10,7 @@ describe('MatrixFlatComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ declarations: [MatrixFlatComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); diff --git a/src/app/views/stix/matrix/matrix-side/matrix-side.component.spec.ts b/src/app/views/stix/matrix/matrix-side/matrix-side.component.spec.ts index 8a2c467fe..5b85d0307 100644 --- a/src/app/views/stix/matrix/matrix-side/matrix-side.component.spec.ts +++ b/src/app/views/stix/matrix/matrix-side/matrix-side.component.spec.ts @@ -1,3 +1,4 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { MatrixSideComponent } from './matrix-side.component'; @@ -8,6 +9,7 @@ describe('MatrixSideComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ declarations: [MatrixSideComponent], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); 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/matrix/matrix-view/matrix-view.component.spec.ts b/src/app/views/stix/matrix/matrix-view/matrix-view.component.spec.ts index 714499eb8..0b5326762 100644 --- a/src/app/views/stix/matrix/matrix-view/matrix-view.component.spec.ts +++ b/src/app/views/stix/matrix/matrix-view/matrix-view.component.spec.ts @@ -1,21 +1,52 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideRouter } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { of } from 'rxjs'; import { MatrixViewComponent } from './matrix-view.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { + createMockRestApiConnector, + createAsyncObservable, + createPaginatedResponse, +} from 'src/app/testing/mocks/rest-api-connector.mock'; describe('MatrixViewComponent', () => { let component: MatrixViewComponent; let fixture: ComponentFixture; beforeEach(waitForAsync(() => { + const mockRestApiConnector = createMockRestApiConnector({ + getDefaultMarkingDefinitions: () => createAsyncObservable([]), + getTechniquesInMatrix: () => + createAsyncObservable({ tactic_objects: [] }), + getAllTactics: () => createAsyncObservable(createPaginatedResponse()), + }); + TestBed.configureTestingModule({ declarations: [MatrixViewComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + provideHttpClient(), + provideRouter([]), + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + { + provide: ActivatedRoute, + useValue: { + params: of({}), + queryParams: of({}), + }, + }, + ], }).compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(MatrixViewComponent); component = fixture.componentInstance; - fixture.detectChanges(); + component.config = { mode: 'view', object: {} as any }; }); it('should create', () => { 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/mitigation-view/mitigation-view.component.spec.ts b/src/app/views/stix/mitigation-view/mitigation-view.component.spec.ts index 957b04839..eb9ab5d06 100644 --- a/src/app/views/stix/mitigation-view/mitigation-view.component.spec.ts +++ b/src/app/views/stix/mitigation-view/mitigation-view.component.spec.ts @@ -1,21 +1,37 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { MitigationViewComponent } from './mitigation-view.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { + createMockRestApiConnector, + createAsyncObservable, +} from 'src/app/testing/mocks/rest-api-connector.mock'; describe('MitigationViewComponent', () => { let component: MitigationViewComponent; let fixture: ComponentFixture; beforeEach(waitForAsync(() => { + const mockRestApiConnector = createMockRestApiConnector({ + getDefaultMarkingDefinitions: () => createAsyncObservable([]), + }); + TestBed.configureTestingModule({ declarations: [MitigationViewComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + provideHttpClient(), + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + ], }).compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(MitigationViewComponent); component = fixture.componentInstance; - fixture.detectChanges(); + component.config = { mode: 'view', object: {} as any }; }); it('should create', () => { 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.spec.ts b/src/app/views/stix/relationship-view/relationship-view.component.spec.ts index 94c06f5a9..a114a9c3d 100644 --- a/src/app/views/stix/relationship-view/relationship-view.component.spec.ts +++ b/src/app/views/stix/relationship-view/relationship-view.component.spec.ts @@ -1,21 +1,43 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideRouter } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { of } from 'rxjs'; import { RelationshipViewComponent } from './relationship-view.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { createMockRestApiConnector } from 'src/app/testing/mocks/rest-api-connector.mock'; describe('RelationshipViewComponent', () => { let component: RelationshipViewComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({}); + await TestBed.configureTestingModule({ declarations: [RelationshipViewComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + provideHttpClient(), + provideRouter([]), + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + { + provide: ActivatedRoute, + useValue: { + params: of({}), + queryParams: of({}), + }, + }, + ], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(RelationshipViewComponent); component = fixture.componentInstance; - fixture.detectChanges(); + component.config = { mode: 'view', object: {} as any }; }); it('should create', () => { 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/software-view/software-view.component.spec.ts b/src/app/views/stix/software-view/software-view.component.spec.ts index e1bedca8c..3a41fe5ab 100644 --- a/src/app/views/stix/software-view/software-view.component.spec.ts +++ b/src/app/views/stix/software-view/software-view.component.spec.ts @@ -1,20 +1,43 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; import { SoftwareViewComponent } from './software-view.component'; +import { Software } from 'src/app/classes/stix/software'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { + createMockRestApiConnector, + createAsyncObservable, +} from 'src/app/testing/mocks/rest-api-connector.mock'; describe('SoftwareViewComponent', () => { let component: SoftwareViewComponent; let fixture: ComponentFixture; beforeEach(waitForAsync(() => { + const mockRestApiConnector = createMockRestApiConnector({ + getDefaultMarkingDefinitions: () => createAsyncObservable([]), + }); + TestBed.configureTestingModule({ declarations: [SoftwareViewComponent], + providers: [ + provideHttpClient(), + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + ], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(SoftwareViewComponent); component = fixture.componentInstance; + // Set required config input with a mock Software object + const mockSoftware = new Software('malware'); + component.config = { + mode: 'view', + object: mockSoftware, + }; fixture.detectChanges(); }); 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 124fa9e6e..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 @@ -1,24 +1,72 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideRouter } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +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, + createPaginatedResponse, +} from 'src/app/testing/mocks/rest-api-connector.mock'; describe('StixDialogComponent', () => { let component: StixDialogComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({ + getRelatedTo: () => createAsyncObservable(createPaginatedResponse([])), + }); + await TestBed.configureTestingModule({ declarations: [StixDialogComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + { + provide: AuthenticationService, + useValue: { canEdit: () => true }, + }, + provideHttpClient(), + provideRouter([]), + { + provide: ActivatedRoute, + useValue: { + params: of({}), + queryParams: of({}), + }, + }, + { + provide: MAT_DIALOG_DATA, + useValue: { mode: 'view', object: {} as any }, + }, + { provide: MatDialogRef, useValue: {} }, + ], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(StixDialogComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); 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-list-page/stix-list-page.component.spec.ts b/src/app/views/stix/stix-list-page/stix-list-page.component.spec.ts index 752649f0b..e19551616 100644 --- a/src/app/views/stix/stix-list-page/stix-list-page.component.spec.ts +++ b/src/app/views/stix/stix-list-page/stix-list-page.component.spec.ts @@ -1,4 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { StixListPageComponent } from './stix-list-page.component'; @@ -9,11 +11,12 @@ describe('StixListPageComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [StixListPageComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [provideHttpClient()], }).compileComponents(); fixture = TestBed.createComponent(StixListPageComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create', () => { diff --git a/src/app/views/stix/stix-page/stix-page.component.html b/src/app/views/stix/stix-page/stix-page.component.html index 6f5abf590..c1f3ac025 100644 --- a/src/app/views/stix/stix-page/stix-page.component.html +++ b/src/app/views/stix/stix-page/stix-page.component.html @@ -50,6 +50,9 @@

+ diff --git a/src/app/views/stix/stix-page/stix-page.component.spec.ts b/src/app/views/stix/stix-page/stix-page.component.spec.ts index 4bb966822..133ab5f9e 100644 --- a/src/app/views/stix/stix-page/stix-page.component.spec.ts +++ b/src/app/views/stix/stix-page/stix-page.component.spec.ts @@ -1,21 +1,63 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideRouter } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { of } from 'rxjs'; import { StixPageComponent } from './stix-page.component'; +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'; describe('StixPageComponent', () => { let component: StixPageComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockRestApiConnector = createMockRestApiConnector({ + getAllNotes: () => createAsyncObservable([]), + getSoftware: () => createAsyncObservable([]), + getGroup: () => createAsyncObservable([]), + getCampaign: () => createAsyncObservable([]), + getMatrix: () => createAsyncObservable([]), + getMitigation: () => createAsyncObservable([]), + getTactic: () => createAsyncObservable([]), + getTechnique: () => createAsyncObservable([]), + getCollection: () => createAsyncObservable([]), + getDataSource: () => createAsyncObservable([]), + getDataComponent: () => createAsyncObservable([]), + getDetectionStrategy: () => createAsyncObservable([]), + getAnalytic: () => createAsyncObservable([]), + getAsset: () => createAsyncObservable([]), + getMarkingDefinition: () => createAsyncObservable([]), + deleteCollection: () => createAsyncObservable({}), + }); + await TestBed.configureTestingModule({ declarations: [StixPageComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + provideHttpClient(), + provideRouter([]), + { + provide: ActivatedRoute, + useValue: { + params: of({}), + queryParams: of({}), + }, + }, + ], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(StixPageComponent); component = fixture.componentInstance; - fixture.detectChanges(); + (component as any).config = { mode: 'view', object: {} as any }; }); it('should create', () => { 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 261a7deff..d6bf0c1bb 100644 --- a/src/app/views/stix/stix-page/stix-page.component.ts +++ b/src/app/views/stix/stix-page/stix-page.component.ts @@ -112,6 +112,7 @@ export class StixPageComponent implements OnInit, OnDestroy { ? this.oldAnalytics : undefined, versionAlreadyIncremented: versionChanged, + showWorkflow: this.editorService.hasWorkflow, }, autoFocus: false, // prevent auto focus on form field }); @@ -143,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({ @@ -339,6 +341,8 @@ export class StixPageComponent implements OnInit, OnDestroy { ); else if (this.objectType == 'asset') objects$ = this.restApiService.getAsset(objectStixID); + else if (this.objectType == 'identity') + objects$ = this.restApiService.getIdentity(objectStixID); else if (this.objectType == 'marking-definition') objects$ = this.restApiService.getMarkingDefinition(objectStixID); const subscription = objects$.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/tactic-view/tactic-view.component.spec.ts b/src/app/views/stix/tactic-view/tactic-view.component.spec.ts index 903177ca2..144c3c433 100644 --- a/src/app/views/stix/tactic-view/tactic-view.component.spec.ts +++ b/src/app/views/stix/tactic-view/tactic-view.component.spec.ts @@ -1,21 +1,38 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { TacticViewComponent } from './tactic-view.component'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { + createMockRestApiConnector, + createAsyncObservable, +} from 'src/app/testing/mocks/rest-api-connector.mock'; describe('TacticViewComponent', () => { let component: TacticViewComponent; let fixture: ComponentFixture; beforeEach(waitForAsync(() => { + const mockRestApiConnector = createMockRestApiConnector({ + getTechniquesInTactic: () => createAsyncObservable([]), + getDefaultMarkingDefinitions: () => createAsyncObservable([]), + }); + TestBed.configureTestingModule({ declarations: [TacticViewComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + provideHttpClient(), + { provide: RestApiConnectorService, useValue: mockRestApiConnector }, + ], }).compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(TacticViewComponent); component = fixture.componentInstance; - fixture.detectChanges(); + component.config = { mode: 'view', object: {} as any }; }); it('should create', () => { 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/app/views/stix/technique-view/technique-view.component.spec.ts b/src/app/views/stix/technique-view/technique-view.component.spec.ts index ebc049bd4..21601d24d 100644 --- a/src/app/views/stix/technique-view/technique-view.component.spec.ts +++ b/src/app/views/stix/technique-view/technique-view.component.spec.ts @@ -1,21 +1,46 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { TechniqueViewComponent } from './technique-view.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 { + createMockRestApiConnector, + createAsyncObservable, + createPaginatedResponse, +} from 'src/app/testing/mocks/rest-api-connector.mock'; +import { createMockAuthenticationService } from 'src/app/testing/mocks/authentication-service.mock'; +import { Role } from 'src/app/classes/authn/role'; describe('TechniqueViewComponent', () => { let component: TechniqueViewComponent; let fixture: ComponentFixture; beforeEach(waitForAsync(() => { + const mockAuthService = createMockAuthenticationService({ + isAuthorized: () => true, + }); + const mockRestApiService = createMockRestApiConnector({ + getAllMarkingDefinitions: () => + createAsyncObservable(createPaginatedResponse()), + }); + TestBed.configureTestingModule({ declarations: [TechniqueViewComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + provideHttpClient(), + { provide: AuthenticationService, useValue: mockAuthService }, + { provide: RestApiConnectorService, useValue: mockRestApiService }, + ], }).compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(TechniqueViewComponent); component = fixture.componentInstance; - fixture.detectChanges(); + component.config = { mode: 'view', object: {} as any }; }); it('should create', () => { 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 7dc530857..00a9ce4c7 100644 --- a/src/style/colors.scss +++ b/src/style/colors.scss @@ -26,6 +26,10 @@ $colors: ( color: $mitre-navy, on-color: $mitre-silver, ), + primary-dark: ( + color: $mitre-blue, + on-color: $mitre-silver, + ), secondary: ( color: $mitre-blue, on-color: $mitre-black, @@ -66,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, @@ -138,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/app-table.scss b/src/style/layouts/app-table.scss index 327ce77e8..ffa4fea66 100644 --- a/src/style/layouts/app-table.scss +++ b/src/style/layouts/app-table.scss @@ -2,10 +2,8 @@ @use '../typography'; @use '@angular/material' as mat; -// @import "../globals"; .app-table { - // border-collapse: collapse; border-spacing: 0px; .light & { @@ -34,3 +32,27 @@ padding: 12px 12px; } } + +.property-table { + width: 100%; + padding: 8px; + .light & { + background-color: colors.color(light); + } + .dark & { + background-color: colors.color(dark); + } + tr.mat-mdc-header-row { + height: 25px; + } + .mat-mdc-cell, + .mat-mdc-header-cell { + padding: 0 10px; + .light & { + border-color: colors.border-color(light); + } + .dark & { + border-color: colors.border-color(dark); + } + } +} diff --git a/src/style/layouts/extended-button.scss b/src/style/layouts/extended-button.scss index bb3af6ad8..8abd16884 100644 --- a/src/style/layouts/extended-button.scss +++ b/src/style/layouts/extended-button.scss @@ -33,6 +33,31 @@ } } +.table-icon-button { + @extend .small-icon-button; + width: 20px !important; + height: 20px !important; + + & > *[role='img'] { + width: 20px; + height: 20px; + font-size: 20px; + + svg { + width: 20px; + height: 20px; + } + } + + .mat-mdc-button-touch-target { + width: 20px !important; + height: 20px !important; + } +} +.table-icon-button + .table-icon-button { + margin-left: 8px; +} + .mini-icon-button { @extend .small-icon-button; width: 20px !important; 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 002de0e83..f03876e06 100644 --- a/src/style/theme.scss +++ b/src/style/theme.scss @@ -54,7 +54,7 @@ $dark-theme: mat.m2-define-dark-theme( ( color: ( primary: mat.m2-define-palette( - colors.to-material-map(primary), + colors.to-material-map(primary-dark), 50, 100, 200 @@ -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; diff --git a/src/test-setup.ts b/src/test-setup.ts new file mode 100644 index 000000000..d713883ad --- /dev/null +++ b/src/test-setup.ts @@ -0,0 +1,27 @@ +// This file is required by vitest and loads the Angular testing environment + +import '@angular/compiler'; +import '@analogjs/vitest-angular/setup-zone'; + +import { getTestBed } from '@angular/core/testing'; +import { + BrowserDynamicTestingModule, + platformBrowserDynamicTesting, +} from '@angular/platform-browser-dynamic/testing'; + +import { initLogger } from './app/utils/logger'; + +// Initialize a dummy logger for all tests +initLogger({ + log: console.log, + error: console.error, +} as any); + +// Initialize the Angular testing environment. +getTestBed().initTestEnvironment( + BrowserDynamicTestingModule, + platformBrowserDynamicTesting(), + { + teardown: { destroyAfterEach: false }, + } +); diff --git a/src/test.ts b/src/test.ts deleted file mode 100644 index 1886cdad9..000000000 --- a/src/test.ts +++ /dev/null @@ -1,17 +0,0 @@ -// This file is required by karma.conf.js and loads recursively all the .spec and framework files - -import 'zone.js/testing'; -import { getTestBed } from '@angular/core/testing'; -import { - BrowserDynamicTestingModule, - platformBrowserDynamicTesting, -} from '@angular/platform-browser-dynamic/testing'; - -// First, initialize the Angular testing environment. -getTestBed().initTestEnvironment( - BrowserDynamicTestingModule, - platformBrowserDynamicTesting(), - { - teardown: { destroyAfterEach: false }, - } -); diff --git a/tsconfig.spec.json b/tsconfig.spec.json index 092345b02..2de449227 100644 --- a/tsconfig.spec.json +++ b/tsconfig.spec.json @@ -4,12 +4,14 @@ "compilerOptions": { "outDir": "./out-tsc/spec", "types": [ - "jasmine" + "vitest/globals", + "node" ] }, "files": [ - "src/test.ts", - "src/polyfills.ts" + "src/test-setup.ts", + "src/polyfills.ts", + "vite.config.ts" ], "include": [ "src/**/*.spec.ts", diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 000000000..d55b37364 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,45 @@ +/// +import angular from '@analogjs/vite-plugin-angular'; +import { defineConfig } from 'vite'; +import path from 'path'; + +// https://vite.dev/config/ +export default defineConfig(({ mode }) => ({ + plugins: [angular()], + build: { + sourcemap: true, + }, + test: { + globals: true, + dir: 'src', + setupFiles: ['src/test-setup.ts'], + environment: 'jsdom', + maxConcurrency: 1, + bail: 10, // Stop after 10 test failures + coverage: { + provider: 'v8', + reporter: ['text', 'text-summary', 'html', 'lcov'], + reportsDirectory: 'coverage', + exclude: [ + '**/*.config.*', + 'node_modules/', + '.nuxt/', + 'dist/', + 'src/environments/', + 'src/app/testing/mocks/', + ], + all: false, + clean: true, + }, + }, + resolve: { + conditions: ['default', 'node'], + alias: { + src: path.resolve(__dirname, './src'), + 'package.json': path.resolve(__dirname, './package.json'), + }, + }, + define: { + 'import.meta.vitest': mode !== 'production', + }, +}));