diff --git a/docs/device-agent/deploy.md b/docs/device-agent/deploy.md index d5757d1673..e63bee5e73 100644 --- a/docs/device-agent/deploy.md +++ b/docs/device-agent/deploy.md @@ -91,6 +91,13 @@ in the Developer Mode options panel. You will be prompted to give the snapshot a name and description. See [Snapshots](../user/snapshots.md) for more information about working with snapshots. +**Auto Device Snapshots** + +For devices that are assigned to an application, the platform will automatically create a snapshot of the device +when it detects flows modified. This snapshot will be created with the name "Auto Snapshot - yyyy-mm-dd hh:mm-ss". +Only the last 10 auto snapshots are kept, others are deleted on a first in first out basis. + + ### Important Notes * Remote access to the editor requires Device Agent v0.8.0 or later. @@ -109,3 +116,6 @@ about working with snapshots. * The device will not receive any updates from the platform while in Developer Mode. * The device must be online and connected to the platform to enable "Editor Access". * To minimise server and device resources, it is recommended to disable "Editor Access" when not actively developing flows on a device. +* Auto snapshots were introduced in FlowFuse V2.1. +* Auto snapshots are only supported for devices assigned to an application. +* If an auto snapshot is set as the target snapshot for a device or assigned to a pipeline stage, it will not be auto cleaned up meaning it is possible to have more than 10 auto snapshots. diff --git a/docs/user/snapshots.md b/docs/user/snapshots.md index 6d81f39aa0..ce5ed1b894 100644 --- a/docs/user/snapshots.md +++ b/docs/user/snapshots.md @@ -60,7 +60,7 @@ To set the **Device Target** of an application owned device: 1. Go to the devices's page and select the **Snapshots** tab. 2. In the list of snapshots available, a "Deploy Snapshot" button will be displayed for each snapshot as you hover over it. -3. You will be asked to confirm - click **Set Target** to continue. +3. You will be asked to confirm - click the **Confirm** button to set it as the target snapshot. This will cause the snapshot to be pushed out to the device the next time it checks in. diff --git a/forge/auditLog/device.js b/forge/auditLog/device.js index 455633a403..874e66f1d6 100644 --- a/forge/auditLog/device.js +++ b/forge/auditLog/device.js @@ -45,6 +45,11 @@ module.exports = { async disabled (actionedBy, error, device) { await log('device.remote-access.disabled', actionedBy, device?.id, generateBody({ error, device })) } + }, + settings: { + async updated (actionedBy, error, device, updates) { + await log('device.settings.updated', actionedBy, device?.id, generateBody({ error, device, updates })) + } } } diff --git a/forge/db/controllers/ProjectSnapshot.js b/forge/db/controllers/ProjectSnapshot.js index 974b8daa14..4caa707310 100644 --- a/forge/db/controllers/ProjectSnapshot.js +++ b/forge/db/controllers/ProjectSnapshot.js @@ -1,3 +1,181 @@ +const { Op } = require('sequelize') +const DEVICE_AUTO_SNAPSHOT_LIMIT = 10 +const DEVICE_AUTO_SNAPSHOT_PREFIX = 'Auto Snapshot' // Any changes to the format should be reflected in frontend/src/pages/device/Snapshots/index.vue + +const deviceAutoSnapshotUtils = { + deployTypeEnum: { + full: 'Full', + flows: 'Modified Flows', + nodes: 'Modified Nodes' + }, + nameRegex: new RegExp(`^${DEVICE_AUTO_SNAPSHOT_PREFIX} - \\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$`), // e.g "Auto Snapshot - 2023-02-01 12:34:56" + prefix: DEVICE_AUTO_SNAPSHOT_PREFIX, + autoBackupLimit: DEVICE_AUTO_SNAPSHOT_LIMIT, + generateName: () => `${DEVICE_AUTO_SNAPSHOT_PREFIX} - ${new Date().toLocaleString('sv-SE')}`, // "base - YYYY-MM-DD HH:MM:SS" + generateDescription: (deploymentType = '') => { + const deployInfo = deviceAutoSnapshotUtils.deployTypeEnum[deploymentType] + ? `${deviceAutoSnapshotUtils.deployTypeEnum[deploymentType]} deployment` + : 'deployment' + return `Device ${deviceAutoSnapshotUtils.prefix} taken following a ${deployInfo}` + }, + isAutoSnapshot: function (snapshot) { + return deviceAutoSnapshotUtils.nameRegex.test(snapshot.name) + }, + /** + * Get all auto snapshots for a device + * + * NOTE: If a `limit` of 10 is provided and some of the snapshots are in use, the actual number of snapshots returned may be less than 10 + * @param {Object} app - the forge application object + * @param {Object} device - a device (model) instance + * @param {boolean} [excludeInUse=true] - whether to exclude snapshots that are currently in use by a device, device group or pipeline stage device group + * @param {number} [limit=0] - the maximum number of snapshots to query in the database (0 means no limit) + */ + getAutoSnapshots: async function (app, device, excludeInUse = true, limit = 0) { + // TODO: the snapshots table should really have a an indexed `type` column to distinguish between auto and manual snapshots + // for now, as per MVP, we'll use the name pattern to identify auto snapshots + + // Get snapshots + const possibleAutoSnapshots = await app.db.models.ProjectSnapshot.findAll({ + where: { + DeviceId: device.id, + // name: { [Op.regexp]: deviceAutoSnapshotUtils.nameRegex } // regex is not supported by sqlite! + name: { [Op.like]: `${DEVICE_AUTO_SNAPSHOT_PREFIX} - %-%-% %:%:%` } + }, + order: [['id', 'ASC']] + }) + + // Filter out any snapshots that don't match the regex + const autoSnapshots = possibleAutoSnapshots.filter(deviceAutoSnapshotUtils.isAutoSnapshot) + + // if caller _wants_ all, including those "in use", we can just return here + if (!excludeInUse) { + return autoSnapshots + } + // utility function to remove items from an array + const removeFromArray = (baseList, removeList) => baseList.filter((item) => !removeList.includes(item)) + + // candidates for are those that are not in use + let candidateIds = autoSnapshots.map((snapshot) => snapshot.id) + + // since we're excluding "in use" snapshots, we need to check the following tables: + // * device + // * device groups + // * pipeline stage device group + // If any of these snapshots are set as active/target, remove them from the candidates list + + // Check `Devices` table + const query = { + where: { + [Op.or]: [ + { targetSnapshotId: { [Op.in]: candidateIds } }, + { activeSnapshotId: { [Op.in]: candidateIds } } + ] + } + } + if (typeof limit === 'number' && limit > 0) { + query.limit = limit + } + const snapshotsInUseInDevices = await app.db.models.Device.findAll(query) + const inUseAsTarget = snapshotsInUseInDevices.map((device) => device.targetSnapshotId) + const inUseAsActive = snapshotsInUseInDevices.map((device) => device.activeSnapshotId) + candidateIds = removeFromArray(candidateIds, inUseAsTarget) + candidateIds = removeFromArray(candidateIds, inUseAsActive) + + // Check `DeviceGroups` table + if (app.db.models.DeviceGroup) { + const snapshotsInUseInDeviceGroups = await app.db.models.DeviceGroup.findAll({ + where: { + targetSnapshotId: { [Op.in]: candidateIds } + } + }) + const inGroupAsTarget = snapshotsInUseInDeviceGroups.map((group) => group.targetSnapshotId) + candidateIds = removeFromArray(candidateIds, inGroupAsTarget) + } + + // Check `PipelineStageDeviceGroups` table + const isLicensed = app.license.active() + if (isLicensed && app.db.models.PipelineStageDeviceGroup) { + const snapshotsInUseInPipelineStage = await app.db.models.PipelineStageDeviceGroup.findAll({ + where: { + targetSnapshotId: { [Op.in]: candidateIds } + } + }) + const inPipelineStageAsTarget = snapshotsInUseInPipelineStage.map((stage) => stage.targetSnapshotId) + candidateIds = removeFromArray(candidateIds, inPipelineStageAsTarget) + } + + return autoSnapshots.filter((snapshot) => candidateIds.includes(snapshot.id)) + }, + cleanupAutoSnapshots: async function (app, device, limit = DEVICE_AUTO_SNAPSHOT_LIMIT) { + // get all auto snapshots for the device (where not in use) + const snapshots = await app.db.controllers.ProjectSnapshot.getDeviceAutoSnapshots(device, true, 0) + if (snapshots.length > limit) { + const toDelete = snapshots.slice(0, snapshots.length - limit).map((snapshot) => snapshot.id) + await app.db.models.ProjectSnapshot.destroy({ where: { id: { [Op.in]: toDelete } } }) + } + }, + doAutoSnapshot: async function (app, device, deploymentType, { clean = true, setAsTarget = false } = {}, meta) { + // eslint-disable-next-line no-useless-catch + try { + // if not permitted, throw an error + if (!device) { + throw new Error('Device is required') + } + if (!app.config.features.enabled('deviceAutoSnapshot')) { + throw new Error('Device auto snapshot feature is not available') + } + if (!(await device.getSetting('autoSnapshot'))) { + throw new Error('Device auto snapshot is not enabled') + } + const teamType = await device.Team.getTeamType() + const deviceAutoSnapshotEnabledForTeam = teamType.getFeatureProperty('deviceAutoSnapshot', false) + if (!deviceAutoSnapshotEnabledForTeam) { + throw new Error('Device auto snapshot is not enabled for the team') + } + + const saneSnapshotOptions = { + name: deviceAutoSnapshotUtils.generateName(), + description: deviceAutoSnapshotUtils.generateDescription(deploymentType), + setAsTarget + } + + // things to do & consider: + // 1. create a snapshot from the device + // 2. log the snapshot creation in audit log + // 3. delete older auto snapshots if the limit is reached (10) + // do NOT delete any snapshots that are currently in use by an target (instance/device/device group) + const user = meta?.user || { id: null } // if no user is available, use `null` (system user) + + // 1. create a snapshot from the device + const snapShot = await app.db.controllers.ProjectSnapshot.createDeviceSnapshot( + device.Application, + device, + user, + saneSnapshotOptions + ) + snapShot.User = user + + // 2. log the snapshot creation in audit log + // TODO: device snapshot: implement audit log + // await deviceAuditLogger.device.snapshot.created(request.session.User, null, request.device, snapShot) + + // 3. clean up older auto snapshots + if (clean === true) { + await app.db.controllers.ProjectSnapshot.cleanupDeviceAutoSnapshots(device) + } + + return snapShot + } catch (error) { + // TODO: device snapshot: implement audit log + // await deviceAuditLogger.device.snapshot.created(request.session.User, error, request.device, null) + throw error + } + } +} + +// freeze the object to prevent accidental changes +Object.freeze(deviceAutoSnapshotUtils) + module.exports = { /** * Creates a snapshot of the current state of a project. @@ -168,5 +346,12 @@ module.exports = { result.flows.credentials = app.db.controllers.Project.exportCredentials(credentials || {}, keyToDecrypt, options.credentialSecret) return result - } + }, + + getDeviceAutoSnapshotDescription: deviceAutoSnapshotUtils.generateDescription, + getDeviceAutoSnapshotName: deviceAutoSnapshotUtils.generateName, + getDeviceAutoSnapshots: deviceAutoSnapshotUtils.getAutoSnapshots, + isDeviceAutoSnapshot: deviceAutoSnapshotUtils.isAutoSnapshot, + cleanupDeviceAutoSnapshots: deviceAutoSnapshotUtils.cleanupAutoSnapshots, + doDeviceAutoSnapshot: deviceAutoSnapshotUtils.doAutoSnapshot } diff --git a/forge/db/models/Device.js b/forge/db/models/Device.js index 372204ac89..5dd147fd71 100644 --- a/forge/db/models/Device.js +++ b/forge/db/models/Device.js @@ -10,7 +10,12 @@ const Controllers = require('../controllers') const { buildPaginationSearchClause } = require('../utils') const ALLOWED_SETTINGS = { - env: 1 + env: 1, + autoSnapshot: 1 +} + +const DEFAULT_SETTINGS = { + autoSnapshot: true } module.exports = { @@ -74,7 +79,7 @@ module.exports = { await app.auditLog.Platform.platform.license.overage('system', null, devices) } }, - beforeSave: async (device, options) => { + afterSave: async (device, options) => { // since `id`, `name` and `type` are added as FF_DEVICE_xx env vars, we // should update the settings checksum if they are modified if (device.changed('name') || device.changed('type') || device.changed('id')) { @@ -131,6 +136,7 @@ module.exports = { }, async updateSettingsHash (settings) { const _settings = settings || await this.getAllSettings() + delete _settings.autoSnapshot // autoSnapshot is not part of the settings hash this.settingsHash = hashSettings(_settings) }, async getAllSettings () { @@ -140,6 +146,9 @@ module.exports = { result[setting.key] = setting.value }) result.env = Controllers.Device.insertPlatformSpecificEnvVars(this, result.env) // add platform specific device env vars + if (!Object.prototype.hasOwnProperty.call(result, 'autoSnapshot')) { + result.autoSnapshot = DEFAULT_SETTINGS.autoSnapshot + } return result }, async updateSettings (obj) { @@ -161,7 +170,7 @@ module.exports = { if (key === 'env' && value && Array.isArray(value)) { value = Controllers.Device.removePlatformSpecificEnvVars(value) // remove platform specific values } - const result = await M.ProjectSettings.upsert({ DeviceId: this.id, key, value }) + const result = await M.DeviceSettings.upsert({ DeviceId: this.id, key, value }) await this.updateSettingsHash() await this.save() return result @@ -177,7 +186,7 @@ module.exports = { } return result.value } - return undefined + return DEFAULT_SETTINGS[key] }, async getLatestSnapshot () { const snapshots = await this.getProjectSnapshots({ diff --git a/forge/ee/lib/index.js b/forge/ee/lib/index.js index bcd7bd8f69..f6d4ee5c98 100644 --- a/forge/ee/lib/index.js +++ b/forge/ee/lib/index.js @@ -25,4 +25,7 @@ module.exports = fp(async function (app, opts) { // Set the Custom Catalogs Flag app.config.features.register('customCatalogs', true, true) + + // Set the Device Auto Snapshot Feature Flag + app.config.features.register('deviceAutoSnapshot', true, true) }, { name: 'app.ee.lib' }) diff --git a/forge/routes/api/device.js b/forge/routes/api/device.js index b702e4d52d..d6a08f4db4 100644 --- a/forge/routes/api/device.js +++ b/forge/routes/api/device.js @@ -4,6 +4,7 @@ const { Roles } = require('../../lib/roles') const DeviceLive = require('./deviceLive') const DeviceSnapshots = require('./deviceSnapshots.js') +const hasProperty = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key) /** * Project Device api routes @@ -552,7 +553,7 @@ module.exports = async function (app) { }) app.put('/:deviceId/settings', { - preHandler: app.needsPermission('device:edit-env'), + preHandler: app.needsPermission('device:edit-env'), // members only schema: { summary: 'Update a devices settings', tags: ['Devices'], @@ -565,7 +566,8 @@ module.exports = async function (app) { body: { type: 'object', properties: { - env: { type: 'array', items: { type: 'object', additionalProperties: true } } + env: { type: 'array', items: { type: 'object', additionalProperties: true } }, + autoSnapshot: { type: 'boolean' } } }, response: { @@ -578,15 +580,51 @@ module.exports = async function (app) { } } }, async (request, reply) => { + const updates = new app.auditLog.formatters.UpdatesCollection() + const currentSettings = await request.device.getAllSettings() + // remove any extra properties from env to ensure they match the format of the body data + // and prevent updates from being logged for unchanged values + currentSettings.env = (currentSettings.env || []).map(e => ({ name: e.name, value: e.value })) + const captureUpdates = (key) => { + if (key === 'env') { + // transform the env array to a map for better logging format + const currentEnv = currentSettings.env.reduce((acc, e) => { + acc[e.name] = e.value + return acc + }, {}) + const newEnv = request.body.env.reduce((acc, e) => { + acc[e.name] = e.value + return acc + }, {}) + updates.pushDifferences({ env: currentEnv }, { env: newEnv }) + } else { + updates.pushDifferences({ [key]: currentSettings[key] }, { [key]: request.body[key] }) + } + } if (request.teamMembership?.role === Roles.Owner) { + // owner is permitted to update all settings await request.device.updateSettings(request.body) + const keys = Object.keys(request.body) + // capture key/val updates sent in body + keys.forEach(key => captureUpdates(key, currentSettings[key], request.body[key])) } else { - const bodySettingsEnvOnly = { - env: request.body.env + // members are only permitted to update the env and autoSnapshot settings + const settings = {} + if (hasProperty(request.body, 'env')) { + settings.env = request.body.env + captureUpdates('env', currentSettings.env, request.body.env) } - await request.device.updateSettings(bodySettingsEnvOnly) + if (hasProperty(request.body, 'autoSnapshot')) { + settings.autoSnapshot = request.body.autoSnapshot + captureUpdates('autoSnapshot', currentSettings.autoSnapshot, request.body.autoSnapshot) + } + await request.device.updateSettings(settings) } await app.db.controllers.Device.sendDeviceUpdateCommand(request.device) + // Log the updates + if (updates.length > 0) { + await app.auditLog.Device.device.settings.updated(request.session.User.id, null, request.device, updates) + } reply.send({ status: 'okay' }) }) @@ -605,7 +643,8 @@ module.exports = async function (app) { 200: { type: 'object', properties: { - env: { type: 'array', items: { type: 'object', additionalProperties: true } } + env: { type: 'array', items: { type: 'object', additionalProperties: true } }, + autoSnapshot: { type: 'boolean' } } }, '4xx': { @@ -619,7 +658,8 @@ module.exports = async function (app) { reply.send(settings) } else { reply.send({ - env: settings?.env + env: settings?.env, + autoSnapshot: settings?.autoSnapshot }) } }) diff --git a/forge/routes/logging/index.js b/forge/routes/logging/index.js index 0b7ec4791f..ca3816eabc 100644 --- a/forge/routes/logging/index.js +++ b/forge/routes/logging/index.js @@ -14,6 +14,8 @@ module.exports = async function (app) { const projectAuditLogger = getProjectLogger(app) /** @type {import('../../db/controllers/AuditLog')} */ const auditLogController = app.db.controllers.AuditLog + /** @type {import('../../db/controllers/ProjectSnapshot')} */ + const snapshotController = app.db.controllers.ProjectSnapshot app.addHook('preHandler', app.verifySession) @@ -124,5 +126,36 @@ module.exports = async function (app) { } response.status(200).send() + + // For application owned devices, perform an auto snapshot + if (request.device.isApplicationOwned) { + if (event === 'flows.set' && ['full', 'flows', 'nodes'].includes(auditEvent.type)) { + if (!app.config.features.enabled('deviceAutoSnapshot')) { + return // device auto snapshot feature is not available + } + + const teamType = await request.device.Team.getTeamType() + const deviceAutoSnapshotEnabledForTeam = teamType.getFeatureProperty('deviceAutoSnapshot', false) + if (!deviceAutoSnapshotEnabledForTeam) { + return // not enabled for team + } + const deviceAutoSnapshotEnabledForDevice = await request.device.getSetting('autoSnapshot') + if (deviceAutoSnapshotEnabledForDevice === true) { + setImmediate(async () => { + // when after the response is sent & IO is done, perform the snapshot + try { + const meta = { user: request.session.User } + const options = { clean: true, setAsTarget: false } + const snapshot = await snapshotController.doDeviceAutoSnapshot(request.device, auditEvent.type, options, meta) + if (!snapshot) { + throw new Error('Auto snapshot was not successful') + } + } catch (error) { + console.warn('Error occurred during auto snapshot', error) + } + }) + } + } + } }) } diff --git a/forge/routes/ui/avatar.js b/forge/routes/ui/avatar.js index 428aa1f317..9cf789a1c1 100644 --- a/forge/routes/ui/avatar.js +++ b/forge/routes/ui/avatar.js @@ -1,6 +1,17 @@ module.exports = async function (app) { app.get('/:id', async (request, reply) => { const identifier = request.params.id + if (identifier === 'camera.svg') { + const result = + `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6"> + <path stroke-linecap="round" stroke-linejoin="round" d="M6.827 6.175A2.31 2.31 0 0 1 5.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 0 0-1.134-.175 2.31 2.31 0 0 1-1.64-1.055l-.822-1.316a2.192 2.192 0 0 0-1.736-1.039 48.774 48.774 0 0 0-5.232 0 2.192 2.192 0 0 0-1.736 1.039l-.821 1.316Z" /> + <path stroke-linecap="round" stroke-linejoin="round" d="M16.5 12.75a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0ZM18.75 10.5h.008v.008h-.008V10.5Z" /> + </svg>` + reply.header('Content-Type', 'image/svg+xml') + reply.send(result) + return + } + const key = Buffer.from(identifier, 'base64').toString() const supportedCharacters = Object.keys(font).join('') const rx = new RegExp('[^' + supportedCharacters + ']', 'g') diff --git a/frontend/src/components/audit-log/AuditEntryIcon.vue b/frontend/src/components/audit-log/AuditEntryIcon.vue index 33c0435391..b01c375803 100644 --- a/frontend/src/components/audit-log/AuditEntryIcon.vue +++ b/frontend/src/components/audit-log/AuditEntryIcon.vue @@ -117,6 +117,7 @@ const iconMap = { 'platform.settings.update', 'team.settings.updated', 'project.settings.updated', + 'device.settings.updated', 'team.type.changed' ], 'user-profile': [ diff --git a/frontend/src/components/audit-log/AuditEntryVerbose.vue b/frontend/src/components/audit-log/AuditEntryVerbose.vue index d91d355f84..9a71cc0554 100644 --- a/frontend/src/components/audit-log/AuditEntryVerbose.vue +++ b/frontend/src/components/audit-log/AuditEntryVerbose.vue @@ -365,6 +365,11 @@ <span v-if="!error && entry.body?.device && entry.body.snapshot">Snapshot '{{ entry.body.snapshot?.name }}' has been set as the target for Application owned device '{{ entry.body.device.name }}'.</span> <span v-else-if="!error">Device data not found in audit entry.</span> </template> + <template v-else-if="entry.event === 'device.settings.updated'"> + <label>{{ AuditEvents[entry.event] }}</label> + <span v-if="!error && entry.body?.device">Device '{{ entry.body.device?.name }}' has had the following changes made to its settings: <AuditEntryUpdates :updates="entry.body.updates" /></span> + <span v-else-if="!error">Instance data not found in audit entry.</span> + </template> <!-- Application Device Group Events --> <template v-else-if="entry.event === 'application.deviceGroup.updated'"> diff --git a/frontend/src/data/audit-events.json b/frontend/src/data/audit-events.json index 1a3b5b3343..0d6cae890f 100644 --- a/frontend/src/data/audit-events.json +++ b/frontend/src/data/audit-events.json @@ -142,6 +142,7 @@ "device.remote-access.disabled": "Remote Access Disabled", "device.remote-access.enabled": "Remote Access Enabled", "device.start-failed": "Device Start Failed", + "device.settings.updated": "Device Settings Updated", "flows.reloaded": "Flows Reloaded", "flows.set": "Flow Deployed", "library.set": "Saved to Library", diff --git a/frontend/src/pages/admin/TeamTypes/dialogs/TeamTypeEditDialog.vue b/frontend/src/pages/admin/TeamTypes/dialogs/TeamTypeEditDialog.vue index f1eda7fb34..c361f68913 100644 --- a/frontend/src/pages/admin/TeamTypes/dialogs/TeamTypeEditDialog.vue +++ b/frontend/src/pages/admin/TeamTypes/dialogs/TeamTypeEditDialog.vue @@ -79,7 +79,7 @@ <FormRow v-model="input.properties.features.customCatalogs" type="checkbox">Custom NPM Catalogs</FormRow> <FormRow v-model="input.properties.features.deviceGroups" type="checkbox">Device Groups</FormRow> <FormRow v-model="input.properties.features.emailAlerts" type="checkbox">Email Alerts</FormRow> - <div /> + <FormRow v-model="input.properties.features.deviceAutoSnapshot" type="checkbox">Device Auto Snapshot</FormRow> <FormRow v-model="input.properties.features.fileStorageLimit">Persistent File storage limit (Mb)</FormRow> <FormRow v-model="input.properties.features.contextLimit">Persistent Context storage limit (Mb)</FormRow> </div> diff --git a/frontend/src/pages/device/DeveloperMode/index.vue b/frontend/src/pages/device/DeveloperMode/index.vue index 929f499ffb..1c23294dec 100644 --- a/frontend/src/pages/device/DeveloperMode/index.vue +++ b/frontend/src/pages/device/DeveloperMode/index.vue @@ -21,6 +21,7 @@ :disabled="!editorCanBeEnabled || closingTunnel || !editorEnabled" kind="primary" size="small" + class="w-20 whitespace-nowrap" @click="closeTunnel" > <span v-if="closingTunnel">Disabling...</span> @@ -31,6 +32,7 @@ :disabled="!editorCanBeEnabled || openingTunnel || editorEnabled" kind="danger" size="small" + class="w-20 whitespace-nowrap" @click="openTunnel" > <span v-if="openingTunnel">Enabling...</span> @@ -40,6 +42,42 @@ </div> </template> </InfoCardRow> + <InfoCardRow v-if="autoSnapshotFeatureEnabled && deviceIsApplicationOwned" property="Auto Snapshot:"> + <template #value> + <div class="flex gap-9 items-center"> + <div class="font-medium forge-badge" :class="'forge-status-' + (autoSnapshotEnabled ? 'running' : 'stopped')"> + <span v-if="autoSnapshotEnabled">enabled</span> + <span v-else>disabled</span> + </div> + <div class="space-x-2 flex align-center"> + <ff-button + v-if="autoSnapshotEnabled" + v-ff-tooltip:bottom="'Automatically take a snapshot of the<br>device after every flow deployment.<br>Only the last 10 snapshots are kept'" + :disabled="savingAutoSnapshotSetting || !autoSnapshotEnabled" + kind="primary" + size="small" + class="w-20 whitespace-nowrap" + @click="toggleAutoSnapshotSetting" + > + <span v-if="savingAutoSnapshotSetting">Saving...</span> + <span v-else>Disable</span> + </ff-button> + <ff-button + v-if="!autoSnapshotEnabled" + v-ff-tooltip:bottom="'Automatically take a snapshot of the<br>device after every flow deployment.<br>Only the last 10 snapshots are kept'" + :disabled="savingAutoSnapshotSetting || autoSnapshotEnabled" + kind="danger" + size="small" + class="w-20 whitespace-nowrap" + @click="toggleAutoSnapshotSetting" + > + <span v-if="savingAutoSnapshotSetting">Saving...</span> + <span v-else>Enable</span> + </ff-button> + </div> + </div> + </template> + </InfoCardRow> <InfoCardRow property="Device Flows:"> <template #value> <div class="flex items-center"> @@ -68,6 +106,8 @@ import { BeakerIcon } from '@heroicons/vue/outline' import semver from 'semver' import { mapState } from 'vuex' +import deviceApi from '../../../api/devices.js' + // components import InfoCard from '../../../components/InfoCard.vue' import InfoCardRow from '../../../components/InfoCardRow.vue' @@ -100,11 +140,13 @@ export default { data () { return { agentSupportsDeviceAccess: false, - busy: false + busy: false, + savingAutoSnapshotSetting: false, + autoSnapshotEnabled: false } }, computed: { - ...mapState('account', ['features']), + ...mapState('account', ['team', 'teamMembership', 'features']), developerMode: function () { return this.device?.mode === 'developer' }, @@ -122,6 +164,18 @@ export default { }, createSnapshotDisabled () { return this.device.ownerType !== 'application' && this.device.ownerType !== 'instance' + }, + autoSnapshotFeatureEnabledForTeam () { + return !!this.team.type.properties.features?.deviceAutoSnapshot + }, + autoSnapshotFeatureEnabledForPlatform () { + return this.features.deviceAutoSnapshot + }, + autoSnapshotFeatureEnabled () { + return this.autoSnapshotFeatureEnabledForTeam && this.autoSnapshotFeatureEnabledForPlatform + }, + deviceIsApplicationOwned () { + return this.device.ownerType === 'application' } }, watch: { @@ -137,6 +191,7 @@ export default { if (this.device && this.device.mode !== 'developer') { this.redirect() } + this.getSettings() }, methods: { redirect () { @@ -173,6 +228,21 @@ export default { } alerts.emit('Failed to create snapshot from the device.', 'warning') this.busy = false + }, + async toggleAutoSnapshotSetting () { + try { + this.savingAutoSnapshotSetting = true + await deviceApi.updateSettings(this.device.id, { autoSnapshot: !this.autoSnapshotEnabled }) + this.autoSnapshotEnabled = !this.autoSnapshotEnabled + } finally { + this.savingAutoSnapshotSetting = false + } + }, + async getSettings () { + if (this.device) { + const settings = await deviceApi.getSettings(this.device.id) + this.autoSnapshotEnabled = settings.autoSnapshot + } } } } diff --git a/frontend/src/pages/device/Snapshots/index.vue b/frontend/src/pages/device/Snapshots/index.vue index 8ffa68588c..09a14ff8bd 100644 --- a/frontend/src/pages/device/Snapshots/index.vue +++ b/frontend/src/pages/device/Snapshots/index.vue @@ -198,6 +198,19 @@ export default { } }) this.snapshots = [...data.snapshots] + // 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" + this.snapshots.forEach(snapshot => { + if (!snapshot.user && autoSnapshotRegex.test(snapshot.name)) { + snapshot.user = { + name: this.device.name, + username: 'Auto Snapshot', + avatar: '../../avatar/camera.svg' + } + } + }) this.loading = false } }, diff --git a/test/unit/forge/auditLog/device_spec.js b/test/unit/forge/auditLog/device_spec.js index ed7ca8aeeb..768097fcf8 100644 --- a/test/unit/forge/auditLog/device_spec.js +++ b/test/unit/forge/auditLog/device_spec.js @@ -3,7 +3,7 @@ const should = require('should') // eslint-disable-line const FF_UTIL = require('flowforge-test-utils') // Declare a dummy getLoggers function for type hint only -/** @type {import('../../../../forge/auditLog/application').getLoggers} */ +/** @type {import('../../../../forge/auditLog/device').getLoggers} */ const getLoggers = (app) => { return {} } describe('Audit Log > Device', async function () { @@ -124,4 +124,19 @@ describe('Audit Log > Device', async function () { logEntry.body.application.should.only.have.keys('id', 'name') logEntry.body.device.should.only.have.keys('id', 'name') }) + + it('Provides a logger for changing a settings of a device', async function () { + await logger.device.settings.updated(ACTIONED_BY, null, DEVICE, [{ key: 'name', old: 'old', new: 'new' }]) + // check log stored + const logEntry = await getLog() + logEntry.should.have.property('event', 'device.settings.updated') + logEntry.should.have.property('scope', { id: DEVICE.hashid, type: 'device' }) + 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', 'updates') + logEntry.body.device.should.only.have.keys('id', 'name') + logEntry.body.device.id.should.equal(DEVICE.hashid) + logEntry.body.updates.should.have.length(1) + logEntry.body.updates[0].should.eql({ key: 'name', old: 'old', new: 'new' }) + }) }) diff --git a/test/unit/forge/configuration/http_security_spec.js b/test/unit/forge/configuration/http_security_spec.js index 88e8d49b42..e0c6de5f47 100644 --- a/test/unit/forge/configuration/http_security_spec.js +++ b/test/unit/forge/configuration/http_security_spec.js @@ -2,8 +2,8 @@ const should = require('should') // eslint-disable-line const FF_UTIL = require('flowforge-test-utils') -describe('Check HTTP Security Headers set', async () => { - describe('CSP Headers', async () => { +describe('Check HTTP Security Headers set', () => { + describe('CSP Headers', () => { let app afterEach(async function () { @@ -12,6 +12,7 @@ describe('Check HTTP Security Headers set', async () => { it('CSP Report only should be disabled', async function () { const config = { + housekeeper: false, content_security_policy: { enabled: false } @@ -30,6 +31,7 @@ describe('Check HTTP Security Headers set', async () => { it('CSP Report only should be enabled', async function () { const config = { + housekeeper: false, content_security_policy: { enabled: true, report_only: true, @@ -51,6 +53,7 @@ describe('Check HTTP Security Headers set', async () => { it('CSP should be enabled', async function () { const config = { + housekeeper: false, content_security_policy: { enabled: true } @@ -70,6 +73,7 @@ describe('Check HTTP Security Headers set', async () => { it('CSP should be enabled, custom directives', async function () { const config = { + housekeeper: false, content_security_policy: { enabled: true, directives: { @@ -91,6 +95,7 @@ describe('Check HTTP Security Headers set', async () => { it('CSP should be enabled with plausible', async function () { const config = { + housekeeper: false, telemetry: { frontend: { plausible: { @@ -116,6 +121,7 @@ describe('Check HTTP Security Headers set', async () => { it('CSP should be enabled with posthog', async function () { const config = { + housekeeper: false, telemetry: { frontend: { posthog: { @@ -141,6 +147,7 @@ describe('Check HTTP Security Headers set', async () => { it('CSP should be enabled with hubspot', async function () { const config = { + housekeeper: false, support: { enabled: true, frontend: { @@ -167,6 +174,7 @@ describe('Check HTTP Security Headers set', async () => { it('CSP should be enabled with hubspot and posthog', async function () { const config = { + housekeeper: false, support: { enabled: true, frontend: { @@ -199,6 +207,7 @@ describe('Check HTTP Security Headers set', async () => { }) it('CSP should be enabled with hubspot and posthog empty directive', async function () { const config = { + housekeeper: false, support: { enabled: true, frontend: { @@ -233,6 +242,7 @@ describe('Check HTTP Security Headers set', async () => { }) it('CSP with sentry.io', async function () { const config = { + housekeeper: false, telemetry: { frontend: { sentry: 'foo' @@ -264,6 +274,7 @@ describe('Check HTTP Security Headers set', async () => { it('HTST not set', async function () { const config = { + housekeeper: false, base_url: 'http://localhost:9999' } app = await FF_UTIL.setupApp(config) diff --git a/test/unit/forge/db/controllers/ProjectSnapshot_spec.js b/test/unit/forge/db/controllers/ProjectSnapshot_spec.js index 1d13dbbc52..2accdae53b 100644 --- a/test/unit/forge/db/controllers/ProjectSnapshot_spec.js +++ b/test/unit/forge/db/controllers/ProjectSnapshot_spec.js @@ -108,25 +108,7 @@ describe('ProjectSnapshot controller', function () { // }) // }) describe('createSnapshot (device)', function () { - after(async function () { - // un-stub app.comms.devices.sendCommandAwaitReply - if (app.comms.devices.sendCommandAwaitReply.restore) { - app.comms.devices.sendCommandAwaitReply.restore() - } - await app.close() - }) - - it('creates a snapshot of a device owned by an application', async function () { - const user = await app.db.models.User.byUsername('alice') - const options = { - name: 'snapshot1', - description: 'a snapshot' - } - const application = app.TestObjects.application1 - const team = app.TestObjects.team1 - const device = await factory.createDevice({ name: 'device-1' }, team, null, application) - // get db Device with all associations - const dbDevice = await app.db.models.Device.byId(device.id) + before(async function () { // mock app.comms.devices.sendCommandAwaitReply(device_Team_hashid, device_hashid, ...) so that it returns a valid config sinon.stub(app.comms.devices, 'sendCommandAwaitReply').resolves({ flows: [{ id: '123', type: 'newNode' }], @@ -141,6 +123,25 @@ describe('ProjectSnapshot controller', function () { } } }) + }) + afterEach(async function () { + app.comms.devices.sendCommandAwaitReply.resetHistory() + }) + after(async function () { + app.comms.devices.sendCommandAwaitReply.restore() + }) + it('creates a snapshot of a device owned by an application', async function () { + const user = await app.db.models.User.byUsername('alice') + const options = { + name: 'snapshot1', + description: 'a snapshot' + } + const application = app.TestObjects.application1 + const team = app.TestObjects.team1 + const device = await factory.createDevice({ name: 'device-1' }, team, null, application) + // get db Device with all associations + const dbDevice = await app.db.models.Device.byId(device.id) + const snapshot = await app.db.controllers.ProjectSnapshot.createDeviceSnapshot(application, dbDevice, user, options) snapshot.should.have.property('name', 'snapshot1') snapshot.should.have.property('description', 'a snapshot') @@ -153,5 +154,117 @@ describe('ProjectSnapshot controller', function () { snapshot.flows.flows.should.have.length(1) snapshot.flows.flows[0].should.have.property('id', '123') }) + + describe('auto snapshots', function () { + it('throws an error when deviceAutoSnapshot feature is not enabled', async function () { + const meta = { user: { id: null } } // simulate node-red situation (i.e. user is null) + const options = { setAsTarget: false } + const auditEventType = 'full' // simulate node-red audit event + await app.db.controllers.ProjectSnapshot.doDeviceAutoSnapshot({}, auditEventType, options, meta).should.be.rejectedWith('Device auto snapshot feature is not available') + }) + it('throws an error when team type feature flag deviceAutoSnapshot is not enabled', async function () { + app.config.features.register('deviceAutoSnapshot', true, true) + const application = app.TestObjects.application1 + const team = app.TestObjects.team1 + const device = await factory.createDevice({ name: 'device' }, team, null, application) + const deviceWithTeam = await app.db.models.Device.byId(device.id, { include: app.db.models.Team }) + const meta = { user: { id: null } } // simulate node-red situation (i.e. user is null) + const options = { setAsTarget: false } + const auditEventType = 'full' // simulate node-red audit event + await app.db.controllers.ProjectSnapshot.doDeviceAutoSnapshot(deviceWithTeam, auditEventType, options, meta).should.be.rejectedWith('Device auto snapshot is not enabled for the team') + }) + it('throws an error when device setting autoSnapshot is not enabled', async function () { + app.config.features.register('deviceAutoSnapshot', true, true) + const application = app.TestObjects.application1 + const team = app.TestObjects.team1 + const device = await factory.createDevice({ name: 'device' }, team, null, application) + const deviceWithTeam = await app.db.models.Device.byId(device.id, { include: app.db.models.Team }) + await deviceWithTeam.updateSettings({ autoSnapshot: false }) + const meta = { user: { id: null } } // simulate node-red situation (i.e. user is null) + const options = { setAsTarget: false } + const auditEventType = 'full' // simulate node-red audit event + await app.db.controllers.ProjectSnapshot.doDeviceAutoSnapshot(deviceWithTeam, auditEventType, options, meta).should.be.rejectedWith('Device auto snapshot is not enabled') + }) + + describe('with deviceAutoSnapshot feature enabled', function () { + before(async function () { + app.config.features.register('deviceAutoSnapshot', true, true) + // Enable deviceAutoSnapshot feature for default team type + const defaultTeamType = await app.db.models.TeamType.findOne({ where: { name: 'starter' } }) + const defaultTeamTypeProperties = defaultTeamType.properties + defaultTeamTypeProperties.features.deviceAutoSnapshot = true + defaultTeamType.properties = defaultTeamTypeProperties + await defaultTeamType.save() + + // create a device + const application = app.TestObjects.application1 + const team = app.TestObjects.team1 + const device1 = await factory.createDevice({ name: 'device 1' }, team, null, application) + const device2 = await factory.createDevice({ name: 'device 2' }, team, null, application) + app.TestObjects.device1 = await app.db.models.Device.byId(device1.id, { include: app.db.models.Team }) + app.TestObjects.device2 = await app.db.models.Device.byId(device2.id, { include: app.db.models.Team }) + }) + + after(async function () { + // delete devices we created in before() + await app.TestObjects.device1.destroy() + await app.TestObjects.device2.destroy() + }) + + it('creates an autoSnapshot for a device following a \'full\' deploy', async function () { + const device = app.TestObjects.device1 + const meta = { user: { id: null } } // simulate node-red situation (i.e. user is null) + const options = { clean: true, setAsTarget: false } + const auditEventType = 'full' // simulate node-red audit event + const snapshot = await app.db.controllers.ProjectSnapshot.doDeviceAutoSnapshot(device, auditEventType, options, meta) + should(snapshot).be.an.Object() + snapshot.should.have.a.property('id') + snapshot.should.have.a.property('name') + snapshot.name.should.match(/Auto Snapshot - \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/) + snapshot.should.have.a.property('description') + snapshot.description.should.match(/Device Auto Snapshot taken following a Full deployment/) + }) + + it('only keeps 10 autoSnapshots for a device', async function () { + const device = app.TestObjects.device1 + const meta = { user: { id: null } } // simulate node-red situation (i.e. user is null) + const options = undefined // use fn defined default options this time + // perform 12 autoSnapshots + for (let i = 1; i <= 12; i++) { + const ss = await app.db.controllers.ProjectSnapshot.doDeviceAutoSnapshot(device, 'full', options, meta) + await ss.update({ description: `Auto Snapshot - ${i}` }) // update description to make it clear the round-robin cleanup is working + } + const snapshots = await app.db.models.ProjectSnapshot.findAll({ where: { DeviceId: device.id } }) + // even though 12 snapshots were created in total, only 10 are kept + snapshots.should.have.length(10) + snapshots[0].description.should.equal('Auto Snapshot - 3') // note ss 1 & 2 were auto cleaned up + snapshots[9].description.should.equal('Auto Snapshot - 12') + }) + + it('keeps 11 autoSnapshots for a device if one of them is assigned as target snapshot to another device', async function () { + const device = app.TestObjects.device1 + const device2 = app.TestObjects.device2 + // create a snapshot and set it as target for device2 + const snapshot1 = await app.db.controllers.ProjectSnapshot.doDeviceAutoSnapshot(device, 'flows', { setAsTarget: true }, { user: { id: null } }) + await snapshot1.update({ description: 'Auto Snapshot - 1' }) // update description to make it clear the round-robin cleanup is working + await device2.update({ targetSnapshotId: snapshot1.id }) + + // create snapshots + const meta = { user: { id: null } } // simulate node-red situation (i.e. user is null) + const options = { clean: true, setAsTarget: false } + for (let i = 2; i <= 13; i++) { + const ss = await app.db.controllers.ProjectSnapshot.doDeviceAutoSnapshot(device, 'nodes', options, meta) + await ss.update({ description: `Auto Snapshot - ${i}` }) // update description to make it clear the round-robin cleanup is working + } + const snapshots = await app.db.models.ProjectSnapshot.findAll({ where: { DeviceId: device.id }, order: [['id', 'ASC']] }) + + // even though 13 snapshots were created in total, only 10+1 (1 is in use) are kept + snapshots.should.have.length(11) + snapshots[0].description.should.equal('Auto Snapshot - 1') // ss 1 is in use & therefore not cleaned up + snapshots[1].description.should.equal('Auto Snapshot - 4') // ss 2 & 3 were auto cleaned up + snapshots[10].description.should.equal('Auto Snapshot - 13') // this was the last one created + }) + }) + }) }) }) diff --git a/test/unit/forge/db/models/Device_spec.js b/test/unit/forge/db/models/Device_spec.js index a520765b28..b7df583f96 100644 --- a/test/unit/forge/db/models/Device_spec.js +++ b/test/unit/forge/db/models/Device_spec.js @@ -70,7 +70,6 @@ describe('Device model', function () { }) it('is updated when the device env vars are changed', async function () { const device = await app.db.models.Device.create({ name: 'D1', type: 'PI', credentialSecret: '' }) - await device.save() const initialSettingsHash = device.settingsHash const initialSettings = await device.getAllSettings() initialSettings.should.have.a.property('env').and.be.an.Array() @@ -82,5 +81,15 @@ describe('Device model', function () { should(settings).be.an.Object().and.have.a.property('env').of.Array() settings.env.length.should.equal(initialEnvCount + 1) // count should be +1 }) + it('is not updated when the device option autoSnapshot is changed', async function () { + const device = await app.db.models.Device.create({ name: 'D1', type: 'PI', credentialSecret: '' }) + const initialSettingsHash = device.settingsHash + const initialSettings = await device.getAllSettings() + initialSettings.should.have.a.property('autoSnapshot', true) // should be true by default + await device.updateSettings({ autoSnapshot: false }) + device.settingsHash.should.equal(initialSettingsHash) + const settings = await device.getAllSettings() + should(settings).be.an.Object().and.have.a.property('autoSnapshot', false) + }) }) }) diff --git a/test/unit/forge/routes/logging/index_spec.js b/test/unit/forge/routes/logging/index_spec.js index 21d129fd30..28d1e551d8 100644 --- a/test/unit/forge/routes/logging/index_spec.js +++ b/test/unit/forge/routes/logging/index_spec.js @@ -229,5 +229,80 @@ describe('Logging API', function () { it.skip('Adds module to instance settings for modules.install event', async function () { // future }) + describe('When an audit event for flows.set is received', function () { + async function injectFlowSetEvent (app, device, token, deployType) { + const url = `/logging/device/${device.hashid}/audit` + const response = await app.inject({ + method: 'POST', + url, + headers: { + authorization: `Bearer ${token}` + }, + payload: { event: 'flows.set', type: deployType } + }) + return response + } + before(async function () { + app.config.features.register('deviceAutoSnapshot', true, true) + // Enable deviceAutoSnapshot feature for default team type + const defaultTeamType = await app.db.models.TeamType.findOne({ where: { name: 'starter' } }) + const defaultTeamTypeProperties = defaultTeamType.properties + defaultTeamTypeProperties.features.deviceAutoSnapshot = true + defaultTeamType.properties = defaultTeamTypeProperties + await defaultTeamType.save() + + // stub sendCommandAwaitReply to fake the device response + /** @type {DeviceCommsHandler} */ + const commsHandler = app.comms.devices + sinon.stub(commsHandler, 'sendCommandAwaitReply').resolves({}) + + // stub ProjectSnapshot controller `doDeviceAutoSnapshot` + sinon.stub(app.db.controllers.ProjectSnapshot, 'doDeviceAutoSnapshot').resolves({}) + + // spy app.config.features.enabled function + sinon.spy(app.config.features, 'enabled') + }) + afterEach(async function () { + app.comms.devices.sendCommandAwaitReply.reset() + app.db.controllers.ProjectSnapshot.doDeviceAutoSnapshot.reset() + app.config.features.enabled.resetHistory() + }) + after(async function () { + app.config.features.register('deviceAutoSnapshot', false, false) + app.comms.devices.sendCommandAwaitReply.restore() + app.db.controllers.ProjectSnapshot.doDeviceAutoSnapshot.restore() + app.config.features.enabled.restore() + }) + it('Generates a snapshot for device when deploy type === full', async function () { + app.config.features.enabled.resetHistory() + const response = await injectFlowSetEvent(app, TestObjects.device1, TestObjects.tokens.device1, 'full') + response.should.have.property('statusCode', 200) + // wait a moment for the (stubbed) methods to be called asynchronously + // then check if the `doDeviceAutoSnapshot` method was called + // with the expected arguments + await new Promise(resolve => setTimeout(resolve, 25)) + app.config.features.enabled.called.should.be.true() // the API calls `app.config.features.enabled` to check if the feature is enabled + app.db.controllers.ProjectSnapshot.doDeviceAutoSnapshot.called.should.be.true() + const args = app.db.controllers.ProjectSnapshot.doDeviceAutoSnapshot.lastCall.args + args.should.have.length(4) + should(args[0]).be.an.Object().and.have.property('id', TestObjects.device1.id) + args[1].should.equal('full') + should(args[2]).be.an.Object().and.deepEqual({ clean: true, setAsTarget: false }) + should(args[3]).be.an.Object() + args[3].should.have.property('user') + }) + it('Does not generates a snapshot for device if the deploy type is not one of full, flows or nodes', async function () { + app.config.features.enabled.resetHistory() + const response = await injectFlowSetEvent(app, TestObjects.device1, TestObjects.tokens.device1, 'bad-deploy-type') + // response should be 200 even if the deploy type is not valid + // that is because the audit event was processed successfully. + // The auto snapshot feature is simply spawned and not awaited. + response.should.have.property('statusCode', 200) // 200 is expected (the audit event was processed successfully) + // wait a moment for the (stubbed) methods to be (not) called + await new Promise(resolve => setTimeout(resolve, 25)) + app.config.features.enabled.called.should.be.false() // should not have reached the feature.enabled check in the API call + app.db.controllers.ProjectSnapshot.doDeviceAutoSnapshot.called.should.be.false() + }) + }) }) })