Repository: OWASP/threat-dragon (js)

Issue:
Version 2.0 - Threat Model Edit Cancel/Reload
**Describe what problem your feature request solves**
Cancel and Reload buttons are not appropriately implemented in the v2 branch.

**Describe the solution you'd like**
When a user clicks cancel _or_ reload, the threat model should be reloaded from its original source.  The current implementation keeps the active changes in the store.  Effectively, we want to _discard_ local changes when these buttons are clicked.


Pull request:
V2/338 edit cancel and reload
**Summary**
Implements cancel and reload buttons on the edit page for threat models

**Description for the changelog**
Implement cancel/reload buttons on threat model edit.
Closes #338 

**Other info**
If anyone is up for taking a swing at the UI/layout on this page, by all means.  The UI for contributors is particularly ugly, and inconsistent with what we have for the diagrams.


Patch:
diff --git a/td.vue/TODO b/td.vue/TODO
deleted file mode 100644
index cdf32f3f..00000000
--- a/td.vue/TODO
+++ /dev/null
@@ -1,5 +0,0 @@
-- Add tests for ThreatmodelEdit view
-- Add report feature
-- Legend for diagram
-- Update or remove the CSP violation reporter
-
diff --git a/td.vue/src/i18n/en.js b/td.vue/src/i18n/en.js
index ba2f4250..03ba0c64 100644
--- a/td.vue/src/i18n/en.js
+++ b/td.vue/src/i18n/en.js
@@ -152,7 +152,10 @@ const en = {
         cancel: 'Cancel',
         close: 'Close',
         search: 'Search',
-        import: 'Import'
+        import: 'Import',
+        ok: 'OK',
+        discardTitle: 'Discard Changes?',
+        discardMessage: 'Are you sure you want to discard your changes?'
     },
     threats: {
         models: {
diff --git a/td.vue/src/store/actions/threatmodel.js b/td.vue/src/store/actions/threatmodel.js
index 17e9b719..55a35ef1 100644
--- a/td.vue/src/store/actions/threatmodel.js
+++ b/td.vue/src/store/actions/threatmodel.js
@@ -5,11 +5,13 @@ export const THREATMODEL_SELECTED = 'THREATMODEL_SELECTED';
 export const THREATMODEL_DIAGRAM_SELECTED = 'THREATMODEL_DIAGRAM_SELECTED';
 export const THREATMODEL_CREATE = 'THREATMODEL_CREATE';
 export const THREATMODEL_CONTRIBUTORS_UPDATED = 'THREATMODEL_CONTRIBUTORS_UPDATED';
+export const THREATMODEL_RESTORE = 'THREATMODEL_RESTORE';
 
 export default {
     clear: THREATMODEL_CLEAR,
     create: THREATMODEL_CREATE, 
     fetch: THREATMODEL_FETCH,
     fetchAll: THREATMODEL_FETCH_ALL,
-    selected: THREATMODEL_SELECTED
+    selected: THREATMODEL_SELECTED,
+    restore: THREATMODEL_RESTORE
 };
diff --git a/td.vue/src/store/modules/threatmodel.js b/td.vue/src/store/modules/threatmodel.js
index cbacdca5..4cd053b7 100644
--- a/td.vue/src/store/modules/threatmodel.js
+++ b/td.vue/src/store/modules/threatmodel.js
@@ -10,6 +10,7 @@ import {
     THREATMODEL_DIAGRAM_SELECTED,
     THREATMODEL_FETCH,
     THREATMODEL_FETCH_ALL,
+    THREATMODEL_RESTORE,
     THREATMODEL_SELECTED
 } from '../actions/threatmodel.js';
 import threatmodelApi from '../../service/threatmodelApi.js';
@@ -17,15 +18,22 @@ import threatmodelApi from '../../service/threatmodelApi.js';
 export const clearState = (state) => {
     state.all.length = 0;
     state.data = {};
+    state.immutableCopy = '';
     state.selectedDiagram = {};
 };
 
 const state = {
     all: [],
     data: {},
+    immutableCopy: {},
     selectedDiagram: {}
 };
 
+const setThreatModel = (theState, threatModel) => {
+    theState.data = threatModel;
+    theState.immutableCopy = JSON.stringify(threatModel);
+};
+
 const actions = {
     [THREATMODEL_CLEAR]: ({ commit }) => commit(THREATMODEL_CLEAR),
     [THREATMODEL_CREATE]: ({ commit }, threatModel) => commit(THREATMODEL_CREATE, threatModel),
@@ -51,31 +59,39 @@ const actions = {
         }
     },
     [THREATMODEL_SELECTED]: ({ commit }, threatModel) => commit(THREATMODEL_SELECTED, threatModel),
-    [THREATMODEL_CONTRIBUTORS_UPDATED]: ({ commit }, contributors) => commit(THREATMODEL_CONTRIBUTORS_UPDATED, contributors)
+    [THREATMODEL_CONTRIBUTORS_UPDATED]: ({ commit }, contributors) => commit(THREATMODEL_CONTRIBUTORS_UPDATED, contributors),
+    [THREATMODEL_RESTORE]: async ({ commit, state, rootState }) => {
+        let originalModel = JSON.parse(state.immutableCopy);
+        if (getProviderType(rootState.provider.selected) !== providerTypes.local) {
+            const originalTitle = (JSON.parse(state.immutableCopy)).summary.title;
+            const resp = await threatmodelApi.modelAsync(
+                rootState.repo.selected,
+                rootState.branch.selected,
+                originalTitle
+            );
+            originalModel = resp.data;
+        }
+        commit(THREATMODEL_RESTORE, originalModel);
+    }
 };
 
 const mutations = {
     [THREATMODEL_CLEAR]: (state) => clearState(state),
-    [THREATMODEL_CREATE]: (state, threatModel) => {
-        state.data = threatModel;
-    },
+    [THREATMODEL_CREATE]: (state, threatModel) => setThreatModel(state, threatModel),
     [THREATMODEL_DIAGRAM_SELECTED]: (state, diagram) => {
         state.selectedDiagram = diagram;
     },
-    [THREATMODEL_FETCH]: (state, threatModel) => {
-        state.data = threatModel;
-    },
+    [THREATMODEL_FETCH]: (state, threatModel) => setThreatModel(state, threatModel),
     [THREATMODEL_FETCH_ALL]: (state, models) => {
         state.all.length = 0;
         models.forEach((model, idx) => Vue.set(state.all, idx, model));
     },
-    [THREATMODEL_SELECTED]: (state, threatModel) => {
-        state.data = threatModel;
-    },
+    [THREATMODEL_SELECTED]: (state, threatModel) => setThreatModel(state, threatModel),
     [THREATMODEL_CONTRIBUTORS_UPDATED]: (state, contributors) => {
         state.data.detail.contributors.length = 0;
         contributors.forEach((name, idx) => Vue.set(state.data.detail.contributors, idx, { name }));
-    }
+    },
+    [THREATMODEL_RESTORE]: (state, originalThreatModel) => setThreatModel(state, originalThreatModel)
 };
 
 const getters = {
@@ -85,7 +101,8 @@ const getters = {
             contribs = state.data.detail.contributors;
         }
         return contribs.map(x => x.name);
-    }
+    },
+    modelChanged: (state) => JSON.stringify(state.data) !== state.immutableCopy
 };
 
 export default {
diff --git a/td.vue/src/views/ThreatModelEdit.vue b/td.vue/src/views/ThreatModelEdit.vue
index a372d553..4876050b 100644
--- a/td.vue/src/views/ThreatModelEdit.vue
+++ b/td.vue/src/views/ThreatModelEdit.vue
@@ -1,7 +1,10 @@
 <template>
     <b-row>
-        <b-col>
-            <b-card :header="`${$t('threatmodel.editing')}: ${model.summary.title}`">
+        <b-col v-if="model && model.summary">
+            <b-card
+                :header="`${$t('threatmodel.editing')}: ${model.summary.title}`"
+                id="parent-card"
+            >
                 <b-form @submit="onSubmit">
 
                     <b-form-row>
@@ -98,9 +101,10 @@
                                 <b-form-input
                                     v-model="model.detail.diagrams[idx].title"
                                     type="text"
+                                    class="td-diagram"
                                 ></b-form-input>
                                 <b-input-group-append>
-                                    <b-button variant="secondary" @click="onRemoveDiagramClick(idx)">
+                                    <b-button variant="secondary" class="td-remove-diagram" @click="onRemoveDiagramClick(idx)">
                                         <font-awesome-icon icon="times"></font-awesome-icon>
                                         {{ $t('forms.remove') }}
                                     </b-button>
@@ -161,7 +165,7 @@ import { mapState } from 'vuex';
 
 import { getProviderType } from '@/service/provider/providers.js';
 import TdFormButton from '@/components/FormButton.vue';
-import { THREATMODEL_CONTRIBUTORS_UPDATED } from '@/store/actions/threatmodel.js';
+import { THREATMODEL_CONTRIBUTORS_UPDATED, THREATMODEL_RESTORE } from '@/store/actions/threatmodel.js';
 
 export default {
     name: 'ThreatModelEdit',
@@ -171,7 +175,7 @@ export default {
     computed: {
         ...mapState({
             model: (state) => state.threatmodel.data,
-            providerType: state => getProviderType(state.provider.selected),
+            providerType: state => getProviderType(state.provider.selected)
         }),
         contributors: {
             get() {
@@ -184,20 +188,22 @@ export default {
     },
     methods: {
         onSubmit() {
-            console.log('Form sumbitting...');
+            // noop
         },
         onSaveClick(evt) {
             evt.preventDefault();
             console.log('Save - TODO');
+            this.$router.push({ name: `${this.providerType}ThreatModel`, params: this.$route.params });
         },
-        onReloadClick(evt) {
+        async onReloadClick(evt) {
             evt.preventDefault();
-            console.log('Reload - TODO');
+            await this.restoreAsync();
         },
-        onCancelClick(evt) {
+        async onCancelClick(evt) {
             evt.preventDefault();
-            this.$router.push({ name: `${this.providerType}ThreatModel`, params: this.$route.params });
-            console.log('Cancel - TODO');
+            if (await this.restoreAsync()) {
+                this.$router.push({ name: `${this.providerType}ThreatModel`, params: this.$route.params });
+            }
         },
         onAddDiagramClick(evt) {
             evt.preventDefault();
@@ -205,6 +211,23 @@ export default {
         },
         onRemoveDiagramClick(idx) {
             this.model.detail.diagrams.splice(idx, 1);
+        },
+        async restoreAsync() {
+            if (!this.$store.getters.modelChanged || await this.getConfirmModal()) {
+                this.$store.dispatch(THREATMODEL_RESTORE);
+                return true;
+            }
+            return false;
+        },
+        getConfirmModal() {
+            return this.$bvModal.msgBoxConfirm(this.$t('forms.discardMessage'), {
+                title: this.$t('forms.discardTitle'),
+                okVariant: 'danger',
+                okTitle: this.$t('forms.ok'),
+                cancelTitle: this.$t('forms.cancel'),
+                hideHeaderClose: true,
+                centered: true
+            });
         }
     }
 };


Test patch:
diff --git a/td.vue/tests/e2e/smokes/create_and_edit.js b/td.vue/tests/e2e/smokes/create_and_edit.js
index 87e2c126..c502c450 100644
--- a/td.vue/tests/e2e/smokes/create_and_edit.js
+++ b/td.vue/tests/e2e/smokes/create_and_edit.js
@@ -46,9 +46,4 @@ describe('create and edit', () => {
         cy.get('#td-reload-btn').should('be.visible');
         cy.get('#td-cancel-btn').should('be.visible');
     });
-
-    it('cancels the edit', () => {
-        cy.get('#td-cancel-btn').click();
-        cy.url().should('not.contain', 'edit');
-    });
 });
diff --git a/td.vue/tests/e2e/specs/create_and_edit.js b/td.vue/tests/e2e/specs/threatmodel/create.js
similarity index 90%
rename from td.vue/tests/e2e/specs/create_and_edit.js
rename to td.vue/tests/e2e/specs/threatmodel/create.js
index 87e2c126..9898fd8d 100644
--- a/td.vue/tests/e2e/specs/create_and_edit.js
+++ b/td.vue/tests/e2e/specs/threatmodel/create.js
@@ -1,4 +1,4 @@
-describe('create and edit', () => {
+describe('create a new threat model', () => {
     before(() => {
         cy.visit('/');
         cy.get('.Vue-Toastification__close-button').click();
@@ -46,9 +46,4 @@ describe('create and edit', () => {
         cy.get('#td-reload-btn').should('be.visible');
         cy.get('#td-cancel-btn').should('be.visible');
     });
-
-    it('cancels the edit', () => {
-        cy.get('#td-cancel-btn').click();
-        cy.url().should('not.contain', 'edit');
-    });
 });
diff --git a/td.vue/tests/e2e/specs/threatmodel/edit-actions.js b/td.vue/tests/e2e/specs/threatmodel/edit-actions.js
new file mode 100644
index 00000000..5df119f0
--- /dev/null
+++ b/td.vue/tests/e2e/specs/threatmodel/edit-actions.js
@@ -0,0 +1,80 @@
+describe('editing a threat model', () => {
+    const newName = 'Something New Goes Here!';
+
+    beforeEach(() => {
+        cy.visit('/');
+        cy.get('.Vue-Toastification__close-button').click();
+        cy.get('#local-login-btn').click();
+        cy.get('a[href="#/local/threatmodel/new"]').click();
+        cy.url().should('contain', '/local/New%20Threat%20Model/edit');
+    });
+
+    afterEach(() => {
+        cy.get('#nav-sign-out').click();
+    });
+
+    describe('cancel', () => {
+        it('goes back without prompting if data has not been edited', () => {
+            cy.get('#td-cancel-btn').click();
+            cy.url().should('equal', Cypress.config().baseUrl + '#/local/New%20Threat%20Model');
+            cy.contains('New Threat Model');
+        });
+
+        describe('after editing', () => {
+            beforeEach(() => {
+                cy.get('#title').clear().type(newName);
+                cy.contains(`Editing: ${newName}`);
+                cy.get('#td-cancel-btn').click();
+            });
+
+            it('does not reset if the user selects cancel', () => {
+                cy.get('.modal-footer .btn-secondary').contains('Cancel').click();
+                cy.url().should('equal', Cypress.config().baseUrl + '#/local/New%20Threat%20Model/edit');
+                cy.contains(`Editing: ${newName}`);
+            });
+
+            it('resets data and goes back if the user selects ok', () => {
+                cy.get('.modal-footer .btn-danger').contains('OK').click();
+                cy.url().should('equal', Cypress.config().baseUrl + '#/local/New%20Threat%20Model');
+                cy.contains('New Threat Model');
+            });
+        });
+    });
+
+    describe('reload', () => {
+        it('does nothing if data has not changed', () => {
+            cy.get('#td-reload-btn').click();
+            cy.contains(`Editing: New Threat Model`);
+        });
+
+        describe('with changed data', () => {
+
+            beforeEach(() => {
+                cy.get('#title').clear().type(newName);
+                cy.contains(`Editing: ${newName}`);
+                cy.get('#td-reload-btn').click();
+            });
+
+            it('does not reload if the user cancels', () => {
+                cy.get('.modal-footer .btn-secondary').contains('Cancel').click();
+                cy.contains(`Editing: ${newName}`);
+            });
+
+            it('reloads if the user confirms', () => {
+                cy.get('.modal-footer .btn-danger').contains('OK').click();
+                cy.contains('Editing: New Threat Model');
+            });
+        });
+    });
+
+    describe('save', () => {
+        // TODO: Write better tests after implementation details of saving locally are determined
+        it('returns to the threat model view', () => {
+            cy.get('#title').clear().type(newName);
+            cy.contains(`Editing: ${newName}`);
+            cy.get('#td-save-btn').click();
+            cy.url().should('equal', Cypress.config().baseUrl + '#/local/New%20Threat%20Model');
+            cy.contains(newName);
+        });
+    });
+});
diff --git a/td.vue/tests/unit/store/actions/threatmodel.spec.js b/td.vue/tests/unit/store/actions/threatmodel.spec.js
index cec1759c..6b3cadf7 100644
--- a/td.vue/tests/unit/store/actions/threatmodel.spec.js
+++ b/td.vue/tests/unit/store/actions/threatmodel.spec.js
@@ -5,7 +5,8 @@ import {
     THREATMODEL_FETCH,
     THREATMODEL_FETCH_ALL,
     THREATMODEL_SELECTED,
-    THREATMODEL_CONTRIBUTORS_UPDATED
+    THREATMODEL_CONTRIBUTORS_UPDATED,
+    THREATMODEL_RESTORE
 } from '@/store/actions/threatmodel.js';
 
 describe('store/actions/threatmodel.js', () => {
@@ -36,4 +37,8 @@ describe('store/actions/threatmodel.js', () => {
     it('defines a contributors updated action', () => {
         expect(THREATMODEL_CONTRIBUTORS_UPDATED).not.toBeUndefined();
     });
+
+    it('defines a restore action', () => {
+        expect(THREATMODEL_RESTORE).not.toBeUndefined();
+    });
 });
diff --git a/td.vue/tests/unit/store/modules/threatmodel.spec.js b/td.vue/tests/unit/store/modules/threatmodel.spec.js
index 5a7dff15..8be04135 100644
--- a/td.vue/tests/unit/store/modules/threatmodel.spec.js
+++ b/td.vue/tests/unit/store/modules/threatmodel.spec.js
@@ -8,7 +8,7 @@ import {
 } from '@/store/actions/threatmodel.js';
 import threatmodelModule, { clearState } from '@/store/modules/threatmodel.js';
 import threatmodelApi from '@/service/threatmodelApi.js';
-import { THREATMODEL_CONTRIBUTORS_UPDATED } from '../../../../src/store/actions/threatmodel';
+import { THREATMODEL_CONTRIBUTORS_UPDATED, THREATMODEL_RESTORE } from '../../../../src/store/actions/threatmodel';
 
 describe('store/modules/threatmodel.js', () => {
     const getRootState = () => ({
@@ -54,6 +54,10 @@ describe('store/modules/threatmodel.js', () => {
         it('defines a selectedDiagram object', () => {
             expect(threatmodelModule.state.selectedDiagram).toBeInstanceOf(Object);
         });
+
+        it('defines an immutableCopy string', () => {
+            expect(threatmodelModule.state.immutableCopy).toEqual('');
+        });
     });
 
     describe('actions', () => {
@@ -128,6 +132,49 @@ describe('store/modules/threatmodel.js', () => {
                 contribs
             );
         });
+
+        describe('threatmodel restore', () => {
+            const originalModel = { summary: { title: 'test' }};
+
+            beforeEach(() => {
+                threatmodelApi.modelAsync = jest.fn().mockReturnValue({ data: originalModel });
+                mocks.state.immutableCopy = JSON.stringify(originalModel);
+                mocks.state.data = { summary: { title: 'edited test', foo: 'bar' } };
+            });
+
+            describe('local provider', () => {
+                beforeEach(async () => {
+                    mocks.rootState.provider.selected = 'local';
+                    await threatmodelModule.actions[THREATMODEL_RESTORE](mocks);
+                });
+
+                it('does not call the api', () => {
+                    expect(threatmodelApi.modelAsync).not.toHaveBeenCalled();
+                });
+
+                it('commits the restore action with the original model', () => {
+                    expect(mocks.commit).toHaveBeenCalledWith(THREATMODEL_RESTORE, originalModel);
+                });
+            });
+
+            describe('git provider', () => {
+                beforeEach(async () => {
+                    await threatmodelModule.actions[THREATMODEL_RESTORE](mocks);
+                });
+
+                it('calls the api to get the threat model based on the original title', () => {
+                    expect(threatmodelApi.modelAsync).toHaveBeenCalledWith(
+                        mocks.rootState.repo.selected,
+                        mocks.rootState.branch.selected,
+                        originalModel.summary.title
+                    );
+                });
+
+                it('commits the restore action with the original model', () => {
+                    expect(mocks.commit).toHaveBeenCalledWith(THREATMODEL_RESTORE, originalModel);
+                });
+            });
+        });
     });
 
     describe('mutations', () => {
@@ -223,41 +270,73 @@ describe('store/modules/threatmodel.js', () => {
                 expect(threatmodelModule.state.data.detail.contributors).toEqual(contribs.map(x => ({ name: x })));
             });
         });
-    });
 
-    describe('getters', () => {
-        let res;
+        describe('restore', () => {
+            const orig = { foo: 'bar' };
+            let state;
 
-        describe('with data', () => {
             beforeEach(() => {
-                threatmodelModule.state.data = {
-                    detail: {
-                        contributors: [
-                            { name: 'contrib 1' },
-                            { name: 'contrib 2' }
-                        ]
-                    }
-                };
-                res = threatmodelModule.getters.contributors(threatmodelModule.state);
+                state = { data: { bar: 'foo' }, immutableCopy: 'test' };
+                threatmodelModule.mutations[THREATMODEL_RESTORE](state, orig);
             });
 
-            it('defines a getters object', () => {
-                expect(threatmodelModule.getters).toBeInstanceOf(Object);
+            it('sets the data to the original model', () => {
+                expect(state.data).toEqual(orig);
             });
 
-            it('gets the contributors', () => {
-                expect(res).toHaveLength(2);
+            it('sets the immutable copy', () => {
+                expect(state.immutableCopy).toEqual(JSON.stringify(orig));
             });
         });
+    });
 
-        describe('without data', () => {
-            beforeEach(() => {
-                threatmodelModule.state.data = {};
+    describe('getters', () => {
+
+        describe('contributors', () => {
+            let res;
+            describe('with data', () => {
+                beforeEach(() => {
+                    threatmodelModule.state.data = {
+                        detail: {
+                            contributors: [
+                                { name: 'contrib 1' },
+                                { name: 'contrib 2' }
+                            ]
+                        }
+                    };
+                    res = threatmodelModule.getters.contributors(threatmodelModule.state);
+                });
+
+                it('defines a getters object', () => {
+                    expect(threatmodelModule.getters).toBeInstanceOf(Object);
+                });
+
+                it('gets the contributors', () => {
+                    expect(res).toHaveLength(2);
+                });
+            });
+
+            describe('without data', () => {
+                beforeEach(() => {
+                    threatmodelModule.state.data = {};
+                });
+
+                it('returns an empty array', () => {
+                    expect(threatmodelModule.getters.contributors(threatmodelModule.state))
+                        .toEqual([]);
+                });
+            });
+        });
+
+        describe('modelChanged', () => {
+            it('returns true when the model has changed', () => {
+                const state = { data: { foo: 'bar' }, immutableCopy: JSON.stringify({ something: 'else' })};
+                expect(threatmodelModule.getters.modelChanged(state)).toEqual(true);
             });
 
-            it('returns an empty array', () => {
-                expect(threatmodelModule.getters.contributors(threatmodelModule.state))
-                    .toEqual([]);
+            it('returns false when the model has not changed', () => {
+                const state = { data: { foo: 'bar' }, immutableCopy: JSON.stringify({ foo: 'bar' })};
+                expect(threatmodelModule.getters.modelChanged(state)).toEqual(false);
             });
         });
     });
diff --git a/td.vue/tests/unit/views/threatmodelEdit.spec.js b/td.vue/tests/unit/views/threatmodelEdit.spec.js
new file mode 100644
index 00000000..9b763e89
--- /dev/null
+++ b/td.vue/tests/unit/views/threatmodelEdit.spec.js
@@ -0,0 +1,293 @@
+import { BootstrapVue, BCard, BCardHeader, BFormTags } from 'bootstrap-vue';
+import { createLocalVue, mount } from '@vue/test-utils';
+import Vuex from 'vuex';
+
+import ThreatModelEdit from '@/views/ThreatModelEdit.vue';
+import { THREATMODEL_CONTRIBUTORS_UPDATED, THREATMODEL_RESTORE } from '@/store/actions/threatmodel.js';
+
+describe('views/Threatmodel.vue', () => {
+    const contributors = ['foo', 'bar' ];
+    const owner = 'owner';
+    const reviewer = 'reviewer';
+    const title = 'title';
+    const description = 'Something about a threat model';
+    const diagrams = [
+        { title: 'd1', diagramType: 'CIA' },
+        { title: 'd2', diagramType: 'STRIDE' }
+    ];
+    const path = '/git/github/foo/bar/baz';
+
+    let wrapper, localVue, mockRouter, mockStore, modelChanged;
+
+    beforeEach(() => {
+        console.log = jest.fn();
+        modelChanged = false;
+        localVue = createLocalVue();
+        localVue.use(BootstrapVue);
+        localVue.use(Vuex);
+        mockStore = new Vuex.Store({
+            state: {
+                provider: { selected: 'local' },
+                threatmodel: {
+                    data: {
+                        summary: {
+                            title,
+                            owner,
+                            description
+                        },
+                        detail: {
+                            contributors: contributors.map(x =>  ({ name: x })),
+                            diagrams,
+                            reviewer
+                        }
+                    }
+                }
+            },
+            actions: {
+                [THREATMODEL_CONTRIBUTORS_UPDATED]: () => { },
+                [THREATMODEL_RESTORE]: () => {}
+            },
+            getters: {
+                modelChanged: () => modelChanged
+            }
+        });
+
+        mockRouter = {
+            push: jest.fn(),
+            path
+        };
+
+        wrapper = mount(ThreatModelEdit, {
+            localVue,
+            store: mockStore,
+            stubs: {
+                'font-awesome-icon': { template: '<div />' }
+            },
+            mocks: {
+                $t: key => key,
+                $route: mockRouter,
+                $router: mockRouter
+            }
+        });
+    });
+
+    describe('layout', () => {
+        it('displays the title in the header', () => {
+            const header = wrapper.findComponent(BCard).findComponent(BCardHeader);
+            expect(header.text()).toEqual(`threatmodel.editing: ${title}`);
+        });
+
+        it('has a title input', () => {
+            expect(wrapper.find('#title').element.value).toEqual(title);
+        });
+
+        it('has an owner input', () => {
+            expect(wrapper.find('#owner').element.value).toEqual(owner);
+        });
+
+        it('has a reviewer input', () => {
+            expect(wrapper.find('#reviewer').element.value).toEqual(reviewer);
+        });
+
+        it('shows the description', () => {
+            expect(wrapper.find('#description').element.value).toEqual(description);
+        });
+
+        it('shows the contributors', () => {
+            expect(wrapper.findComponent(BFormTags).exists()).toEqual(true);
+        });
+
+        it('displays all diagrams', () => {
+            expect(wrapper.findAll('.td-diagram').length).toEqual(diagrams.length);
+        });
+    });
+
+    describe('form actions', () => {
+        const evt = { preventDefault: jest.fn() };
+
+        describe('save', () => {
+            beforeEach(async () => {
+                await wrapper.find('#td-save-btn').trigger('click', evt);
+            });
+
+            it('prevents the default event', () => {
+                expect(evt.preventDefault).toHaveBeenCalledTimes(1);
+            });
+
+            it('logs a todo in the console', () => {
+                expect(console.log).toHaveBeenCalledTimes(1);
+            });
+        });
+
+        describe('reload', () => {
+
+            beforeEach(() => {
+                mockStore.dispatch = jest.fn();
+            });
+
+            describe('without changes', () => {
+                beforeEach(async () => {
+                    modelChanged = false;
+                    await wrapper.find('#td-reload-btn').trigger('click', evt);
+                });
+
+                it('dispatches the restore action', () => {
+                    expect(mockStore.dispatch).toHaveBeenCalledWith(THREATMODEL_RESTORE);
+                });
+            });
+
+            describe('with changes', () => {
+                beforeEach(() => {
+                    modelChanged = true;
+                });
+
+                describe('with confirmation', () => {
+                    beforeEach(async () => {
+                        wrapper.vm.getConfirmModal = jest.fn().mockResolvedValue(true);
+                        await wrapper.find('#td-reload-btn').trigger('click', evt);
+                    });
+
+                    it('dispatches the restore action', () => {
+                        expect(mockStore.dispatch).toHaveBeenCalledWith(THREATMODEL_RESTORE);
+                    });
+                });
+
+                describe('without confirmation', () => {
+                    beforeEach(async () => {
+                        wrapper.vm.getConfirmModal = jest.fn().mockResolvedValue(false);
+                        await wrapper.find('#td-reload-btn').trigger('click', evt);
+                    });
+
+                    it('dispatches the restore action', () => {
+                        expect(mockStore.dispatch).not.toHaveBeenCalled();
+                    });
+
+                    it('prevents the default event', () => {
+                        expect(evt.preventDefault).toHaveBeenCalledTimes(1);
+                    });
+                });
+            });
+        });
+
+        describe('cancel', () => {
+            describe('with confirmation', () => {
+                beforeEach(async () => {
+                    mockRouter.push = jest.fn();
+                    wrapper.vm.restoreAsync = jest.fn().mockResolvedValue(true);
+                    await wrapper.find('#td-cancel-btn').trigger('click', evt);
+                });
+
+                it('prevents the default event', () => {
+                    expect(evt.preventDefault).toHaveBeenCalledTimes(1);
+                });
+
+                it('routes back to the threatmodel view', () => {
+                    expect(mockRouter.push).toHaveBeenCalledWith({
+                        name: 'localThreatModel',
+                        params: mockRouter.params
+                    });
+                });
+            });
+
+
+            describe('without confirmation', () => {
+                beforeEach(async () => {
+                    mockRouter.push = jest.fn();
+                    wrapper.vm.restoreAsync = jest.fn().mockResolvedValue(false);
+                    await wrapper.find('#td-cancel-btn').trigger('click', evt);
+                });
+
+                it('calls the restoreAsync function', () => {
+                    expect(wrapper.vm.restoreAsync).toHaveBeenCalledTimes(1);
+                });
+
+                it('does not route', () => {
+                    expect(mockRouter.push).not.toHaveBeenCalled();
+                });
+            });
+        });
+
+        describe('add diagram', () => {
+            let diagramCount, link;
+
+            beforeEach(async () => {
+                diagramCount = diagrams.length;
+                link = wrapper.find('.add-diagram-link');
+                await link.trigger('click', evt);
+            });
+
+            it('prevents the default action', () => {
+                expect(evt.preventDefault).toHaveBeenCalledTimes(1);
+            });
+
+            it('adds a new diagram', () => {
+                expect(mockStore.state.threatmodel.data.detail.diagrams.length).toEqual(diagramCount  + 1);
+            });
+        });
+
+        describe('remove diagram', () => {
+            let diagramCount, link;
+
+            beforeEach(async () => {
+                diagramCount = diagrams.length;
+                link = wrapper.find('.td-remove-diagram');
+                await link.trigger('click', evt);
+            });
+
+            it('removes the diagram', () => {
+                expect(mockStore.state.threatmodel.data.detail.diagrams.length).toEqual(diagramCount  - 1);
+            });
+        });
+    });
+
+    describe('methods', () => {
+        describe('onSubmit', () => {
+            it('is a noop', () => {
+                expect(() => wrapper.vm.onSubmit()).not.toThrow();
+            });
+        });
+
+        describe('getConfirmModal', () => {
+            beforeEach(() => {
+                wrapper.vm.$bvModal.msgBoxConfirm = jest.fn();
+                wrapper.vm.getConfirmModal();
+            });
+
+            it('sets the message', () => {
+                expect(wrapper.vm.$bvModal.msgBoxConfirm).toHaveBeenCalledWith(
+                    'forms.discardMessage',
+                    expect.anything()
+                );
+            });
+
+            it('sets the message box config', () => {
+                expect(wrapper.vm.$bvModal.msgBoxConfirm).toHaveBeenCalledWith(
+                    expect.anything(),
+                    {
+                        title: 'forms.discardTitle',
+                        okVariant: 'danger',
+                        okTitle: 'forms.ok',
+                        cancelTitle: 'forms.cancel',
+                        hideHeaderClose: true,
+                        centered: true
+                    }
+                );
+            });
+        });
+
+        describe('contributors setter', () => {
+            const newContribs = [ '1a', '2b', '3c' ];
+            beforeEach(() => {
+                mockStore.dispatch = jest.fn();
+                wrapper.setData({ contributors: newContribs });
+            });
+
+            it('dispatches the contributors updated event', () => {
+                expect(mockStore.dispatch).toHaveBeenCalledWith(
+                    THREATMODEL_CONTRIBUTORS_UPDATED,
+                    newContribs
+                );
+            });
+        });
+    });
+});