diff --git a/forge/auditLog/application.js b/forge/auditLog/application.js index 4464512cdf..4c67bb1b5c 100644 --- a/forge/auditLog/application.js +++ b/forge/auditLog/application.js @@ -55,6 +55,11 @@ module.exports = { }, async membersChanged (actionedBy, error, application, deviceGroup, updates, info) { await log('application.deviceGroup.members.changed', actionedBy, application?.id, generateBody({ error, application, deviceGroup, updates, info })) + }, + settings: { + async updated (actionedBy, error, application, deviceGroup, updates) { + await log('application.deviceGroup.settings.updated', actionedBy, application?.id, generateBody({ error, application, deviceGroup, updates })) + } } } } diff --git a/forge/db/migrations/20241016-01-add-settings-to-devicegroup.js b/forge/db/migrations/20241016-01-add-settings-to-devicegroup.js new file mode 100644 index 0000000000..30bbc2b3db --- /dev/null +++ b/forge/db/migrations/20241016-01-add-settings-to-devicegroup.js @@ -0,0 +1,19 @@ +/** + * Add settings to DeviceGroups table + */ +const { DataTypes } = require('sequelize') + +module.exports = { + /** + * upgrade database + * @param {QueryInterface} context Sequelize.QueryInterface + */ + up: async (context) => { + await context.addColumn('DeviceGroups', 'settings', { + type: DataTypes.TEXT, + defaultValue: null + }) + }, + down: async (context) => { + } +} diff --git a/forge/db/models/Device.js b/forge/db/models/Device.js index 444efccef7..76b6915a12 100644 --- a/forge/db/models/Device.js +++ b/forge/db/models/Device.js @@ -148,17 +148,36 @@ module.exports = { }) }, async updateSettingsHash (settings) { - const _settings = settings || await this.getAllSettings() + const _settings = settings || await this.getAllSettings({ mergeDeviceGroupSettings: true }) delete _settings.autoSnapshot // autoSnapshot is not part of the settings hash this.settingsHash = hashSettings(_settings) }, - async getAllSettings () { + async getAllSettings (options = { mergeDeviceGroupSettings: false }) { + const mergeDeviceGroupSettings = options.mergeDeviceGroupSettings || false const result = {} const settings = await this.getDeviceSettings() settings.forEach(setting => { result[setting.key] = setting.value }) result.env = Controllers.Device.insertPlatformSpecificEnvVars(this, result.env) // add platform specific device env vars + // if the device is a group member, we need to merge the group settings + if (mergeDeviceGroupSettings && this.DeviceGroupId) { + const group = this.DeviceGroup || await M.DeviceGroup.byId(this.DeviceGroupId) + if (group) { + const groupEnv = await group.settings.env || [] + // Merge rule: If the device has an env var AND it has a value, it remains unchanged. + // Otherwise, the value is taken from the group. + // This is to allow the device to override a (global) group env setting. + groupEnv.forEach(env => { + const existing = result.env.find(e => e.name === env.name) + if (!existing) { + result.env.push(env) + } else if (existing && !existing.value) { + existing.value = env.value + } + }) + } + } if (!Object.prototype.hasOwnProperty.call(result, 'autoSnapshot')) { result.autoSnapshot = DEFAULT_SETTINGS.autoSnapshot } diff --git a/forge/db/models/DeviceGroup.js b/forge/db/models/DeviceGroup.js index b1546b8896..5a2951944a 100644 --- a/forge/db/models/DeviceGroup.js +++ b/forge/db/models/DeviceGroup.js @@ -1,131 +1,172 @@ -/** - * A DeviceGroup. - * A logical grouping of devices for the primary intent of group deployments in the pipeline stages. - * @namespace forge.db.models.DeviceGroup - */ - -const { DataTypes, literal } = require('sequelize') - -const { buildPaginationSearchClause } = require('../utils') -const nameValidator = { msg: 'Device Group name cannot be empty' } - -module.exports = { - name: 'DeviceGroup', - schema: { - name: { - type: DataTypes.STRING, - allowNull: false, - validate: { - notEmpty: nameValidator, - notNull: nameValidator - } - }, - description: { type: DataTypes.TEXT }, - targetSnapshotId: { type: DataTypes.INTEGER, allowNull: true } - }, - associations: function (M) { - this.belongsTo(M.Application, { onDelete: 'CASCADE' }) - this.belongsTo(M.ProjectSnapshot, { as: 'targetSnapshot' }) - this.hasMany(M.Device) - }, - finders: function (M) { - const self = this - const deviceCountLiteral = literal(`( - SELECT COUNT(*) - FROM "Devices" AS "device" - WHERE - "device"."DeviceGroupId" = "DeviceGroup"."id" - AND - "device"."ApplicationId" = "DeviceGroup"."ApplicationId" - )`) - return { - static: { - byId: async function (id) { - if (typeof id === 'string') { - id = M.DeviceGroup.decodeHashid(id) - } - // find one, include application, devices and device count - return self.findOne({ - where: { id }, - include: [ - { model: M.Application, attributes: ['hashid', 'id', 'name', 'TeamId'] }, - { - model: M.Device, - attributes: ['hashid', 'id', 'name', 'type', 'TeamId', 'ApplicationId', 'ProjectId', 'ownerType'], - where: { - ApplicationId: literal('"Devices"."ApplicationId" = "Application"."id"') - }, - required: false - }, - { - model: M.ProjectSnapshot, - as: 'targetSnapshot', - attributes: ['hashid', 'id', 'name'] - } - ], - attributes: { - include: [ - [ - deviceCountLiteral, - 'deviceCount' - ] - ] - } - }) - }, - getAll: async (pagination = {}, where = {}) => { - const limit = parseInt(pagination.limit) || 1000 - if (pagination.cursor) { - pagination.cursor = M.DeviceGroup.decodeHashid(pagination.cursor) - } - if (where.ApplicationId && typeof where.ApplicationId === 'string') { - where.ApplicationId = M.Application.decodeHashid(where.ApplicationId) - } - const [rows, count] = await Promise.all([ - this.findAll({ - where: buildPaginationSearchClause(pagination, where, ['DeviceGroup.name', 'DeviceGroup.description']), - include: [ - { - model: M.ProjectSnapshot, - as: 'targetSnapshot', - attributes: ['hashid', 'id', 'name'] - } - ], - attributes: { - include: [ - [ - deviceCountLiteral, - 'deviceCount' - ] - ] - }, - order: [['id', 'ASC']], - limit - }), - this.count({ where }) - ]) - return { - meta: { - next_cursor: rows.length === limit ? rows[rows.length - 1].hashid : undefined - }, - count, - groups: rows - } - } - }, - instance: { - deviceCount: async function () { - return await M.Device.count({ where: { DeviceGroupId: this.id, ApplicationId: this.ApplicationId } }) - }, - getDevices: async function () { - return await M.Device.findAll({ - where: { - DeviceGroupId: this.id, - ApplicationId: this.ApplicationId - } - }) - } - } - } - } -} +/** + * A DeviceGroup. + * A logical grouping of devices for the primary intent of group deployments in the pipeline stages. + * @namespace forge.db.models.DeviceGroup + */ + +const { DataTypes, literal } = require('sequelize') + +const { buildPaginationSearchClause } = require('../utils') +const nameValidator = { msg: 'Device Group name cannot be empty' } + +module.exports = { + name: 'DeviceGroup', + schema: { + name: { + type: DataTypes.STRING, + allowNull: false, + validate: { + notEmpty: nameValidator, + notNull: nameValidator + } + }, + description: { type: DataTypes.TEXT }, + targetSnapshotId: { type: DataTypes.INTEGER, allowNull: true }, + settings: { + type: DataTypes.TEXT, + set (value) { + this.setDataValue('settings', JSON.stringify(value)) + }, + get () { + const rawValue = this.getDataValue('settings') || '{}' + return JSON.parse(rawValue) + } + } + }, + associations: function (M) { + this.belongsTo(M.Application, { onDelete: 'CASCADE' }) + this.belongsTo(M.ProjectSnapshot, { as: 'targetSnapshot' }) + this.hasMany(M.Device) + }, + hooks: function (M, app) { + return { + afterUpdate: async (deviceGroup, options) => { + // if `settings` is updated, we need to update the settings hash + // of any member devices + if (deviceGroup.changed('settings')) { + const include = [ + { model: M.ProjectSnapshot, as: 'targetSnapshot', attributes: ['id', 'hashid', 'name'] }, + { + model: M.DeviceGroup, + attributes: ['hashid', 'id', 'ApplicationId', 'settings'] + }, + { model: M.Application, attributes: ['hashid', 'id', 'name', 'links'] } + ] + const where = { + DeviceGroupId: deviceGroup.id, + ApplicationId: deviceGroup.ApplicationId + } + const devices = await M.Device.findAll({ where, include }) + const updateAndSave = async (device) => { + await device.updateSettingsHash() + await device.save({ + hooks: false, // skip the afterSave hook for device model (we have only updated the settings hash) + transaction: options?.transaction // pass the transaction (if any) + }) + } + await Promise.all(devices.map(updateAndSave)) + } + } + } + }, + finders: function (M) { + const self = this + const deviceCountLiteral = literal(`( + SELECT COUNT(*) + FROM "Devices" AS "device" + WHERE + "device"."DeviceGroupId" = "DeviceGroup"."id" + AND + "device"."ApplicationId" = "DeviceGroup"."ApplicationId" + )`) + return { + static: { + byId: async function (id) { + if (typeof id === 'string') { + id = M.DeviceGroup.decodeHashid(id) + } + // find one, include application, devices and device count + return self.findOne({ + where: { id }, + include: [ + { model: M.Application, attributes: ['hashid', 'id', 'name', 'TeamId'] }, + { + model: M.Device, + attributes: ['hashid', 'id', 'name', 'type', 'TeamId', 'ApplicationId', 'ProjectId', 'ownerType'], + where: { + ApplicationId: literal('"Devices"."ApplicationId" = "Application"."id"') + }, + required: false + }, + { + model: M.ProjectSnapshot, + as: 'targetSnapshot', + attributes: ['hashid', 'id', 'name'] + } + ], + attributes: { + include: [ + [ + deviceCountLiteral, + 'deviceCount' + ] + ] + } + }) + }, + getAll: async (pagination = {}, where = {}) => { + const limit = parseInt(pagination.limit) || 1000 + if (pagination.cursor) { + pagination.cursor = M.DeviceGroup.decodeHashid(pagination.cursor) + } + if (where.ApplicationId && typeof where.ApplicationId === 'string') { + where.ApplicationId = M.Application.decodeHashid(where.ApplicationId) + } + const [rows, count] = await Promise.all([ + this.findAll({ + where: buildPaginationSearchClause(pagination, where, ['DeviceGroup.name', 'DeviceGroup.description']), + include: [ + { + model: M.ProjectSnapshot, + as: 'targetSnapshot', + attributes: ['hashid', 'id', 'name'] + } + ], + attributes: { + include: [ + [ + deviceCountLiteral, + 'deviceCount' + ] + ] + }, + order: [['id', 'ASC']], + limit + }), + this.count({ where }) + ]) + return { + meta: { + next_cursor: rows.length === limit ? rows[rows.length - 1].hashid : undefined + }, + count, + groups: rows + } + } + }, + instance: { + deviceCount: async function () { + return await M.Device.count({ where: { DeviceGroupId: this.id, ApplicationId: this.ApplicationId } }) + }, + getDevices: async function () { + return await M.Device.findAll({ + where: { + DeviceGroupId: this.id, + ApplicationId: this.ApplicationId + } + }) + } + } + } + } +} diff --git a/forge/db/views/DeviceGroup.js b/forge/db/views/DeviceGroup.js index 9f678109eb..f88ae3b624 100644 --- a/forge/db/views/DeviceGroup.js +++ b/forge/db/views/DeviceGroup.js @@ -97,6 +97,7 @@ module.exports = function (app) { application: item.Application ? app.db.views.Application.applicationSummary(item.Application) : null, deviceCount: item.deviceCount || 0, devices: item.Devices ? item.Devices.map(app.db.views.Device.device) : [], + settings: item.settings, targetSnapshot: app.db.views.ProjectSnapshot.snapshotSummary(item.targetSnapshot) } return filtered diff --git a/forge/ee/db/controllers/DeviceGroup.js b/forge/ee/db/controllers/DeviceGroup.js index 9cab468f61..991fb21426 100644 --- a/forge/ee/db/controllers/DeviceGroup.js +++ b/forge/ee/db/controllers/DeviceGroup.js @@ -1,6 +1,9 @@ const { Op, ValidationError } = require('sequelize') const { ControllerError } = require('../../../lib/errors') + +const hasProperty = (obj, key) => obj && Object.prototype.hasOwnProperty.call(obj, key) + class DeviceGroupMembershipValidationError extends ControllerError { /** * @param {string} code @@ -50,7 +53,7 @@ module.exports = { * @param {string} [options.description] - The new description of the Device Group. Exclude to keep the current description. * @param {number} [options.targetSnapshotId] - The new target snapshot id of the Device Group. Exclude to keep the current snapshot. Send null to clear the current target snapshot. */ - updateDeviceGroup: async function (app, deviceGroup, { name = undefined, description = undefined, targetSnapshotId = undefined } = {}) { + updateDeviceGroup: async function (app, deviceGroup, { name = undefined, description = undefined, targetSnapshotId = undefined, settings = undefined } = {}) { // * deviceGroup is required. // * name, description, color are optional if (!deviceGroup) { @@ -67,6 +70,32 @@ module.exports = { changed = true } + if (typeof settings !== 'undefined' && hasProperty(settings, 'env')) { + // NOTE: For now, device group settings only support environment variables + + // validate settings + if (!Array.isArray(settings.env)) { + throw new ValidationError('Invalid settings') + } + settings.env.forEach((envVar) => { + if (!envVar?.name?.match(/^[a-zA-Z_]+[a-zA-Z0-9_]*$/)) { + throw new ValidationError(`Invalid Env Var name '${envVar.name}'`) + } + }) + // find duplicates + const seen = new Set() + const duplicates = settings.env.some(item => { return seen.size === seen.add(item.name).size }) + if (duplicates) { + throw new ValidationError('Duplicate Env Var names provided') + } + + deviceGroup.settings = { + ...deviceGroup.settings, + env: settings.env + } + changed = true + } + if (typeof targetSnapshotId !== 'undefined') { let snapshotId = targetSnapshotId // ensure the snapshot exists (if targetSnapshotId is not null) @@ -90,21 +119,21 @@ module.exports = { } await transaction.commit() saved = true + changed = true } catch (err) { await transaction.rollback() throw err } - - // inform the devices an update is required - if (devices?.length) { - await this.sendUpdateCommand(app, deviceGroup) - } } if (changed && !saved) { await deviceGroup.save() } await deviceGroup.reload() + + if (changed) { + await this.sendUpdateCommand(app, deviceGroup) + } return deviceGroup }, @@ -178,8 +207,8 @@ module.exports = { const remainingDevices = await deviceGroup.deviceCount() if (remainingDevices === 0) { deviceGroup.targetSnapshotId = null - await deviceGroup.save() } + await deviceGroup.save() // finally, inform the devices an update may be required await this.sendUpdateCommand(app, deviceGroup, actualRemoveDevices) } @@ -214,6 +243,58 @@ module.exports = { await app.db.models.Device.update({ DeviceGroupId: null }, { where: { id: deviceIds.removeList, DeviceGroupId: deviceGroup.id }, transaction }) }, + // updateSettings: async function (app, deviceGroup, settings, user) { + // // NOTE: For now, device group settings only support environment variables + // if (!hasProperty(settings, 'env')) { + // return // nothing to do + // } + + // // validate settings + // if (!Array.isArray(settings.env)) { + // throw new ValidationError('Invalid settings') + // } + // settings.env.forEach((envVar) => { + // if (!envVar?.name?.match(/^[a-zA-Z_]+[a-zA-Z0-9_]*$/)) { + // throw new ValidationError(`Invalid Env Var name '${envVar.name}'`) + // } + // }) + // // find duplicates + // const seen = new Set() + // const duplicates = settings.env.some(item => { return seen.size === seen.add(item.name).size }) + // if (duplicates) { + // throw new ValidationError('Duplicate Env Var names provided') + // } + + // // for audit log + // const deviceGroupLogger = getApplicationLogger(app) + // const updates = new app.auditLog.formatters.UpdatesCollection() + // if (!deviceGroup.Application) { + // await deviceGroup.reload({ include: [{ model: app.db.models.Application }] }) + // } + // // transform the env arrays to a map for better logging format + // const currentEnv = (deviceGroup.settings?.env || []).reduce((acc, e) => { + // acc[e.name] = e.value + // return acc + // }, {}) + // const newEnv = settings.env.reduce((acc, e) => { + // acc[e.name] = e.value + // return acc + // }, {}) + // updates.pushDifferences({ env: currentEnv }, { env: newEnv }) + + // // perform update & log + // if (updates.length === 0) { + // return // nothing to do + // } + // deviceGroup.settings = { + // ...deviceGroup.settings, + // env: settings.env + // } + // await deviceGroup.save() + + // await deviceGroupLogger.application.deviceGroup.settings.updated(user, null, deviceGroup.Application, deviceGroup, updates) + // }, + /** * Sends an update to all devices in the group and/or the specified list of devices * so that they can determine what/if it needs to be updated diff --git a/forge/ee/routes/applicationDeviceGroups/index.js b/forge/ee/routes/applicationDeviceGroups/index.js index c8c80a6644..87f45a0147 100644 --- a/forge/ee/routes/applicationDeviceGroups/index.js +++ b/forge/ee/routes/applicationDeviceGroups/index.js @@ -10,6 +10,7 @@ const { ValidationError } = require('sequelize') const { UpdatesCollection } = require('../../../auditLog/formatters.js') +const { Roles } = require('../../../lib/roles.js') const { DeviceGroupMembershipValidationError } = require('../../db/controllers/DeviceGroup.js') // Declare getLogger function to provide type hints / quick code nav / code completion @@ -371,6 +372,80 @@ module.exports = async function (app) { reply.send({}) }) + /** + * Update Device Group Settings (Environment Variables) + * @method PUT + * @name /api/v1/applications/:applicationId/device-groups/:groupId/settings + * @memberof forge.routes.api.application + */ + app.put('/:groupId/settings', { + preHandler: app.needsPermission('application:device-group:update'), // re-use update permission (owner only) + schema: { + summary: 'Update a Device Group Settings', + tags: ['Application Device Groups'], + body: { + type: 'object', + properties: { + env: { type: 'array', items: { type: 'object', additionalProperties: true } } + } + }, + params: { + type: 'object', + properties: { + applicationId: { type: 'string' }, + groupId: { type: 'string' } + } + }, + response: { + 200: { + $ref: 'APIStatus' + }, + '4xx': { + $ref: 'APIError' + } + } + } + }, async (request, reply) => { + /** @type {Model} */ + const deviceGroup = request.deviceGroup + const isMember = request.teamMembership.role === Roles.Member + /** @type {import('../../db/controllers/DeviceGroup.js')} */ + const deviceGroupController = app.db.controllers.DeviceGroup + try { + let bodySettings + if (isMember) { + bodySettings = { + env: request.body.env + } + } else { + bodySettings = request.body + } + + // for audit log + const updates = new app.auditLog.formatters.UpdatesCollection() + // transform the env arrays to a map for better logging format + const currentEnv = (deviceGroup.settings?.env || []).reduce((acc, e) => { + acc[e.name] = e.value + return acc + }, {}) + const newEnv = bodySettings.env.reduce((acc, e) => { + acc[e.name] = e.value + return acc + }, {}) + updates.pushDifferences({ env: currentEnv }, { env: newEnv }) + + // perform update & log + await deviceGroupController.updateDeviceGroup(deviceGroup, { settings: bodySettings }) + if (updates.length > 0) { + await deviceGroupLogger.settings.updated(request.session.User, null, request.application, deviceGroup, updates) + } + + reply.send({}) + } catch (err) { + return handleError(err, reply) + } + }) + function handleError (err, reply) { let statusCode = 500 let code = 'unexpected_error' diff --git a/forge/routes/api/deviceLive.js b/forge/routes/api/deviceLive.js index b4239ff85b..b031b2b4cd 100644 --- a/forge/routes/api/deviceLive.js +++ b/forge/routes/api/deviceLive.js @@ -217,7 +217,9 @@ module.exports = async function (app) { hash: request.device.settingsHash, env: {} } - const settings = await request.device.getAllSettings() + const settings = await request.device.getAllSettings({ + mergeDeviceGroupSettings: true + }) Object.keys(settings).forEach(key => { if (key === 'env') { settings.env.forEach(envVar => { diff --git a/test/unit/forge/auditLog/application_spec.js b/test/unit/forge/auditLog/application_spec.js index 93015481d8..6ad759d932 100644 --- a/test/unit/forge/auditLog/application_spec.js +++ b/test/unit/forge/auditLog/application_spec.js @@ -278,5 +278,23 @@ describe('Audit Log > Application', async function () { logEntry.body.info.should.have.property('info', info) }) + it('Provides a logger for updating device group settings', async function () { + const updates = new UpdatesCollection() + updates.pushDifferences({ name: 'before' }, { name: 'after' }) + await logger.application.deviceGroup.settings.updated(ACTIONED_BY, null, APPLICATION, DEVICEGROUP, updates) + + // check log stored + const logEntry = await getLog() + logEntry.should.have.property('event', 'application.deviceGroup.settings.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('application', 'deviceGroup', 'updates') + logEntry.body.application.should.only.have.keys('id', 'name') + logEntry.body.application.id.should.equal(APPLICATION.id) + logEntry.body.deviceGroup.should.only.have.keys('id', 'name') + logEntry.body.updates.should.be.an.Array().and.have.length(1) + }) + // #endregion }) diff --git a/test/unit/forge/ee/routes/api/applicationDeviceGroups_spec.js b/test/unit/forge/ee/routes/api/applicationDeviceGroups_spec.js index cb01e56902..99c4458aa3 100644 --- a/test/unit/forge/ee/routes/api/applicationDeviceGroups_spec.js +++ b/test/unit/forge/ee/routes/api/applicationDeviceGroups_spec.js @@ -580,6 +580,225 @@ describe('Application Device Groups API', function () { }) }) + describe('Update Device Settings', async function () { + async function prepare () { + const sid = await login('bob', 'bbPassword') + const application = await factory.createApplication({ name: generateName('app') }, TestObjects.BTeam) + const deviceGroup = await factory.createApplicationDeviceGroup({ name: generateName('device-group') + ' original name', description: 'original desc' }, application) + deviceGroup.should.have.property('name').and.endWith('original name') + deviceGroup.should.have.property('description', 'original desc') + + const device1of2 = await factory.createDevice({ name: generateName('device 1') }, TestObjects.BTeam, null, application) + const device2of2 = await factory.createDevice({ name: generateName('device 2') }, TestObjects.BTeam, null, application) + + // add the devices to the group + await controller.updateDeviceGroupMembership(deviceGroup, { addDevices: [device1of2.id, device2of2.id] }) + await device1of2.reload({ include: [app.db.models.Team, app.db.models.Application] }) + await device2of2.reload({ include: [app.db.models.Team, app.db.models.Application] }) + + // manually set some env-vars on the group + deviceGroup.settings = { + env: [{ name: 'ENV1', value: 'group value' }, { name: 'ENV2', value: 'group value' }, { name: 'ENV3', value: 'group value' }] + } + await deviceGroup.save() + + // set some env-vars on the devices + await device1of2.updateSettings({ env: [{ name: 'ENV1', value: 'device 1 value 1' }, { name: 'ENV2', value: 'device 1 value 2' }] }) + await device2of2.updateSettings({ env: [{ name: 'ENV1', value: 'device 2 value 2' }, { name: 'ENV2', value: '' }] }) + + const tokens = { + device1: await device1of2.refreshAuthTokens({ refreshOTC: false }), + device2: await device2of2.refreshAuthTokens({ refreshOTC: false }) + } + + // reset the mock call history + app.comms.devices.sendCommand.resetHistory() + + return { sid, tokens, application, deviceGroup, device1of2, device2of2, device1SettingsHash: device1of2.settingsHash, device2SettingsHash: device2of2.settingsHash } + } + /** + * Call the API to update the device group settings + * e.g http://xxxxx/api/v1/applications/ZdEbXMg9mD/device-groups/50z9ynpNdO/settings + * @param {*} sid - session id + * @param {Object} application - application object + * @param {Object} deviceGroup - device group object + * @param {{ env: [{name: String, value: String}]}} settings - settings to update + * @returns {Promise} - the response object + */ + async function callSettingsUpdate (sid, application, deviceGroup, settings) { + return app.inject({ + method: 'PUT', + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup.hashid}/settings`, + cookies: { sid }, + payload: settings + }) + } + /** /api/v1/devices/:deviceId/live/settings */ + async function callDeviceAgentLiveSettingsAPI (token, device) { + return app.inject({ + method: 'GET', + url: `/api/v1/devices/${device.hashid}/live/settings`, + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json' + } + }) + } + + it('Owner can update a device group settings', async function () { + const { sid, tokens, application, deviceGroup, device1of2, device2of2, device1SettingsHash, device2SettingsHash } = await prepare() + + const groupEnv = deviceGroup.settings.env.map(e => ({ ...e })) // clone the group env vars before modifying + groupEnv.find(e => e.name === 'ENV3').value = 'group value updated' // update the value of ENV3 + + // reset the mock call history + app.comms.devices.sendCommand.resetHistory() + + // update an env var that is not overridden by the devices + const response = await callSettingsUpdate(sid, application, deviceGroup, { + env: groupEnv + }) + response.statusCode.should.equal(200) // ensure success + + // check the group env is updated + const updatedDeviceGroup = await app.db.models.DeviceGroup.byId(deviceGroup.hashid) + updatedDeviceGroup.should.have.property('settings').and.be.an.Object() + updatedDeviceGroup.settings.should.have.property('env').and.be.an.Array() + const gEnv3 = updatedDeviceGroup.settings.env.find(e => e.name === 'ENV3') + gEnv3.should.have.property('value', 'group value updated') + + // check device settings hash has changed + const updatedDevice1 = await app.db.models.Device.byId(device1of2.hashid) + const updatedDevice2 = await app.db.models.Device.byId(device2of2.hashid) + updatedDevice1.should.have.property('settingsHash').and.not.equal(device1SettingsHash) // device 1 should have a new settings hash + updatedDevice2.should.have.property('settingsHash').and.not.equal(device2SettingsHash) // device 2 should have a new settings hash + + // check the settings.env delivered to the devices are merged with the group settings + const d1SettingsResponse = await callDeviceAgentLiveSettingsAPI(tokens.device1.token, updatedDevice1) + const d1Settings = d1SettingsResponse.json() + d1Settings.should.have.property('hash', updatedDevice1.settingsHash) + d1Settings.should.have.property('env').and.be.an.Object() + d1Settings.env.should.have.property('ENV1', 'device 1 value 1') // device value should not be changed + d1Settings.env.should.have.property('ENV2', 'device 1 value 2') // device value should not be changed + d1Settings.env.should.have.property('ENV3', 'group value updated') // device value should be overridden by the updated group value since ENV3 is not set on the device + + const d2SettingsResponse = await callDeviceAgentLiveSettingsAPI(tokens.device2.token, updatedDevice2) + const d2Settings = d2SettingsResponse.json() + d2Settings.should.have.property('hash', updatedDevice2.settingsHash) + d2Settings.should.have.property('env').and.be.an.Object() + d2Settings.env.should.have.property('ENV1', 'device 2 value 2') // device value should not be changed + d2Settings.env.should.have.property('ENV2', 'group value') // device value should be overridden by original group value (since it is empty on the device) + d2Settings.env.should.have.property('ENV3', 'group value updated') // device value should be overridden by the updated group value since ENV3 is not set on the device + + // check devices got an update request + app.comms.devices.sendCommand.callCount.should.equal(2) + const calls = app.comms.devices.sendCommand.getCalls() + checkDeviceUpdateCall(calls, device1of2) + checkDeviceUpdateCall(calls, device2of2) + }) + + it('Merges new Group Env Var', async function () { + const { sid, tokens, application, deviceGroup, device1of2, device2of2, device1SettingsHash, device2SettingsHash } = await prepare() + + const groupEnv = deviceGroup.settings.env.map(e => ({ ...e })) // clone the group env vars before modifying + groupEnv.push({ name: 'ENV4', value: 'group value 4' }) // add a new env var + + // update an env var that is not overridden by the devices + const response = await callSettingsUpdate(sid, application, deviceGroup, { + env: groupEnv + }) + response.statusCode.should.equal(200) // ensure success + + // check the group env is updated + const updatedDeviceGroup = await app.db.models.DeviceGroup.byId(deviceGroup.hashid) + updatedDeviceGroup.should.have.property('settings').and.be.an.Object() + updatedDeviceGroup.settings.should.have.property('env').and.be.an.Array() + updatedDeviceGroup.settings.env.length.should.equal(4) + const gEnv4 = updatedDeviceGroup.settings.env.find(e => e.name === 'ENV4') + gEnv4.should.have.property('value', 'group value 4') + + // check device settings hash has changed + const updatedDevice1 = await app.db.models.Device.byId(device1of2.hashid) + const updatedDevice2 = await app.db.models.Device.byId(device2of2.hashid) + updatedDevice1.should.have.property('settingsHash').and.not.equal(device1SettingsHash) + updatedDevice2.should.have.property('settingsHash').and.not.equal(device2SettingsHash) + + // check the settings.env delivered to the devices are merged with the group settings + const d1SettingsResponse = await callDeviceAgentLiveSettingsAPI(tokens.device1.token, updatedDevice1) + const d1Settings = d1SettingsResponse.json() + d1Settings.should.have.property('hash', updatedDevice1.settingsHash) + d1Settings.env.should.have.keys('ENV1', 'ENV2', 'ENV3', 'ENV4') + d1Settings.env.should.have.property('ENV4', 'group value 4') // device should inherit the new group value + + const d2SettingsResponse = await callDeviceAgentLiveSettingsAPI(tokens.device2.token, updatedDevice2) + const d2Settings = d2SettingsResponse.json() + d2Settings.should.have.property('hash', updatedDevice2.settingsHash) + d2Settings.env.should.have.keys('ENV1', 'ENV2', 'ENV3', 'ENV4') + d2Settings.env.should.have.property('ENV4', 'group value 4') // device should inherit the new group value + }) + + it('Only updates a device settings hash if the group env var change affects the merged settings env', async function () { + const { sid, application, deviceGroup, device1of2, device2of2, device1SettingsHash, device2SettingsHash } = await prepare() + + // reset the mock call history + app.comms.devices.sendCommand.resetHistory() + + const groupEnv = deviceGroup.settings.env.map(e => ({ ...e })) // clone the group env vars before modifying + groupEnv.find(e => e.name === 'ENV2').value = 'device 1 value 2' // set the value of ENV2 to the same as the device 1s current value + const response = await callSettingsUpdate(sid, application, deviceGroup, { + env: groupEnv + }) + response.statusCode.should.equal(200) // ensure success + + // check the group env is updated + const updatedDeviceGroup = await app.db.models.DeviceGroup.byId(deviceGroup.hashid) + updatedDeviceGroup.should.have.property('settings').and.be.an.Object() + updatedDeviceGroup.settings.should.have.property('env').and.be.an.Array() + const gEnv2 = updatedDeviceGroup.settings.env.find(e => e.name === 'ENV2') + gEnv2.should.have.property('value', 'device 1 value 2') // the group value should now be the same as the device 1 value + + // check device 1 settings hash has NOT changed but device 2 has! + const updatedDevice1 = await app.db.models.Device.byId(device1of2.hashid) + const updatedDevice2 = await app.db.models.Device.byId(device2of2.hashid) + updatedDevice1.should.have.property('settingsHash').and.equal(device1SettingsHash) + updatedDevice2.should.have.property('settingsHash').and.not.equal(device2SettingsHash) + + // check devices got an update request + app.comms.devices.sendCommand.callCount.should.equal(2) + const calls = app.comms.devices.sendCommand.getCalls() + checkDeviceUpdateCall(calls, device1of2) + checkDeviceUpdateCall(calls, device2of2) + }) + + it('Member can not update a device group settings (403)', async function () { + const sid = await login('chris', 'ccPassword') + const application = await factory.createApplication({ name: generateName('app') }, TestObjects.BTeam) + const deviceGroup = await factory.createApplicationDeviceGroup({ name: generateName('device-group') }, application) + const response = await callSettingsUpdate(sid, application, deviceGroup, { + env: [{ name: 'ENV1', value: 'new group value' }] + }) + + response.statusCode.should.equal(403) + + const result = response.json() + result.should.have.property('code', 'unauthorized') + result.should.have.property('error') + }) + + it('Non Member can not update a device group settings (404)', async function () { + const sid = await login('dave', 'ddPassword') + const application = await factory.createApplication({ name: generateName('app') }, TestObjects.BTeam) + const deviceGroup = await factory.createApplicationDeviceGroup({ name: generateName('device-group') }, application) + const response = await callSettingsUpdate(sid, application, deviceGroup, { + name: 'updated name', + description: 'updated description', + targetSnapshotId: null + }) + + response.statusCode.should.be.equal(404) + }) + }) + describe('Delete Device Group', async function () { it('Owner can delete a device group', async function () { const sid = await login('bob', 'bbPassword')