diff --git a/.dockerignore b/.dockerignore index c2658d7d1..b5caeaf2e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1 +1,3 @@ node_modules/ +tests/ +*.spec.ts \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 213a258ff..64d3df3a1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,4 +33,4 @@ jobs: if: always() with: name: code-coverage-report - path: nav-app/coverage/chrome/index.html + path: nav-app/coverage/chrome/ diff --git a/nav-app/package-lock.json b/nav-app/package-lock.json index e80c2d60b..1d3275a22 100644 --- a/nav-app/package-lock.json +++ b/nav-app/package-lock.json @@ -8416,9 +8416,9 @@ } }, "node_modules/ip": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", - "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz", + "integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==", "dev": true }, "node_modules/ipaddr.js": { @@ -21324,9 +21324,9 @@ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==" }, "ip": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", - "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz", + "integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==", "dev": true }, "ipaddr.js": { diff --git a/nav-app/src/app/app.component.spec.ts b/nav-app/src/app/app.component.spec.ts index 4a0127c55..eabebbfe0 100755 --- a/nav-app/src/app/app.component.spec.ts +++ b/nav-app/src/app/app.component.spec.ts @@ -5,6 +5,7 @@ import { deleteCookie, getCookie, hasCookie, setCookie } from './utils/cookies'; import { TabsComponent } from './tabs/tabs.component'; import { MatDialogModule } from '@angular/material/dialog'; import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { ConfigService } from './services/config.service'; describe('AppComponent', () => { let fixture: ComponentFixture; @@ -15,6 +16,11 @@ describe('AppComponent', () => { imports: [HttpClientTestingModule, MatDialogModule, MatSnackBarModule], declarations: [AppComponent, TabsComponent], }).compileComponents(); + + // set up config service + let configService = TestBed.inject(ConfigService); + configService.defaultLayers = { enabled: false }; + fixture = TestBed.createComponent(AppComponent); app = fixture.debugElement.componentInstance; })); diff --git a/nav-app/src/app/app.module.ts b/nav-app/src/app/app.module.ts index 0789acc4e..259f9c6db 100755 --- a/nav-app/src/app/app.module.ts +++ b/nav-app/src/app/app.module.ts @@ -1,6 +1,6 @@ import { BrowserModule, Title } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { NgModule } from '@angular/core'; +import { APP_INITIALIZER, NgModule } from '@angular/core'; import 'rxjs/add/operator/map'; // material @@ -49,6 +49,7 @@ import { LayerInformationComponent } from './layer-information/layer-information import { ChangelogComponent } from './changelog/changelog.component'; import { MatTabsModule } from '@angular/material/tabs'; import { ListInputComponent } from './list-input/list-input.component'; +import { ConfigService } from './services/config.service'; @NgModule({ declarations: [ @@ -102,7 +103,18 @@ import { ListInputComponent } from './list-input/list-input.component'; MatTabsModule, ], exports: [MatSelectModule, MatInputModule, MatButtonModule, MatIconModule, MatTooltipModule, MatMenuModule, MatExpansionModule, MatTabsModule], - providers: [Title], + providers: [ + Title, + ConfigService, + { + provide: APP_INITIALIZER, + useFactory: (configService: ConfigService) => { + return () => configService.loadConfig(); + }, + deps: [ConfigService], + multi: true, + }, + ], bootstrap: [AppComponent], }) export class AppModule {} diff --git a/nav-app/src/app/layer-upgrade/changelog-cell/changelog-cell.component.html b/nav-app/src/app/layer-upgrade/changelog-cell/changelog-cell.component.html index 8d0c8b20e..762aa9063 100644 --- a/nav-app/src/app/layer-upgrade/changelog-cell/changelog-cell.component.html +++ b/nav-app/src/app/layer-upgrade/changelog-cell/changelog-cell.component.html @@ -1,15 +1,15 @@
- + {{ tactic.name }}
- {{ technique.attackID }} + {{ technique.attackID }}
- {{ technique.name }} + {{ technique.name }}
diff --git a/nav-app/src/app/layer-upgrade/changelog-cell/changelog-cell.component.spec.ts b/nav-app/src/app/layer-upgrade/changelog-cell/changelog-cell.component.spec.ts index 6e9625cae..cedf90d64 100644 --- a/nav-app/src/app/layer-upgrade/changelog-cell/changelog-cell.component.spec.ts +++ b/nav-app/src/app/layer-upgrade/changelog-cell/changelog-cell.component.spec.ts @@ -5,10 +5,13 @@ import { Link, Metadata, TechniqueVM, ViewModel } from '../../classes'; import { Note, Tactic, Technique } from '../../classes/stix'; import tinycolor from 'tinycolor2'; import { deleteCookie, hasCookie } from '../../utils/cookies'; +import { ConfigService } from '../../services/config.service'; +import * as MockData from '../../../tests/utils/mock-data'; describe('ChangelogCellComponent', () => { let component: ChangelogCellComponent; let fixture: ComponentFixture; + let configService: ConfigService; let technique_list: Technique[] = []; let stixSDO = { name: 'Name', @@ -65,27 +68,16 @@ describe('ChangelogCellComponent', () => { imports: [HttpClientTestingModule], declarations: [ChangelogCellComponent], }).compileComponents(); - }); - beforeEach(() => { + // set up config service + configService = TestBed.inject(ConfigService); + configService.versions = MockData.configData; + fixture = TestBed.createComponent(ChangelogCellComponent); component = fixture.debugElement.componentInstance; - let versions = [ - { - name: 'ATT&CK v13', - version: '13', - domains: [ - { - name: 'Enterprise', - identifier: 'enterprise-attack', - data: ['https://raw.githubusercontent.com/mitre/cti/ATT%26CK-v13.1/enterprise-attack/enterprise-attack.json'], - }, - ], - }, - ]; - component.dataService.setUpURLs(versions); - component.configService.setFeature('aggregate_score_color', true); - component.configService.setFeature('non_aggregate_score_color', true); + + configService.setFeature('aggregate_score_color', true); + configService.setFeature('non_aggregate_score_color', true); component.viewModel = new ViewModel('layer', '33', 'enterprise-attack-13', null); component.viewModel.domainVersionID = 'enterprise-attack-13'; let st1 = new Technique(subtechniqueSDO1, [], null); @@ -160,7 +152,7 @@ describe('ChangelogCellComponent', () => { component.viewModel.layout._showAggregateScores = true; expect(component.getTechniqueBackground()).toEqual({ background: component.emulate_alpha('black') }); component.viewModel.getTechniqueVM(component.technique, component.tactic).enabled = true; - component.configService.setFeature('background_color', true); + configService.setFeature('background_color', true); expect(component.getTechniqueBackground()).toEqual({ background: component.emulate_alpha('black') }); component.viewModel.highlightedTechniques.add('attack-pattern-0'); component.viewModel.selectTechnique(component.technique, component.tactic); @@ -172,14 +164,14 @@ describe('ChangelogCellComponent', () => { }); it('should get the underline color for the given technique', () => { - component.configService.setFeature('link_underline', true); - component.configService.link_color = 'purple'; + configService.setFeature('link_underline', true); + configService.linkColor = 'purple'; expect(component.getTechniqueUnderlineColor()).toEqual('purple'); - component.configService.setFeature('metadata_underline', true); - component.configService.metadata_color = 'blue'; + configService.setFeature('metadata_underline', true); + configService.metadataColor = 'blue'; expect(component.getTechniqueUnderlineColor()).toEqual('blue'); - component.configService.setFeature('comment_underline', true); - component.configService.comment_color = 'yellow'; + configService.setFeature('comment_underline', true); + configService.commentColor = 'yellow'; expect(component.getTechniqueUnderlineColor()).toEqual('yellow'); }); @@ -192,7 +184,7 @@ describe('ChangelogCellComponent', () => { component.isDarkTheme = false; component.viewModel.layout._showAggregateScores = true; expect(component.getTechniqueTextColor()).toEqual(tinycolor.mostReadable(component.emulate_alpha('black'), ['white', 'black'])); - component.configService.setFeature('background_color', true); + configService.setFeature('background_color', true); expect(component.getTechniqueTextColor()).toEqual(tinycolor.mostReadable(component.emulate_alpha('black'), ['white', 'black'])); component.viewModel.getTechniqueVM(component.technique, component.tactic).enabled = false; expect(component.getTechniqueTextColor()).toEqual('#aaaaaa'); diff --git a/nav-app/src/app/matrix/cell.ts b/nav-app/src/app/matrix/cell.ts index ec7cb029c..7858b01ab 100644 --- a/nav-app/src/app/matrix/cell.ts +++ b/nav-app/src/app/matrix/cell.ts @@ -144,13 +144,13 @@ export abstract class Cell { if (this.tactic) { let tvm = this.viewModel.getTechniqueVM(this.technique, this.tactic); if (tvm.comment.length > 0 || this.hasNotes()) { - if (this.configService.getFeature('comment_underline')) return this.configService.comment_color; + if (this.configService.getFeature('comment_underline')) return this.configService.commentColor; } if (tvm.metadata.length > 0) { - if (this.configService.getFeature('metadata_underline')) return this.configService.metadata_color; + if (this.configService.getFeature('metadata_underline')) return this.configService.metadataColor; } if (tvm.links.length > 0) { - if (this.configService.getFeature('link_underline')) return this.configService.link_color; + if (this.configService.getFeature('link_underline')) return this.configService.linkColor; } } return ''; diff --git a/nav-app/src/app/matrix/matrix-flat/matrix-flat.component.spec.ts b/nav-app/src/app/matrix/matrix-flat/matrix-flat.component.spec.ts index 5bb9fa6a9..61ad8cd9b 100644 --- a/nav-app/src/app/matrix/matrix-flat/matrix-flat.component.spec.ts +++ b/nav-app/src/app/matrix/matrix-flat/matrix-flat.component.spec.ts @@ -11,12 +11,10 @@ describe('MatrixFlatComponent', () => { imports: [HttpClientTestingModule], declarations: [MatrixFlatComponent], }).compileComponents(); - })); - beforeEach(() => { fixture = TestBed.createComponent(MatrixFlatComponent); component = fixture.componentInstance; - }); + })); it('should create', () => { expect(component).toBeTruthy(); diff --git a/nav-app/src/app/matrix/technique-cell/technique-cell.component.spec.ts b/nav-app/src/app/matrix/technique-cell/technique-cell.component.spec.ts index 480308c4c..a2fbdc9f9 100644 --- a/nav-app/src/app/matrix/technique-cell/technique-cell.component.spec.ts +++ b/nav-app/src/app/matrix/technique-cell/technique-cell.component.spec.ts @@ -5,67 +5,12 @@ import { TechniqueVM } from '../../classes'; import { Matrix, Tactic, Technique } from '../../classes/stix'; import { HttpClientTestingModule } from '@angular/common/http/testing'; import { Cell } from '../cell'; +import * as MockData from '../../../tests/utils/mock-data'; +import { ConfigService } from '../../services/config.service'; describe('TechniqueCellComponent', () => { let component: TechniqueCellComponent; let fixture: ComponentFixture; - let stixSDO = { - name: 'Name', - description: 'Description', - created: '2001-01-01T01:01:00.000Z', - modified: '2001-01-01T01:01:00.000Z', - x_mitre_version: '1.0', - }; - let techniqueSDO = { - ...stixSDO, - type: 'attack-pattern', - x_mitre_platforms: ['platform'], - kill_chain_phases: [ - { - kill_chain_name: 'mitre-attack', - phase_name: 'tactic-name', - }, - ], - }; - let t0000 = { - ...techniqueSDO, - id: 'attack-pattern-0', - external_references: [{ external_id: 'T0000' }], - }; - let t0000_000 = { - ...techniqueSDO, - id: 'attack-pattern-1', - x_mitre_is_subtechnique: true, - external_references: [{ external_id: 'T0000.000' }], - }; - let t0000_001 = { - ...techniqueSDO, - id: 'attack-pattern-2', - x_mitre_is_subtechnique: true, - revoked: true, - external_references: [{ external_id: 'T0000.001' }], - }; - let tacticSDO = { - ...stixSDO, - id: 'tactic-0', - type: 'x-mitre-tactic', - x_mitre_shortname: 'tactic-name', - external_references: [{ external_id: 'TA0000' }], - }; - let tacticSDO2 = { - ...stixSDO, - id: 'tactic-1', - type: 'x-mitre-tactic', - x_mitre_shortname: 'tactic-name', - external_references: [{ external_id: 'TA0001' }], - }; - let matrixSDO = { - ...stixSDO, - id: 'matrix-0', - type: 'x-mitre-matrix', - tactic_refs: ['tactic-0'], - external_references: [{ external_id: 'enterprise-matrix' }], - }; let techniqueTacticUnionId = 'T0000^tactic-name'; beforeEach(() => { @@ -74,29 +19,17 @@ describe('TechniqueCellComponent', () => { providers: [ViewModelsService], declarations: [TechniqueCellComponent], }); + let configService = TestBed.inject(ConfigService); + configService.versions = MockData.configData; fixture = TestBed.createComponent(TechniqueCellComponent); component = fixture.debugElement.componentInstance; - let versions = [ - { - name: 'ATT&CK v13', - version: '13', - domains: [ - { - name: 'Enterprise', - identifier: 'enterprise-attack', - data: ['https://raw.githubusercontent.com/mitre/cti/ATT%26CK-v13.1/enterprise-attack/enterprise-attack.json'], - }, - ], - }, - ]; - component.dataService.setUpURLs(versions); - let sub1 = new Technique(t0000_000, [], null); - let sub2 = new Technique(t0000_001, [], null); - component.technique = new Technique(t0000, [sub1, sub2], null); - component.tactic = new Tactic(tacticSDO, [component.technique], null); + let sub1 = new Technique(MockData.T0000_000, [], null); + let sub2 = new Technique(MockData.T0000_001, [], null); + component.technique = new Technique(MockData.T0000, [sub1, sub2], null); + component.tactic = new Tactic(MockData.TA0000, [component.technique], null); let map = new Map(); - map.set(component.tactic.id, tacticSDO); - component.matrix = new Matrix(matrixSDO, map, [component.technique, sub1, sub2], null); + map.set(component.tactic.id, MockData.TA0000); + component.matrix = new Matrix(MockData.matrixSDO, map, [component.technique, sub1, sub2], null); component.viewModel = component.viewModelsService.newViewModel('vm', 'enterprise-attack-13'); component.viewModel.setTechniqueVM(new TechniqueVM(techniqueTacticUnionId)); component.viewModel.setTechniqueVM(new TechniqueVM('T0000.000^tactic-name')); @@ -158,7 +91,7 @@ describe('TechniqueCellComponent', () => { const ttid = component.technique.get_technique_tactic_id(component.tactic); component.viewModel.highlightedTechniques = new Set([ttid]); component.viewModel.highlightedTechnique = component.technique; - component.viewModel.highlightedTactic = new Tactic(tacticSDO2, [component.technique], null); + component.viewModel.highlightedTactic = new Tactic(MockData.TA0001, [component.technique], null); expect(component.showTooltip).toBe(false); }); @@ -217,19 +150,6 @@ describe('TechniqueCellComponent', () => { expect(unhighlightSpy).toHaveBeenCalled(); }); - it('should return the number of annotated subtechniques', () => { - component.viewModel.getTechniqueVM = jasmine.createSpy('getTechniqueVM').and.callFake((subtechnique, tactic) => { - return { - ...jasmine.createSpyObj('TechniqueVM', ['annotated', 'setIsVisible']), - annotated: jasmine.createSpy('annotated').and.returnValue(true), - }; - }); - const result = component.annotatedSubtechniques(); - expect(component.viewModel.getTechniqueVM).toHaveBeenCalledTimes(2); - expect(component.viewModel.getTechniqueVM).toHaveBeenCalledWith(jasmine.any(Object), component.tactic); - expect(result).toEqual(component.applyControls([], component.tactic).length); - }); - it('should return the correct class when not annotated and not editing', () => { spyOn(Cell.prototype, 'getClass').and.returnValue('base-class'); spyOn(component, 'annotatedSubtechniques').and.returnValue(0); diff --git a/nav-app/src/app/services/config.service.spec.ts b/nav-app/src/app/services/config.service.spec.ts index 0be2cc65a..4be440d0f 100644 --- a/nav-app/src/app/services/config.service.spec.ts +++ b/nav-app/src/app/services/config.service.spec.ts @@ -1,125 +1,76 @@ -import { TestBed, inject } from '@angular/core/testing'; +import { TestBed } from '@angular/core/testing'; import { HttpClientTestingModule } from '@angular/common/http/testing'; import { ConfigService } from './config.service'; -import { DataService } from './data.service'; -import { Subscription, of } from 'rxjs'; import * as MockData from '../../tests/utils/mock-data'; describe('ConfigService', () => { - let config = { - banner: '', - comment_color: 'yellow', - custom_context_menu_items: [ - { - label: 'view technique on ATT&CK website', - url: 'https://attack.mitre.org/techniques/{{technique_attackID}}', - subtechnique_url: 'https://attack.mitre.org/techniques/{{parent_technique_attackID}}/{{subtechnique_attackID_suffix}}', - }, - ], - features: [ - { name: 'leave_site_dialog', enabled: true, description: 'Disable to remove the dialog prompt when leaving site.' }, - { name: 'comments', enabled: true, description: 'Disable to remove the ability to add comments to techniques.' }, - ], - link_color: 'blue', - metadata_color: 'purple', - versions: [{ name: 'ATT&CK v13', version: '13', domains: ['Enterprise'] }], - }; + let service: ConfigService; beforeEach(() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], providers: [ConfigService], }); + service = TestBed.inject(ConfigService); }); - it('should be created', inject([ConfigService], (service: ConfigService) => { + it('should be created', () => { expect(service).toBeTruthy(); - })); - - it('should set up data in constructor', inject([ConfigService], (service: ConfigService) => { - service.dataService.setUpURLs(MockData.configDataExtended); - spyOn(DataService.prototype, 'getConfig').and.returnValue(of(config)); - let fragments = new Map(); - fragments.set('comments', 'false'); //'https://mitre-attack.github.io/attack-navigator/#comments=false' - spyOn(ConfigService.prototype, 'getAllFragments').and.returnValue(fragments); - ConfigService.prototype.subscription = new Subscription(); - const unsubscribeSpy = spyOn(ConfigService.prototype.subscription, 'unsubscribe'); - let cs = new ConfigService(service.dataService); - expect(cs).toBeTruthy(); - expect(unsubscribeSpy).toHaveBeenCalled(); - })); + }); - it('should set feature object', inject([ConfigService], (service: ConfigService) => { + it('should set feature object', () => { expect(service.setFeature_object(MockData.configTechniqueControls)).toEqual(['disable_techniques', 'manual_color', 'background_color']); - })); + }); - it('should set single feature to given value', inject([ConfigService], (service: ConfigService) => { + it('should set single feature to given value', () => { expect(service.setFeature('sticky_toolbar', true)).toEqual(['sticky_toolbar']); - })); + }); - it('should get feature', inject([ConfigService], (service: ConfigService) => { + it('should get feature', () => { service.setFeature('sticky_toolbar', true); expect(service.getFeature('sticky_toolbar')).toBeTruthy(); - })); + }); - it('should get feature group', inject([ConfigService], (service: ConfigService) => { + it('should get feature group', () => { expect(service.getFeatureGroup('technique_controls')).toBeTruthy(); service.setFeature_object(MockData.configTechniqueControls); expect(service.getFeatureGroup('technique_controls')).toBeTruthy(); - })); + }); - it('should get feature group count', inject([ConfigService], (service: ConfigService) => { + it('should get feature group count', () => { expect(service.getFeatureGroupCount('technique_controls')).toEqual(-1); service.setFeature_object(MockData.configTechniqueControls); expect(service.getFeatureGroupCount('technique_controls')).toEqual(3); - })); - - it('should get all features', inject([ConfigService], (service: ConfigService) => { - service.setFeature_object(MockData.configTechniqueControls); - service.setFeature_object(MockData.configToolbarControls); - expect(service.getFeatures()).toEqual(['disable_techniques', 'manual_color', 'background_color', 'sticky_toolbar']); - })); - - it('should get all feature groups', inject([ConfigService], (service: ConfigService) => { - service.setFeature_object(MockData.configTechniqueControls); - service.setFeature_object(MockData.configToolbarControls); - expect(service.getFeatureGroups()).toEqual(['technique_controls', 'toolbar_controls']); - })); + }); - it('should check if feature exists', inject([ConfigService], (service: ConfigService) => { + it('should check if feature exists', () => { service.setFeature_object(MockData.configTechniqueControls); expect(service.isFeature('disable_techniques')).toBeTruthy(); - })); + }); - it('should check if feature group exists', inject([ConfigService], (service: ConfigService) => { + it('should check if feature group exists', () => { service.setFeature_object(MockData.configTechniqueControls); expect(service.isFeatureGroup('technique_controls')).toBeTruthy(); - })); - - it('should get feature list', inject([ConfigService], (service: ConfigService) => { - expect(service.getFeatureList()).toEqual([]); - service.featureStructure = [{ name: 'sticky_toolbar', enabled: true }]; - expect(service.getFeatureList()).toEqual([{ name: 'sticky_toolbar', enabled: true }]); - })); + }); - it('should set features of the given group to provided value', inject([ConfigService], (service: ConfigService) => { + it('should set features of the given group to provided value', () => { service.setFeature_object(MockData.configTechniqueControls); expect(service.setFeature('technique_controls', true)).toEqual(['technique_controls']); - })); + }); - it('should set features of the given group to the value object', inject([ConfigService], (service: ConfigService) => { + it('should set features of the given group to the value object', () => { let value_object = { scoring: true, comments: false }; expect(service.setFeature('technique_controls', value_object)).toEqual(['scoring', 'comments']); - })); + }); - it('should get all url fragments', inject([ConfigService], (service: ConfigService) => { + it('should get all url fragments', () => { let fragments = new Map(); expect(service.getAllFragments()).toEqual(fragments); fragments.set('comments', 'false'); expect(service.getAllFragments('https://mitre-attack.github.io/attack-navigator/#comments=false')).toEqual(fragments); - })); + }); - it('should set up data in constructor', inject([ConfigService], (service: ConfigService) => { + it('should set up data in constructor', () => { expect(service).toBeTruthy(); - })); + }); }); diff --git a/nav-app/src/app/services/config.service.ts b/nav-app/src/app/services/config.service.ts index 120c7e678..373895720 100644 --- a/nav-app/src/app/services/config.service.ts +++ b/nav-app/src/app/services/config.service.ts @@ -1,67 +1,45 @@ import { Injectable } from '@angular/core'; -import { DataService } from './data.service'; import { ContextMenuItem } from '../classes/context-menu-item'; +import { HttpClient } from '@angular/common/http'; @Injectable({ providedIn: 'root', }) export class ConfigService { - public comment_color = 'yellow'; - public link_color = 'blue'; - public metadata_color = 'purple'; + public versions: any[] = []; + public contextMenuItems: ContextMenuItem[] = []; + public defaultLayers: any; + public commentColor = 'yellow'; + public linkColor = 'blue'; + public metadataColor = 'purple'; public banner: string; + public featureList: any[] = []; + private features = new Map(); private featureGroups = new Map(); - public featureStructure: object[]; - public contextMenuItems: ContextMenuItem[] = []; - public subscription; - constructor(public dataService: DataService) { - console.debug('initializing config service'); - let self = this; - this.subscription = dataService.getConfig().subscribe({ - next: (config) => { - //parse feature preferences from config json - config['features'].forEach(function (featureObject: any) { - self.setFeature_object(featureObject); - }); - //override json preferences with preferences from URL fragment - self.getAllFragments().forEach(function (value: string, key: string) { - let valueBool = value == 'true'; - if (self.isFeatureGroup(key) || self.isFeature(key)) { - self.setFeature(key, valueBool); - } - }); - dataService.subtechniquesEnabled = self.getFeature('subtechniques'); - self.featureStructure = config['features']; - self.comment_color = config['comment_color']; - self.metadata_color = config['metadata_color']; - self.link_color = config['link_color']; - self.banner = config['banner']; - for (let obj of config['custom_context_menu_items']) { - self.contextMenuItems.push(new ContextMenuItem(obj.label, obj.url, obj.subtechnique_url)); - } - }, - complete: () => { - if (this.subscription) this.subscription.unsubscribe(); - }, //prevent memory leaks - }); + public get subtechniquesEnabled(): boolean { + return this.features.get('subtechniques'); } - public getFeatureList(): object[] { - if (!this.featureStructure) return []; - return this.featureStructure; + constructor(private http: HttpClient) { + // intentionally left blank } + /** + * Checks if the feature is enabled + * @param featureName feature name + * @returns true if the feature is enabled, false otherwise + */ public getFeature(featureName: string): boolean { return this.features.get(featureName); } /** - * Return true if any/all features in the group are enabled + * Checks if any/all features in the group are enabled * @param featureGroup feature group name - * @param type 'any' or 'all' for logical or/and - * @return true iffany/all are enabled, false otherwise + * @param type 'any' or 'all' for logical or/and + * @returns true iff any/all are enabled, false otherwise */ public getFeatureGroup(featureGroup: string, type?: string): boolean { if (!this.featureGroups.has(featureGroup)) return true; @@ -72,19 +50,16 @@ export class ConfigService { } /** - * Return the number of enabled features in the group + * Get the number of enabled features in the group * @param featureGroup feature group name - * @return the number of enabled features in the group, or -1 if - * the group does not exist + * @returns the number of enabled features in the group, or -1 if + * the group does not exist */ public getFeatureGroupCount(featureGroup: string): number { if (!this.featureGroups.has(featureGroup)) return -1; - let count = 0; let subFeatures = this.featureGroups.get(featureGroup); - for (let subFeature of subFeatures) { - if (this.getFeature(subFeature)) count += 1; - } - return count; + let enabled = subFeatures.filter((f) => this.getFeature(f)); + return enabled.length; } /** @@ -174,22 +149,6 @@ export class ConfigService { return this.featureGroups.has(featureGroupName); } - public getFeatureGroups(): string[] { - let keys = []; - this.featureGroups.forEach(function (value, key) { - keys.push(key); - }); - return keys; - } - - public getFeatures(): string[] { - let keys = []; - this.features.forEach(function (value, key) { - keys.push(key); - }); - return keys; - } - /** * Get all url fragments * @param url optional, url to parse instead of window location href @@ -207,4 +166,40 @@ export class ConfigService { return fragments; } + + /** + * Load the configuration file + * Note: this is done at startup + */ + public loadConfig() { + return this.http + .get('./assets/config.json') + .toPromise() + .then((config) => { + console.debug(`loaded app configuration settings`); + + this.versions = config['versions']; + config['custom_context_menu_items'].forEach((item) => { + this.contextMenuItems.push(new ContextMenuItem(item.label, item.url, item.subtechnique_url)); + }); + this.defaultLayers = config['default_layers']; + this.commentColor = config['comment_color']; + this.linkColor = config['link_color']; + this.metadataColor = config['metadata_color']; + this.banner = config['banner']; + + // parse feature preferences + this.featureList = config['features']; + config['features'].forEach((feature) => { + this.setFeature_object(feature); + }); + + // override preferences with preferences from URL fragments + this.getAllFragments().forEach((value: string, key: string) => { + if (this.isFeature(key) || this.isFeatureGroup(key)) { + this.setFeature(key, value == 'true'); + } + }); + }); + } } diff --git a/nav-app/src/app/services/data.service.spec.ts b/nav-app/src/app/services/data.service.spec.ts index a151f4715..84f49e011 100755 --- a/nav-app/src/app/services/data.service.spec.ts +++ b/nav-app/src/app/services/data.service.spec.ts @@ -1,449 +1,443 @@ -import { TestBed, inject } from '@angular/core/testing'; +import { TestBed } from '@angular/core/testing'; import { HttpClientTestingModule } from '@angular/common/http/testing'; import { Version, VersionChangelog } from '../classes'; import { Asset, Campaign, DataComponent, Domain, Group, Matrix, Mitigation, Note, Software, Tactic, Technique } from '../classes/stix'; -import { Subscription, of } from 'rxjs'; +import { of } from 'rxjs'; import { HttpClient } from '@angular/common/http'; import { Collection } from '../utils/taxii2lib'; import * as MockData from '../../tests/utils/mock-data'; import { DataService } from './data.service'; +import { ConfigService } from './config.service'; describe('DataService', () => { let dataService: DataService; + let configService: ConfigService; + let http: HttpClient; + let mockService; beforeEach(() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], providers: [DataService], }); + configService = TestBed.inject(ConfigService); + configService.versions = []; dataService = TestBed.inject(DataService); + http = TestBed.inject(HttpClient); }); - it('should be created', () => { - expect(dataService).toBeTruthy(); - }); + describe('setup with default versions', () => { + it('should be created', () => { + expect(dataService).toBeTruthy(); + }); - it('should set up data in constructor', inject([HttpClient], (http: HttpClient) => { - let return$ = { versions: MockData.configData }; - let functionSpy = spyOn(DataService.prototype, 'setUpURLs').and.stub(); - spyOn(DataService.prototype, 'getConfig').and.returnValue(of(return$)); - DataService.prototype.subscription = new Subscription(); - const unsubscribeSpy = spyOn(DataService.prototype.subscription, 'unsubscribe'); - const mockService = new DataService(http); - expect(mockService).toBeTruthy(); - expect(functionSpy).toHaveBeenCalledOnceWith(return$.versions); - expect(unsubscribeSpy).toHaveBeenCalled(); - })); - - it('should set up data', () => { - dataService.setUpURLs(MockData.configData); - expect(dataService.versions).toBeTruthy(); - expect(dataService.versions.length).toEqual(1); - let version = dataService.versions[0]; - expect(version).toBeInstanceOf(Version); - expect(version.name).toEqual('ATT&CK v13'); - expect(version.number).toEqual('13'); - }); + it('should set up data on failure to load config', () => { + expect(dataService.versions.length).toEqual(1); + expect(dataService.versions[0]).toEqual(dataService.latestVersion); + expect(dataService.domains.length).toEqual(3); + let last = dataService.domains[dataService.domains.length - 1]; + expect(last).toBeInstanceOf(Domain); + }); - it('should set up data on failure to load config', () => { - dataService.setUpURLs([]); - expect(dataService.versions.length).toEqual(1); - expect(dataService.versions[0]).toEqual(dataService.latestVersion); - expect(dataService.domains.length).toEqual(3); - let last = dataService.domains[dataService.domains.length - 1]; - expect(last).toBeInstanceOf(Domain); - }); + it('should get domainVersionID with latest version', () => { + let domainIdentifier = 'enterprise-attack'; + let result = dataService.getDomainVersionID(domainIdentifier, ''); + let latestVersion = dataService.versions[0].number; + expect(result).toEqual(`${domainIdentifier}-${latestVersion}`); + }); - it('should define Workbench authentication', () => { - dataService.setUpURLs(MockData.workbenchData); - let domain = dataService.domains[0]; - expect(dataService.versions.length).toEqual(1); - expect(domain).toBeInstanceOf(Domain); - expect(domain.authentication).toBeTruthy(); - expect(domain.authentication).toEqual(MockData.workbenchData[0].authentication); - }); + it('should get domainVersionID', () => { + let domainIdentifier = 'enterprise-attack'; + let version = '13'; + let result = dataService.getDomainVersionID(domainIdentifier, version); + expect(result).toEqual(`${domainIdentifier}-${version}`); + }); - it('should define TAXII connection', () => { - dataService.setUpURLs(MockData.taxiiData); - let domain = dataService.domains[0]; - expect(dataService.versions.length).toEqual(1); - expect(domain).toBeInstanceOf(Domain); - expect(domain.taxii_url).toBeTruthy(); - expect(domain.taxii_url).toEqual(MockData.taxiiData[0].domains[0].taxii_url); - expect(domain.taxii_collection).toBeTruthy(); - expect(domain.taxii_collection).toEqual(MockData.taxiiData[0].domains[0].taxii_collection); - }); + it('should get current version', () => { + let result = dataService.getCurrentVersion(); + expect(result.name).toEqual(dataService.latestVersion.name); + expect(result.number).toEqual(dataService.latestVersion.number); + }); - it('should get domainVersionID with latest version', () => { - dataService.setUpURLs([]); - let domainIdentifier = 'enterprise-attack'; - let result = dataService.getDomainVersionID(domainIdentifier, ''); - let latestVersion = dataService.versions[0].number; - expect(result).toEqual(`${domainIdentifier}-${latestVersion}`); - }); + it('should not be supported version', () => { + let result = dataService.isSupported('3'); + expect(result).toBeFalse(); + }); - it('should get domainVersionID', () => { - dataService.setUpURLs([]); - let domainIdentifier = 'enterprise-attack'; - let version = '13'; - let result = dataService.getDomainVersionID(domainIdentifier, version); - expect(result).toEqual(`${domainIdentifier}-${version}`); - }); + it('should be a supported version', () => { + let version = dataService.latestVersion.number; + let result = dataService.isSupported(version); + expect(result).toBeTrue(); + }); - it('should get current version', () => { - dataService.setUpURLs([]); - let result = dataService.getCurrentVersion(); - expect(result.name).toEqual(dataService.latestVersion.name); - expect(result.number).toEqual(dataService.latestVersion.number); - }); + it('should compare the same version as unchanged', () => { + let domain = dataService.domains[0]; + dataService.parseBundle(domain, MockData.stixBundleSDO); - it('should not be supported version', () => { - dataService.setUpURLs([]); - let result = dataService.isSupported('3'); - expect(result).toBeFalse(); + let result = dataService.compareVersions(domain.id, domain.id); + expect(result).toBeInstanceOf(VersionChangelog); + expect(result.newDomainVersionID).toEqual(domain.id); + expect(result.oldDomainVersionID).toEqual(domain.id); + expect(result.unchanged.length).toEqual(3); + }); }); - it('should be a supported version', () => { - dataService.setUpURLs([]); - let version = dataService.latestVersion.number; - let result = dataService.isSupported(version); - expect(result).toBeTrue(); - }); + describe('setup with Workbench integration', () => { + beforeEach(() => { + configService.versions = MockData.workbenchData; + spyOn(DataService.prototype, 'setUpURLs').and.callThrough(); + mockService = new DataService(http, configService); + }); - it('should fetch domain data via URL', () => { - dataService.setUpURLs(MockData.configData); - let domain = dataService.domains[0]; - let result$ = dataService.getDomainData(domain); - expect(result$).toBeTruthy(); - }); + it('should define authentication', () => { + let domain = mockService.domains[0]; + expect(mockService.versions.length).toEqual(1); + expect(domain).toBeInstanceOf(Domain); + expect(domain.authentication).toBeTruthy(); + expect(domain.authentication).toEqual(MockData.workbenchData[0].authentication); + }); - it('should fetch domain data via TAXII', () => { - let functionSpy = spyOn(Collection.prototype, 'getObjects').and.returnValue(Promise.resolve([])); - dataService.setUpURLs(MockData.taxiiData); - let domain = dataService.domains[0]; - let result$ = dataService.getDomainData(domain); - expect(result$).toBeTruthy(); - expect(functionSpy).toHaveBeenCalled(); + it('should fetch domain data via Workbench', () => { + let domain = dataService.domains[0]; + let result$ = dataService.getDomainData(domain); + expect(result$).toBeTruthy(); + }); }); - it('should fetch domain data via Workbench', () => { - dataService.setUpURLs(MockData.workbenchData); - let domain = dataService.domains[0]; - let result$ = dataService.getDomainData(domain); - expect(result$).toBeTruthy(); - }); + describe('setup with TAXII', () => { + beforeEach(() => { + configService.versions = MockData.taxiiData; + spyOn(DataService.prototype, 'setUpURLs').and.callThrough(); + mockService = new DataService(http, configService); + }); - it('should resolve with data loaded', async () => { - dataService.setUpURLs(MockData.configData); - let domain = dataService.domains[0]; - domain.dataLoaded = true; - await expectAsync(dataService.loadDomainData(domain.id, false)).toBeResolved(); - }); + it('should define TAXII connection', () => { + let domain = mockService.domains[0]; + expect(mockService.versions.length).toEqual(1); + expect(domain).toBeInstanceOf(Domain); + expect(domain.taxii_url).toBeTruthy(); + expect(domain.taxii_url).toEqual(MockData.taxiiData[0].domains[0].taxii_url); + expect(domain.taxii_collection).toBeTruthy(); + expect(domain.taxii_collection).toEqual(MockData.taxiiData[0].domains[0].taxii_collection); + }); - it('should resolve after data loaded', async () => { - dataService.setUpURLs(MockData.configData); - let domain = dataService.domains[0]; - let functionSpy = spyOn(dataService, 'getDomainData').and.returnValue(of(MockData.stixBundleSDO)); - DataService.prototype.subscription = new Subscription(); - spyOn(DataService.prototype.subscription, 'unsubscribe'); - await expectAsync(dataService.loadDomainData(domain.id, false)).toBeResolved(); - expect(functionSpy).toHaveBeenCalledOnceWith(domain, false); + it('should fetch domain data via TAXII', () => { + let functionSpy = spyOn(Collection.prototype, 'getObjects').and.returnValue(Promise.resolve([])); + let domain = mockService.domains[0]; + let result$ = mockService.getDomainData(domain); + expect(result$).toBeTruthy(); + expect(functionSpy).toHaveBeenCalled(); + }); }); - it('should reject with invalid domain', async () => { - let functionSpy = spyOn(dataService, 'getDomain').and.returnValue(undefined); - dataService.setUpURLs(MockData.configData); - let domainId = 'enterprise-attack-4'; - await expectAsync(dataService.loadDomainData(domainId)).toBeRejected(); - expect(functionSpy).toHaveBeenCalledOnceWith(domainId); - }); + describe('setup with config data', () => { + beforeEach(() => { + configService.versions = MockData.configData; + spyOn(DataService.prototype, 'setUpURLs').and.callThrough(); + mockService = new DataService(http, configService); + }); - it('should not get technique in domain', () => { - dataService.setUpURLs(MockData.configData); - let result = dataService.getTechnique('T0000', dataService.domains[0].id); - expect(result).toBeFalsy(); - }); + it('should set up config data in constructor', () => { + expect(mockService).toBeTruthy(); + expect(mockService.versions).toBeTruthy(); + expect(mockService.versions.length).toEqual(1); + let version = mockService.versions[0]; + expect(version).toBeInstanceOf(Version); + expect(version.name).toEqual('ATT&CK v13'); + expect(version.number).toEqual('13'); + }); - it('should get technique in domain', () => { - dataService.setUpURLs(MockData.configData); - dataService.domains[0].techniques = [new Technique(MockData.T0000, [], dataService)]; - let result = dataService.getTechnique('T0000', dataService.domains[0].id); - expect(result).toBeTruthy(); - expect(result).toBeInstanceOf(Technique); - expect(result.attackID).toEqual('T0000'); - }); + it('should fetch domain data via URL', () => { + let domain = mockService.domains[0]; + let result$ = mockService.getDomainData(domain); + expect(result$).toBeTruthy(); + }); - it('should test software', () => { - dataService.subtechniquesEnabled = true; // enable to parse subs - dataService.setUpURLs(MockData.configData); - dataService.domains[0].relationships['software_uses'].set('malware-0', ['attack-pattern-0']); - let software_test = new Software(MockData.malwareS0000, dataService); - expect(software_test.relatedTechniques('enterprise-attack-13')).toEqual(['attack-pattern-0']); - software_test = new Software(MockData.M0000, dataService); // should return empty list because 'malware-0' is not in softwareUsesTechnique - expect(software_test.relatedTechniques('enterprise-attack-13')).toEqual([]); - }); + it('should resolve with data loaded', async () => { + let domain = mockService.domains[0]; + domain.dataLoaded = true; + await expectAsync(mockService.loadDomainData(domain.id, false)).toBeResolved(); + }); - it('should test mitigation', () => { - dataService.subtechniquesEnabled = true; // enable to parse subs - dataService.setUpURLs(MockData.configData); - dataService.domains[0].relationships['mitigates'].set('mitigation-0', ['attack-pattern-0']); - let mitigation_test = new Mitigation(MockData.M0000, dataService); - expect(mitigation_test.relatedTechniques('enterprise-attack-13')).toEqual(['attack-pattern-0']); - mitigation_test = new Mitigation(MockData.malwareS0000, dataService); // should return empty list because 'mitigation-0' is not in mitigationMitigates - expect(mitigation_test.relatedTechniques('enterprise-attack-13')).toEqual([]); - }); + it('should resolve after data loaded', async () => { + let domain = mockService.domains[0]; + let functionSpy = spyOn(mockService, 'getDomainData').and.returnValue(of(MockData.stixBundleSDO)); + await expectAsync(mockService.loadDomainData(domain.id, false)).toBeResolved(); + expect(functionSpy).toHaveBeenCalledOnceWith(domain, false); + }); - it('should test asset', () => { - dataService.subtechniquesEnabled = true; // enable to parse subs - dataService.setUpURLs(MockData.configData); - dataService.domains[0].relationships['targeted_assets'].set('asset-0', ['attack-pattern-0']); - let asset_test = new Asset(MockData.A0000, dataService); - expect(asset_test.relatedTechniques('enterprise-attack-13')).toEqual(['attack-pattern-0']); - asset_test = new Asset(MockData.malwareS0000, dataService); // should return empty list because 'mitigation-0' is not in mitigationMitigates - expect(asset_test.relatedTechniques('enterprise-attack-13')).toEqual([]); - }); + it('should reject with invalid domain', async () => { + let functionSpy = spyOn(dataService, 'getDomain').and.returnValue(undefined); + let domainId = 'enterprise-attack-4'; + await expectAsync(dataService.loadDomainData(domainId)).toBeRejected(); + expect(functionSpy).toHaveBeenCalledOnceWith(domainId); + }); - it('should test campaign', () => { - dataService.subtechniquesEnabled = true; // enable to parse subs - dataService.setUpURLs(MockData.configData); - dataService.domains[0].relationships['campaign_uses'].set('campaign-0', ['attack-pattern-0']); - let campaign_test = new Campaign(MockData.C0000, dataService); - expect(campaign_test.relatedTechniques('enterprise-attack-13')).toEqual(['attack-pattern-0']); - campaign_test = new Campaign(MockData.malwareS0000, dataService); // should return empty list because 'mitigation-0' is not in mitigationMitigates - expect(campaign_test.relatedTechniques('enterprise-attack-13')).toEqual([]); - }); + it('should parse stix bundle', () => { + Object.defineProperty(configService, 'subtechniquesEnabled', { get: () => true }); // enable to parse subs + mockService.domains[0].relationships['group_uses'].set('intrusion-set-0', ['attack-pattern-0']); + mockService.domains[0].relationships['software_uses'].set('malware-0', ['attack-pattern-0']); + mockService.domains[0].relationships['campaign_uses'].set('campaign-0', ['attack-pattern-0']); + mockService.domains[0].relationships['mitigates'].set('mitigation-0', ['attack-pattern-0']); + mockService.domains[0].relationships['component_rel'].set('component-0', ['attack-pattern-0']); + mockService.domains[0].relationships['campaigns_attributed_to'].set('intrusion-set-0', ['attack-pattern-0']); + mockService.domains[0].relationships['targeted_assets'].set('asset-0', ['attack-pattern-0']); + let domain = mockService.domains[0]; + mockService.parseBundle(domain, MockData.stixBundleSDO); + // check data loaded + expect(domain.dataLoaded).toBeTrue(); + expect(domain.platforms).toEqual(MockData.T0000.x_mitre_platforms); + + // check objects parsed + let testObjectType = function (testArr, quantity, instance) { + expect(testArr.length).toEqual(quantity); + expect(testArr[0]).toBeInstanceOf(instance); + }; + testObjectType(domain.techniques, 4, Technique); + testObjectType(domain.subtechniques, 2, Technique); + testObjectType(domain.assets, 1, Asset); + testObjectType(domain.campaigns, 2, Campaign); + testObjectType(domain.dataComponents, 1, DataComponent); + testObjectType(domain.groups, 1, Group); + testObjectType(domain.matrices, 1, Matrix); + testObjectType(domain.mitigations, 1, Mitigation); + testObjectType(domain.notes, 1, Note); + testObjectType(domain.software, 2, Software); + testObjectType(domain.tactics, 1, Tactic); + // check filteredMitigation has been skipped + expect(domain.mitigations[0].id).not.toBe(MockData.filteredM0001.id); + // check deprecated matrix has been skipped + expect(domain.matrices[0].id).not.toBe(MockData.deprecatedMatrixSDO.id); + // check relationships parsed + let relationships = domain.relationships; + expect(relationships['campaign_uses'].size).toEqual(1); + expect(relationships['campaigns_attributed_to'].size).toEqual(1); + expect(relationships['component_rel'].size).toEqual(2); + expect(relationships['group_uses'].size).toEqual(1); + expect(relationships['mitigates'].size).toEqual(1); + expect(relationships['revoked_by'].size).toEqual(1); + expect(relationships['software_uses'].size).toEqual(1); + expect(relationships['subtechniques_of'].size).toEqual(1); + expect(relationships['targeted_assets'].size).toEqual(1); + }); - it('should test data components', () => { - dataService.subtechniquesEnabled = true; // enable to parse subs - dataService.setUpURLs(MockData.configData); - let t1 = new Technique(MockData.T0000, [], dataService); - dataService.domains[0].techniques = [t1]; - let data_component_test = new DataComponent(MockData.DC0000, dataService); - dataService.domains[0].dataSources.set(MockData.DC0000.id, { - name: MockData.stixSDO.name, - external_references: MockData.DC0000.external_references, - }); - expect(data_component_test.source('enterprise-attack-13')).toEqual({ name: '', url: '' }); - dataService.domains[0].dataSources.set(MockData.DS0000.id, { - name: MockData.stixSDO.name, - external_references: MockData.DC0001.external_references, - }); - expect(data_component_test.source('enterprise-attack-13')).toEqual({ name: 'Name', url: 'test-url.com' }); - dataService.domains[0].relationships['component_rel'].set('data-component-0', ['attack-pattern-0']); - expect(data_component_test.techniques('enterprise-attack-13')).toEqual(['attack-pattern-0']); + it('should update domain watchers', () => { + let functionSpy = spyOn(dataService, 'getCurrentVersion'); + dataService.setUpURLs(MockData.configData); + let domain = dataService.domains[0]; + domain.dataLoadedCallbacks = [dataService.getCurrentVersion]; + dataService.parseBundle(domain, MockData.stixBundleSDO); + expect(functionSpy).toHaveBeenCalled(); + }); }); - it('should test group', () => { - dataService.subtechniquesEnabled = true; // enable to parse subs - dataService.setUpURLs(MockData.configData); - dataService.domains[0].relationships['group_uses'].set('intrusion-set-0', ['attack-pattern-0']); - dataService.domains[0].relationships['campaign_uses'].set('intrusion-set-0', ['attack-pattern-0']); - dataService.domains[0].relationships['campaigns_attributed_to'].set('intrusion-set-0', ['intrusion-set-0']); - let group_test = new Group(MockData.G0000, dataService); - expect(group_test.relatedTechniques('enterprise-attack-13')).toEqual(['attack-pattern-0']); - group_test = new Group(MockData.malwareS0000, dataService); // should return empty list because 'mitigation-0' is not in mitigationMitigates - expect(group_test.relatedTechniques('enterprise-attack-13')).toEqual([]); - }); + describe('setup with version comparison', () => { + beforeEach(() => { + let newVersion = { + name: 'ATT&CK v14', + version: '14', + domains: MockData.configData[0].domains, + }; + configService.versions = [MockData.configData[0], newVersion]; + spyOn(DataService.prototype, 'setUpURLs').and.callThrough(); + mockService = new DataService(http, configService); + }); - it('should test stixObject', () => { - dataService.subtechniquesEnabled = true; // enable to parse subs - dataService.setUpURLs(MockData.configData); - let stixObject_test = new Campaign(MockData.G0000, dataService); - let group_test = new Group(MockData.G0001, dataService); - dataService.domains[0].relationships['revoked_by'].set('relationship-9', ['attack-pattern-1']); - dataService.domains[0].relationships['targeted_assets'].set('asset-0', ['attack-pattern-0']); - expect(stixObject_test.revoked_by('enterprise-attack-13')).toBeUndefined(); - dataService.domains[0].relationships['revoked_by'].set('intrusion-set-0', ['attack-pattern-1']); - expect(stixObject_test.revoked_by('enterprise-attack-13')).toEqual(['attack-pattern-1']); - let campaign_test = new Campaign(MockData.C0000, dataService); - expect(campaign_test.compareVersion(group_test)).toEqual(-1); - let asset_test = new Asset(MockData.invalidAsset, dataService, false); - expect(campaign_test.compareVersion(asset_test)).toEqual(0); + it('should compare versions', () => { + // should have two domains/versions + expect(mockService.domains.length).toEqual(2); + + // parse stix bundles into old domain + let [oldDomain, newDomain] = mockService.domains; + mockService.parseBundle(oldDomain, MockData.stixBundleSDO); + expect(oldDomain.dataLoaded).toBeTrue(); + + // deprecation + let deprecateSubtechnique = { ...MockData.T0000_000 }; + deprecateSubtechnique['x_mitre_deprecated'] = true; + deprecateSubtechnique['modified'] = '2002-01-01T01:01:00.000Z'; + // revocation + let revokeTechnique = { ...MockData.T0001 }; + revokeTechnique['revoked'] = true; + revokeTechnique['modified'] = '2002-01-01T01:01:00.000Z'; + // minor change + let minorTechnique = { ...MockData.T0000 }; + minorTechnique['modified'] = '2002-01-01T01:01:00.000Z'; + // major change + let majorTechnique = { ...MockData.T0003 }; + majorTechnique['x_mitre_version'] = '2.0'; + majorTechnique['modified'] = '2002-01-01T01:01:00.000Z'; + // update deprecated object + let deprecatedTechnique = { ...MockData.T0002 }; + deprecatedTechnique['modified'] = '2002-01-01T01:01:00.000Z'; + // update revoked object + let revokedSubtechnique = { ...MockData.T0000_001 }; + revokedSubtechnique['modified'] = '2002-01-01T01:01:00.000Z'; + // parse stix bundle into new domain + mockService.parseBundle(newDomain, [ + { + type: 'bundle', + id: 'bundle--1', + spec_version: '2.0', + objects: [ + MockData.T0004, + minorTechnique, + revokeTechnique, + deprecatedTechnique, + deprecateSubtechnique, + revokedSubtechnique, + majorTechnique, + MockData.A0000, + MockData.C0000, + MockData.DC0000, + MockData.DS0000, + MockData.G0000, + MockData.matrixSDO, + MockData.M0000, + MockData.note, + MockData.malwareS0000, + MockData.toolS0001, + MockData.TA0000, + MockData.G0001usesT0000, + MockData.S0000usesT0000, + MockData.C0000usesT0000, + MockData.C0000attributedToG0000, + MockData.M0000mitigatesT0000, + MockData.T0000_000subtechniqueOfT0000, + MockData.T0000_001subtechniqueOfT0000, + MockData.DC0000detectsT0000, + MockData.T0000targetsA0000, + MockData.T0000_001revokedByT0000_000, + MockData.filteredM0001, + MockData.deprecatedMatrixSDO, + MockData.G0000usesT0000_000, + MockData.S0000usesT0000_000, + MockData.C0000usesT0000_000, + MockData.M0000mitigatesT0000_000, + MockData.DC0000detectsT0000_000, + MockData.C0001attributedToG0000, + MockData.C0001, + MockData.T0001targetsA0000, + MockData.T0000_Duplicate, + ], + }, + ]); + // compare versions + let result = mockService.compareVersions(oldDomain.id, newDomain.id); + console.log(result); + // validate comparison result + expect(result).toBeInstanceOf(VersionChangelog); + expect(result.newDomainVersionID).toEqual(newDomain.id); + expect(result.oldDomainVersionID).toEqual(oldDomain.id); + // validate parsed changes + expect(result.additions.length).toEqual(1); + expect(result.changes.length).toEqual(1); + expect(result.deprecations.length).toEqual(0); + expect(result.minor_changes.length).toEqual(1); + expect(result.revocations.length).toEqual(1); + expect(result.unchanged.length).toEqual(0); + }); }); - it('should test technique', () => { - dataService.subtechniquesEnabled = true; // enable to parse subs - dataService.setUpURLs(MockData.configData); - let technique_test = new Technique(MockData.T0002, [], dataService); - expect(technique_test.get_all_technique_tactic_ids()).toEqual([]); - }); + describe('StixObject tests', () => { + beforeEach(() => { + configService.versions = MockData.configData; + spyOn(DataService.prototype, 'setUpURLs').and.callThrough(); + mockService = new DataService(http, configService); + }); - it('should throw error if tactic does not exist', () => { - let technique_test = new Technique(MockData.T0001, [], dataService); - expect(() => { - technique_test.get_technique_tactic_id('impact'); - }).toThrowError(); - }); + it('should get technique in domain', () => { + mockService.domains[0].techniques = [new Technique(MockData.T0000, [], mockService)]; + let result = mockService.getTechnique('T0000', mockService.domains[0].id); + expect(result).toBeTruthy(); + expect(result).toBeInstanceOf(Technique); + expect(result.attackID).toEqual('T0000'); + }); - it('should parse stix bundle', () => { - dataService.subtechniquesEnabled = true; // enable to parse subs - dataService.setUpURLs(MockData.configData); - dataService.domains[0].relationships['group_uses'].set('intrusion-set-0', ['attack-pattern-0']); - dataService.domains[0].relationships['software_uses'].set('malware-0', ['attack-pattern-0']); - dataService.domains[0].relationships['campaign_uses'].set('campaign-0', ['attack-pattern-0']); - dataService.domains[0].relationships['mitigates'].set('mitigation-0', ['attack-pattern-0']); - dataService.domains[0].relationships['component_rel'].set('component-0', ['attack-pattern-0']); - dataService.domains[0].relationships['campaigns_attributed_to'].set('intrusion-set-0', ['attack-pattern-0']); - dataService.domains[0].relationships['targeted_assets'].set('asset-0', ['attack-pattern-0']); - let domain = dataService.domains[0]; - dataService.parseBundle(domain, MockData.stixBundleSDO); - // check data loaded - expect(domain.dataLoaded).toBeTrue(); - expect(domain.platforms).toEqual(MockData.T0000.x_mitre_platforms); - - // check objects parsed - let testObjectType = function (testArr, quantity, instance) { - expect(testArr.length).toEqual(quantity); - expect(testArr[0]).toBeInstanceOf(instance); - }; - testObjectType(domain.techniques, 4, Technique); - testObjectType(domain.subtechniques, 2, Technique); - testObjectType(domain.assets, 1, Asset); - testObjectType(domain.campaigns, 2, Campaign); - testObjectType(domain.dataComponents, 1, DataComponent); - testObjectType(domain.groups, 1, Group); - testObjectType(domain.matrices, 1, Matrix); - testObjectType(domain.mitigations, 1, Mitigation); - testObjectType(domain.notes, 1, Note); - testObjectType(domain.software, 2, Software); - testObjectType(domain.tactics, 1, Tactic); - // check filteredMitigation has been skipped - expect(domain.mitigations[0].id).not.toBe(MockData.filteredM0001.id); - // check deprecated matrix has been skipped - expect(domain.matrices[0].id).not.toBe(MockData.deprecatedMatrixSDO.id); - // check relationships parsed - let relationships = domain.relationships; - expect(relationships['campaign_uses'].size).toEqual(1); - expect(relationships['campaigns_attributed_to'].size).toEqual(1); - expect(relationships['component_rel'].size).toEqual(2); - expect(relationships['group_uses'].size).toEqual(1); - expect(relationships['mitigates'].size).toEqual(1); - expect(relationships['revoked_by'].size).toEqual(1); - expect(relationships['software_uses'].size).toEqual(1); - expect(relationships['subtechniques_of'].size).toEqual(1); - expect(relationships['targeted_assets'].size).toEqual(1); - }); + it('should test software', () => { + mockService.domains[0].relationships['software_uses'].set('malware-0', ['attack-pattern-0']); + let software_test = new Software(MockData.malwareS0000, mockService); + expect(software_test.relatedTechniques('enterprise-attack-13')).toEqual(['attack-pattern-0']); + software_test = new Software(MockData.M0000, mockService); // should return empty list because 'malware-0' is not in softwareUsesTechnique + expect(software_test.relatedTechniques('enterprise-attack-13')).toEqual([]); + }); - it('should update domain watchers', () => { - let functionSpy = spyOn(dataService, 'getCurrentVersion'); - dataService.setUpURLs(MockData.configData); - let domain = dataService.domains[0]; - domain.dataLoadedCallbacks = [dataService.getCurrentVersion]; - dataService.parseBundle(domain, MockData.stixBundleSDO); - expect(functionSpy).toHaveBeenCalled(); - }); + it('should test mitigation', () => { + mockService.domains[0].relationships['mitigates'].set('mitigation-0', ['attack-pattern-0']); + let mitigation_test = new Mitigation(MockData.M0000, mockService); + expect(mitigation_test.relatedTechniques('enterprise-attack-13')).toEqual(['attack-pattern-0']); + mitigation_test = new Mitigation(MockData.malwareS0000, mockService); // should return empty list because 'mitigation-0' is not in mitigationMitigates + expect(mitigation_test.relatedTechniques('enterprise-attack-13')).toEqual([]); + }); - it('should compare the same version as unchanged', () => { - dataService.setUpURLs([]); - let domain = dataService.domains[0]; - dataService.parseBundle(domain, MockData.stixBundleSDO); + it('should test asset', () => { + mockService.domains[0].relationships['targeted_assets'].set('asset-0', ['attack-pattern-0']); + let asset_test = new Asset(MockData.A0000, mockService); + expect(asset_test.relatedTechniques('enterprise-attack-13')).toEqual(['attack-pattern-0']); + asset_test = new Asset(MockData.malwareS0000, mockService); // should return empty list because 'mitigation-0' is not in mitigationMitigates + expect(asset_test.relatedTechniques('enterprise-attack-13')).toEqual([]); + }); - let result = dataService.compareVersions(domain.id, domain.id); - expect(result).toBeInstanceOf(VersionChangelog); - expect(result.newDomainVersionID).toEqual(domain.id); - expect(result.oldDomainVersionID).toEqual(domain.id); - expect(result.unchanged.length).toEqual(4); - }); + it('should test campaign', () => { + mockService.domains[0].relationships['campaign_uses'].set('campaign-0', ['attack-pattern-0']); + let campaign_test = new Campaign(MockData.C0000, mockService); + expect(campaign_test.relatedTechniques('enterprise-attack-13')).toEqual(['attack-pattern-0']); + campaign_test = new Campaign(MockData.malwareS0000, mockService); // should return empty list because 'mitigation-0' is not in mitigationMitigates + expect(campaign_test.relatedTechniques('enterprise-attack-13')).toEqual([]); + }); + + it('should test data components', () => { + let t1 = new Technique(MockData.T0000, [], mockService); + mockService.domains[0].techniques = [t1]; + let data_component_test = new DataComponent(MockData.DC0000, mockService); + mockService.domains[0].dataSources.set(MockData.DC0000.id, { + name: MockData.stixSDO.name, + external_references: MockData.DC0000.external_references, + }); + expect(data_component_test.source('enterprise-attack-13')).toEqual({ name: '', url: '' }); + mockService.domains[0].dataSources.set(MockData.DS0000.id, { + name: MockData.stixSDO.name, + external_references: MockData.DC0001.external_references, + }); + expect(data_component_test.source('enterprise-attack-13')).toEqual({ name: 'Name', url: 'test-url.com' }); + mockService.domains[0].relationships['component_rel'].set('data-component-0', ['attack-pattern-0']); + expect(data_component_test.techniques('enterprise-attack-13')).toEqual(['attack-pattern-0']); + }); + + it('should test group', () => { + mockService.domains[0].relationships['group_uses'].set('intrusion-set-0', ['attack-pattern-0']); + mockService.domains[0].relationships['campaign_uses'].set('intrusion-set-0', ['attack-pattern-0']); + mockService.domains[0].relationships['campaigns_attributed_to'].set('intrusion-set-0', ['intrusion-set-0']); + let group_test = new Group(MockData.G0000, mockService); + expect(group_test.relatedTechniques('enterprise-attack-13')).toEqual(['attack-pattern-0']); + group_test = new Group(MockData.malwareS0000, mockService); // should return empty list because 'mitigation-0' is not in mitigationMitigates + expect(group_test.relatedTechniques('enterprise-attack-13')).toEqual([]); + }); + + it('should test stixObject', () => { + let stixObject_test = new Campaign(MockData.G0000, mockService); + let group_test = new Group(MockData.G0001, mockService); + mockService.domains[0].relationships['revoked_by'].set('relationship-9', ['attack-pattern-1']); + mockService.domains[0].relationships['targeted_assets'].set('asset-0', ['attack-pattern-0']); + expect(stixObject_test.revoked_by('enterprise-attack-13')).toBeUndefined(); + mockService.domains[0].relationships['revoked_by'].set('intrusion-set-0', ['attack-pattern-1']); + expect(stixObject_test.revoked_by('enterprise-attack-13')).toEqual(['attack-pattern-1']); + let campaign_test = new Campaign(MockData.C0000, mockService); + expect(campaign_test.compareVersion(group_test)).toEqual(-1); + let asset_test = new Asset(MockData.invalidAsset, mockService, false); + expect(campaign_test.compareVersion(asset_test)).toEqual(0); + }); + + it('should test technique', () => { + let technique_test = new Technique(MockData.T0002, [], mockService); + expect(technique_test.get_all_technique_tactic_ids()).toEqual([]); + }); - it('should compare versions', () => { - let newVersion = { - name: 'ATT&CK v14', - version: '14', - domains: MockData.configData[0].domains, - }; - dataService.setUpURLs([MockData.configData[0], newVersion]); - - // should have two domains/versions - expect(dataService.domains.length).toEqual(2); - - // parse stix bundles into old domain - let [oldDomain, newDomain] = dataService.domains; - dataService.parseBundle(oldDomain, MockData.stixBundleSDO); - expect(oldDomain.dataLoaded).toBeTrue(); - - // deprecation - let deprecateSubtechnique = { ...MockData.T0000_000 }; - deprecateSubtechnique['x_mitre_deprecated'] = true; - deprecateSubtechnique['modified'] = '2002-01-01T01:01:00.000Z'; - // revocation - let revokeTechnique = { ...MockData.T0001 }; - revokeTechnique['revoked'] = true; - revokeTechnique['modified'] = '2002-01-01T01:01:00.000Z'; - // minor change - let minorTechnique = { ...MockData.T0000 }; - minorTechnique['modified'] = '2002-01-01T01:01:00.000Z'; - // major change - let majorTechnique = { ...MockData.T0003 }; - majorTechnique['x_mitre_version'] = '2.0'; - majorTechnique['modified'] = '2002-01-01T01:01:00.000Z'; - // update deprecated object - let deprecatedTechnique = { ...MockData.T0002 }; - deprecatedTechnique['modified'] = '2002-01-01T01:01:00.000Z'; - // update revoked object - let revokedSubtechnique = { ...MockData.T0000_001 }; - revokedSubtechnique['modified'] = '2002-01-01T01:01:00.000Z'; - // parse stix bundle into new domain - dataService.parseBundle(newDomain, [ - { - type: 'bundle', - id: 'bundle--1', - spec_version: '2.0', - objects: [ - MockData.T0004, - minorTechnique, - revokeTechnique, - deprecatedTechnique, - deprecateSubtechnique, - revokedSubtechnique, - majorTechnique, - MockData.A0000, - MockData.C0000, - MockData.DC0000, - MockData.DS0000, - MockData.G0000, - MockData.matrixSDO, - MockData.M0000, - MockData.note, - MockData.malwareS0000, - MockData.toolS0001, - MockData.TA0000, - MockData.G0001usesT0000, - MockData.S0000usesT0000, - MockData.C0000usesT0000, - MockData.C0000attributedToG0000, - MockData.M0000mitigatesT0000, - MockData.T0000_000subtechniqueOfT0000, - MockData.T0000_001subtechniqueOfT0000, - MockData.DC0000detectsT0000, - MockData.T0000targetsA0000, - MockData.T0000_001revokedByT0000_000, - MockData.filteredM0001, - MockData.deprecatedMatrixSDO, - MockData.G0000usesT0000_000, - MockData.S0000usesT0000_000, - MockData.C0000usesT0000_000, - MockData.M0000mitigatesT0000_000, - MockData.DC0000detectsT0000_000, - MockData.C0001attributedToG0000, - MockData.C0001, - MockData.T0001targetsA0000, - MockData.T0000_Duplicate, - ], - }, - ]); - // compare versions - let result = dataService.compareVersions(oldDomain.id, newDomain.id); - console.log(result); - // validate comparison result - expect(result).toBeInstanceOf(VersionChangelog); - expect(result.newDomainVersionID).toEqual(newDomain.id); - expect(result.oldDomainVersionID).toEqual(oldDomain.id); - // validate parsed changes - expect(result.additions.length).toEqual(1); - expect(result.changes.length).toEqual(1); - expect(result.deprecations.length).toEqual(1); - expect(result.minor_changes.length).toEqual(1); - expect(result.revocations.length).toEqual(1); - expect(result.unchanged.length).toEqual(0); + it('should throw error if tactic does not exist', () => { + let technique_test = new Technique(MockData.T0001, [], mockService); + expect(() => { + technique_test.get_technique_tactic_id('impact'); + }).toThrowError(); + }); }); }); diff --git a/nav-app/src/app/services/data.service.ts b/nav-app/src/app/services/data.service.ts index dfd30bef5..01ef6ba3d 100755 --- a/nav-app/src/app/services/data.service.ts +++ b/nav-app/src/app/services/data.service.ts @@ -6,22 +6,18 @@ import { fromPromise } from 'rxjs/observable/fromPromise'; import { Asset, Campaign, Domain, DataComponent, Group, Software, Matrix, Technique, Mitigation, Note } from '../classes/stix'; import { TaxiiConnect, Collection } from '../utils/taxii2lib'; import { Version, VersionChangelog } from '../classes'; +import { ConfigService } from './config.service'; @Injectable({ providedIn: 'root', }) export class DataService { - public subscription; - constructor(private http: HttpClient) { + constructor( + private http: HttpClient, + private configService: ConfigService + ) { console.debug('initializing data service'); - this.subscription = this.getConfig().subscribe({ - next: (config) => { - this.setUpURLs(config['versions']); - }, - complete: () => { - if (this.subscription) this.subscription.unsubscribe(); - }, //prevent memory leaks - }); + this.setUpURLs(configService.versions); } public domain_backwards_compatibility = { @@ -31,8 +27,6 @@ export class DataService { public domains: Domain[] = []; public versions: Version[] = []; - public subtechniquesEnabled: boolean = true; - /** * Callback functions passed to this function will be called after data is loaded * @param {string} domainVersionID the ID of the domain and version to load @@ -98,7 +92,7 @@ export class DataService { domain.mitigations.push(new Mitigation(sdo, this)); break; case 'relationship': - if (sdo.relationship_type == 'subtechnique-of' && this.subtechniquesEnabled) { + if (sdo.relationship_type == 'subtechnique-of' && this.configService.subtechniquesEnabled) { // record subtechnique:technique relationship if (domain.relationships['subtechniques_of'].has(sdo.target_ref)) { let ids = domain.relationships['subtechniques_of'].get(sdo.target_ref); @@ -191,7 +185,7 @@ export class DataService { //create techniques for (let techniqueSDO of techniqueSDOs) { let subtechniques: Technique[] = []; - if (this.subtechniquesEnabled) { + if (this.configService.subtechniquesEnabled) { if (domain.relationships.subtechniques_of.has(techniqueSDO.id)) { domain.relationships.subtechniques_of.get(techniqueSDO.id).forEach((sub_id) => { if (idToTechniqueSDO.has(sub_id)) { @@ -299,17 +293,6 @@ export class DataService { this.lowestSupportedVersion = this.versions[this.versions.length - 1]; } - /** - * get the current config - * @param {boolean} refresh: if true fetches the config from file. Otherwise, only fetches if it's never been fetched before - */ - getConfig(refresh: boolean = false) { - if (refresh || !this.configData$) { - this.configData$ = this.http.get('./assets/config.json'); - } - return this.configData$; - } - /** * Fetch the domain data from the endpoint */ @@ -354,13 +337,14 @@ export class DataService { let domain = this.getDomain(domainVersionID); if (domain) { if (domain.dataLoaded && !refresh) resolve(null); - this.subscription = this.getDomainData(domain, refresh).subscribe({ + let subscription; + subscription = this.getDomainData(domain, refresh).subscribe({ next: (data: Object[]) => { this.parseBundle(domain, data); resolve(null); }, complete: () => { - if (this.subscription) this.subscription.unsubscribe(); + if (subscription) subscription.unsubscribe(); }, //prevent memory leaks }); } else if (!domain) { diff --git a/nav-app/src/app/services/viewmodels.service.spec.ts b/nav-app/src/app/services/viewmodels.service.spec.ts index 8bce05691..15b456113 100755 --- a/nav-app/src/app/services/viewmodels.service.spec.ts +++ b/nav-app/src/app/services/viewmodels.service.spec.ts @@ -1,4 +1,4 @@ -import { TestBed, inject } from '@angular/core/testing'; +import { TestBed } from '@angular/core/testing'; import { HttpClientTestingModule } from '@angular/common/http/testing'; import { MatDialogModule } from '@angular/material/dialog'; import { ViewModelsService } from './viewmodels.service'; @@ -6,6 +6,7 @@ import { TechniqueVM, LayoutOptions, Metadata, ViewModel, Link, VersionChangelog import { Technique, Tactic, Matrix } from '../classes/stix'; import tinygradient from 'tinygradient'; import * as MockData from '../../tests/utils/mock-data'; +import { ConfigService } from './config.service'; describe('ViewmodelsService', () => { let viewModelsService: ViewModelsService; @@ -15,6 +16,9 @@ describe('ViewmodelsService', () => { imports: [HttpClientTestingModule, MatDialogModule], providers: [ViewModelsService], }); + // set up config service + let configService = TestBed.inject(ConfigService); + configService.versions = MockData.configData; viewModelsService = TestBed.inject(ViewModelsService); }); @@ -264,44 +268,6 @@ describe('ViewmodelsService', () => { expect(vm1.gradient.colors.length).toEqual(3); }); - it('should copy annotations from one technique VM to another', () => { - let technique_list: Technique[] = []; - let idToTacticSDO = new Map(); - idToTacticSDO.set('tactic-0', MockData.TA0000); - let to_vm = viewModelsService.newViewModel('test1', 'enterprise-attack-13'); - to_vm.dataService.setUpURLs(MockData.configDataExtended); - let matrix = new Matrix(MockData.matrixSDO, idToTacticSDO, technique_list, to_vm.dataService); - to_vm.dataService.domains[0].matrices = [matrix]; - let t1 = new Technique(MockData.T0000, [], null); - technique_list.push(t1); - let to_tvm_1 = new TechniqueVM('T0000^tactic-name'); - let t2 = new Technique(MockData.T0001, [], null); - let to_tvm_2 = new TechniqueVM('T0001^tactic-name'); - technique_list.push(t2); - to_vm.setTechniqueVM(to_tvm_1); - to_vm.setTechniqueVM(to_tvm_2); - to_vm.dataService.domains[0].techniques.push(t1); - to_vm.dataService.domains[0].techniques.push(t2); - let tactic1 = new Tactic(MockData.TA0000, technique_list, null); - to_vm.versionChangelog = new VersionChangelog('enterprise-attack-12', 'enterprise-attack-13'); - expect(to_vm.versionChangelog.length()).toEqual(0); - to_vm.versionChangelog.minor_changes = ['T0000']; - to_vm.versionChangelog.unchanged = ['T0001']; - let from_vm = viewModelsService.newViewModel('test2', 'enterprise-attack-12'); - let from_tvm_1 = new TechniqueVM('T0001^tactic-name'); - from_tvm_1.score = '2'; - from_tvm_1.comment = 'test'; - from_vm.setTechniqueVM(to_tvm_1); - from_vm.setTechniqueVM(from_tvm_1); - from_vm.dataService.domains[1].techniques.push(t1); - from_vm.dataService.domains[1].techniques.push(t2); - to_vm.compareTo = from_vm; - to_vm.initCopyAnnotations(); - expect(to_vm.getTechniqueVM(t2, tactic1).score).toEqual('2'); - to_vm.revertCopy(t1, t2, tactic1); - expect(to_vm.getTechniqueVM(t2, tactic1).score).toEqual(''); - }); - const validTechniqueVMRep = '{"comment": "test comment","color": "#ffffff", "score": 1,"enabled": true,"showSubtechniques": false}'; const techniqueID = 'T0001'; const tacticName = 'tactic-name'; @@ -616,7 +582,6 @@ describe('ViewmodelsService', () => { it('should throw errors for deserializing domain version', () => { let vm1 = viewModelsService.newViewModel('test1', 'enterprise-attack-13'); - vm1.dataService.setUpURLs(MockData.configData); let viewmodel_error_file1 = { versions: { attack: 6, @@ -629,7 +594,6 @@ describe('ViewmodelsService', () => { it('should test versions for layer format 3', () => { let vm1 = viewModelsService.newViewModel('test1', 'enterprise-attack-13'); - vm1.dataService.setUpURLs(MockData.configData); let viewmodel_version_file1 = { version: 6, }; @@ -638,7 +602,6 @@ describe('ViewmodelsService', () => { it('should test patch for old domain name convention', () => { let vm1 = viewModelsService.newViewModel('test1', 'enterprise-attack-13'); - vm1.dataService.setUpURLs(MockData.configData); let viewmodel_version_file1 = { domain: 'mitre-enterprise', }; @@ -648,7 +611,6 @@ describe('ViewmodelsService', () => { it('should check values', () => { let vm1 = viewModelsService.newViewModel('test1', 'enterprise-attack-13'); - vm1.dataService.setUpURLs(MockData.configData); let tvm_1 = new TechniqueVM('T0000^tactic-name'); let l1 = new Link(); l1.label = 'test1'; @@ -677,7 +639,6 @@ describe('ViewmodelsService', () => { it('should load vm data with custom url', () => { let vm1 = viewModelsService.newViewModel('test1', 'enterprise-attack-13'); - vm1.dataService.setUpURLs(MockData.configData); vm1.dataService.domains[0].urls = ['https://raw.githubusercontent.com/mitre/cti/master/enterprise-attack/enterprise-attack.json']; vm1.dataService.domains[0].isCustom = true; expect(vm1.loadVMData()).toBeUndefined(); diff --git a/nav-app/src/app/tabs/tabs.component.html b/nav-app/src/app/tabs/tabs.component.html index 2d1aa01ce..dbe6dd590 100755 --- a/nav-app/src/app/tabs/tabs.component.html +++ b/nav-app/src/app/tabs/tabs.component.html @@ -496,7 +496,7 @@

Default Layers

Navigator Features

- +
diff --git a/nav-app/src/app/tabs/tabs.component.spec.ts b/nav-app/src/app/tabs/tabs.component.spec.ts index 01b20f2a9..598aab8f9 100755 --- a/nav-app/src/app/tabs/tabs.component.spec.ts +++ b/nav-app/src/app/tabs/tabs.component.spec.ts @@ -1,10 +1,9 @@ import { ComponentFixture, TestBed, fakeAsync, flush, waitForAsync } from '@angular/core/testing'; import { HttpClientTestingModule } from '@angular/common/http/testing'; import { TabsComponent } from './tabs.component'; -import { MatDialogModule } from '@angular/material/dialog'; -import { MarkdownModule, MarkdownService } from 'ngx-markdown'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { DataService } from '../services/data.service'; -import { Filter, Gradient, Link, Metadata, TechniqueVM, ViewModel } from '../classes'; +import { Tab, TechniqueVM, Version, ViewModel } from '../classes'; import { HelpComponent } from '../help/help.component'; import { SvgExportComponent } from '../svg-export/svg-export.component'; import { MatSnackBar } from '@angular/material/snack-bar'; @@ -12,222 +11,150 @@ import { ChangelogComponent } from '../changelog/changelog.component'; import { LayerInformationComponent } from '../layer-information/layer-information.component'; import * as is from 'is_js'; import { HttpClient } from '@angular/common/http'; -import { Subscription, of } from 'rxjs'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { Technique } from '../classes/stix'; +import { of } from 'rxjs'; +import { Domain, Technique } from '../classes/stix'; +import { ConfigService } from '../services/config.service'; import * as MockLayers from '../../tests/utils/mock-layers'; import * as MockData from '../../tests/utils/mock-data'; describe('TabsComponent', () => { - let component: any; + let component: TabsComponent; let fixture: ComponentFixture; + let dialog: MatDialog; + let dataService: DataService; + let configService: ConfigService; + let http: HttpClient; let snackBar: MatSnackBar; - let httpClient: HttpClient; - let viewModel: ViewModel; - let bundles: any[] = [ - { - type: 'bundle', - id: 'bundle--0', - spec_version: '2.0', - objects: [], - }, - ]; - - beforeEach(waitForAsync(() => { + + let testTab = new Tab('test tab', true, false, 'enterprise-attack', true); + let loadData = { + url: 'https://raw.githubusercontent.com/mitre-attack/attack-navigator/master/layers/data/samples/Bear_APT.json', + version: '14', + identifier: 'enterprise-attack', + }; + + beforeEach(() => { TestBed.configureTestingModule({ - imports: [HttpClientTestingModule, MatDialogModule, BrowserAnimationsModule, MarkdownModule.forRoot({ loader: HttpClient })], - declarations: [TabsComponent], - providers: [ - { - provide: MatSnackBar, - useValue: { - open() { - return { - onAction: () => of({}), - }; - }, - }, - }, - DataService, - MarkdownService, - ], + imports: [HttpClientTestingModule, MatDialogModule], + providers: [DataService, { provide: MatSnackBar, useValue: {} }], }).compileComponents(); + dialog = TestBed.inject(MatDialog); + configService = TestBed.inject(ConfigService); + configService.versions = []; + configService.banner = 'test banner'; + configService.defaultLayers = MockData.defaultLayersDisabled; + dataService = TestBed.inject(DataService); + http = TestBed.inject(HttpClient); snackBar = TestBed.inject(MatSnackBar); - httpClient = TestBed.inject(HttpClient); fixture = TestBed.createComponent(TabsComponent); component = fixture.debugElement.componentInstance; - fixture.detectChanges(); - viewModel = new ViewModel('layer', '1', 'enterprise-attack-13', null); - })); - - it('should set up data in constructor', () => { - spyOn(DataService.prototype, 'setUpURLs').and.stub(); - spyOn(DataService.prototype, 'getConfig').and.returnValue(of(MockData.configData)); - TabsComponent.prototype.subscription = new Subscription(); - spyOn(TabsComponent.prototype, 'getNamedFragmentValue').and.returnValues( - ['https://raw.githubusercontent.com/mitre/cti/ATT%26CK-v14.1/enterprise-attack/enterprise-attack.json'], - ['13'], - ['defending-iaas'], - ['https://raw.githubusercontent.com/mitre-attack/attack-navigator/master/layers/data/samples/Bear_APT.json'] - ); - expect(component).toBeTruthy(); }); - it('should create new tab', () => { - component.newBlankTab(); - expect(component.layerTabs.length).toEqual(1); - }); + describe('constructor', () => { + beforeEach(() => { + configService.defaultLayers = MockData.defaultLayersEnabled; + }); - it('should close all open tabs and create new one', () => { - let vm1 = component.viewModelsService.newViewModel('layer', 'enterprise-attack-13'); - vm1.sidebarContentType = 'search'; - component.openTab('new layer 1', vm1, true, true, true, true); - component.openTab('new tab', viewModel, true, true, true, true); - component.openTab('new tab', viewModel, true, true, true, true); - component.openTab('new layer 1', viewModel, true, true, false, true); - component.closeTab(component.layerTabs[0]); - component.closeTab(component.layerTabs[0]); - expect(component.layerTabs.length).toEqual(1); - }); + it('should create TabsComponent', () => { + fixture = TestBed.createComponent(TabsComponent); + component = fixture.debugElement.componentInstance; + expect(component).toBeTruthy(); + }); - it('should close one tab', () => { - component.openTab('new layer1', viewModel, true, true, true, true); - component.openTab('new layer', viewModel, true, true, true, true); - component.closeTab(component.layerTabs[0]); - expect(component.layerTabs.length).toEqual(1); - }); + it('should call newBlankTab on initialization', () => { + let blankTabSpy = spyOn(TabsComponent.prototype, 'newBlankTab'); + fixture = TestBed.createComponent(TabsComponent); + component = fixture.debugElement.componentInstance; + expect(blankTabSpy).toHaveBeenCalled(); + }); - it('should set the active tab to the latest created tab', () => { - component.openTab('new layer', viewModel, true, true, true, true); - component.openTab('new layer1', viewModel, true, true, true, true); - component.selectTab(component.layerTabs[1]); - component.closeTab(component.layerTabs[1]); - component.openTab('new layer2', viewModel, true, true, true, true); - expect(component.activeTab.title).toEqual('new layer2'); - }); + it('should call loadTabs with default layers and handle success', () => { + let loadTabsSuccess = spyOn(TabsComponent.prototype, 'loadTabs').and.returnValue(Promise.resolve()); + fixture = TestBed.createComponent(TabsComponent); + component = fixture.debugElement.componentInstance; + expect(loadTabsSuccess).toHaveBeenCalledWith(MockData.defaultLayersEnabled); + }); - it('should close the latest created tab', () => { - component.openTab('new layer', viewModel, true, true, true, true); - component.openTab('new layer1', viewModel, true, true, true, true); - component.openTab('new layer2', viewModel, true, true, true, true); - component.closeTab(component.layerTabs[0]); //closes new layer 2 - expect(component.isAlphabetical('newlayer')).toEqual(true); - expect(component.activeTab.title).toEqual('new layer1'); + it('should set bannerContent from ConfigService', () => { + fixture = TestBed.createComponent(TabsComponent); + component = fixture.debugElement.componentInstance; + expect(component.bannerContent).toEqual(configService.banner); + }); }); - it('should create new layer', () => { - component.dataService.setUpURLs(MockData.configData); - expect(component.latestDomains.length).toEqual(1); - component.newLayer('enterprise-attack-13'); - expect(component.layerTabs.length).toEqual(1); - component.newLayer('enterprise-attack-13', JSON.parse(JSON.stringify(MockLayers.layerFile1))); - expect(component.layerTabs.length).toEqual(2); - }); + describe('ngAfterViewInit', () => { + it('should open Safari warning for Safari version <= 13', () => { + spyOn(is, 'safari').withArgs('<=13').and.returnValue(true); + let dialogSpy = spyOn(dialog, 'open'); + component.ngAfterViewInit(); + expect(dialogSpy).toHaveBeenCalled(); + }); - it('should check if feature is defined in config file', () => { - component.configService.setFeature_object(MockData.configTechniqueControls); - expect(component.hasFeature('manual_color')).toBeTrue(); + it('should not open Safari warning for Safari version > 13 or non-Safari browsers', () => { + spyOn(is, 'safari').withArgs('<=13').and.returnValue(false); + let dialogSpy = spyOn(dialog, 'open'); + component.ngAfterViewInit(); + expect(dialogSpy).not.toHaveBeenCalled(); + }); }); - it('should open dialog', () => { - const openDialogSpy = spyOn(component.dialog, 'open'); - component.openDialog('layers'); - expect(openDialogSpy).toHaveBeenCalledWith(LayerInformationComponent, { - maxWidth: '90ch', - }); - component.openDialog('help'); - const settings = { maxWidth: '75ch', panelClass: component.userTheme }; - expect(openDialogSpy).toHaveBeenCalledWith(HelpComponent, settings); - component.openDialog('changelog'); - expect(openDialogSpy).toHaveBeenCalledWith(ChangelogComponent, settings); - spyOn(is, 'safari').and.returnValue(true); - component.ngAfterViewInit(); - expect(openDialogSpy).toHaveBeenCalledWith(component.safariWarning, { - width: '350px', - disableClose: true, - panelClass: component.userTheme, + describe('loadTabs', () => { + it('should load bundle when all fragment values are provided', async () => { + let bundleURL = 'testbundleurl'; + let bundleVersion = '1'; + let bundleDomain = 'enterprise-attack'; + spyOn(component, 'getNamedFragmentValue').and.returnValues([bundleURL], [bundleVersion], [bundleDomain]); + let newLayerSpy = spyOn(component, 'newLayerFromURL'); + await component.loadTabs(MockData.defaultLayersDisabled); + expect(newLayerSpy).toHaveBeenCalledWith({ url: bundleURL, version: bundleVersion, identifier: bundleDomain }); }); - }); - it('should open svg dialog', () => { - const openDialogSpy = spyOn(component.dialog, 'open'); - component.openSVGDialog(viewModel); - const settings = { - data: { vm: viewModel }, - panelClass: ['dialog-custom', component.userTheme], - }; - expect(openDialogSpy).toHaveBeenCalledWith(SvgExportComponent, settings); - }); + it('should load layers from URL when provided', async () => { + let layerURLs = ['testlayerurl1', 'testlayerurl2']; + spyOn(component, 'getNamedFragmentValue') + .and.returnValue([]) // return empty list for bundle fragments + .withArgs('layerURL') + .and.returnValue(layerURLs); + let loadLayerSpy = spyOn(component, 'loadLayerFromURL'); + await component.loadTabs(MockData.defaultLayersDisabled); + expect(loadLayerSpy).toHaveBeenCalledTimes(layerURLs.length); + }); - it('should adjust the height', () => { - component.adjustHeader(5); - expect(component.adjustedHeaderHeight).toEqual(5); - }); + it('should not load default layers when disabled', async () => { + spyOn(component, 'getNamedFragmentValue').and.returnValue([]); // return empty list for all fragments + let loadLayerSpy = spyOn(component, 'loadLayerFromURL'); + await component.loadTabs(MockData.defaultLayersDisabled); + expect(loadLayerSpy).not.toHaveBeenCalled(); + }); - it('should handle tab click', () => { - component.newBlankTab(); - component.handleTabClick(component.layerTabs[0]); - component.newBlankTab(); - component.handleTabClick(component.layerTabs[0]); - expect(component.activeTab).toEqual(component.layerTabs[0]); + it('should load default layers when enabled', async () => { + spyOn(component, 'getNamedFragmentValue').and.returnValue([]); // return empty list for all fragments + let loadLayerSpy = spyOn(component, 'loadLayerFromURL'); + await component.loadTabs(MockData.defaultLayersEnabled); + expect(loadLayerSpy).toHaveBeenCalledTimes(MockData.defaultLayersEnabled.urls.length); + }); }); - it('should handle links', () => { - component.customizedConfig = [ - { - name: 'technique_controls', - enabled: true, - description: 'Disable to disable all subfeatures', - subfeatures: [{ name: 'disable_techniques', enabled: false, description: 'Disable to remove the ability to disable techniques.' }], - }, - { name: 'sticky_toolbar', enabled: false }, - ]; - expect(component.getNamedFragmentValue('sticky_toolbar')).toEqual([]); - expect(component.getNamedFragmentValue('sticky_toolbar', 'https://mitre-attack.github.io/attack-navigator/#sticky_toolbar=false')).toEqual([ - 'false', - ]); - expect(component.trackByFunction(1)).toEqual(1); - component.addLayerLink(); - expect(component.layerLinkURLs.length).toEqual(1); - component.addLayerLink(); - component.removeLayerLink(1); - expect(component.layerLinkURLs.length).toEqual(1); - component.getLayerLink(); - component.removeLayerLink(0); - let url_string = component.getLayerLink(); - expect(url_string).toContain('disable_techniques=false&sticky_toolbar=false'); - }); + describe('openTab', () => { + let existingTab = new Tab('existing test tab', true, false, 'enterprise-attack', true); + let selectTabSpy; + let closeActiveTabSpy; - it('should create new layer by operation based on user input', async () => { - component.opSettings.scoreExpression = 'a+b'; - component.opSettings.domain = 'enterprise-atack-13'; - let vm1 = component.viewModelsService.newViewModel('layer', 'enterprise-attack-13'); - let vm2 = component.viewModelsService.newViewModel('layer1', 'enterprise-attack-13'); - component.openTab('layer', vm1, true, true, true, true); - component.openTab('layer1', vm2, true, true, true, true); - expect(component.getScoreExpressionError()).toEqual('Layer b does not match the chosen domain'); - component.dataService.setUpURLs(MockData.configData); // set up data - component.opSettings.domain = 'enterprise-attack-13'; - expect(component.getFilteredVMs()).toEqual(component.viewModelsService.viewModels); - spyOn(component.dataService, 'loadDomainData').and.returnValue(Promise.resolve()); - component.dataService.getDomain(component.opSettings.domain).dataLoaded = false; - await component.layerByOperation(); - expect(component.layerTabs.length).toEqual(3); - }); + beforeEach(() => { + component.layerTabs = []; // reset tabs + component.activeTab = undefined; - it('should create new layer by operation based on user input when data is loaded', async () => { - component.opSettings.scoreExpression = 'a+2'; - let vm1 = component.viewModelsService.newViewModel('layer', 'enterprise-attack-13'); - component.openTab('layer', vm1, true, true, true, true); - expect(component.getScoreExpressionError()).toEqual(null); - component.dataService.setUpURLs(MockData.configData); // set up data - component.dataService.parseBundle(component.dataService.getDomain('enterprise-attack-13'), bundles); //load the data - component.opSettings.domain = 'enterprise-attack-13'; - spyOn(component.dataService, 'loadDomainData').and.returnValue(Promise.resolve()); - await component.layerByOperation(); - expect(component.layerTabs.length).toEqual(2); - }); + selectTabSpy = spyOn(component, 'selectTab'); + closeActiveTabSpy = spyOn(component, 'closeActiveTab'); + }); + it('should change to existing tab', () => { + component.layerTabs = [existingTab]; + component.openTab(existingTab.title, null, existingTab.isCloseable, true, false); + expect(selectTabSpy).toHaveBeenCalledWith(existingTab); + }); + +<<<<<<< HEAD it('should emit on theme change', () => { spyOn(component.onUserThemeChange, 'emit'); component.handleUserThemeChange('dark'); @@ -322,335 +249,595 @@ describe('TabsComponent', () => { let blob = new Blob([JSON.stringify(MockLayers.layerFile2)], { type: 'text/json' }); let file = new File([blob], 'layer-2.json'); component.readJSONFile(file).then(() => { +======= + it('should create and select new tab', () => { + component.openTab('new test tab', null, false, false, true); +>>>>>>> develop expect(component.layerTabs.length).toEqual(1); + expect(component.layerTabs[0].title).toEqual('new test tab'); + expect(selectTabSpy).toHaveBeenCalledWith(component.layerTabs[0]); + expect(closeActiveTabSpy).not.toHaveBeenCalled(); }); - let layer = MockLayers.layerFile2; - layer.viewMode = 2; - blob = new Blob([JSON.stringify(layer)], { type: 'text/json' }); - file = new File([blob], 'layer-2.json'); - component.readJSONFile(file).then(() => { + + it('should replace the active tab', () => { + component.layerTabs = [existingTab]; + component.activeTab = existingTab; + component.openTab('new test tab', null, false, true, true); + console.log('mytest', component.layerTabs); expect(component.layerTabs.length).toEqual(2); + expect(component.layerTabs[0].title).toEqual('new test tab'); + expect(selectTabSpy).toHaveBeenCalledWith(component.layerTabs[0]); + expect(closeActiveTabSpy).not.toHaveBeenCalled(); }); - layer.viewMode = 0; - blob = new Blob([JSON.stringify(layer)], { type: 'text/json' }); - file = new File([blob], 'layer-2.json'); - component.readJSONFile(file).then(() => { + + it('should close current tab and select new tab', () => { + let newTab = new Tab('new tab', true, false, 'enterprise-attack', true); + component.layerTabs = [existingTab, newTab]; + component.activeTab = newTab; + component.openTab('new test tab', null, false, true, true); expect(component.layerTabs.length).toEqual(3); + expect(component.layerTabs[1].title).toEqual('new test tab'); + expect(selectTabSpy).toHaveBeenCalledWith(component.layerTabs[1]); + expect(closeActiveTabSpy).toHaveBeenCalled(); }); - })); - it('should get unique layer names', () => { - component.dataService.setUpURLs(MockData.configData); - expect(component.latestDomains.length).toEqual(1); - component.newLayer('enterprise-attack-13'); - component.newLayer('enterprise-attack-13'); - let vm1 = component.viewModelsService.newViewModel('layer2', 'enterprise-attack-13'); - let vm1_name = component.getUniqueLayerName('layer'); - component.openTab(vm1_name, vm1, true, true, true, true); - expect(component.layerTabs.length).toEqual(3); + it('should reset dropdown when selecting new tab', () => { + component.dropdownEnabled = 'comment'; + component.openTab('new test tab', null, false, false, true); + expect(component.dropdownEnabled).toEqual(''); + }); + + it('should not reset dropdown when replacing active tab', () => { + component.dropdownEnabled = 'comment'; + component.layerTabs = [existingTab]; + component.activeTab = existingTab; + component.openTab('new test tab', null, false, true, true); + expect(component.dropdownEnabled).toEqual('comment'); + }); }); - it('should upgrade layer', waitForAsync(() => { - component.dataService.setUpURLs(MockData.configDataExtended); - let layer = JSON.parse(JSON.stringify(MockLayers.layerFile1)); - let vm1 = component.viewModelsService.newViewModel('layer2', 'enterprise-attack-12'); - let versionUpgradeSpy = spyOn(component, 'versionUpgradeDialog').and.returnValue( - Promise.resolve({ oldID: 'enterprise-attack-12', newID: 'enterprise-attack-13' }) - ); - spyOn(component.dataService, 'loadDomainData').and.returnValue(of(null)); - component.upgradeLayer(vm1, layer, false, false).then(() => { - expect(versionUpgradeSpy).toHaveBeenCalled(); + describe('close tab', () => { + let firstTab = new Tab('first tab', true, false, 'enterprise-attack', true); + let secondTab = new Tab('second tab', true, false, 'enterprise-attack', true); + let selectTabSpy; + let newBlankTabSpy; + + beforeEach(() => { + component.layerTabs = []; // reset tabs + component.activeTab = undefined; + + selectTabSpy = spyOn(component, 'selectTab'); + newBlankTabSpy = spyOn(component, 'newBlankTab'); }); - fixture.whenStable().then(() => { + + it('should close the first tab and select the second tab', () => { + component.layerTabs = [firstTab, secondTab]; + component.activeTab = firstTab; + component.closeTab(firstTab); + expect(component.layerTabs.length).toEqual(1); + expect(component.layerTabs[0]).toBe(secondTab); + expect(selectTabSpy).toHaveBeenCalledWith(secondTab); + expect(newBlankTabSpy).not.toHaveBeenCalled(); }); - })); - it('should not upgrade layer', waitForAsync(() => { - component.dataService.setUpURLs(MockData.configDataExtended); - let layer = JSON.parse(JSON.stringify(MockLayers.layerFile1)); - let vm1 = component.viewModelsService.newViewModel('layer2', 'enterprise-attack-12'); - let versionUpgradeSpy = spyOn(component, 'versionUpgradeDialog').and.returnValue(Promise.resolve(null)); - spyOn(component.dataService, 'loadDomainData').and.returnValue(Promise.resolve()); - component.upgradeLayer(vm1, layer, false, false).then(() => { - expect(versionUpgradeSpy).toHaveBeenCalled(); - }); - fixture.whenStable().then(() => { + it('should close the second tab and select the first', () => { + component.layerTabs = [firstTab, secondTab]; + component.activeTab = secondTab; + component.closeTab(secondTab); + expect(component.layerTabs.length).toEqual(1); + expect(component.layerTabs[0]).toBe(firstTab); + expect(selectTabSpy).toHaveBeenCalledWith(firstTab); + expect(newBlankTabSpy).not.toHaveBeenCalled(); }); - })); - it('should not upgrade layer with default layer enabled', waitForAsync(() => { - component.dataService.setUpURLs(MockData.configDataExtended); - let layer = JSON.parse(JSON.stringify(MockLayers.layerFile1)); - let vm1 = component.viewModelsService.newViewModel('layer2', 'enterprise-attack-12'); - spyOn(component.dataService, 'loadDomainData').and.returnValue(Promise.resolve()); - component.upgradeLayer(vm1, layer, false, true); - fixture.whenStable().then(() => { - expect(component.layerTabs.length).toEqual(1); + it('should close the only tab and create a new blank tab', () => { + component.layerTabs = [firstTab]; + component.activeTab = firstTab; + component.closeTab(firstTab); + + expect(component.layerTabs.length).toEqual(0); + expect(selectTabSpy).not.toHaveBeenCalled(); + expect(newBlankTabSpy).toHaveBeenCalled(); }); - })); - it('should not upgrade layer with default layer enabled and domain data loaded', waitForAsync(() => { - component.dataService.setUpURLs(MockData.configDataExtended); - component.dataService.parseBundle(component.dataService.getDomain('enterprise-attack-13'), bundles); - let bb = JSON.parse(JSON.stringify(MockLayers.layerFile1)); - let vm1 = component.viewModelsService.newViewModel('layer2', 'enterprise-attack-13'); - spyOn(component.dataService, 'loadDomainData').and.returnValue(Promise.resolve()); - component.upgradeLayer(vm1, bb, false, true); - fixture.whenStable().then(() => { + it('should close non-active tab', () => { + component.layerTabs = [firstTab, secondTab]; + component.activeTab = firstTab; + component.closeTab(secondTab); + expect(component.layerTabs.length).toEqual(1); + expect(component.layerTabs[0]).toBe(firstTab); + expect(selectTabSpy).not.toHaveBeenCalled(); + expect(newBlankTabSpy).not.toHaveBeenCalled(); }); - })); - it('should not upgrade layer with domain data loaded', waitForAsync(() => { - component.dataService.setUpURLs(MockData.configDataExtended); - component.dataService.parseBundle(component.dataService.getDomain('enterprise-attack-13'), bundles); - let layer = JSON.parse(JSON.stringify(MockLayers.layerFile1)); - let vm1 = component.viewModelsService.newViewModel('layer2', 'enterprise-attack-13'); - let st1 = new Technique(MockData.T0000_000, [], null); - let t1 = new Technique(MockData.T0000, [st1], null); - let tvm_1 = new TechniqueVM('T0000^tactic-name'); - tvm_1.showSubtechniques = true; - let stvm_1 = new TechniqueVM('0000.000^tactic-name'); - vm1.setTechniqueVM(tvm_1); - vm1.setTechniqueVM(stvm_1); - component.dataService.domains[0].techniques.push(t1); - let versionUpgradeSpy = spyOn(component, 'versionUpgradeDialog').and.returnValue(Promise.resolve(null)); - spyOn(component.dataService, 'loadDomainData').and.returnValue(Promise.resolve()); - component.upgradeLayer(vm1, layer, false, false).then(() => { - expect(versionUpgradeSpy).toHaveBeenCalled(); - }); - fixture.whenStable().then(() => { - expect(component.layerTabs.length).toEqual(1); + it('should close the only tab and not create a new one when allowNoTab is true', () => { + component.layerTabs = [firstTab]; + component.activeTab = firstTab; + component.closeTab(firstTab, true); + + expect(component.layerTabs.length).toEqual(0); + expect(selectTabSpy).not.toHaveBeenCalled(); + expect(newBlankTabSpy).not.toHaveBeenCalled(); }); - })); - it('should open version upgrade dialog with upgrade', waitForAsync(() => { - component.dataService.setUpURLs(MockData.configDataExtended); - let vm1 = component.viewModelsService.newViewModel('layer2', 'enterprise-attack-12'); - vm1.version = '12'; - const versionUpgradeSpy = spyOn(component.dialog, 'open').and.returnValue({ afterClosed: () => of({ upgrade: true }) }); - component.versionUpgradeDialog(vm1).then(() => { - expect(versionUpgradeSpy).toHaveBeenCalled(); + it('should close the active tab', () => { + component.activeTab = testTab; + spyOn(component, 'closeTab'); + component.closeActiveTab(); + expect(component.closeTab).toHaveBeenCalledWith(testTab, false); }); - })); + }); - it('should open version upgrade dialog with no upgrade', waitForAsync(() => { - component.dataService.setUpURLs(MockData.configDataExtended); - let vm1 = component.viewModelsService.newViewModel('layer2', 'enterprise-attack-12'); - vm1.version = '12'; - const versionUpgradeSpy = spyOn(component.dialog, 'open').and.returnValue({ afterClosed: () => of({ upgrade: false }) }); - component.versionUpgradeDialog(vm1).then(() => { - expect(versionUpgradeSpy).toHaveBeenCalled(); + describe('getUniqueLayerName', () => { + let viewModel = new ViewModel('layer', '1', 'enterprise-attack-13', null); + let viewModel1 = new ViewModel('layer1', '1', 'enterprise-attack-13', null); + const root = 'layer'; + + it('should return root layer name when no existing layers match root', () => { + component.viewModelsService.viewModels = []; + let rootLayerName = component.getUniqueLayerName(root); + expect(rootLayerName).toEqual(root); }); - })); - it('should serialize viewmodel and only save techniqueVMs which have been modified', waitForAsync(() => { - component.dataService.setUpURLs(MockData.configData); - let vm1 = component.viewModelsService.newViewModel('layer2', 'enterprise-attack-13'); - vm1.version = '13'; - let st1 = new Technique(MockData.T0000_000, [], null); - let t1 = new Technique(MockData.T0000, [st1], null); - let t2 = new Technique(MockData.T0001, [], null); - let tvm_1 = new TechniqueVM('T0000^tactic-name'); - tvm_1.score = '3'; - let stvm_1 = new TechniqueVM('T0000.000^tactic-name'); - let tvm_2 = new TechniqueVM('T0001^tactic-name'); - vm1.setTechniqueVM(tvm_1); - vm1.setTechniqueVM(tvm_2); - vm1.setTechniqueVM(stvm_1); - component.dataService.domains[0].techniques.push(t1); - component.dataService.domains[0].techniques.push(t2); - let m2 = new Metadata(); - m2.name = 'test1'; - m2.value = 't1'; - m2.divider = true; - vm1.metadata = [m2]; - let l1 = new Link(); - l1.label = 'test1'; - l1.url = 't1'; - let l2 = new Link(); - vm1.links = [l1, l2]; - vm1.serialize(); - tvm_1.showSubtechniques = true; - vm1.initTechniqueVMs(); - tvm_1.showSubtechniques = false; - vm1.layout.expandedSubtechniques = 'annotated'; - vm1.initTechniqueVMs(); - vm1.layout.expandedSubtechniques = 'all'; - vm1.initTechniqueVMs(); - expect(component).toBeTruthy(); - })); + it('should generate unique layer name when existing layer matches root exactly', () => { + component.viewModelsService.viewModels = [viewModel]; + let nextRootName = component.getUniqueLayerName(root); + expect(nextRootName).toEqual('layer1'); + }); - it('should serialize viewmodel and only save techniqueVMs which have been modified and are visible', waitForAsync(() => { - component.dataService.setUpURLs(MockData.configData); - let vm1 = component.viewModelsService.newViewModel('layer2', 'enterprise-attack-13'); - let t1 = new Technique(MockData.T0000, [], null); - let t2 = new Technique(MockData.T0001, [], null); - let tvm_1 = new TechniqueVM('T0000^tactic-name'); - let tvm_2 = new TechniqueVM('T0001^tactic-name'); - tvm_1.isVisible = true; - tvm_1.score = '3'; - vm1.setTechniqueVM(tvm_1); - vm1.setTechniqueVM(tvm_2); - component.dataService.domains[0].techniques.push(t1); - component.dataService.domains[0].techniques.push(t2); - vm1.dataService.domains[0].urls = ['https://raw.githubusercontent.com/mitre/cti/master/enterprise-attack/enterprise-attack.json']; - vm1.dataService.domains[0].isCustom = true; - vm1.serialize(true); - expect(component).toBeTruthy(); - })); + it('should generate unique layer name when multiple existing layers match root', () => { + component.viewModelsService.viewModels = [viewModel, viewModel1]; + let nextRootName = component.getUniqueLayerName(root); + expect(nextRootName).toEqual('layer2'); + }); + }); - it('should throw errors for deserializing viewmodel', waitForAsync(() => { - component.dataService.setUpURLs(MockData.configData); - let consoleSpy = spyOn(console, 'error'); - let vm1 = component.viewModelsService.newViewModel('layer2', 'enterprise-attack-13'); - let st1 = new Technique(MockData.T0000_001, [], null); - vm1.dataService.domains[0].techniques.push(new Technique(MockData.T0000, [st1], null)); - vm1.dataService.domains[0].techniques[0].subtechniques.push(st1); - vm1.dataService.domains[0].techniques.push(new Technique(MockData.T0002, [], null)); - vm1.deserialize(JSON.stringify(MockLayers.invalidLayerFile1)); - expect(consoleSpy).toHaveBeenCalled(); - })); + describe('tab utility functions', () => { + it('should handle links', () => { + configService.featureList = [ + { + name: 'technique_controls', + enabled: true, + description: 'Disable to disable all subfeatures', + subfeatures: [ + { name: 'disable_techniques', enabled: false, description: 'Disable to remove the ability to disable techniques.' }, + ], + }, + { name: 'sticky_toolbar', enabled: false }, + ]; + expect(component.getNamedFragmentValue('sticky_toolbar')).toEqual([]); + expect( + component.getNamedFragmentValue('sticky_toolbar', 'https://mitre-attack.github.io/attack-navigator/#sticky_toolbar=false') + ).toEqual(['false']); + expect(component.trackByFunction(1)).toEqual(1); + component.addLayerLink(); + expect(component.layerLinkURLs.length).toEqual(1); + component.addLayerLink(); + component.removeLayerLink(1); + expect(component.layerLinkURLs.length).toEqual(1); + component.getLayerLink(); + component.removeLayerLink(0); + let url_string = component.getLayerLink(); + expect(url_string).toContain('disable_techniques=false&sticky_toolbar=false'); + }); - it('should validate input and throw errors', waitForAsync(() => { - component.dataService.setUpURLs(MockData.configData); - let layer = JSON.parse(JSON.stringify(MockLayers.invalidLayerFile1)); - let alertSpy = spyOn(window, 'alert'); - let consoleSpy = spyOn(console, 'error'); - component.validateInput(layer, 'enterprise-attack-13'); - expect(consoleSpy).toHaveBeenCalled(); - expect(alertSpy).toHaveBeenCalled(); - })); + it('should copy link', fakeAsync(() => { + let mockedDocElement = document.createElement('input'); + mockedDocElement.id = 'layerLink'; + mockedDocElement.value = 'test1'; + mockedDocElement.type = 'text'; + document.getElementById = jasmine.createSpy('layerLink').and.returnValue(mockedDocElement); + const logSpy = spyOn(console, 'debug'); + component.copyLayerLink(); + flush(); + expect(logSpy).toHaveBeenCalledWith('copied', mockedDocElement.value); + })); + + it('should open upload prompt', fakeAsync(() => { + let mockedDocElement = document.createElement('input'); + mockedDocElement.id = 'uploader'; + mockedDocElement.value = 'test1'; + mockedDocElement.type = 'text'; + document.getElementById = jasmine.createSpy('uploader').and.returnValue(mockedDocElement); + const logSpy = spyOn(mockedDocElement, 'click'); + component.openUploadPrompt(); + flush(); + expect(logSpy).toHaveBeenCalled(); + })); + + it('should adjust the header height', () => { + let newHeight = 5; + component.adjustHeader(newHeight); + expect(component.adjustedHeaderHeight).toEqual(newHeight); + }); - it('should validate if the domainVersionID is unique', waitForAsync(() => { - component.dataService.setUpURLs(MockData.configData); - let layer = JSON.parse(JSON.stringify(MockLayers.invalidLayerFile1)); - let alertSpy = spyOn(window, 'alert'); - let consoleSpy = spyOn(console, 'error'); - component.validateInput(layer, 'enterprise-attack-13'); - expect(consoleSpy).toHaveBeenCalled(); - expect(alertSpy).toHaveBeenCalled(); - })); + it('should call openTab when opening a new blank tab', () => { + spyOn(component, 'openTab'); + component.newBlankTab(); + expect(component.openTab).toHaveBeenCalled(); + }); - it('should through error for invalid domain', waitForAsync(() => { - component.dataService.setUpURLs(MockData.configData); - spyOn(console, 'error'); - let blob = new Blob([JSON.stringify(MockLayers.layerFile1)], { type: 'text/json' }); - let file = new File([blob], 'layer-2.json'); - component.readJSONFile(file); - expect(component).toBeTruthy(); - })); + it('should select the specified tab', () => { + component.selectTab(testTab); + expect(component.activeTab).toBe(testTab); + }); - it('should read and open json file with 2 layers', waitForAsync(() => { - component.dataService.setUpURLs(MockData.configData); - let combined_layer = [MockLayers.layerFile3, MockLayers.layerFile3]; - let blob = new Blob([JSON.stringify(combined_layer)], { type: 'text/json' }); - let file = new File([blob], 'layer-2.json'); - component.readJSONFile(file); - expect(component).toBeTruthy(); - })); + it('should activate clicked tab and reset dropdown', () => { + let activeTab = new Tab('active tab', true, false, 'enterprise-attack', true); + let clickedTab = new Tab('clicked tab', true, false, 'enterprise-attack', true); + component.activeTab = activeTab; - it('should create new layer by operation based on user input when data is loaded errors', async () => { - component.opSettings.scoreExpression = 'a+b+2'; - expect(component.getScoreExpressionError()).toEqual('Variable b does not match any layers'); - let vm1 = component.viewModelsService.newViewModel('layer', 'enterprise-attack-13'); - let vm2 = component.viewModelsService.newViewModel('layer', 'enterprise-attack-12'); - component.openTab('layer', vm1, true, true, true, true); - component.openTab('layer2', vm2, true, true, true, true); - - component.dataService.setUpURLs(MockData.configDataExtended); // set up data - component.dataService.parseBundle(component.dataService.getDomain('enterprise-attack-13'), bundles); //load the data - component.opSettings.domain = 'enterprise-attack-13'; - let alertSpy = spyOn(window, 'alert'); - let consoleSpy = spyOn(console, 'error'); - component.layerByOperation(); - expect(consoleSpy).toHaveBeenCalled(); - expect(alertSpy).toHaveBeenCalled(); - }); + component.handleTabClick(clickedTab); - it('should load from url', waitForAsync(() => { - component.dataService.setUpURLs(MockData.configData); - component.http = httpClient; - spyOn(component.http, 'get').and.returnValue(of(MockLayers.layerFile1)); - let versionMismatchSpy = spyOn(component, 'versionMismatchWarning').and.returnValue(Promise.resolve([])); - let upgradeLayerSpy = spyOn(component, 'upgradeLayer').and.returnValue(Promise.resolve([])); - component - .loadLayerFromURL('https://raw.githubusercontent.com/mitre-attack/attack-navigator/master/layers/data/samples/Bear_APT.json') - .then(() => { - expect(versionMismatchSpy).toHaveBeenCalled(); - expect(upgradeLayerSpy).toHaveBeenCalled(); + expect(component.activeTab).toBe(clickedTab); + expect(component.dropdownEnabled).toEqual(''); + }); + + it('should toggle dropdown state if clicked tab is active', () => { + let activeTab = new Tab('active tab', true, false, 'enterprise-attack', true); + component.activeTab = activeTab; + component.dropdownEnabled = ''; + + component.handleTabClick(activeTab); + + expect(component.activeTab).toEqual(activeTab); + expect(component.dropdownEnabled).toEqual('description'); + + component.handleTabClick(activeTab); + + expect(component.activeTab).toEqual(activeTab); + expect(component.dropdownEnabled).toEqual(''); + }); + + it('should filter domains based on version', () => { + let v13 = new Version('ATT&CK v13', '13'); + let v12 = new Version('ATT&CK v12', '12'); + let domainv13 = new Domain('enterprise-attack-13', 'Enterprise ATT&CK', v13); + let domainv12 = new Domain('enterprise-attack-12', 'Enterprise ATT&CK', v12); + dataService.domains = [domainv13, domainv12]; + let filteredDomains = component.filterDomains(v12); + expect(filteredDomains).toEqual([domainv12]); + }); + + it('should return empty array if no domains match the version', () => { + let v13 = new Version('ATT&CK v13', '13'); + let v12 = new Version('ATT&CK v12', '12'); + let domainv13 = new Domain('enterprise-attack-13', 'Enterprise ATT&CK', v13); + dataService.domains = [domainv13]; + let filteredDomains = component.filterDomains(v12); + expect(filteredDomains).toEqual([]); + }); + + it('should check if feature is defined in config file', () => { + component.configService.setFeature_object(MockData.configTechniqueControls); + expect(component.hasFeature('manual_color')).toBeTrue(); + }); + + it('should emit event on theme change', () => { + spyOn(component.onUserThemeChange, 'emit'); + component.handleUserThemeChange('dark'); + expect(component.onUserThemeChange.emit).toHaveBeenCalled(); + }); + + it('should open the selected dialog', () => { + const settings = { maxWidth: '75ch', panelClass: component.userTheme }; + const openDialogSpy = spyOn(component.dialog, 'open'); + + // layer dialog + component.openDialog('layers'); + expect(openDialogSpy).toHaveBeenCalledWith(LayerInformationComponent, { + maxWidth: '90ch', }); - })); - it('should throw errors when loading from url', waitForAsync(() => { - let versions = [ - { - name: 'ATT&CK v13', - version: '13', - domains: [ - { - name: 'Mobile', - identifier: 'mobile-attack', - data: ['https://raw.githubusercontent.com/mitre/cti/ATT%26CK-v13.1/mobile-attack/attack-attack.json'], - }, - ], - }, - ]; - component.dataService.setUpURLs(versions); - component.http = httpClient; - spyOn(component.http, 'get').and.returnValue(of(MockLayers.layerFile1)); - let versionMismatchSpy = spyOn(component, 'versionMismatchWarning').and.returnValue(Promise.resolve([])); - let alertSpy = spyOn(window, 'alert'); - let consoleSpy = spyOn(console, 'error'); - component - .loadLayerFromURL('https://raw.githubusercontent.com/mitre-attack/attack-navigator/master/layers/data/samples/Bear_APT.json') - .then(() => { - expect(consoleSpy).toHaveBeenCalled(); - expect(alertSpy).toHaveBeenCalled(); - expect(versionMismatchSpy).toHaveBeenCalled(); + // help dialog + component.openDialog('help'); + expect(openDialogSpy).toHaveBeenCalledWith(HelpComponent, settings); + + // changelog dialog + component.openDialog('changelog'); + expect(openDialogSpy).toHaveBeenCalledWith(ChangelogComponent, settings); + }); + + it('should open the SVG exporter dialog', () => { + const openDialogSpy = spyOn(component.dialog, 'open'); + let viewModel = new ViewModel('layer', '1', 'enterprise-attack-13', null); + + component.openSVGDialog(viewModel); + const settings = { + data: { vm: viewModel }, + panelClass: ['dialog-custom', component.userTheme], + }; + expect(openDialogSpy).toHaveBeenCalledWith(SvgExportComponent, settings); + }); + + it('should create new layer from url', waitForAsync(() => { + component.dataService.domains[0].dataLoaded = true; + component.http = http; + spyOn(component.http, 'get').and.returnValue(of(MockLayers.layerFile1)); + spyOn(component.dataService, 'loadDomainData').and.returnValue(Promise.resolve()); + component.newLayerFromURL(loadData, JSON.parse(JSON.stringify(MockLayers.layerFile1))); + expect(component.dataService.domains.length).toEqual(3); + })); + + it('should read and open json file', waitForAsync(() => { + component.dataService.setUpURLs(MockData.configData); + let mockedDocElement = document.createElement('input'); + mockedDocElement.id = 'uploader'; + mockedDocElement.value = 'test1'; + mockedDocElement.type = 'text'; + document.getElementById = jasmine.createSpy('uploader').and.returnValue(mockedDocElement); + const logSpy = spyOn(mockedDocElement, 'click'); + component.openUploadPrompt(); + expect(logSpy).toHaveBeenCalled(); + let blob = new Blob([JSON.stringify(MockLayers.layerFile2)], { type: 'text/json' }); + let file = new File([blob], 'layer-2.json'); + component.readJSONFile(file).then(() => { + expect(component.layerTabs.length).toEqual(1); }); - })); + let layer = MockLayers.layerFile2; + layer.viewMode = 2; + blob = new Blob([JSON.stringify(layer)], { type: 'text/json' }); + file = new File([blob], 'layer-2.json'); + component.readJSONFile(file).then(() => { + expect(component.layerTabs.length).toEqual(2); + }); + layer.viewMode = 0; + blob = new Blob([JSON.stringify(layer)], { type: 'text/json' }); + file = new File([blob], 'layer-2.json'); + component.readJSONFile(file).then(() => { + expect(component.layerTabs.length).toEqual(3); + }); + })); + }); - it('should create new layer from url', waitForAsync(() => { - let loadData = { - url: 'https://raw.githubusercontent.com/mitre-attack/attack-navigator/master/layers/data/samples/Bear_APT.json', - version: '12', - identifier: 'enterprise-attack', - }; - component.dataService.setUpURLs(MockData.configData); - component.dataService.domains[0].dataLoaded = true; - component.http = httpClient; - spyOn(component.http, 'get').and.returnValue(of(MockLayers.layerFile1)); - spyOn(component.dataService, 'loadDomainData').and.returnValue(Promise.resolve()); - component.newLayerFromURL(loadData, JSON.parse(JSON.stringify(MockLayers.layerFile1))); - expect(component.dataService.domains.length).toEqual(2); // mobile-attack-13, enterprise-attack-12 - })); + describe('validateInput', () => { + it('should validate input and throw errors', waitForAsync(() => { + let layer = JSON.parse(JSON.stringify(MockLayers.invalidLayerFile1)); + let alertSpy = spyOn(window, 'alert'); + let consoleSpy = spyOn(console, 'error'); + component.validateInput(layer, 'enterprise-attack-13'); + expect(consoleSpy).toHaveBeenCalled(); + expect(alertSpy).toHaveBeenCalled(); + })); + + it('should validate if the domainVersionID is unique', waitForAsync(() => { + let layer = JSON.parse(JSON.stringify(MockLayers.invalidLayerFile1)); + let alertSpy = spyOn(window, 'alert'); + let consoleSpy = spyOn(console, 'error'); + component.validateInput(layer, 'enterprise-attack-13'); + expect(consoleSpy).toHaveBeenCalled(); + expect(alertSpy).toHaveBeenCalled(); + })); + }); - it('should load base data from URL', waitForAsync(() => { - component.dataService.setUpURLs(MockData.configData); - component.http = httpClient; - spyOn(component.http, 'get').and.returnValue(of(MockLayers.layerFile1)); - spyOn(component.dataService, 'loadDomainData').and.returnValue(Promise.resolve()); - })); + describe('layerByOperation', () => { + it('should create new layer by operation based on user input', () => { + component.opSettings.scoreExpression = 'a+b'; + component.opSettings.domain = 'enterprise-atack-13'; + let vm1 = component.viewModelsService.newViewModel('layer', 'enterprise-attack-13'); + let vm2 = component.viewModelsService.newViewModel('layer1', 'enterprise-attack-13'); + component.openTab('layer', vm1, true, true, true, true); + component.openTab('layer1', vm2, true, true, true, true); + expect(component.getScoreExpressionError()).toEqual('Layer b does not match the chosen domain'); + component.dataService.setUpURLs(MockData.configData); // set up data + component.opSettings.domain = 'enterprise-attack-13'; + expect(component.getFilteredVMs()).toEqual(component.viewModelsService.viewModels); + spyOn(component.dataService, 'loadDomainData').and.returnValue(Promise.resolve()); + component.dataService.getDomain(component.opSettings.domain).dataLoaded = false; + component.layerByOperation(); + expect(component.layerTabs.length).toEqual(2); + }); - it('should load layer from URL', waitForAsync(() => { - component.dataService.setUpURLs(MockData.configData); - component.http = httpClient; - spyOn(component.http, 'get').and.returnValue(of(MockLayers.layerFile1)); - spyOn(component.dataService, 'loadDomainData').and.returnValue(Promise.resolve()); - let versionMismatchSpy = spyOn(component, 'versionMismatchWarning').and.returnValue(Promise.resolve([])); - let upgradeLayerSpy = spyOn(component, 'upgradeLayer').and.returnValue(Promise.resolve([])); - spyOn(component, 'getNamedFragmentValue').and.returnValues( - [], - ['13'], - ['defending-iaas'], - ['https://raw.githubusercontent.com/mitre-attack/attack-navigator/master/layers/data/samples/Bear_APT.json'] - ); - component.loadTabs(MockData.defaultLayersDisabled).then(() => { - expect(versionMismatchSpy).toHaveBeenCalled(); - expect(upgradeLayerSpy).toHaveBeenCalled(); + it('should create new layer by operation based on user input when data is loaded', () => { + component.opSettings.scoreExpression = 'a+2'; + let vm1 = component.viewModelsService.newViewModel('layer', 'enterprise-attack-13'); + component.openTab('layer', vm1, true, true, true, true); + expect(component.getScoreExpressionError()).toEqual(null); + component.dataService.setUpURLs(MockData.configData); // set up data + component.dataService.parseBundle(component.dataService.getDomain('enterprise-attack-13'), MockData.stixBundleSDO); //load the data + component.opSettings.domain = 'enterprise-attack-13'; + spyOn(component.dataService, 'loadDomainData').and.returnValue(Promise.resolve()); + component.layerByOperation(); + expect(component.layerTabs.length).toEqual(2); }); - })); + + it('should create new layer by operation based on user input when data is loaded errors', async () => { + component.opSettings.scoreExpression = 'a+b+2'; + expect(component.getScoreExpressionError()).toEqual('Variable b does not match any layers'); + let vm1 = component.viewModelsService.newViewModel('layer', 'enterprise-attack-13'); + let vm2 = component.viewModelsService.newViewModel('layer', 'enterprise-attack-12'); + component.openTab('layer', vm1, true, true, true, true); + component.openTab('layer2', vm2, true, true, true, true); + + component.dataService.setUpURLs(MockData.configDataExtended); // set up data + component.dataService.parseBundle(component.dataService.getDomain('enterprise-attack-13'), MockData.stixBundleSDO); //load the data + component.opSettings.domain = 'enterprise-attack-13'; + let alertSpy = spyOn(window, 'alert'); + let consoleSpy = spyOn(console, 'error'); + component.layerByOperation(); + expect(consoleSpy).toHaveBeenCalled(); + expect(alertSpy).toHaveBeenCalled(); + }); + }); + + describe('versionUpgradeDialog', () => { + it('should upgrade layer', waitForAsync(() => { + component.dataService.setUpURLs(MockData.configDataExtended); + let layer = JSON.parse(JSON.stringify(MockLayers.layerFile1)); + let vm1 = component.viewModelsService.newViewModel('layer2', 'enterprise-attack-12'); + let versionUpgradeSpy = spyOn(component, 'versionUpgradeDialog').and.returnValue( + Promise.resolve({ oldID: 'enterprise-attack-12', newID: 'enterprise-attack-13' }) + ); + spyOn(component.dataService, 'loadDomainData').and.returnValue(Promise.resolve()); + component.upgradeLayer(vm1, layer, false, false).then(() => { + expect(versionUpgradeSpy).toHaveBeenCalled(); + }); + fixture.whenStable().then(() => { + expect(component.layerTabs.length).toEqual(2); + }); + })); + + it('should not upgrade layer', waitForAsync(() => { + component.dataService.setUpURLs(MockData.configDataExtended); + let layer = JSON.parse(JSON.stringify(MockLayers.layerFile1)); + let vm1 = component.viewModelsService.newViewModel('layer2', 'enterprise-attack-12'); + let versionUpgradeSpy = spyOn(component, 'versionUpgradeDialog').and.returnValue(Promise.resolve(null)); + spyOn(component.dataService, 'loadDomainData').and.returnValue(Promise.resolve()); + component.upgradeLayer(vm1, layer, false, false).then(() => { + expect(versionUpgradeSpy).toHaveBeenCalled(); + }); + fixture.whenStable().then(() => { + expect(component.layerTabs.length).toEqual(2); + }); + })); + + it('should not upgrade layer with domain data loaded', waitForAsync(() => { + component.dataService.setUpURLs(MockData.configDataExtended); + component.dataService.parseBundle(component.dataService.getDomain('enterprise-attack-13'), MockData.stixBundleSDO); + let layer = JSON.parse(JSON.stringify(MockLayers.layerFile1)); + let vm1 = component.viewModelsService.newViewModel('layer2', 'enterprise-attack-13'); + let st1 = new Technique(MockData.T0000_000, [], null); + let t1 = new Technique(MockData.T0000, [st1], null); + let tvm_1 = new TechniqueVM('T0000^tactic-name'); + tvm_1.showSubtechniques = true; + let stvm_1 = new TechniqueVM('0000.000^tactic-name'); + vm1.setTechniqueVM(tvm_1); + vm1.setTechniqueVM(stvm_1); + component.dataService.domains[0].techniques.push(t1); + let versionUpgradeSpy = spyOn(component, 'versionUpgradeDialog').and.returnValue(Promise.resolve(null)); + spyOn(component.dataService, 'loadDomainData').and.returnValue(Promise.resolve()); + component.upgradeLayer(vm1, layer, false, false).then(() => { + expect(versionUpgradeSpy).toHaveBeenCalled(); + }); + fixture.whenStable().then(() => { + expect(component.layerTabs.length).toEqual(2); + }); + })); + }); + + describe('upgradeLayer', () => { + it('should upgrade layer', waitForAsync(() => { + component.dataService.setUpURLs(MockData.configDataExtended); + let layer = JSON.parse(JSON.stringify(MockLayers.layerFile1)); + let vm1 = component.viewModelsService.newViewModel('layer2', 'enterprise-attack-12'); + let versionUpgradeSpy = spyOn(component, 'versionUpgradeDialog').and.returnValue( + Promise.resolve({ oldID: 'enterprise-attack-12', newID: 'enterprise-attack-13' }) + ); + spyOn(component.dataService, 'loadDomainData').and.returnValue(Promise.resolve()); + component.upgradeLayer(vm1, layer, false, false).then(() => { + expect(versionUpgradeSpy).toHaveBeenCalled(); + }); + fixture.whenStable().then(() => { + expect(component.layerTabs.length).toEqual(2); + }); + })); + + it('should not upgrade layer', waitForAsync(() => { + component.dataService.setUpURLs(MockData.configDataExtended); + let layer = JSON.parse(JSON.stringify(MockLayers.layerFile1)); + let vm1 = component.viewModelsService.newViewModel('layer2', 'enterprise-attack-12'); + let versionUpgradeSpy = spyOn(component, 'versionUpgradeDialog').and.returnValue(Promise.resolve(null)); + spyOn(component.dataService, 'loadDomainData').and.returnValue(Promise.resolve()); + component.upgradeLayer(vm1, layer, false, false).then(() => { + expect(versionUpgradeSpy).toHaveBeenCalled(); + }); + fixture.whenStable().then(() => { + expect(component.layerTabs.length).toEqual(2); + }); + })); + + it('should not upgrade layer with default layer enabled', waitForAsync(() => { + component.dataService.setUpURLs(MockData.configDataExtended); + let layer = JSON.parse(JSON.stringify(MockLayers.layerFile1)); + let vm1 = component.viewModelsService.newViewModel('layer2', 'enterprise-attack-12'); + spyOn(component.dataService, 'loadDomainData').and.returnValue(Promise.resolve()); + component.upgradeLayer(vm1, layer, false, true); + fixture.whenStable().then(() => { + expect(component.layerTabs.length).toEqual(2); + }); + })); + + it('should not upgrade layer with default layer enabled and domain data loaded', waitForAsync(() => { + component.dataService.setUpURLs(MockData.configDataExtended); + component.dataService.parseBundle(component.dataService.getDomain('enterprise-attack-13'), MockData.stixBundleSDO); + let bb = JSON.parse(JSON.stringify(MockLayers.layerFile1)); + let vm1 = component.viewModelsService.newViewModel('layer2', 'enterprise-attack-13'); + spyOn(component.dataService, 'loadDomainData').and.returnValue(Promise.resolve()); + component.upgradeLayer(vm1, bb, false, true); + fixture.whenStable().then(() => { + expect(component.layerTabs.length).toEqual(2); + }); + })); + + it('should not upgrade layer with domain data loaded', waitForAsync(() => { + component.dataService.setUpURLs(MockData.configDataExtended); + component.dataService.parseBundle(component.dataService.getDomain('enterprise-attack-13'), MockData.stixBundleSDO); + let layer = JSON.parse(JSON.stringify(MockLayers.layerFile1)); + let vm1 = component.viewModelsService.newViewModel('layer2', 'enterprise-attack-13'); + let st1 = new Technique(MockData.T0000_000, [], null); + let t1 = new Technique(MockData.T0000, [st1], null); + let tvm_1 = new TechniqueVM('T0000^tactic-name'); + tvm_1.showSubtechniques = true; + let stvm_1 = new TechniqueVM('0000.000^tactic-name'); + vm1.setTechniqueVM(tvm_1); + vm1.setTechniqueVM(stvm_1); + component.dataService.domains[0].techniques.push(t1); + let versionUpgradeSpy = spyOn(component, 'versionUpgradeDialog').and.returnValue(Promise.resolve(null)); + spyOn(component.dataService, 'loadDomainData').and.returnValue(Promise.resolve()); + component.upgradeLayer(vm1, layer, false, false).then(() => { + expect(versionUpgradeSpy).toHaveBeenCalled(); + }); + fixture.whenStable().then(() => { + expect(component.layerTabs.length).toEqual(2); + }); + })); + }); + + describe('loadLayerFromURL', () => { + it('should load from url', waitForAsync(() => { + component.dataService.setUpURLs(MockData.configData); + component.http = http; + spyOn(component.http, 'get').and.returnValue(of(MockLayers.layerFile1)); + let versionMismatchSpy = spyOn(component, 'versionMismatchWarning').and.returnValue(Promise.resolve(true)); + let upgradeLayerSpy = spyOn(component, 'upgradeLayer').and.returnValue(Promise.resolve([])); + component + .loadLayerFromURL('https://raw.githubusercontent.com/mitre-attack/attack-navigator/master/layers/data/samples/Bear_APT.json', false) + .then(() => { + expect(versionMismatchSpy).toHaveBeenCalled(); + expect(upgradeLayerSpy).toHaveBeenCalled(); + }); + })); + + it('should throw errors when loading from url', waitForAsync(() => { + let versions = [ + { + name: 'ATT&CK v13', + version: '13', + domains: [ + { + name: 'Mobile', + identifier: 'mobile-attack', + data: ['https://raw.githubusercontent.com/mitre/cti/ATT%26CK-v13.1/mobile-attack/attack-attack.json'], + }, + ], + }, + ]; + component.dataService.setUpURLs(versions); + component.http = http; + spyOn(component.http, 'get').and.returnValue(of(MockLayers.layerFile1)); + let versionMismatchSpy = spyOn(component, 'versionMismatchWarning').and.returnValue(Promise.resolve(true)); + let alertSpy = spyOn(window, 'alert'); + let consoleSpy = spyOn(console, 'error'); + component + .loadLayerFromURL('https://raw.githubusercontent.com/mitre-attack/attack-navigator/master/layers/data/samples/Bear_APT.json', false) + .then(() => { + expect(consoleSpy).toHaveBeenCalled(); + expect(alertSpy).toHaveBeenCalled(); + expect(versionMismatchSpy).toHaveBeenCalled(); + }); + })); + }); }); diff --git a/nav-app/src/app/tabs/tabs.component.ts b/nav-app/src/app/tabs/tabs.component.ts index b847ca4b6..4877508cb 100755 --- a/nav-app/src/app/tabs/tabs.component.ts +++ b/nav-app/src/app/tabs/tabs.component.ts @@ -40,7 +40,6 @@ export class TabsComponent implements AfterViewInit { public showHelpDropDown: boolean = false; public loadURL: string = ''; public layerLinkURLs: string[] = []; - public customizedConfig: any[] = []; public bannerContent: string; public subscription: Subscription; public copiedRecently: boolean = false; // true if copyLayerLink is called, reverts to false after 2 seconds @@ -70,30 +69,22 @@ export class TabsComponent implements AfterViewInit { constructor( public dialog: MatDialog, - private viewModelsService: ViewModelsService, + public viewModelsService: ViewModelsService, public dataService: DataService, - private http: HttpClient, - private configService: ConfigService, + public http: HttpClient, + public configService: ConfigService, public snackBar: MatSnackBar ) { console.debug('initializing tabs component'); - this.subscription = dataService.getConfig().subscribe({ - next: (config: Object) => { - this.newBlankTab(); - this.loadTabs(config['default_layers']).then(() => { - // if failed to load from url, create a new blank layer - if (this.layerTabs.length == 0) this.newLayer(this.dataService.domains[0].id); - - // if there is no active tab set, activate the first - if (!this.activeTab) this.selectTab(this.layerTabs[0]); - }); - this.customizedConfig = this.configService.getFeatureList(); - this.bannerContent = this.configService.banner; - }, - complete: () => { - if (this.subscription) this.subscription.unsubscribe(); - }, // prevent memory leaks + this.newBlankTab(); + this.loadTabs(configService.defaultLayers).then(() => { + // failed to load from URL, create a new blank layer + if (this.layerTabs.length == 0) this.newLayer(this.dataService.domains[0].id); + + // if there is no active tab set, activate the first + if (!this.activeTab) this.selectTab(this.layerTabs[0]); }); + this.bannerContent = this.configService.banner; } ngAfterViewInit(): void { @@ -158,7 +149,7 @@ export class TabsComponent implements AfterViewInit { * @param {Boolean} forceNew force open a new tab even if a tab of that name already exists, default false * @param {Boolean} isDataTable is the tab a data table, if so tab text should be editable, default false */ - private openTab(title: string, viewModel: ViewModel, isCloseable = false, replace = true, forceNew = false, isDataTable = false): void { + public openTab(title: string, viewModel: ViewModel, isCloseable = false, replace = true, forceNew = false, isDataTable = false): void { if (!forceNew) { // if tab is already open, change to that tab let tab: Tab = this.layerTabs.find((t) => t.title === title); @@ -205,7 +196,7 @@ export class TabsComponent implements AfterViewInit { * Select the specified tab, deselect other tabs * @param {Tab} tab the tab to select */ - private selectTab(tab: Tab): void { + public selectTab(tab: Tab): void { this.activeTab = tab; // close search sidebar @@ -263,7 +254,7 @@ export class TabsComponent implements AfterViewInit { * Close the currently selected tab * @param {boolean} allowNoTab if true, doesn't select another tab, and won't open a new tab if there are none, default false */ - private closeActiveTab(allowNoTab: boolean = false): void { + public closeActiveTab(allowNoTab: boolean = false): void { if (this.activeTab) this.closeTab(this.activeTab, allowNoTab); } @@ -337,7 +328,7 @@ export class TabsComponent implements AfterViewInit { * @param {string} root the root string to get the non-conflicting version of * @return {string} non-conflicted version */ - private getUniqueLayerName(root: string): string { + public getUniqueLayerName(root: string): string { let id = 0; function isInteger(str: string): boolean { @@ -489,7 +480,7 @@ export class TabsComponent implements AfterViewInit { * @param {string} char the score expression character * @return {number} the index of the tab */ - private charToIndex(char: string): number { + public charToIndex(char: string): number { let viewModelIndex = 0; for (let i = 0; i < this.layerTabs.length; i++) { if (this.layerTabs[i].viewModel) { @@ -593,7 +584,7 @@ export class TabsComponent implements AfterViewInit { /** * Dialog to upgrade version if layer is not the latest version */ - private versionUpgradeDialog(viewModel: ViewModel): Promise { + public versionUpgradeDialog(viewModel: ViewModel): Promise { let dataPromise: Promise = new Promise((resolve, reject) => { let currVersion = this.dataService.getCurrentVersion().number; if (viewModel.version !== currVersion) { @@ -638,7 +629,7 @@ export class TabsComponent implements AfterViewInit { * @param {boolean} defaultLayers is this a layer being loaded by default (from the config or query string)? * if so, will act as if the user decided not to upgrade the layer */ - private upgradeLayer(oldViewModel: ViewModel, serialized: any, replace: boolean, defaultLayers: boolean = false): Promise { + public upgradeLayer(oldViewModel: ViewModel, serialized: any, replace: boolean, defaultLayers: boolean = false): Promise { return new Promise((resolve, reject) => { if (!defaultLayers) { this.versionUpgradeDialog(oldViewModel) @@ -728,7 +719,7 @@ export class TabsComponent implements AfterViewInit { * Reads the JSON file, adds the properties to a view model, and * loads the view model into a new layer */ - private async readJSONFile(file: File): Promise { + public async readJSONFile(file: File): Promise { return new Promise((resolve, reject) => { let reader = new FileReader(); let viewModel: ViewModel; @@ -755,7 +746,7 @@ export class TabsComponent implements AfterViewInit { }); } - private loadObjAsLayer(self, obj): void { + public loadObjAsLayer(self, obj): void { let viewModel: ViewModel; viewModel = self.viewModelsService.newViewModel('loading layer...', undefined); let layerVersionStr = viewModel.deserializeDomainVersionID(obj); @@ -792,7 +783,7 @@ export class TabsComponent implements AfterViewInit { * (for major mismatches) * @param {string} layerVersionStr the uploaded layer version */ - private async versionMismatchWarning(layerVersionStr: string): Promise { + public async versionMismatchWarning(layerVersionStr: string): Promise { return new Promise((resolve, reject) => { let globalVersionSplit = globals.layerVersion.split('.'); let layerVersion = layerVersionStr.split('.'); @@ -903,7 +894,7 @@ export class TabsComponent implements AfterViewInit { str += join + 'layerURL=' + encodeURIComponent(layerLinkURL); join = '&'; } - for (let feature of this.customizedConfig) { + for (let feature of this.configService.featureList) { if (feature.subfeatures) { for (let subfeature of feature.subfeatures) { if (!subfeature.enabled) { diff --git a/nav-app/src/tests/utils/mock-data.ts b/nav-app/src/tests/utils/mock-data.ts index 4ea87401a..663e413df 100644 --- a/nav-app/src/tests/utils/mock-data.ts +++ b/nav-app/src/tests/utils/mock-data.ts @@ -321,6 +321,13 @@ export const TA0000 = { x_mitre_shortname: 'tactic-name', external_references: [{ external_id: 'TA0000' }], }; +export const TA0001 = { + ...stixSDO, + id: 'tactic-1', + type: 'x-mitre-tactic', + x_mitre_shortname: 'tactic-name-2', + external_references: [{ external_id: 'TA0001' }], +}; // mock relationship SDOs export const G0001usesT0000 = {