diff --git a/docs/user/envvar.md b/docs/user/envvar.md index ce71c0ef22..d2f7e9e9a1 100644 --- a/docs/user/envvar.md +++ b/docs/user/envvar.md @@ -62,3 +62,7 @@ In addition, the following variables are set when running on a device: When deploying the same set of flows out to multiple devices, these variables can be used by the flows to identify the specific device being run on. + +NOTE: `FF_SNAPSHOT_NAME` will not be immediately updated when the current snapshot is edited. +It will only be updated when the snapshot is changed or a setting that causes the device to +be restarted is changed. diff --git a/docs/user/snapshots.md b/docs/user/snapshots.md index b00f91f25b..d0cdc103ad 100644 --- a/docs/user/snapshots.md +++ b/docs/user/snapshots.md @@ -24,6 +24,20 @@ To create a snapshot: The list of snapshots will update with the newly created entry at the top. +## Edit a snapshot + +To edit a snapshot: + +1. Go to the instance's page and select the **Snapshots** tab. +2. Open the dropdown menu to the right of the snapshot you want to edit and + select the **Edit Snapshot** option. +3. Update the name and description as required. +4. Click **Update** + +NOTE: +Changes made to a snapshot will not be immediately reflected on devices that are already running this snapshot. + + ## Download a snapshot A snapshot can be downloaded to your local machine for backup or sharing. diff --git a/forge/auditLog/application.js b/forge/auditLog/application.js index 7f4d7dbab8..4464512cdf 100644 --- a/forge/auditLog/application.js +++ b/forge/auditLog/application.js @@ -26,6 +26,9 @@ module.exports = { async created (actionedBy, error, application, device, snapshot) { await log('application.device.snapshot.created', actionedBy, application?.id, generateBody({ error, device, snapshot })) }, + async updated (actionedBy, error, application, device, snapshot, updates) { + await log('application.device.snapshot.updated', actionedBy, application?.id, generateBody({ error, device, snapshot, updates })) + }, async deleted (actionedBy, error, application, device, snapshot) { await log('application.device.snapshot.deleted', actionedBy, application?.id, generateBody({ error, device, snapshot })) }, diff --git a/forge/auditLog/project.js b/forge/auditLog/project.js index 89d0d386e5..4e07398e42 100644 --- a/forge/auditLog/project.js +++ b/forge/auditLog/project.js @@ -78,6 +78,9 @@ module.exports = { async created (actionedBy, error, project, snapshot) { await log('project.snapshot.created', actionedBy, project?.id, generateBody({ error, project, snapshot })) }, + async updated (actionedBy, error, project, snapshot, updates) { + await log('project.snapshot.updated', actionedBy, project?.id, generateBody({ error, project, snapshot, updates })) + }, async rolledBack (actionedBy, error, project, snapshot) { await log('project.snapshot.rolled-back', actionedBy, project?.id, generateBody({ error, project, snapshot })) }, diff --git a/forge/db/controllers/Snapshot.js b/forge/db/controllers/Snapshot.js index 8061b1568d..3ed3dfa7c6 100644 --- a/forge/db/controllers/Snapshot.js +++ b/forge/db/controllers/Snapshot.js @@ -1,3 +1,6 @@ +const { ValidationError } = require('sequelize') +const hasProperty = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key) + module.exports = { /** * Get a snapshot by ID @@ -108,6 +111,31 @@ module.exports = { return true }, + /** + * Update a snapshot + * @param {*} app - app instance + * @param {*} snapshot - snapshot object + * @param {*} options - options to update + * @param {String} [options.name] - name of the snapshot + * @param {String} [options.description] - description of the snapshot + */ + async updateSnapshot (app, snapshot, options) { + const updates = {} + if (hasProperty(options, 'name') && (typeof options.name !== 'string' || options.name.trim() === '')) { + throw new ValidationError('Snapshot name is required') + } + if (options.name) { + updates.name = options.name + } + if (typeof options.description !== 'undefined') { + updates.description = options.description + } + if (Object.keys(updates).length > 0) { + await snapshot.update(updates) + } + return snapshot + }, + /** * Upload a snapshot. * @param {*} app - app instance diff --git a/forge/lib/permissions.js b/forge/lib/permissions.js index 1cabcd47c3..8cb046ff81 100644 --- a/forge/lib/permissions.js +++ b/forge/lib/permissions.js @@ -84,6 +84,7 @@ const Permissions = { 'snapshot:meta': { description: 'View a Snapshot', role: Roles.Viewer }, 'snapshot:full': { description: 'View full snapshot details excluding credentials', role: Roles.Member }, 'snapshot:export': { description: 'Export a snapshot including credentials', role: Roles.Member }, + 'snapshot:edit': { description: 'Edit a Snapshot', role: Roles.Owner }, 'snapshot:delete': { description: 'Delete a Snapshot', role: Roles.Owner }, 'snapshot:import': { description: 'Import a Snapshot', role: Roles.Owner }, diff --git a/forge/routes/api/snapshot.js b/forge/routes/api/snapshot.js index 037b5d48ba..4ab2de5314 100644 --- a/forge/routes/api/snapshot.js +++ b/forge/routes/api/snapshot.js @@ -7,6 +7,8 @@ * @memberof forge.routes.api */ +const { UpdatesCollection } = require('../../auditLog/formatters.js') + module.exports = async function (app) { /** @type {typeof import('../../db/controllers/Snapshot.js')} */ const snapshotController = app.db.controllers.Snapshot @@ -160,6 +162,54 @@ module.exports = async function (app) { reply.send({ status: 'okay' }) }) + /** + * Update a snapshot + */ + app.put('/:id', { + preHandler: app.needsPermission('snapshot:edit'), + schema: { + summary: 'Update a snapshot', + tags: ['Snapshots'], + params: { + type: 'object', + properties: { + id: { type: 'string' } + } + }, + body: { + type: 'object', + properties: { + name: { type: 'string' }, + description: { type: 'string' } + } + }, + response: { + 200: { + $ref: 'Snapshot' + }, + '4xx': { + $ref: 'APIError' + } + } + } + }, async (request, reply) => { + // capture the original name/description for the audit log + const snapshotBefore = { name: request.snapshot.name, description: request.snapshot.description } + // perform the update + const snapshot = await snapshotController.updateSnapshot(request.snapshot, request.body) + // log the update + const snapshotAfter = { name: snapshot.name, description: snapshot.description } + const updates = new UpdatesCollection() + updates.pushDifferences(snapshotBefore, snapshotAfter) + if (request.ownerType === 'device') { + const application = await request.owner.getApplication() + await applicationLogger.application.device.snapshot.updated(request.session.User, null, application, request.owner, request.snapshot, updates) + } else if (request.ownerType === 'instance') { + await projectLogger.project.snapshot.updated(request.session.User, null, request.owner, request.snapshot, updates) + } + reply.send(projectSnapshotView.snapshot(snapshot)) + }) + /** * Export a snapshot for later import in another project or platform */ diff --git a/frontend/src/api/snapshots.js b/frontend/src/api/snapshots.js index 14fa0a18ad..8cd5986b5d 100644 --- a/frontend/src/api/snapshots.js +++ b/frontend/src/api/snapshots.js @@ -78,10 +78,29 @@ const deleteSnapshot = async (snapshotId) => { }) } +/** + * Update a snapshot + * @param {String} snapshotId - id of the snapshot + * @param {Object} options - options to update + * @param {String} [options.name] - name of the snapshot + * @param {String} [options.description] - description of the snapshot + */ +const updateSnapshot = async (snapshotId, options) => { + return client.put(`/api/v1/snapshots/${snapshotId}`, options).then(res => { + const props = { + 'snapshot-id': snapshotId, + 'updated-at': (new Date()).toISOString() + } + product.capture('$ff-snapshot-updated', props, {}) + return res.data + }) +} + export default { getSummary, getFullSnapshot, exportSnapshot, importSnapshot, - deleteSnapshot + deleteSnapshot, + updateSnapshot } diff --git a/frontend/src/components/audit-log/AuditEntryIcon.vue b/frontend/src/components/audit-log/AuditEntryIcon.vue index 91d881d89e..465430c6e2 100644 --- a/frontend/src/components/audit-log/AuditEntryIcon.vue +++ b/frontend/src/components/audit-log/AuditEntryIcon.vue @@ -167,6 +167,7 @@ const iconMap = { ], clock: [ 'project.snapshot.created', + 'project.snapshot.updated', 'project.device.snapshot.created', 'project.snapshot.deleted', 'project.snapshot.rollback', @@ -176,6 +177,7 @@ const iconMap = { 'project.snapshot.device-target-set', 'project.snapshot.deviceTarget', // legacy event 'application.device.snapshot.created', + 'application.device.snapshot.updated', 'application.device.snapshot.deleted', 'application.device.snapshot.exported', 'application.device.snapshot.imported', diff --git a/frontend/src/components/audit-log/AuditEntryVerbose.vue b/frontend/src/components/audit-log/AuditEntryVerbose.vue index 070e8ce323..7c61fcf624 100644 --- a/frontend/src/components/audit-log/AuditEntryVerbose.vue +++ b/frontend/src/components/audit-log/AuditEntryVerbose.vue @@ -392,6 +392,11 @@ Snapshot '{{ entry.body.snapshot?.name }}' has been been created from Application owned Device '{{ entry.body.device?.name }}'. Device or Snapshot data not found in audit entry. + + {{ AuditEvents[entry.event] }} + Snapshot '{{ entry.body.snapshot?.name }}' of Application owned Device '{{ entry.body.device?.name }}' has been been updated has with following changes: + Change data not found in audit entry. + {{ AuditEvents[entry.event] }} Snapshot '{{ entry.body.snapshot?.name }}' has been been deleted for Application owned Device '{{ entry.body.device?.name }}'. @@ -541,6 +546,11 @@ A new Snapshot '{{ entry.body.snapshot?.name }}' has been created for Instance '{{ entry.body.project?.name }}'. Instance data not found in audit entry. + + {{ AuditEvents[entry.event] }} + Snapshot '{{ entry.body.snapshot?.name }}' of Instance '{{ entry.body.project?.name }}' has been been updated has with following changes: + Change data not found in audit entry. + {{ AuditEvents[entry.event] }} A new Snapshot '{{ entry.body.snapshot?.name }}' has been created from Device '{{ entry.body.device?.name }}' for Instance '{{ entry.body.project?.name }}'. diff --git a/frontend/src/components/dialogs/SnapshotEditDialog.vue b/frontend/src/components/dialogs/SnapshotEditDialog.vue new file mode 100644 index 0000000000..46b6e6c4bd --- /dev/null +++ b/frontend/src/components/dialogs/SnapshotEditDialog.vue @@ -0,0 +1,112 @@ + + + + + Name + + Description + + + + + + + + Note: Changes made to a snapshot will not be immediately reflected on devices that are already running this snapshot. + + + + + Cancel + Update + + + + diff --git a/frontend/src/data/audit-events.json b/frontend/src/data/audit-events.json index 5eb1a87475..da43468996 100644 --- a/frontend/src/data/audit-events.json +++ b/frontend/src/data/audit-events.json @@ -47,6 +47,7 @@ "application.device.assigned": "Device Assigned to Application", "application.device.unassigned": "Device Unassigned from Application", "application.device.snapshot.created": "Device Snapshot Created", + "application.device.snapshot.updated": "Device Snapshot Updated", "application.device.snapshot.deleted": "Device Snapshot Deleted", "application.device.snapshot.exported": "Device Snapshot Exported", "application.device.snapshot.imported": "Snapshot Imported", @@ -74,6 +75,7 @@ "project.stack.changed": "Instance Stack Changed", "project.settings.updated": "Instance Settings Updated", "project.snapshot.created": "Instance Snapshot Created", + "project.snapshot.updated": "Instance Snapshot Updated", "project.device.snapshot.created": "Device Snapshot Created", "project.snapshot.rolled-back": "Instance Rolled Back", "project.snapshot.rollback": "Instance Rolled Back", diff --git a/frontend/src/pages/application/Snapshots.vue b/frontend/src/pages/application/Snapshots.vue index fc8e278de4..c0a78a804f 100644 --- a/frontend/src/pages/application/Snapshots.vue +++ b/frontend/src/pages/application/Snapshots.vue @@ -13,6 +13,7 @@ + @@ -40,6 +41,7 @@ + @@ -56,6 +58,7 @@ import EmptyState from '../../components/EmptyState.vue' import SectionTopMenu from '../../components/SectionTopMenu.vue' import AssetCompareDialog from '../../components/dialogs/AssetCompareDialog.vue' import AssetDetailDialog from '../../components/dialogs/AssetDetailDialog.vue' +import SnapshotEditDialog from '../../components/dialogs/SnapshotEditDialog.vue' import UserCell from '../../components/tables/cells/UserCell.vue' import { downloadData } from '../../composables/Download.js' import permissionsMixin from '../../mixins/Permissions.js' @@ -73,6 +76,7 @@ export default { name: 'ApplicationSnapshots', components: { SectionTopMenu, + SnapshotEditDialog, SnapshotExportDialog, AssetDetailDialog, AssetCompareDialog, @@ -169,6 +173,16 @@ export default { showDownloadSnapshotDialog (snapshot) { this.$refs.snapshotExportDialog.show(snapshot) }, + showEditSnapshotDialog (snapshot) { + this.$refs.snapshotEditDialog.show(snapshot) + }, + onSnapshotEdit (snapshot) { + const index = this.snapshots.findIndex(s => s.id === snapshot.id) + if (index >= 0) { + this.snapshots[index].name = snapshot.name + this.snapshots[index].description = snapshot.description + } + }, async downloadSnapshotPackage (snapshot) { const ss = await SnapshotsApi.getSummary(snapshot.id) const owner = ss.device || ss.project diff --git a/frontend/src/pages/device/Snapshots/index.vue b/frontend/src/pages/device/Snapshots/index.vue index 52c620a2f1..0132e28a9a 100644 --- a/frontend/src/pages/device/Snapshots/index.vue +++ b/frontend/src/pages/device/Snapshots/index.vue @@ -23,6 +23,7 @@ + @@ -61,6 +62,7 @@ + @@ -80,6 +82,7 @@ import EmptyState from '../../../components/EmptyState.vue' import SectionTopMenu from '../../../components/SectionTopMenu.vue' import AssetCompareDialog from '../../../components/dialogs/AssetCompareDialog.vue' import AssetDetailDialog from '../../../components/dialogs/AssetDetailDialog.vue' +import SnapshotEditDialog from '../../../components/dialogs/SnapshotEditDialog.vue' import SnapshotImportDialog from '../../../components/dialogs/SnapshotImportDialog.vue' import UserCell from '../../../components/tables/cells/UserCell.vue' import { downloadData } from '../../../composables/Download.js' @@ -100,6 +103,7 @@ export default { SectionTopMenu, EmptyState, SnapshotCreateDialog, + SnapshotEditDialog, SnapshotImportDialog, SnapshotExportDialog, AssetDetailDialog, @@ -352,7 +356,16 @@ export default { showDeploySnapshotDialog (snapshot) { this.deploySnapshot(snapshot.id) }, - + showEditSnapshotDialog (snapshot) { + this.$refs.snapshotEditDialog.show(snapshot) + }, + onSnapshotEdit (snapshot) { + const index = this.snapshots.findIndex(s => s.id === snapshot.id) + if (index >= 0) { + this.snapshots[index].name = snapshot.name + this.snapshots[index].description = snapshot.description + } + }, // enable/disable snapshot actions canDeploy (_row) { return !this.developerMode && this.hasPermission('device:snapshot:set-target') diff --git a/frontend/src/pages/instance/Snapshots/index.vue b/frontend/src/pages/instance/Snapshots/index.vue index 210dbf4402..2981f07d6d 100644 --- a/frontend/src/pages/instance/Snapshots/index.vue +++ b/frontend/src/pages/instance/Snapshots/index.vue @@ -18,6 +18,7 @@ + @@ -55,6 +56,7 @@ + @@ -75,6 +77,7 @@ import EmptyState from '../../../components/EmptyState.vue' import SectionTopMenu from '../../../components/SectionTopMenu.vue' import AssetCompareDialog from '../../../components/dialogs/AssetCompareDialog.vue' import AssetDetailDialog from '../../../components/dialogs/AssetDetailDialog.vue' +import SnapshotEditDialog from '../../../components/dialogs/SnapshotEditDialog.vue' import SnapshotImportDialog from '../../../components/dialogs/SnapshotImportDialog.vue' import UserCell from '../../../components/tables/cells/UserCell.vue' import { downloadData } from '../../../composables/Download.js' @@ -96,6 +99,7 @@ export default { SectionTopMenu, EmptyState, SnapshotCreateDialog, + SnapshotEditDialog, SnapshotExportDialog, SnapshotImportDialog, PlusSmIcon, @@ -322,6 +326,16 @@ export default { }, onSnapshotImportCancel () { this.busyImportingSnapshot = false + }, + showEditSnapshotDialog (snapshot) { + this.$refs.snapshotEditDialog.show(snapshot) + }, + onSnapshotEdit (snapshot) { + const index = this.snapshots.findIndex(s => s.id === snapshot.id) + if (index >= 0) { + this.snapshots[index].name = snapshot.name + this.snapshots[index].description = snapshot.description + } } } } diff --git a/frontend/src/transformers/snapshots.transformer.js b/frontend/src/transformers/snapshots.transformer.js index 7ebbe663ce..4b9d1fb48f 100644 --- a/frontend/src/transformers/snapshots.transformer.js +++ b/frontend/src/transformers/snapshots.transformer.js @@ -6,12 +6,9 @@ * @returns Array of snapshots with user details applied */ function applySystemUserDetails (snapshots, owner) { - // For any snapshots that have no user and match the autoSnapshot name format - // we mimic a user so that the table can display the device name and a suitable image - // NOTE: Any changes to the below regex should be reflected in forge/db/controllers/ProjectSnapshot.js - const autoSnapshotRegex = /^Auto Snapshot - \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/ // e.g "Auto Snapshot - 2023-02-01 12:34:56" + // For any snapshots that have no user we mimic a system-user so that the table can display the device name and a suitable image return snapshots.map((snapshot) => { - if (!snapshot.user && autoSnapshotRegex.test(snapshot.name)) { + if (!snapshot.user) { snapshot.user = { name: owner?.name || (snapshot.project || snapshot.device || {}).name || 'Unknown', username: 'Auto Snapshot', diff --git a/test/e2e/frontend/cypress/tests-ee/devices/snapshots.spec.js b/test/e2e/frontend/cypress/tests-ee/devices/snapshots.spec.js index 294ec640c8..d0a4300c69 100644 --- a/test/e2e/frontend/cypress/tests-ee/devices/snapshots.spec.js +++ b/test/e2e/frontend/cypress/tests-ee/devices/snapshots.spec.js @@ -7,13 +7,16 @@ const snapshots = { count: 2, snapshots: [deviceSnapshots.snapshots[0], instanceSnapshots.snapshots[0]] } +let idx = 0 +const IDX_DEPLOY_SNAPSHOT = idx++ +const IDX_EDIT_SNAPSHOT = idx++ +const IDX_VIEW_SNAPSHOT = idx++ +const IDX_COMPARE_SNAPSHOT = idx++ +const IDX_DOWNLOAD_SNAPSHOT = idx++ +const IDX_DOWNLOAD_PACKAGE = idx++ +const IDX_DELETE_SNAPSHOT = idx++ -const IDX_DEPLOY_SNAPSHOT = 0 -const IDX_VIEW_SNAPSHOT = 1 -const IDX_COMPARE_SNAPSHOT = 2 -const IDX_DOWNLOAD_SNAPSHOT = 3 -const IDX_DOWNLOAD_PACKAGE = 4 -const IDX_DELETE_SNAPSHOT = 5 +const MENU_ITEM_COUNT = idx describe('FlowForge - Devices - With Billing', () => { beforeEach(() => { @@ -122,8 +125,9 @@ describe('FlowForge - Devices - With Billing', () => { // click kebab menu in row 1 - a device snapshot cy.get('[data-el="snapshots"] tbody').find('.ff-kebab-menu').eq(0).click() // check the options are present - cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').should('have.length', 6) + cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').should('have.length', MENU_ITEM_COUNT) cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').eq(IDX_DEPLOY_SNAPSHOT).contains('Deploy Snapshot') + cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').eq(IDX_EDIT_SNAPSHOT).contains('Edit Snapshot') cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').eq(IDX_VIEW_SNAPSHOT).contains('View Snapshot') cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').eq(IDX_COMPARE_SNAPSHOT).contains('Compare Snapshot...') cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').eq(IDX_DOWNLOAD_SNAPSHOT).contains('Download Snapshot') @@ -135,8 +139,9 @@ describe('FlowForge - Devices - With Billing', () => { // click kebab menu in row 2 - an instance snapshot cy.get('[data-el="snapshots"] tbody').find('.ff-kebab-menu').eq(1).click() // click kebab menu in row 2 - an instance snapshot - cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').should('have.length', 6) + cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').should('have.length', MENU_ITEM_COUNT) cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').eq(IDX_DEPLOY_SNAPSHOT).contains('Deploy Snapshot') + cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').eq(IDX_EDIT_SNAPSHOT).contains('Edit Snapshot') cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').eq(IDX_VIEW_SNAPSHOT).contains('View Snapshot') cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').eq(IDX_COMPARE_SNAPSHOT).contains('Compare Snapshot...') cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').eq(IDX_DOWNLOAD_SNAPSHOT).contains('Download Snapshot') @@ -166,6 +171,50 @@ describe('FlowForge - Devices - With Billing', () => { cy.get('[data-el="dialog-view-snapshot"] .ff-dialog-content svg').should('exist') }) + it('provides functionality to edit a snapshot', () => { + cy.intercept('GET', '/api/*/applications/*/snapshots*', snapshots).as('getSnapshots') + cy.intercept('PUT', '/api/*/snapshots/*', {}).as('updateSnapshot') + + cy.contains('span', 'application-device-a').click() + cy.get('[data-nav="device-snapshots"]').click() + + cy.wait('@getSnapshots') + + // click kebab menu in row 2 + cy.get('[data-el="snapshots"] tbody').find('.ff-kebab-menu').eq(1).click() + // click the Edit Snapshot option + cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').eq(IDX_EDIT_SNAPSHOT).click() + + // check the snapshot dialog is visible and contains the snapshot name + cy.get('[data-el="dialog-edit-snapshot"]').should('be.visible') + cy.get('[data-el="dialog-edit-snapshot"] .ff-dialog-header').contains('Edit Snapshot: ' + snapshots.snapshots[1].name) + // check the edit form is present + cy.get('[data-el="dialog-edit-snapshot"] [data-form="snapshot-edit"]').should('exist') + // check the buttons are present + cy.get('[data-el="dialog-edit-snapshot"] [data-action="dialog-confirm"]').should('exist').should('be.enabled') + cy.get('[data-el="dialog-edit-snapshot"] [data-action="dialog-cancel"]').should('exist').should('be.enabled') + + // clear the snapshot name + cy.get('[data-el="dialog-edit-snapshot"] [data-form="snapshot-name"] input').clear() + // the confirm button should be disabled + cy.get('[data-el="dialog-edit-snapshot"] [data-action="dialog-confirm"]').should('be.disabled') + + // enter a new snapshot name and description + cy.get('[data-el="dialog-edit-snapshot"] [data-form="snapshot-name"] input').type('Edited Snapshot Name!!!') + cy.get('[data-el="dialog-edit-snapshot"] [data-form="snapshot-description"] textarea').clear() + cy.get('[data-el="dialog-edit-snapshot"] [data-form="snapshot-description"] textarea').type('Edited Snapshot Description!!!') + // the confirm button should be enabled + cy.get('[data-el="dialog-edit-snapshot"] [data-action="dialog-confirm"]').should('be.enabled').click() + + cy.wait('@updateSnapshot').then((interception) => { + expect(interception.request.body.name).to.equal('Edited Snapshot Name!!!') + expect(interception.request.body.description).to.equal('Edited Snapshot Description!!!') + }) + + // check the snapshot name is updated in the table + cy.get('[data-el="snapshots"] tbody').find('tr').contains('Edited Snapshot Name!!!') + }) + it('provides functionality to compare snapshots', () => { cy.intercept('GET', '/api/*/applications/*/snapshots*', deviceSnapshots).as('getSnapshots') cy.intercept('GET', '/api/*/snapshots/*/full', deviceFullSnapshot).as('fullSnapshot') diff --git a/test/e2e/frontend/cypress/tests/applications/snapshots.spec.js b/test/e2e/frontend/cypress/tests/applications/snapshots.spec.js index 53a1820143..db3a7f3906 100644 --- a/test/e2e/frontend/cypress/tests/applications/snapshots.spec.js +++ b/test/e2e/frontend/cypress/tests/applications/snapshots.spec.js @@ -12,12 +12,15 @@ const emptySnapshots = { count: 0, snapshots: [] } +let idx = 0 +const IDX_EDIT_SNAPSHOT = idx++ +const IDX_VIEW_SNAPSHOT = idx++ +const IDX_COMPARE_SNAPSHOT = idx++ +const IDX_DOWNLOAD_SNAPSHOT = idx++ +const IDX_DOWNLOAD_PACKAGE = idx++ +const IDX_DELETE_SNAPSHOT = idx++ -const IDX_VIEW_SNAPSHOT = 0 -const IDX_COMPARE_SNAPSHOT = 1 -const IDX_DOWNLOAD_SNAPSHOT = 2 -const IDX_DOWNLOAD_PACKAGE = 3 -const IDX_DELETE_SNAPSHOT = 4 +const MENU_ITEM_COUNT = idx describe('FlowForge - Application - Snapshots', () => { let application @@ -70,8 +73,9 @@ describe('FlowForge - Application - Snapshots', () => { cy.get('[data-el="snapshots"] tbody').find('.ff-kebab-menu').eq(0).click() // check the options are present - cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').should('have.length', 5) + cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').should('have.length', MENU_ITEM_COUNT) cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').eq(IDX_VIEW_SNAPSHOT).contains('View Snapshot') + cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').eq(IDX_EDIT_SNAPSHOT).contains('Edit Snapshot') cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').eq(IDX_COMPARE_SNAPSHOT).contains('Compare Snapshot...') cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').eq(IDX_DOWNLOAD_SNAPSHOT).contains('Download Snapshot') cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').eq(IDX_DOWNLOAD_PACKAGE).contains('Download package.json') @@ -82,6 +86,7 @@ describe('FlowForge - Application - Snapshots', () => { // click kebab menu in row 2 - a instance snapshot cy.get('[data-el="snapshots"] tbody').find('.ff-kebab-menu').eq(1).click() cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').eq(IDX_VIEW_SNAPSHOT).contains('View Snapshot') + cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').eq(IDX_EDIT_SNAPSHOT).contains('Edit Snapshot') cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').eq(IDX_COMPARE_SNAPSHOT).contains('Compare Snapshot...') cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').eq(IDX_DOWNLOAD_SNAPSHOT).contains('Download Snapshot') cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').eq(IDX_DOWNLOAD_PACKAGE).contains('Download package.json') @@ -128,6 +133,48 @@ describe('FlowForge - Application - Snapshots', () => { cy.get('[data-el="dialog-view-snapshot"] .ff-dialog-content svg').should('exist') }) + it('provides functionality to edit a snapshot', () => { + cy.intercept('GET', '/api/*/applications/*/snapshots*', snapshots).as('getSnapshots') + cy.intercept('GET', '/api/*/snapshots/*/full', instanceFullSnapshot).as('fullSnapshot') + cy.intercept('PUT', '/api/*/snapshots/*', {}).as('updateSnapshot') + + cy.get('[data-nav="application-snapshots"]').click() + + // click kebab menu in row 2 + cy.get('[data-el="snapshots"] tbody').find('.ff-kebab-menu').eq(1).click() + // click the Edit Snapshot option + cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').eq(IDX_EDIT_SNAPSHOT).click() + + // check the snapshot dialog is visible and contains the snapshot name + cy.get('[data-el="dialog-edit-snapshot"]').should('be.visible') + cy.get('[data-el="dialog-edit-snapshot"] .ff-dialog-header').contains(instanceFullSnapshot.name) + // check the edit form is present + cy.get('[data-el="dialog-edit-snapshot"] [data-form="snapshot-edit"]').should('exist') + // check the buttons are present + cy.get('[data-el="dialog-edit-snapshot"] [data-action="dialog-confirm"]').should('exist').should('be.enabled') + cy.get('[data-el="dialog-edit-snapshot"] [data-action="dialog-cancel"]').should('exist').should('be.enabled') + + // clear the snapshot name + cy.get('[data-el="dialog-edit-snapshot"] [data-form="snapshot-name"] input').clear() + // the confirm button should be disabled + cy.get('[data-el="dialog-edit-snapshot"] [data-action="dialog-confirm"]').should('be.disabled') + + // enter a new snapshot name and description + cy.get('[data-el="dialog-edit-snapshot"] [data-form="snapshot-name"] input').type('Edited Snapshot Name!!!') + cy.get('[data-el="dialog-edit-snapshot"] [data-form="snapshot-description"] textarea').clear() + cy.get('[data-el="dialog-edit-snapshot"] [data-form="snapshot-description"] textarea').type('Edited Snapshot Description!!!') + // the confirm button should be enabled + cy.get('[data-el="dialog-edit-snapshot"] [data-action="dialog-confirm"]').should('be.enabled').click() + + cy.wait('@updateSnapshot').then((interception) => { + expect(interception.request.body.name).to.equal('Edited Snapshot Name!!!') + expect(interception.request.body.description).to.equal('Edited Snapshot Description!!!') + }) + + // check the snapshot name is updated in the table + cy.get('[data-el="snapshots"] tbody').find('tr').contains('Edited Snapshot Name!!!') + }) + it('provides functionality to compare snapshots', () => { cy.intercept('GET', '/api/*/applications/*/snapshots*', snapshots).as('getSnapshots') cy.intercept('GET', '/api/*/snapshots/*/full', instanceFullSnapshot).as('fullSnapshot') diff --git a/test/e2e/frontend/cypress/tests/instances/snapshots.spec.js b/test/e2e/frontend/cypress/tests/instances/snapshots.spec.js index 4c959ff474..0c62b6bad1 100644 --- a/test/e2e/frontend/cypress/tests/instances/snapshots.spec.js +++ b/test/e2e/frontend/cypress/tests/instances/snapshots.spec.js @@ -2,13 +2,16 @@ import instanceSnapshots from '../../fixtures/snapshots/instance-snapshots.json' import instanceFullSnapshot from '../../fixtures/snapshots/instance2-full-snapshot2.json' import instanceSnapshot from '../../fixtures/snapshots/instance2-snapshot2.json' -const IDX_DEPLOY_SNAPSHOT = 0 -const IDX_VIEW_SNAPSHOT = 1 -const IDX_COMPARE_SNAPSHOT = 2 -const IDX_DOWNLOAD_SNAPSHOT = 3 -const IDX_DOWNLOAD_PACKAGE = 4 -const IDX_SET_TARGET = 5 -const IDX_DELETE_SNAPSHOT = 6 +let idx = 0 +const IDX_DEPLOY_SNAPSHOT = idx++ +const IDX_EDIT_SNAPSHOT = idx++ +const IDX_VIEW_SNAPSHOT = idx++ +const IDX_COMPARE_SNAPSHOT = idx++ +const IDX_DOWNLOAD_SNAPSHOT = idx++ +const IDX_DOWNLOAD_PACKAGE = idx++ +const IDX_SET_TARGET = idx++ +const IDX_DELETE_SNAPSHOT = idx++ +const MENU_ITEM_COUNT = idx describe('FlowForge - Instance Snapshots', () => { let projectId @@ -49,10 +52,10 @@ describe('FlowForge - Instance Snapshots', () => { // disabled primary button by default cy.get('.ff-dialog-box button.ff-btn.ff-btn--primary').should('be.disabled') - cy.get('[data-form="snapshot-name"] input[type="text"]').type('snapshot1') + cy.get('[data-el="dialog-create-snapshot"] [data-form="snapshot-name"] input[type="text"]').type('snapshot1') // inserting snapshot name is enough to enable button cy.get('[data-el="dialog-create-snapshot"] button.ff-btn.ff-btn--primary').should('not.be.disabled') - cy.get('[data-form="snapshot-description"] textarea').type('snapshot1 description') + cy.get('[data-el="dialog-create-snapshot"] [data-form="snapshot-description"] textarea').type('snapshot1 description') // click "Create" cy.get('[data-el="dialog-create-snapshot"] button.ff-btn.ff-btn--primary').click() @@ -69,8 +72,9 @@ describe('FlowForge - Instance Snapshots', () => { cy.get('[data-el="snapshots"] tbody').find('.ff-kebab-menu').eq(0).click() // check the options are present - cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').should('have.length', 7) + cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').should('have.length', MENU_ITEM_COUNT) cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').eq(IDX_DEPLOY_SNAPSHOT).contains('Deploy Snapshot') + cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').eq(IDX_EDIT_SNAPSHOT).contains('Edit Snapshot') cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').eq(IDX_VIEW_SNAPSHOT).contains('View Snapshot') cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').eq(IDX_COMPARE_SNAPSHOT).contains('Compare Snapshot...') cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').eq(IDX_DOWNLOAD_SNAPSHOT).contains('Download Snapshot') @@ -97,6 +101,45 @@ describe('FlowForge - Instance Snapshots', () => { cy.get('[data-el="dialog-view-snapshot"] .ff-dialog-content svg').should('exist') }) + it('provides functionality to edit a snapshot', () => { + cy.intercept('GET', '/api/*/snapshots/*/full', instanceFullSnapshot).as('fullSnapshot') + cy.intercept('PUT', '/api/*/snapshots/*', {}).as('updateSnapshot') + + // click kebab menu in row 1 + cy.get('[data-el="snapshots"] tbody').find('.ff-kebab-menu').eq(0).click() + // click the Edit Snapshot option + cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').eq(IDX_EDIT_SNAPSHOT).click() + + // check the snapshot dialog is visible and contains the snapshot name + cy.get('[data-el="dialog-edit-snapshot"]').should('be.visible') + cy.get('[data-el="dialog-edit-snapshot"] .ff-dialog-header').contains('Edit Snapshot: snapshot1') // brittle! (depends on prior test / ordered execution) + // check the edit form is present + cy.get('[data-el="dialog-edit-snapshot"] [data-form="snapshot-edit"]').should('exist') + // check the buttons are present + cy.get('[data-el="dialog-edit-snapshot"] [data-action="dialog-confirm"]').should('exist').should('be.enabled') + cy.get('[data-el="dialog-edit-snapshot"] [data-action="dialog-cancel"]').should('exist').should('be.enabled') + + // clear the snapshot name + cy.get('[data-el="dialog-edit-snapshot"] [data-form="snapshot-name"] input').clear() + // the confirm button should be disabled + cy.get('[data-el="dialog-edit-snapshot"] [data-action="dialog-confirm"]').should('be.disabled') + + // enter a new snapshot name and description + cy.get('[data-el="dialog-edit-snapshot"] [data-form="snapshot-name"] input').type('Edited Snapshot Name!!!') + cy.get('[data-el="dialog-edit-snapshot"] [data-form="snapshot-description"] textarea').clear() + cy.get('[data-el="dialog-edit-snapshot"] [data-form="snapshot-description"] textarea').type('Edited Snapshot Description!!!') + // the confirm button should be enabled + cy.get('[data-el="dialog-edit-snapshot"] [data-action="dialog-confirm"]').should('be.enabled').click() + + cy.wait('@updateSnapshot').then((interception) => { + expect(interception.request.body.name).to.equal('Edited Snapshot Name!!!') + expect(interception.request.body.description).to.equal('Edited Snapshot Description!!!') + }) + + // check the snapshot name is updated in the table + cy.get('[data-el="snapshots"] tbody').find('tr').contains('Edited Snapshot Name!!!') + }) + it('provides functionality to compare snapshots', () => { cy.intercept('GET', '/api/*/snapshots/*/full', instanceFullSnapshot).as('fullSnapshot') // click kebab menu in row 1 diff --git a/test/unit/forge/auditLog/application_spec.js b/test/unit/forge/auditLog/application_spec.js index 70a8979d68..4aa3362079 100644 --- a/test/unit/forge/auditLog/application_spec.js +++ b/test/unit/forge/auditLog/application_spec.js @@ -151,6 +151,24 @@ describe('Audit Log > Application', async function () { logEntry.body.snapshot.id.should.equal(SNAPSHOT.hashid) }) + it('Provides a logger for application device snapshot updated', async function () { + const updates = new UpdatesCollection() + updates.pushDifferences({ name: 'snapshot' }, { name: 'snapshot-new-name' }) + await logger.application.device.snapshot.updated(ACTIONED_BY, null, APPLICATION, DEVICE, SNAPSHOT, updates) + // check log stored + const logEntry = await getLog() + logEntry.should.have.property('event', 'application.device.snapshot.updated') + logEntry.should.have.property('scope', { id: APPLICATION.hashid, type: 'application' }) + logEntry.should.have.property('trigger', { id: ACTIONED_BY.hashid, type: 'user', name: ACTIONED_BY.username }) + logEntry.should.have.property('body') + logEntry.body.should.only.have.keys('device', 'snapshot', 'updates') + logEntry.body.device.should.only.have.keys('id', 'name') + logEntry.body.device.id.should.equal(DEVICE.hashid) + logEntry.body.snapshot.should.only.have.keys('id', 'name') + logEntry.body.snapshot.id.should.equal(SNAPSHOT.hashid) + logEntry.body.updates.should.be.an.Array().and.have.length(1) + }) + it('Provides a logger for application device snapshot deleted', async function () { await logger.application.device.snapshot.deleted(ACTIONED_BY, null, APPLICATION, DEVICE, SNAPSHOT) // check log stored diff --git a/test/unit/forge/auditLog/project_spec.js b/test/unit/forge/auditLog/project_spec.js index 493af48397..2dd91cf3ea 100644 --- a/test/unit/forge/auditLog/project_spec.js +++ b/test/unit/forge/auditLog/project_spec.js @@ -1,4 +1,6 @@ const should = require('should') // eslint-disable-line +const { UpdatesCollection } = require('../../../../forge/auditLog/formatters') + const FF_UTIL = require('flowforge-test-utils') // Declare a dummy getLoggers function for type hint only /** @type {import('../../../../forge/auditLog/project').getLoggers} */ @@ -286,6 +288,25 @@ describe('Audit Log > Project', async function () { logEntry.body.snapshot.id.should.equal(SNAPSHOT.hashid) }) + it('Provides a logger for updating snapshots of a project', async function () { + const updates = new UpdatesCollection() + updates.pushDifferences({ name: 'old' }, { name: 'new' }) + await projectLogger.project.snapshot.updated(ACTIONED_BY, null, PROJECT, SNAPSHOT, updates) + // check log stored + const logEntry = await getLog() + logEntry.should.have.property('event', 'project.snapshot.updated') + logEntry.should.have.property('scope', { id: PROJECT.id, type: 'project' }) + logEntry.should.have.property('trigger', { id: ACTIONED_BY.hashid, type: 'user', name: ACTIONED_BY.username }) + logEntry.should.have.property('body') + logEntry.body.should.only.have.keys('project', 'snapshot', 'updates') + logEntry.body.project.should.only.have.keys('id', 'name') + logEntry.body.project.id.should.equal(PROJECT.id) + logEntry.body.snapshot.should.only.have.keys('id', 'name') + logEntry.body.snapshot.id.should.equal(SNAPSHOT.hashid) + logEntry.body.updates.should.have.length(1) + logEntry.body.updates[0].should.eql({ key: 'name', old: 'old', new: 'new', dif: 'updated' }) + }) + it('Provides a logger for rolling back a snapshot of a project', async function () { await projectLogger.project.snapshot.rolledBack(ACTIONED_BY, null, PROJECT, SNAPSHOT) // check log stored diff --git a/test/unit/forge/routes/api/snapshots_spec.js b/test/unit/forge/routes/api/snapshots_spec.js index 1ec859e5d6..f883a56f03 100644 --- a/test/unit/forge/routes/api/snapshots_spec.js +++ b/test/unit/forge/routes/api/snapshots_spec.js @@ -827,4 +827,127 @@ describe('Snapshots API', function () { tests('device') }) }) + + // Tests for PUT /api/v1/snapshots/{snapshotId} + // * Updates a snapshot + + describe('Update snapshot', function () { + afterEach(async function () { + await app.db.models.ProjectSnapshot.destroy({ where: {} }) + }) + + /** + * put snapshot tests + * @param {'instance' | 'device'} kind - 'instance' or 'device' + */ + function tests (kind) { + const createSnapshot = kind === 'instance' ? createInstanceSnapshot : createAppDeviceSnapshot + + it('Returns 404 for non-existent snapshot', async function () { + const response = await app.inject({ + method: 'PUT', + url: '/api/v1/snapshots/non-existent-snapshot-id', + payload: { name: 'new-name' }, + cookies: { sid: TestObjects.tokens.alice } + }) + response.statusCode.should.equal(404) + response.json().should.have.property('code', 'not_found') + }) + + it('Non-member cannot update snapshot', async function () { + const snapshotResponse = await createSnapshot() + const result = snapshotResponse.json() + + // ensure it really exists before assuming the non-member cannot access it + const ownerResponse = await app.inject({ + method: 'PUT', + url: `/api/v1/snapshots/${result.id}`, + payload: { name: 'new-name' }, + cookies: { sid: TestObjects.tokens.alice } + }) + ownerResponse.statusCode.should.equal(200) + + const response = await app.inject({ + method: 'PUT', + url: `/api/v1/snapshots/${result.id}`, + payload: { name: 'new-name' }, + cookies: { sid: TestObjects.tokens.chris } + }) + + // 404 as a non member should not know the resource exists + response.statusCode.should.equal(404) + response.json().should.have.property('code', 'not_found') + }) + + it('Owner can update snapshot', async function () { + const snapshotResponse = await createSnapshot() + const result = snapshotResponse.json() + + const response = await app.inject({ + method: 'PUT', + url: `/api/v1/snapshots/${result.id}`, + payload: { name: 'new-name', description: 'new-description' }, + cookies: { sid: TestObjects.tokens.alice } + }) + + response.statusCode.should.equal(200) + response.json().should.have.property('name', 'new-name') + response.json().should.have.property('description', 'new-description') + }) + + it('Can update name only', async function () { + const snapshotResponse = await createSnapshot() + const result = snapshotResponse.json() + + const response = await app.inject({ + method: 'PUT', + url: `/api/v1/snapshots/${result.id}`, + payload: { name: 'new-name' }, + cookies: { sid: TestObjects.tokens.alice } + }) + + response.statusCode.should.equal(200) + response.json().should.have.property('name', 'new-name') + response.json().should.have.property('description', result.description) // description should not change + }) + + it('Can update description only', async function () { + const snapshotResponse = await createSnapshot() + const result = snapshotResponse.json() + + const response = await app.inject({ + method: 'PUT', + url: `/api/v1/snapshots/${result.id}`, + payload: { description: 'new-description' }, + cookies: { sid: TestObjects.tokens.alice } + }) + + response.statusCode.should.equal(200) + response.json().should.have.property('name', result.name) // name should not change + response.json().should.have.property('description', 'new-description') + }) + + it('Member cannot update snapshot', async function () { + const snapshotResponse = await createSnapshot() + const result = snapshotResponse.json() + + const response = await app.inject({ + method: 'PUT', + url: `/api/v1/snapshots/${result.id}`, + payload: { name: 'new-name' }, + cookies: { sid: TestObjects.tokens.bob } + }) + + response.statusCode.should.equal(403) + response.json().should.have.property('code', 'unauthorized') + }) + } + describe('instance', function () { + tests('instance') + }) + + describe('device', function () { + tests('device') + }) + }) })
+ + Note: Changes made to a snapshot will not be immediately reflected on devices that are already running this snapshot. + +