From 477b6cd48acc1aaba8d6793aab1dde9011944da5 Mon Sep 17 00:00:00 2001 From: Stephen McLaughlin <44235289+Steve-Mcl@users.noreply.github.com> Date: Sat, 2 Dec 2023 17:17:40 +0000 Subject: [PATCH 01/85] Add migration for DeviceGroups --- .../20231202-01-add-device-devicegroup.js | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 forge/db/migrations/20231202-01-add-device-devicegroup.js diff --git a/forge/db/migrations/20231202-01-add-device-devicegroup.js b/forge/db/migrations/20231202-01-add-device-devicegroup.js new file mode 100644 index 000000000..a18ed8c13 --- /dev/null +++ b/forge/db/migrations/20231202-01-add-device-devicegroup.js @@ -0,0 +1,62 @@ +/* eslint-disable no-unused-vars */ +/** + * Add DeviceGroups table that has an FK association with Applications and Devices + */ + +const { Sequelize, QueryInterface } = require('sequelize') + +module.exports = { + /** + * upgrade database + * @param {QueryInterface} context QueryInterface + */ + up: async (context) => { + await context.createTable('DeviceGroups', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + name: { + type: Sequelize.STRING(255), + allowNull: false + }, + description: { + type: Sequelize.TEXT + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + }, + ApplicationId: { + type: Sequelize.INTEGER, + references: { + model: 'Applications', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL' + } + }) + + // add a column to Devices table that references DeviceGroups + await context.addColumn('Devices', 'DeviceGroupId', { + type: Sequelize.INTEGER, + references: { + model: 'DeviceGroups', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL' + }) + }, + + down: async (queryInterface, Sequelize) => { + + } +} From 8581dff58c6d71650ca34d50c303a80559cd2879 Mon Sep 17 00:00:00 2001 From: Stephen McLaughlin <44235289+Steve-Mcl@users.noreply.github.com> Date: Sat, 2 Dec 2023 17:18:20 +0000 Subject: [PATCH 02/85] Add DeviceGroups model --- forge/db/models/Application.js | 1 + forge/db/models/Device.js | 1 + forge/db/models/DeviceGroup.js | 130 +++++++++++++++++++++++++++++++++ forge/db/models/index.js | 4 +- 4 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 forge/db/models/DeviceGroup.js diff --git a/forge/db/models/Application.js b/forge/db/models/Application.js index 4feb9ecad..2a19e611c 100644 --- a/forge/db/models/Application.js +++ b/forge/db/models/Application.js @@ -16,6 +16,7 @@ module.exports = { this.hasMany(M.Project) this.hasMany(M.Project, { as: 'Instances' }) this.belongsTo(M.Team, { foreignKey: { allowNull: false } }) + this.hasMany(M.DeviceGroup, { foreignKey: { allowNull: true } }) }, finders: function (M) { return { diff --git a/forge/db/models/Device.js b/forge/db/models/Device.js index c2e9b96d4..2af77d825 100644 --- a/forge/db/models/Device.js +++ b/forge/db/models/Device.js @@ -53,6 +53,7 @@ module.exports = { this.belongsTo(M.ProjectSnapshot, { as: 'activeSnapshot' }) this.hasMany(M.DeviceSettings) this.hasMany(M.ProjectSnapshot) // associate device at application level with snapshots + this.belongsTo(M.DeviceGroup, { foreignKey: { allowNull: true } }) // SEE: forge/db/models/DeviceGroup.js for the other side of this relationship }, hooks: function (M, app) { return { diff --git a/forge/db/models/DeviceGroup.js b/forge/db/models/DeviceGroup.js new file mode 100644 index 000000000..6c2df5b78 --- /dev/null +++ b/forge/db/models/DeviceGroup.js @@ -0,0 +1,130 @@ +/** + * 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') + +module.exports = { + name: 'DeviceGroup', + schema: { + name: { type: DataTypes.STRING, allowNull: false }, + description: { type: DataTypes.TEXT } + }, + associations: function (M) { + this.belongsTo(M.Application) + // this.belongsTo(M.ProjectSnapshot, { as: 'targetSnapshot' }) + this.hasMany(M.Device) + }, + finders: function (M) { + const self = this + 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', 'TeamId', 'ApplicationId', 'ProjectId', 'ownerType'], + where: { + ApplicationId: literal('"DeviceGroup"."ApplicationId"'), + DeviceGroupId: literal('"DeviceGroup"."id"') + }, + required: false + } + ], + attributes: { + include: [ + [ + literal(`( + SELECT COUNT(*) + FROM "Devices" AS "device" + WHERE + "device"."DeviceGroupId" = "DeviceGroup"."id" + AND + "device"."ApplicationId" = "DeviceGroup"."ApplicationId" + )`), + 'deviceCount' + ] + ] + } + }) + }, + forApplication: async function (pagination = {}, applicationIdOrHash) { + let id = applicationIdOrHash + if (typeof applicationIdOrHash === 'string') { + id = M.Application.decodeHashid(applicationIdOrHash) + } + return self.getAll(pagination || {}, { ApplicationId: id }) + }, + getAll: async (pagination = {}, where = {}, { includeApplication = false } = {}) => { + 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 include = [] + if (includeApplication) { + include.push({ + model: M.Application, + attributes: ['hashid', 'id', 'name', 'TeamId'] + }) + } + const [rows, count] = await Promise.all([ + this.findAll({ + where: buildPaginationSearchClause(pagination, where, ['DeviceGroup.name', 'DeviceGroup.description']), + attributes: { + include: [ + [ + literal(`( + SELECT COUNT(*) + FROM "Devices" AS "device" + WHERE + "device"."DeviceGroupId" = "DeviceGroup"."id" + )`), + 'deviceCount' + ] + ] + }, + include, + 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/models/index.js b/forge/db/models/index.js index ce4ad6d66..9bcabc0a9 100644 --- a/forge/db/models/index.js +++ b/forge/db/models/index.js @@ -48,6 +48,7 @@ const { Model, DataTypes } = require('sequelize') const { getHashId } = require('../utils') +const DeviceGroup = require('./DeviceGroup') // The models that should be loaded const modelTypes = [ @@ -76,7 +77,8 @@ const modelTypes = [ 'StorageSession', 'StorageLibrary', 'AuditLog', - 'BrokerClient' + 'BrokerClient', + 'DeviceGroup' ] // A local map of the known models. From a825fbee6a5f454a5523982e69705c2c79a8b04d Mon Sep 17 00:00:00 2001 From: Stephen McLaughlin <44235289+Steve-Mcl@users.noreply.github.com> Date: Sat, 2 Dec 2023 17:18:43 +0000 Subject: [PATCH 03/85] Add App Device Groups API --- forge/db/views/Device.js | 4 +- forge/db/views/index.js | 1 + forge/routes/api/application.js | 4 + forge/routes/api/applicationDeviceGroup.js | 331 +++++++++++++++++++++ 4 files changed, 338 insertions(+), 2 deletions(-) create mode 100644 forge/routes/api/applicationDeviceGroup.js diff --git a/forge/db/views/Device.js b/forge/db/views/Device.js index a5744bfb8..1f97b11af 100644 --- a/forge/db/views/Device.js +++ b/forge/db/views/Device.js @@ -36,7 +36,7 @@ module.exports = function (app) { return null } - const result = device.toJSON() + const result = device.toJSON ? device.toJSON() : device if (statusOnly) { return { @@ -102,7 +102,7 @@ module.exports = function (app) { }) function deviceSummary (device, { includeSnapshotIds = false } = {}) { if (device) { - const result = device.toJSON() + const result = device.toJSON ? device.toJSON() : device const filtered = { id: result.hashid, ownerType: result.ownerType, diff --git a/forge/db/views/index.js b/forge/db/views/index.js index 540a5ae8d..0b6157e3e 100644 --- a/forge/db/views/index.js +++ b/forge/db/views/index.js @@ -15,6 +15,7 @@ const modelTypes = [ 'Application', 'AuditLog', 'Device', + 'DeviceGroup', 'Invitation', 'Project', 'ProjectSnapshot', diff --git a/forge/routes/api/application.js b/forge/routes/api/application.js index 613176207..9e72ce98b 100644 --- a/forge/routes/api/application.js +++ b/forge/routes/api/application.js @@ -1,3 +1,5 @@ +const applicationDeviceGroup = require('./applicationDeviceGroup.js') + module.exports = async function (app) { app.addHook('preHandler', async (request, reply) => { const applicationId = request.params.applicationId @@ -26,6 +28,8 @@ module.exports = async function (app) { } }) + app.register(applicationDeviceGroup, { prefix: '/:applicationId/devicegroups' }) + /** * Create an application * @name /api/v1/applications diff --git a/forge/routes/api/applicationDeviceGroup.js b/forge/routes/api/applicationDeviceGroup.js new file mode 100644 index 000000000..6a40496a7 --- /dev/null +++ b/forge/routes/api/applicationDeviceGroup.js @@ -0,0 +1,331 @@ +/** + * Application DeviceGroup api routes + * + * - /api/v1/applications/:applicationId/devicegroups + * + * @namespace application + * @memberof forge.routes.api + */ + +const { registerPermissions } = require('../../lib/permissions') +const { Roles } = require('../../lib/roles.js') + +/** + * @param {import('../../forge.js').ForgeApplication} app The application instance + */ +module.exports = async function (app) { + // ### Routes in this file + // GET /api/v1/applications/:applicationId/devicegroups + // - get a list of devicegroups in this application + // POST /api/v1/applications/:applicationId/devicegroups + // - add a new Device Group to an Application + // > body: { name, [description] } + // PUT /api/v1/applications/:applicationId/devicegroups/:groupId + // - update a device group settings + // > body: { name, [description] } + // GET /api/v1/applications/:applicationId/devicegroups/:groupId + // - get a specific deviceGroup (must be assigned to this application) + // PATCH /api/v1/applications/:applicationId/devicegroups/:groupId + // - update Device Group membership + // > OPTION1: body: { add: [deviceIds], remove: [deviceIds] } + // > OPTION2: body: { set: [deviceIds] } + // DELETE /api/v1/applications/:applicationId/devicegroups/:groupId + // - delete app owned deviceGroup + + registerPermissions({ + 'application:devicegroup:create': { description: 'Create a device group', role: Roles.Owner }, + 'application:devicegroup:list': { description: 'List device groups', role: Roles.Member }, + 'application:devicegroup:update': { description: 'Update a device group', role: Roles.Owner }, + 'application:devicegroup:delete': { description: 'Delete a device group', role: Roles.Owner }, + 'application:devicegroup:read': { description: 'View a device group', role: Roles.Member }, + 'application:devicegroup:membership:update': { description: 'Update a device group membership', role: Roles.Owner } + }) + + // pre-handler for all routes in this file + app.addHook('preHandler', async (request, reply) => { + // get the device group + const groupId = request.params.groupId + if (groupId) { + request.deviceGroup = await app.db.models.DeviceGroup.byId(groupId) + if (!request.deviceGroup || request.deviceGroup.ApplicationId !== request.application.id) { + reply.code(404).send({ code: 'not_found', error: 'Not Found' }) + } + } + }) + + /** + * Get a list of device groups in an application + * @method GET + * @name /api/v1/applications/:applicationId/devicegroups + * @memberof forge.routes.api.application + */ + app.get('/', { + preHandler: app.needsPermission('application:devicegroup:list') + // TODO: When mature and ready to be exposed + // schema: { + // summary: 'Get a list of device groups in an application', + // tags: ['Applications'], + // query: { $ref: 'PaginationParams' }, + // params: { + // type: 'object', + // properties: { + // applicationId: { type: 'string' } + // } + // }, + // response: { + // 200: { + // type: 'object', + // properties: { + // meta: { $ref: 'PaginationMeta' }, + // count: { type: 'number' }, + // groups: { type: 'array', items: { $ref: 'DeviceGroup' } } + // } + // }, + // '4xx': { + // $ref: 'APIError' + // } + // } + // } + }, async (request, reply) => { + const paginationOptions = app.db.controllers.Device.getDevicePaginationOptions(request) + + const where = { + ApplicationId: request.application.hashid + } + + const groupData = await app.db.models.DeviceGroup.getAll(paginationOptions, where) + const result = { + count: groupData.count, + meta: groupData.meta, + groups: (groupData.groups || []).map(d => app.db.views.DeviceGroup.deviceGroupSummary(d, { includeApplication: false })) + } + reply.send(result) + }) + + /** + * Add a new Device Group to an Application + * @method POST + * @name /api/v1/applications/:applicationId/devicegroups + * @memberof forge.routes.api.application + */ + app.post('/', { + preHandler: app.needsPermission('application:devicegroup:create') + // TODO: When mature and ready to be exposed + // schema: { + // summary: 'Add a new Device Group to an Application', + // tags: ['Applications'], + // body: { + // type: 'object', + // properties: { + // name: { type: 'string' }, + // description: { type: 'string' }, + // devices: { type: 'array', items: { type: 'DeviceGroup' } } + // }, + // required: ['name'] + // }, + // params: { + // type: 'object', + // properties: { + // applicationId: { type: 'string' } + // } + // }, + // response: { + // 201: { + // type: 'object', + // properties: { + // group: { $ref: 'DeviceGroup' } + // } + // }, + // '4xx': { + // $ref: 'APIError' + // } + // } + // } + }, async (request, reply) => { + const application = request.application + const name = request.body.name + const description = request.body.description + + const newGroup = await app.db.controllers.DeviceGroup.createDeviceGroup(name, { application, description }) + const newGroupView = app.db.views.DeviceGroup.deviceGroupSummary(newGroup) + reply.code(201).send(newGroupView) + }) + + /** + * Update a Device Group + * @method PUT + * @name /api/v1/applications/:applicationId/devicegroups/:groupId + * @memberof forge.routes.api.application + */ + app.put('/:groupId', { + preHandler: app.needsPermission('application:devicegroup:update') + // TODO: When mature and ready to be exposed + // schema: { + // summary: 'Update a Device Group', + // tags: ['Applications'], + // body: { + // type: 'object', + // properties: { + // name: { type: 'string' }, + // description: { type: 'string' }, + // color: { type: 'string' }, + // icon: { type: 'string' } + // } + // }, + // params: { + // type: 'object', + // properties: { + // applicationId: { type: 'string' }, + // groupId: { type: 'string' } + // } + // }, + // response: { + // 200: { + // type: 'object', + // properties: { + // group: { $ref: 'DeviceGroup' } + // } + // }, + // '4xx': { + // $ref: 'APIError' + // } + // } + // } + }, async (request, reply) => { + const group = request.deviceGroup + const name = request.body.name + const description = request.body.description + + await app.db.controllers.DeviceGroup.updateDeviceGroup(group, { name, description }) + reply.send({}) + }) + + /** + * Get a specific deviceGroup + * @method GET + * @name /api/v1/applications/:applicationId/devicegroups/:groupId + * @memberof forge.routes.api.application + */ + app.get('/:groupId', { + preHandler: app.needsPermission('application:devicegroup:read') + // TODO: When mature and ready to be exposed + // schema: { + // summary: 'Get a specific deviceGroup', + // tags: ['Applications'], + // params: { + // type: 'object', + // properties: { + // applicationId: { type: 'string' }, + // groupId: { type: 'string' } + // } + // }, + // response: { + // 200: { + // type: 'object', + // properties: { + // group: { $ref: 'DeviceGroup' } + // } + // }, + // '4xx': { + // $ref: 'APIError' + // } + // } + // } + }, async (request, reply) => { + const group = request.deviceGroup // already loaded in preHandler + const groupView = app.db.views.DeviceGroup.deviceGroup(group) + reply.send(groupView) + }) + + /** + * Update Device Group membership + * @method PATCH + * @name /api/v1/applications/:applicationId/devicegroups/:groupId + * @memberof forge.routes.api.application + */ + app.patch('/:groupId', { + preHandler: app.needsPermission('application:devicegroup:membership:update') + // TODO: When mature and ready to be exposed + // schema: { + // summary: 'Update Device Group membership', + // tags: ['Applications'], + // body: { + // type: 'object', + // properties: { + // add: { type: 'array', items: { type: 'string' } }, + // remove: { type: 'array', items: { type: 'string' } }, + // set: { type: 'array', items: { type: 'string' } } + // } + // }, + // params: { + // type: 'object', + // properties: { + // applicationId: { type: 'string' }, + // groupId: { type: 'string' } + // } + // }, + // response: { + // 200: { + // type: 'object', + // properties: { + // group: { $ref: 'DeviceGroup' } + // } + // }, + // '4xx': { + // $ref: 'APIError' + // } + // } + // } + }, async (request, reply) => { + const group = request.deviceGroup + const addDevices = request.body.add + const removeDevices = request.body.remove + const setDevices = request.body.set + try { + await app.db.controllers.DeviceGroup.updateDeviceGroupMembership(group, { addDevices, removeDevices, setDevices }) + reply.send({}) + } catch (err) { + return reply.code(err.statusCode || 500).send({ + code: err.code || 'unexpected_error', + error: err.error || err.message + }) + } + }) + + /** + * Delete a Device Group + * @method DELETE + * @name /api/v1/applications/:applicationId/devicegroups/:groupId + * @memberof forge.routes.api.application + */ + app.delete('/:groupId', { + preHandler: app.needsPermission('application:devicegroup:delete') + // TODO: When mature and ready to be exposed + // schema: { + // summary: 'Delete a Device Group', + // tags: ['Applications'], + // params: { + // type: 'object', + // properties: { + // applicationId: { type: 'string' }, + // groupId: { type: 'string' } + // } + // }, + // response: { + // 200: { + // type: 'object', + // properties: { + // group: { $ref: 'DeviceGroup' } + // } + // }, + // '4xx': { + // $ref: 'APIError' + // } + // } + // } + }, async (request, reply) => { + const group = request.deviceGroup + await group.destroy() + reply.send({}) + }) +} From 518138d8c82cdc3b3ad18b6f5edb0a108e8d9c70 Mon Sep 17 00:00:00 2001 From: Stephen McLaughlin <44235289+Steve-Mcl@users.noreply.github.com> Date: Sat, 2 Dec 2023 17:20:22 +0000 Subject: [PATCH 04/85] DeviceGroups API tests --- .../unit/forge/routes/api/application_spec.js | 472 +++++++++++++++++- 1 file changed, 471 insertions(+), 1 deletion(-) diff --git a/test/unit/forge/routes/api/application_spec.js b/test/unit/forge/routes/api/application_spec.js index 54081ed8d..1e02e6ef7 100644 --- a/test/unit/forge/routes/api/application_spec.js +++ b/test/unit/forge/routes/api/application_spec.js @@ -9,12 +9,28 @@ const { Roles } = FF_UTIL.require('forge/lib/roles') describe('Application API', function () { let app - const TestObjects = {} + const TestObjects = { + /** admin - owns ateam */ + alice: {}, + /** owner of bteam */ + bob: {}, + /** member of b team */ + chris: {}, + /** not connected to any teams */ + dave: {}, + ATeam: {}, + BTeam: {}, + /** B-team Application */ + application: {} + } + /** @type {import('../../../../lib/TestModelFactory')} */ + let factory = null let objectCount = 0 const generateName = (root = 'object') => `${root}-${objectCount++}` before(async function () { app = await setup() + factory = app.factory // ATeam ( alice (owner), bob ) // BTeam ( bob (owner), chris ) @@ -706,4 +722,458 @@ describe('Application API', function () { response.statusCode.should.equal(403) }) }) + + describe('Application Device Groups', async function () { + describe('Create Device Group', async function () { + it('Owner can create a device group', async function () { + const sid = await login('bob', 'bbPassword') + const application = await factory.createApplication({ name: generateName('app') }, TestObjects.BTeam) + + const response = await app.inject({ + method: 'POST', + url: `/api/v1/applications/${application.hashid}/devicegroups`, + cookies: { sid }, + payload: { + name: 'my device group', + description: 'my device group description' + } + }) + + response.statusCode.should.equal(201) + + const result = response.json() + result.should.have.property('id') + result.should.have.property('name', 'my device group') + result.should.have.property('description', 'my device group description') + }) + + it('Non Owner can not create a device group', async function () { + const sid = await login('chris', 'ccPassword') + const application = await factory.createApplication({ name: generateName('app') }, TestObjects.BTeam) + + const response = await app.inject({ + method: 'POST', + url: `/api/v1/applications/${application.hashid}/devicegroups`, + cookies: { sid }, + payload: { + name: 'my device group', + description: 'my device group description' + } + }) + + 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 create a device group', async function () { + const sid = await login('dave', 'ddPassword') + const application = TestObjects.application + + const response = await app.inject({ + method: 'POST', + url: `/api/v1/applications/${application.hashid}/devicegroups`, + cookies: { sid }, + payload: { + name: 'my device group', + description: 'my device group description' + } + }) + response.statusCode.should.be.oneOf([400, 403, 404]) + }) + }) + + describe('Read Device Groups', async function () { + it('Owner can read device groups', async function () { + 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'), description: 'a description' }, application) + + const response = await app.inject({ + method: 'GET', + url: `/api/v1/applications/${application.hashid}/devicegroups`, + cookies: { sid } + }) + + response.statusCode.should.equal(200) + + const result = response.json() + result.should.have.property('groups') + result.groups.should.have.length(1) + result.groups[0].should.have.property('id', deviceGroup.hashid) + result.groups[0].should.have.property('name', deviceGroup.name) + result.groups[0].should.have.property('description', deviceGroup.description) + }) + + it('Non Owner can read device groups', async function () { + const sid = await login('chris', 'ccPassword') + const application = await factory.createApplication({ name: generateName('app') }, TestObjects.BTeam) + await factory.createApplicationDeviceGroup({ name: generateName('device-group') }, application) + + const response = await app.inject({ + method: 'GET', + url: `/api/v1/applications/${application.hashid}/devicegroups`, + cookies: { sid } + }) + + response.statusCode.should.equal(200) + }) + + it('Non Member can not read device groups', async function () { + const sid = await login('dave', 'ddPassword') + const application = await factory.createApplication({ name: generateName('app') }, TestObjects.BTeam) + await factory.createApplicationDeviceGroup({ name: generateName('device-group') }, application) + + const response = await app.inject({ + method: 'GET', + url: `/api/v1/applications/${application.hashid}/devicegroups`, + cookies: { sid } + }) + + response.statusCode.should.be.oneOf([400, 403, 404]) + }) + }) + + describe('Update Device Group', async function () { + it('Owner can update a device group', async function () { + 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 originalId = deviceGroup.id + + // now call the API to update name and desc + const response = await app.inject({ + method: 'PUT', + url: `/api/v1/applications/${application.hashid}/devicegroups/${deviceGroup.hashid}`, + cookies: { sid }, + payload: { + name: 'updated name', + description: 'updated description' + } + }) + + // ensure success + response.statusCode.should.equal(200) + const updatedDeviceGroup = await app.db.models.DeviceGroup.byId(deviceGroup.hashid) + updatedDeviceGroup.should.have.property('id', originalId) + updatedDeviceGroup.should.have.property('name', 'updated name') + updatedDeviceGroup.should.have.property('description', 'updated description') + }) + + it('Non Owner can not update a device group', 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 app.inject({ + method: 'PUT', + url: `/api/v1/applications/${application.hashid}/devicegroups/${deviceGroup.hashid}`, + cookies: { sid }, + payload: { + name: 'updated name', + description: 'updated description' + } + }) + + 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', 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 app.inject({ + method: 'PUT', + url: `/api/v1/applications/${application.hashid}/devicegroups/${deviceGroup.hashid}`, + cookies: { sid }, + payload: { + name: 'updated name', + description: 'updated description' + } + }) + + response.statusCode.should.be.oneOf([400, 403, 404]) + }) + }) + + describe('Delete Device Group', async function () { + it('Owner can delete a device group', async function () { + 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') }, application) + + const response = await app.inject({ + method: 'DELETE', + url: `/api/v1/applications/${application.hashid}/devicegroups/${deviceGroup.hashid}`, + cookies: { sid } + }) + + response.statusCode.should.equal(200) + }) + it('Non Owner can not delete a device group', 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 app.inject({ + method: 'DELETE', + url: `/api/v1/applications/${application.hashid}/devicegroups/${deviceGroup.hashid}`, + cookies: { sid } + }) + + 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 delete a device group', 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 app.inject({ + method: 'DELETE', + url: `/api/v1/applications/${application.hashid}/devicegroups/${deviceGroup.hashid}`, + cookies: { sid } + }) + + response.statusCode.should.be.oneOf([400, 403, 404]) + }) + }) + + describe('Update Device Group Membership', async function () { + it('Owner can add a device to a new group', async function () { + 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') }, application) + const device = await factory.createDevice({ name: generateName('device') }, TestObjects.BTeam, null, application) + const response = await app.inject({ + method: 'PATCH', + url: `/api/v1/applications/${application.hashid}/devicegroups/${deviceGroup.hashid}`, + cookies: { sid }, + payload: { + add: [device.hashid] + } + }) + + response.statusCode.should.equal(200) + + // call the various db accessors and verify the group contains 1 device + const updatedDeviceGroup = await app.db.models.DeviceGroup.byId(deviceGroup.hashid) + updatedDeviceGroup.should.have.property('Devices').and.have.length(1) + const deviceCount = await updatedDeviceGroup.deviceCount() + deviceCount.should.equal(1) + const devices = await updatedDeviceGroup.getDevices() + devices.should.have.length(1) + }) + it('Owner can add a device to an existing group', async function () { + 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') }, application) + const device1 = await factory.createDevice({ name: generateName('device') }, TestObjects.BTeam, null, application) + const device2 = await factory.createDevice({ name: generateName('device') }, TestObjects.BTeam, null, application) + await app.db.controllers.DeviceGroup.updateDeviceGroupMembership(deviceGroup, { addDevices: [device1] }) + // verify the group contains 1 device + const updatedDeviceGroup = await app.db.models.DeviceGroup.byId(deviceGroup.hashid) + updatedDeviceGroup.should.have.property('Devices').and.have.length(1) + + const response = await app.inject({ + method: 'PATCH', + url: `/api/v1/applications/${application.hashid}/devicegroups/${deviceGroup.hashid}`, + cookies: { sid }, + payload: { + add: [device2.hashid] + } + }) + + response.statusCode.should.equal(200) + updatedDeviceGroup.reload() + const deviceCount = await updatedDeviceGroup.deviceCount() + deviceCount.should.equal(2) + const devices = await updatedDeviceGroup.getDevices() + devices.should.have.length(2) + const updatedDeviceGroup2 = await app.db.models.DeviceGroup.byId(deviceGroup.hashid) + updatedDeviceGroup2.should.have.property('Devices').and.have.length(2) + }) + it('Owner can remove a device from an existing group', async function () { + 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') }, application) + const device1 = await factory.createDevice({ name: generateName('device') }, TestObjects.BTeam, null, application) + const device2 = await factory.createDevice({ name: generateName('device') }, TestObjects.BTeam, null, application) + await app.db.controllers.DeviceGroup.updateDeviceGroupMembership(deviceGroup, { addDevices: [device1, device2] }) + // verify the group contains 2 devices + const updatedDeviceGroup = await app.db.models.DeviceGroup.byId(deviceGroup.hashid) + updatedDeviceGroup.should.have.property('Devices').and.have.length(2) + + const response = await app.inject({ + method: 'PATCH', + url: `/api/v1/applications/${application.hashid}/devicegroups/${deviceGroup.hashid}`, + cookies: { sid }, + payload: { + remove: [device2.hashid] + } + }) + + response.statusCode.should.equal(200) + + // get the group from DB and verify it contains 1 device (device1) + const updatedDeviceGroup2 = await app.db.models.DeviceGroup.byId(deviceGroup.hashid) + updatedDeviceGroup2.should.have.property('Devices').and.have.length(1) + updatedDeviceGroup2.Devices[0].should.have.property('id', device1.id) + }) + it('Owner can add and remove devices from an existing group', async function () { + 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') }, application) + const device1 = await factory.createDevice({ name: generateName('device') }, TestObjects.BTeam, null, application) + const device2 = await factory.createDevice({ name: generateName('device') }, TestObjects.BTeam, null, application) + const device3 = await factory.createDevice({ name: generateName('device') }, TestObjects.BTeam, null, application) + await app.db.controllers.DeviceGroup.updateDeviceGroupMembership(deviceGroup, { addDevices: [device1, device2] }) + // verify the group contains 2 devices + const updatedDeviceGroup = await app.db.models.DeviceGroup.byId(deviceGroup.hashid) + updatedDeviceGroup.should.have.property('Devices').and.have.length(2) + + const response = await app.inject({ + method: 'PATCH', + url: `/api/v1/applications/${application.hashid}/devicegroups/${deviceGroup.hashid}`, + cookies: { sid }, + payload: { + add: [device3.hashid], + remove: [device2.hashid] + } + }) + + response.statusCode.should.equal(200) + + // get the group from DB and verify it contains 2 devices (device1, device3) + const updatedDeviceGroup2 = await app.db.models.DeviceGroup.byId(deviceGroup.hashid) + updatedDeviceGroup2.should.have.property('Devices').and.have.length(2) + updatedDeviceGroup2.Devices[0].should.have.property('id', device1.id) + updatedDeviceGroup2.Devices[1].should.have.property('id', device3.id) + }) + + it('Owner can set the list of devices in existing group', async function () { + 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') }, application) + const device1 = await factory.createDevice({ name: generateName('device') }, TestObjects.BTeam, null, application) + const device2 = await factory.createDevice({ name: generateName('device') }, TestObjects.BTeam, null, application) + const device3 = await factory.createDevice({ name: generateName('device') }, TestObjects.BTeam, null, application) + await app.db.controllers.DeviceGroup.updateDeviceGroupMembership(deviceGroup, { addDevices: [device1, device2] }) + // verify the group contains 2 devices + const updatedDeviceGroup = await app.db.models.DeviceGroup.byId(deviceGroup.hashid) + await updatedDeviceGroup.should.have.property('Devices').and.have.length(2) + + const response = await app.inject({ + method: 'PATCH', + url: `/api/v1/applications/${application.hashid}/devicegroups/${deviceGroup.hashid}`, + cookies: { sid }, + payload: { + set: [device3.hashid] + } + }) + + response.statusCode.should.equal(200) + + // get the group from DB and verify it contains 1 device (device3) + const updatedDeviceGroup2 = await app.db.models.DeviceGroup.byId(deviceGroup.hashid) + updatedDeviceGroup2.should.have.property('Devices').and.have.length(1) + updatedDeviceGroup2.Devices[0].should.have.property('id', device3.id) + }) + + it('Can not add a device to a group in a different team', async function () { + const sid = await login('bob', 'bbPassword') + const application = TestObjects.application // BTeam application + const deviceGroup = await factory.createApplicationDeviceGroup({ name: generateName('device-group') }, application) + const device = await factory.createDevice({ name: generateName('device') }, TestObjects.ATeam, null, application) + + const response = await app.inject({ + method: 'PATCH', + url: `/api/v1/applications/${application.hashid}/devicegroups/${deviceGroup.hashid}`, + cookies: { sid }, + payload: { + add: [device.hashid] + } + }) + + response.statusCode.should.equal(400) + response.json().should.have.property('code', 'invalid_input') + // double check the device did not get added to the group + const updatedDeviceGroup = await app.db.models.DeviceGroup.byId(deviceGroup.hashid) + updatedDeviceGroup.should.have.property('Devices').and.have.length(0) + }) + it('Can not add a device to a group if they belong to different applications', async function () { + const sid = await login('bob', 'bbPassword') + const application = TestObjects.application // BTeam application + const application2 = await factory.createApplication({ name: generateName('application') }, TestObjects.BTeam) + const deviceGroup = await factory.createApplicationDeviceGroup({ name: generateName('device-group') }, application) + const device = await factory.createDevice({ name: generateName('device') }, TestObjects.ATeam, null, application2) + + const response = await app.inject({ + method: 'PATCH', + url: `/api/v1/applications/${application.hashid}/devicegroups/${deviceGroup.hashid}`, + cookies: { sid }, + payload: { + add: [device.hashid] + } + }) + + response.statusCode.should.equal(400) + response.json().should.have.property('code', 'invalid_input') + // double check the device did not get added to the group + const updatedDeviceGroup = await app.db.models.DeviceGroup.byId(deviceGroup.hashid) + updatedDeviceGroup.should.have.property('Devices').and.have.length(0) + }) + + it('Non Owner can not update a device group membership', 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 device = await factory.createDevice({ name: generateName('device') }, TestObjects.BTeam, null, application) + + const response = await app.inject({ + method: 'PATCH', + url: `/api/v1/applications/${application.hashid}/devicegroups/${deviceGroup.hashid}`, + cookies: { sid }, + payload: { + set: [device.hashid] + } + }) + + 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 membership', 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 device = await factory.createDevice({ name: generateName('device') }, TestObjects.BTeam, null, application) + + const response = await app.inject({ + method: 'PATCH', + url: `/api/v1/applications/${application.hashid}/devicegroups/${deviceGroup.hashid}`, + cookies: { sid }, + payload: { + set: [device.hashid] + } + }) + + response.statusCode.should.be.oneOf([400, 403, 404]) + }) + }) + }) }) From 9a8e72e7a31cd279009196d6525feb40129f8339 Mon Sep 17 00:00:00 2001 From: Stephen McLaughlin <44235289+Steve-Mcl@users.noreply.github.com> Date: Sat, 2 Dec 2023 17:29:10 +0000 Subject: [PATCH 05/85] Add createApplicationDeviceGroup support method --- test/lib/TestModelFactory.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/lib/TestModelFactory.js b/test/lib/TestModelFactory.js index 5eb9b697c..2694b8714 100644 --- a/test/lib/TestModelFactory.js +++ b/test/lib/TestModelFactory.js @@ -164,6 +164,30 @@ module.exports = class TestModelFactory { return instance } + /** + * Create a device group and add it to an application. + * @param {Object} deviceGroupDetails - device group details to override defaults + * @param {String} deviceGroupDetails.name - device group name + * @param {String} deviceGroupDetails.description - device group description + * @param {Object} application - application that this device group will belong to + * @returns {Object} - the created device group + * @example + * const deviceGroup = await factory.createApplicationDeviceGroup({ name: 'my-device-group' }, application) + * // deviceGroup.name === 'my-device-group' + */ + async createApplicationDeviceGroup (deviceGroupDetails, application) { + const defaultApplicationDetails = { + name: 'unnamed-application-device-group', + model: 'device' + } + + return await this.forge.db.models.DeviceGroup.create({ + ...defaultApplicationDetails, + ...deviceGroupDetails, + ApplicationId: application.id + }) + } + /** * Create a device and add it to a team. Optionally, add it to a project instance or application. * @param {Object} deviceDetails - device details to override defaults From ef462b266dac63df0c60fc709b5f5b9f0e24789e Mon Sep 17 00:00:00 2001 From: Stephen McLaughlin <44235289+Steve-Mcl@users.noreply.github.com> Date: Sun, 3 Dec 2023 10:50:54 +0000 Subject: [PATCH 06/85] Add DeviceGroup db view --- forge/db/views/DeviceGroup.js | 65 +++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 forge/db/views/DeviceGroup.js diff --git a/forge/db/views/DeviceGroup.js b/forge/db/views/DeviceGroup.js new file mode 100644 index 000000000..a37305da3 --- /dev/null +++ b/forge/db/views/DeviceGroup.js @@ -0,0 +1,65 @@ +module.exports = function (app) { + // TODO: add schema for DeviceGroupSummary when mature + // app.addSchema({ + // $id: 'DeviceGroupSummary', + // type: 'object', + // properties: { + // id: { type: 'string' }, + // name: { type: 'string' }, + // description: { type: 'string' }, + // model: { type: 'string' } + // } + // }) + function deviceGroupSummary (group) { + // if (Object.hasOwn(group, 'get')) { + // group = group.get({ plain: true }) + // } + if (group.toJSON) { + group = group.toJSON() + } + const result = { + id: group.hashid, + name: group.name, + description: group.description, + deviceCount: group.deviceCount || 0 + } + return result + } + + // TODO: add schema for DeviceGroup when mature + // app.addSchema({ + // $id: 'DeviceGroup', + // type: 'object', + // allOf: [{ $ref: 'DeviceGroupSummary' }], + // properties: { + // createdAt: { type: 'string' }, + // updatedAt: { type: 'string' }, + // DeviceGroup: { type: 'object', additionalProperties: true } + // }, + // additionalProperties: true + // }) + function deviceGroup (group) { + if (group) { + let item = group + if (item.toJSON) { + item = item.toJSON() + } + const filtered = { + id: item.hashid, + name: item.name, + description: item.description, + 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) : [] + } + return filtered + } else { + return null + } + } + + return { + deviceGroup, + deviceGroupSummary + } +} From b423241a2adb97c6fcfa227ccd21de94b8db3f48 Mon Sep 17 00:00:00 2001 From: Stephen McLaughlin <44235289+Steve-Mcl@users.noreply.github.com> Date: Sun, 3 Dec 2023 11:15:06 +0000 Subject: [PATCH 07/85] re-order DeviceGroup index position --- forge/db/models/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/forge/db/models/index.js b/forge/db/models/index.js index 9bcabc0a9..6b53593f9 100644 --- a/forge/db/models/index.js +++ b/forge/db/models/index.js @@ -70,6 +70,7 @@ const modelTypes = [ 'AccessToken', 'AuthClient', 'Device', + 'DeviceGroup', 'DeviceSettings', 'StorageFlow', 'StorageCredentials', @@ -77,8 +78,7 @@ const modelTypes = [ 'StorageSession', 'StorageLibrary', 'AuditLog', - 'BrokerClient', - 'DeviceGroup' + 'BrokerClient' ] // A local map of the known models. From be47ffc0f089f64b9d862105981228d43648b0fa Mon Sep 17 00:00:00 2001 From: Stephen McLaughlin <44235289+Steve-Mcl@users.noreply.github.com> Date: Sun, 3 Dec 2023 11:23:30 +0000 Subject: [PATCH 08/85] add DeviceGroup controller --- forge/db/controllers/DeviceGroup.js | 231 ++++++++++++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 forge/db/controllers/DeviceGroup.js diff --git a/forge/db/controllers/DeviceGroup.js b/forge/db/controllers/DeviceGroup.js new file mode 100644 index 000000000..c20e82ebe --- /dev/null +++ b/forge/db/controllers/DeviceGroup.js @@ -0,0 +1,231 @@ +const { Op } = require('sequelize') + +const { ControllerError } = require('../../lib/errors') +class DeviceGroupMembershipValidationError extends ControllerError { + /** + * @param {string} code + * @param {string} message + * @param {number} statusCode + * @param {Object} options + */ + constructor (code, message, statusCode, options) { + super(code, message, statusCode, options) + this.name = 'DeviceGroupMembershipValidationError' + } +} + +module.exports = { + + /** + * Create a Device Group + * @param {import("../../forge").ForgeApplication} app The application object + * @param {string} name The name of the Device Group + * @param {Object} options + * @param {Object} [options.application] The application this Device Group will belong to + * @param {string} [options.description] The description of the Device Group + * @returns {Promise} The created Device Group + */ + createDeviceGroup: async function (app, name, { application = null, description } = {}) { + // Create a Device Group that devices can be linked to + // * name is required + // * application, description are optional + // * FUTURE: colors (background, border, text) and icon will optional + + if (typeof name !== 'string' || name.length === 0) { + throw new Error('Tag name is required') + } + return await app.db.models.DeviceGroup.create({ + name, + description, + ApplicationId: application?.id + }) + }, + + updateDeviceGroup: async function (app, deviceGroup, { name = undefined, description = undefined } = {}) { + // * deviceGroup is required. + // * name, description, color are optional + if (!deviceGroup) { + throw new Error('DeviceGroup is required') + } + let changed = false + if (typeof name !== 'undefined') { + if (typeof name !== 'string' || name.length === 0) { + throw new Error('Tag name is required') + } + deviceGroup.name = name + changed = true + } + if (typeof description !== 'undefined') { + deviceGroup.description = description + changed = true + } + if (changed) { + await deviceGroup.save() + await deviceGroup.reload() + } + return deviceGroup + }, + + updateDeviceGroupMembership: async function (app, deviceGroup, { addDevices, removeDevices, setDevices } = {}) { + // * deviceGroup is required. The object must be a Sequelize model instance and must include the Devices + // * addDevices, removeDevices, setDevices are optional + // * if setDevices is provided, this will be used to set the devices assigned to the group, removing any devices that are not in the set + // * if addDevices is provided, these devices will be added to the group + // * if removeDevices is provided, these devices will be removed from the group + // if a device appears in both addDevices and removeDevices, it will be removed from the group (remove occurs after add) + if (!setDevices && !addDevices && !removeDevices) { + return // nothing to do + } + if (!deviceGroup || typeof deviceGroup !== 'object') { + throw new Error('DeviceGroup is required') + } + let actualRemoveDevices = [] + let actualAddDevices = [] + const currentMembers = await deviceGroup.getDevices() + // from this point on, all IDs need to be numeric (convert as needed) + const currentMemberIds = deviceListToIds(currentMembers, app.db.models.Device.decodeHashid) + setDevices = setDevices && deviceListToIds(setDevices, app.db.models.Device.decodeHashid) + addDevices = addDevices && deviceListToIds(addDevices, app.db.models.Device.decodeHashid) + removeDevices = removeDevices && deviceListToIds(removeDevices, app.db.models.Device.decodeHashid) + + // setDevices is an atomic operation, it will replace the current list of devices with the specified list + if (typeof setDevices !== 'undefined') { + // create a list of devices that are currently assigned to the group, minus the devices in the set, these are the ones to remove + actualRemoveDevices = currentMemberIds.filter(d => !setDevices.includes(d)) + // create a list of devices that are in the set, minus the devices that are currently assigned to the group, these are the ones to add + actualAddDevices = setDevices.filter(d => !currentMemberIds.includes(d)) + } else { + if (typeof removeDevices !== 'undefined') { + actualRemoveDevices = currentMemberIds.filter(d => removeDevices.includes(d)) + } + if (typeof addDevices !== 'undefined') { + actualAddDevices = addDevices.filter(d => !currentMemberIds.includes(d)) + } + } + + // wrap the dual operation in a transaction to avoid inconsistent state + const t = await app.db.sequelize.transaction() + try { + // add devices + if (actualAddDevices.length > 0) { + await this.assignDevicesToGroup(app, deviceGroup, actualAddDevices) + } + // remove devices + if (actualRemoveDevices.length > 0) { + await this.removeDevicesFromGroup(app, deviceGroup, actualRemoveDevices) + } + // commit the transaction + await t.commit() + } catch (err) { + // Rollback transaction if any errors were encountered + await t.rollback() + // if the error is a DeviceGroupMembershipValidationError, rethrow it + if (err instanceof DeviceGroupMembershipValidationError) { + throw err + } + // otherwise, throw a friendly error message along with the original error + throw new Error(`Failed to update device group membership: ${err.message}`) + } + }, + + assignDevicesToGroup: async function (app, deviceGroup, deviceList) { + const deviceIds = await validateDeviceList(app, deviceGroup, deviceList, null) + await app.db.models.Device.update({ DeviceGroupId: deviceGroup.id }, { where: { id: deviceIds.addList } }) + }, + + /** + * Remove 1 or more devices from the specified DeviceGroup + * @param {*} app The application object + * @param {*} deviceGroupId The device group id + * @param {*} deviceList A list of devices to remove from the group + */ + removeDevicesFromGroup: async function (app, deviceGroup, deviceList) { + const deviceIds = await validateDeviceList(app, deviceGroup, null, deviceList) + // null every device.DeviceGroupId row in device table where the id === deviceGroupId and device.id is in the deviceList + await app.db.models.Device.update({ DeviceGroupId: null }, { where: { id: deviceIds.removeList, DeviceGroupId: deviceGroup.id } }) + } +} + +/** + * Convert a list of devices to a list of device ids + * @param {Object[]|String[]|Number[]} deviceList List of devices to convert to ids + * @param {Function} decoderFn The decoder function to use on hashes + * @returns {Number[]} Array of device IDs + */ +function deviceListToIds (deviceList, decoderFn) { + // Convert a list of devices (object|id|hash) to a list of device ids + const ids = deviceList?.map(device => { + let id = device + if (typeof device === 'string') { + [id] = decoderFn(device) + } else if (typeof device === 'object') { + id = device.id + } + return id + }) + return ids +} + +/** + * Verify devices are suitable for the specified group: + * + * * All devices in the list must either have DeviceGroupId===null or DeviceGroupId===deviceGroupId + * * All devices in the list must belong to the same Application as the DeviceGroup + * * All devices in the list must belong to the same Team as the DeviceGroup + * @param {*} app The application object + * @param {*} deviceGroupId The device group id + * @param {*} deviceList A list of devices to verify + */ +async function validateDeviceList (app, deviceGroup, addList, removeList) { + // check to ensure all devices in deviceList are not assigned to any group before commencing + // Assign 1 or more devices to a DeviceGroup + if (!deviceGroup || typeof deviceGroup !== 'object') { + throw new Error('DeviceGroup is required') + } + + // reload with the Application association if not already loaded + if (!deviceGroup.Application) { + await deviceGroup.reload({ include: [{ model: app.db.models.Application }] }) + } + + const teamId = deviceGroup.Application.TeamId + if (!teamId) { + throw new Error('DeviceGroup must belong to an Application that belongs to a Team') + } + + const deviceIds = { + addList: addList && deviceListToIds(addList, app.db.models.Device.decodeHashid), + removeList: removeList && deviceListToIds(removeList, app.db.models.Device.decodeHashid) + } + const deviceGroupId = deviceGroup.id + if (deviceIds.addList) { + const okCount = await app.db.models.Device.count({ + where: { + id: deviceIds.addList, + [Op.or]: [ + { DeviceGroupId: null }, + { DeviceGroupId: deviceGroupId } + ], + ApplicationId: deviceGroup.ApplicationId, + TeamId: teamId + } + }) + if (okCount !== deviceIds.addList.length) { + throw new DeviceGroupMembershipValidationError('invalid_input', 'One or more devices cannot be added to the group', 400) + } + } + if (deviceIds.removeList) { + const okCount = await app.db.models.Device.count({ + where: { + id: deviceIds.removeList, + DeviceGroupId: deviceGroupId, + ApplicationId: deviceGroup.ApplicationId, + TeamId: teamId + } + }) + if (okCount !== deviceIds.removeList.length) { + throw new DeviceGroupMembershipValidationError('invalid_input', 'One or more devices cannot be removed from the group', 400) + } + } + return deviceIds +} From 9420f146462ce40ce08c9e2e449b3e37a3ecc571 Mon Sep 17 00:00:00 2001 From: Stephen McLaughlin <44235289+Steve-Mcl@users.noreply.github.com> Date: Sun, 3 Dec 2023 11:29:25 +0000 Subject: [PATCH 09/85] delete erronous import --- forge/db/models/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/forge/db/models/index.js b/forge/db/models/index.js index 6b53593f9..53843787d 100644 --- a/forge/db/models/index.js +++ b/forge/db/models/index.js @@ -48,7 +48,6 @@ const { Model, DataTypes } = require('sequelize') const { getHashId } = require('../utils') -const DeviceGroup = require('./DeviceGroup') // The models that should be loaded const modelTypes = [ From 8b61c24cfd88a62b30f1933b9a4f6ed266072dc9 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Sun, 3 Dec 2023 11:39:47 +0000 Subject: [PATCH 10/85] Update TestModelFactory to use description instead of model --- test/lib/TestModelFactory.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/lib/TestModelFactory.js b/test/lib/TestModelFactory.js index 2694b8714..116cc7af3 100644 --- a/test/lib/TestModelFactory.js +++ b/test/lib/TestModelFactory.js @@ -178,7 +178,7 @@ module.exports = class TestModelFactory { async createApplicationDeviceGroup (deviceGroupDetails, application) { const defaultApplicationDetails = { name: 'unnamed-application-device-group', - model: 'device' + description: 'an unnamed device group' } return await this.forge.db.models.DeviceGroup.create({ From 207f61948eab67767a42f719d2bdb68199d26b06 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 4 Dec 2023 09:43:14 +0000 Subject: [PATCH 11/85] load DeviceGroup controller --- forge/db/controllers/index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/forge/db/controllers/index.js b/forge/db/controllers/index.js index 869a99151..aeb82a4df 100644 --- a/forge/db/controllers/index.js +++ b/forge/db/controllers/index.js @@ -24,6 +24,7 @@ const modelTypes = [ 'ProjectTemplate', 'ProjectSnapshot', 'Device', + 'DeviceGroup', 'BrokerClient', 'StorageCredentials', 'StorageFlows', From 7254305cddfda448445c3cdb0eba6fd03c7cf266 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 4 Dec 2023 11:42:26 +0000 Subject: [PATCH 12/85] fix failing postgres tests (byId sub-clause) --- forge/db/models/DeviceGroup.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/forge/db/models/DeviceGroup.js b/forge/db/models/DeviceGroup.js index 6c2df5b78..f4fede21f 100644 --- a/forge/db/models/DeviceGroup.js +++ b/forge/db/models/DeviceGroup.js @@ -36,8 +36,7 @@ module.exports = { model: M.Device, attributes: ['hashid', 'id', 'name', 'TeamId', 'ApplicationId', 'ProjectId', 'ownerType'], where: { - ApplicationId: literal('"DeviceGroup"."ApplicationId"'), - DeviceGroupId: literal('"DeviceGroup"."id"') + ApplicationId: literal('"Devices"."ApplicationId" = "DeviceGroup"."ApplicationId"') }, required: false } From 97ae431acfc397e5595d6595e96066e5964e4e6b Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 4 Dec 2023 12:15:18 +0000 Subject: [PATCH 13/85] improve test coverage --- forge/db/models/DeviceGroup.js | 17 +--------- .../unit/forge/routes/api/application_spec.js | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/forge/db/models/DeviceGroup.js b/forge/db/models/DeviceGroup.js index f4fede21f..20d896dce 100644 --- a/forge/db/models/DeviceGroup.js +++ b/forge/db/models/DeviceGroup.js @@ -58,14 +58,7 @@ module.exports = { } }) }, - forApplication: async function (pagination = {}, applicationIdOrHash) { - let id = applicationIdOrHash - if (typeof applicationIdOrHash === 'string') { - id = M.Application.decodeHashid(applicationIdOrHash) - } - return self.getAll(pagination || {}, { ApplicationId: id }) - }, - getAll: async (pagination = {}, where = {}, { includeApplication = false } = {}) => { + getAll: async (pagination = {}, where = {}) => { const limit = parseInt(pagination.limit) || 1000 if (pagination.cursor) { pagination.cursor = M.DeviceGroup.decodeHashid(pagination.cursor) @@ -73,13 +66,6 @@ module.exports = { if (where.ApplicationId && typeof where.ApplicationId === 'string') { where.ApplicationId = M.Application.decodeHashid(where.ApplicationId) } - const include = [] - if (includeApplication) { - include.push({ - model: M.Application, - attributes: ['hashid', 'id', 'name', 'TeamId'] - }) - } const [rows, count] = await Promise.all([ this.findAll({ where: buildPaginationSearchClause(pagination, where, ['DeviceGroup.name', 'DeviceGroup.description']), @@ -96,7 +82,6 @@ module.exports = { ] ] }, - include, order: [['id', 'ASC']], limit }), diff --git a/test/unit/forge/routes/api/application_spec.js b/test/unit/forge/routes/api/application_spec.js index 1e02e6ef7..e49e6d036 100644 --- a/test/unit/forge/routes/api/application_spec.js +++ b/test/unit/forge/routes/api/application_spec.js @@ -821,6 +821,38 @@ describe('Application API', function () { response.statusCode.should.equal(200) }) + it('Can get a specific group and associated devices', async function () { + const sid = await login('chris', 'ccPassword') + const application = await factory.createApplication({ name: generateName('app') }, TestObjects.BTeam) + await factory.createApplicationDeviceGroup({ name: generateName('device-group') }, application) + const deviceGroup = await factory.createApplicationDeviceGroup({ name: generateName('device-group') }, application) + const device1 = await factory.createDevice({ name: generateName('device-1') }, TestObjects.BTeam, null, application) + const device2 = await factory.createDevice({ name: generateName('device-2') }, TestObjects.BTeam, null, application) + await app.db.controllers.DeviceGroup.updateDeviceGroupMembership(deviceGroup, { addDevices: [device1, device2] }) + + const response = await app.inject({ + method: 'GET', + url: `/api/v1/applications/${application.hashid}/devicegroups/${deviceGroup.hashid}`, + cookies: { sid } + }) + + response.statusCode.should.equal(200) + + // check the response + const result = response.json() + result.should.have.property('id', deviceGroup.hashid) + result.should.have.property('name', deviceGroup.name) + result.should.have.property('description', deviceGroup.description) + result.should.have.property('devices') + result.devices.should.have.length(2) + // ensure one of the 2 devices matches device1.hashid + const device1Result = result.devices.find((device) => device.id === device1.hashid) + should(device1Result).be.an.Object().and.not.be.null() + // ensure one of the 2 devices matches device2.hashid + const device2Result = result.devices.find((device) => device.id === device2.hashid) + should(device2Result).be.an.Object().and.not.be.null() + }) + it('Non Member can not read device groups', async function () { const sid = await login('dave', 'ddPassword') const application = await factory.createApplication({ name: generateName('app') }, TestObjects.BTeam) From dbab7ef9a21cae43e8f9728a54f357c553203b9c Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 4 Dec 2023 13:17:58 +0000 Subject: [PATCH 14/85] Test device group pagination and 404 handling --- .../unit/forge/routes/api/application_spec.js | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/test/unit/forge/routes/api/application_spec.js b/test/unit/forge/routes/api/application_spec.js index e49e6d036..e4ac64e55 100644 --- a/test/unit/forge/routes/api/application_spec.js +++ b/test/unit/forge/routes/api/application_spec.js @@ -807,6 +807,25 @@ describe('Application API', function () { result.groups[0].should.have.property('description', deviceGroup.description) }) + it('Paginates device groups', async function () { + const sid = await login('bob', 'bbPassword') + const application = await factory.createApplication({ name: generateName('app') }, TestObjects.BTeam) + await factory.createApplicationDeviceGroup({ name: generateName('device-group'), description: 'a description' }, application) + await factory.createApplicationDeviceGroup({ name: generateName('device-group'), description: 'a description' }, application) + + const response = await app.inject({ + method: 'GET', + url: `/api/v1/applications/${application.hashid}/devicegroups?limit=1`, + cookies: { sid } + }) + + response.statusCode.should.equal(200) + + const result = response.json() + result.should.have.property('groups') + result.groups.should.have.length(1) + }) + it('Non Owner can read device groups', async function () { const sid = await login('chris', 'ccPassword') const application = await factory.createApplication({ name: generateName('app') }, TestObjects.BTeam) @@ -853,6 +872,22 @@ describe('Application API', function () { should(device2Result).be.an.Object().and.not.be.null() }) + it('404s when getting a specific group that does not exist', async function () { + const sid = await login('chris', 'ccPassword') + const application = await factory.createApplication({ name: generateName('app') }, TestObjects.BTeam) + + const response = await app.inject({ + method: 'GET', + url: `/api/v1/applications/${application.hashid}/devicegroups/doesNotExist`, + cookies: { sid } + }) + + response.statusCode.should.equal(404) + const result = response.json() + result.should.have.property('error') + result.should.have.property('code', 'not_found') + }) + it('Non Member can not read device groups', async function () { const sid = await login('dave', 'ddPassword') const application = await factory.createApplication({ name: generateName('app') }, TestObjects.BTeam) From 808469312745d1854ad8d7ea2ffdb496f64d99e2 Mon Sep 17 00:00:00 2001 From: Stephen McLaughlin <44235289+Steve-Mcl@users.noreply.github.com> Date: Mon, 4 Dec 2023 13:34:57 +0000 Subject: [PATCH 15/85] Update forge/db/models/DeviceGroup.js Co-authored-by: Pez Cuckow --- forge/db/models/DeviceGroup.js | 1 - 1 file changed, 1 deletion(-) diff --git a/forge/db/models/DeviceGroup.js b/forge/db/models/DeviceGroup.js index 20d896dce..f50122090 100644 --- a/forge/db/models/DeviceGroup.js +++ b/forge/db/models/DeviceGroup.js @@ -16,7 +16,6 @@ module.exports = { }, associations: function (M) { this.belongsTo(M.Application) - // this.belongsTo(M.ProjectSnapshot, { as: 'targetSnapshot' }) this.hasMany(M.Device) }, finders: function (M) { From bee32802b7d3ebf27a366febf6c2ee64f501acec Mon Sep 17 00:00:00 2001 From: Stephen McLaughlin <44235289+Steve-Mcl@users.noreply.github.com> Date: Mon, 4 Dec 2023 14:34:10 +0000 Subject: [PATCH 16/85] extract literals to single common literal --- forge/db/models/DeviceGroup.js | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/forge/db/models/DeviceGroup.js b/forge/db/models/DeviceGroup.js index f50122090..4781b4ce3 100644 --- a/forge/db/models/DeviceGroup.js +++ b/forge/db/models/DeviceGroup.js @@ -20,6 +20,14 @@ module.exports = { }, 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) { @@ -43,14 +51,7 @@ module.exports = { attributes: { include: [ [ - literal(`( - SELECT COUNT(*) - FROM "Devices" AS "device" - WHERE - "device"."DeviceGroupId" = "DeviceGroup"."id" - AND - "device"."ApplicationId" = "DeviceGroup"."ApplicationId" - )`), + deviceCountLiteral, 'deviceCount' ] ] @@ -71,12 +72,7 @@ module.exports = { attributes: { include: [ [ - literal(`( - SELECT COUNT(*) - FROM "Devices" AS "device" - WHERE - "device"."DeviceGroupId" = "DeviceGroup"."id" - )`), + deviceCountLiteral, 'deviceCount' ] ] From 20e7bfb0fd412a14e13b6b6fb34c2fbf433840e8 Mon Sep 17 00:00:00 2001 From: Stephen McLaughlin <44235289+Steve-Mcl@users.noreply.github.com> Date: Mon, 4 Dec 2023 14:34:45 +0000 Subject: [PATCH 17/85] correct clause for device include --- forge/db/models/DeviceGroup.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/forge/db/models/DeviceGroup.js b/forge/db/models/DeviceGroup.js index 4781b4ce3..eaa85aa9c 100644 --- a/forge/db/models/DeviceGroup.js +++ b/forge/db/models/DeviceGroup.js @@ -43,7 +43,7 @@ module.exports = { model: M.Device, attributes: ['hashid', 'id', 'name', 'TeamId', 'ApplicationId', 'ProjectId', 'ownerType'], where: { - ApplicationId: literal('"Devices"."ApplicationId" = "DeviceGroup"."ApplicationId"') + ApplicationId: literal('"Devices"."ApplicationId" = "Application"."id"') }, required: false } From f854a424b6c72c9c07c3f1912e96e767425d30a6 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 4 Dec 2023 15:47:52 +0000 Subject: [PATCH 18/85] Cascade deletion of Application to DeviceGroup --- forge/db/migrations/20231202-01-add-device-devicegroup.js | 4 ++-- forge/db/models/Application.js | 2 +- forge/db/models/DeviceGroup.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/forge/db/migrations/20231202-01-add-device-devicegroup.js b/forge/db/migrations/20231202-01-add-device-devicegroup.js index a18ed8c13..c8ebf8ffc 100644 --- a/forge/db/migrations/20231202-01-add-device-devicegroup.js +++ b/forge/db/migrations/20231202-01-add-device-devicegroup.js @@ -39,8 +39,8 @@ module.exports = { model: 'Applications', key: 'id' }, - onUpdate: 'CASCADE', - onDelete: 'SET NULL' + onDelete: 'CASCADE', + onUpdate: 'CASCADE' } }) diff --git a/forge/db/models/Application.js b/forge/db/models/Application.js index 2a19e611c..9418510f6 100644 --- a/forge/db/models/Application.js +++ b/forge/db/models/Application.js @@ -16,7 +16,7 @@ module.exports = { this.hasMany(M.Project) this.hasMany(M.Project, { as: 'Instances' }) this.belongsTo(M.Team, { foreignKey: { allowNull: false } }) - this.hasMany(M.DeviceGroup, { foreignKey: { allowNull: true } }) + this.hasMany(M.DeviceGroup, { onDelete: 'CASCADE' }) }, finders: function (M) { return { diff --git a/forge/db/models/DeviceGroup.js b/forge/db/models/DeviceGroup.js index eaa85aa9c..2a4db2f5d 100644 --- a/forge/db/models/DeviceGroup.js +++ b/forge/db/models/DeviceGroup.js @@ -15,7 +15,7 @@ module.exports = { description: { type: DataTypes.TEXT } }, associations: function (M) { - this.belongsTo(M.Application) + this.belongsTo(M.Application, { onDelete: 'CASCADE' }) this.hasMany(M.Device) }, finders: function (M) { From 70047f45799cbe7df15da1ef7ddb6a585991ff33 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 4 Dec 2023 16:18:14 +0000 Subject: [PATCH 19/85] Enable schemas --- forge/db/views/DeviceGroup.js | 44 ++- forge/routes/api/applicationDeviceGroup.js | 321 ++++++++++----------- 2 files changed, 171 insertions(+), 194 deletions(-) diff --git a/forge/db/views/DeviceGroup.js b/forge/db/views/DeviceGroup.js index a37305da3..448dd6d0a 100644 --- a/forge/db/views/DeviceGroup.js +++ b/forge/db/views/DeviceGroup.js @@ -1,15 +1,14 @@ module.exports = function (app) { - // TODO: add schema for DeviceGroupSummary when mature - // app.addSchema({ - // $id: 'DeviceGroupSummary', - // type: 'object', - // properties: { - // id: { type: 'string' }, - // name: { type: 'string' }, - // description: { type: 'string' }, - // model: { type: 'string' } - // } - // }) + app.addSchema({ + $id: 'DeviceGroupSummary', + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + description: { type: 'string' }, + model: { type: 'string' } + } + }) function deviceGroupSummary (group) { // if (Object.hasOwn(group, 'get')) { // group = group.get({ plain: true }) @@ -26,18 +25,17 @@ module.exports = function (app) { return result } - // TODO: add schema for DeviceGroup when mature - // app.addSchema({ - // $id: 'DeviceGroup', - // type: 'object', - // allOf: [{ $ref: 'DeviceGroupSummary' }], - // properties: { - // createdAt: { type: 'string' }, - // updatedAt: { type: 'string' }, - // DeviceGroup: { type: 'object', additionalProperties: true } - // }, - // additionalProperties: true - // }) + app.addSchema({ + $id: 'DeviceGroup', + type: 'object', + allOf: [{ $ref: 'DeviceGroupSummary' }], + properties: { + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, + DeviceGroup: { type: 'object', additionalProperties: true } + }, + additionalProperties: true + }) function deviceGroup (group) { if (group) { let item = group diff --git a/forge/routes/api/applicationDeviceGroup.js b/forge/routes/api/applicationDeviceGroup.js index 6a40496a7..2ccbcadd3 100644 --- a/forge/routes/api/applicationDeviceGroup.js +++ b/forge/routes/api/applicationDeviceGroup.js @@ -60,32 +60,31 @@ module.exports = async function (app) { * @memberof forge.routes.api.application */ app.get('/', { - preHandler: app.needsPermission('application:devicegroup:list') - // TODO: When mature and ready to be exposed - // schema: { - // summary: 'Get a list of device groups in an application', - // tags: ['Applications'], - // query: { $ref: 'PaginationParams' }, - // params: { - // type: 'object', - // properties: { - // applicationId: { type: 'string' } - // } - // }, - // response: { - // 200: { - // type: 'object', - // properties: { - // meta: { $ref: 'PaginationMeta' }, - // count: { type: 'number' }, - // groups: { type: 'array', items: { $ref: 'DeviceGroup' } } - // } - // }, - // '4xx': { - // $ref: 'APIError' - // } - // } - // } + preHandler: app.needsPermission('application:devicegroup:list'), + schema: { + summary: 'Get a list of device groups in an application', + tags: ['Applications'], + query: { $ref: 'PaginationParams' }, + params: { + type: 'object', + properties: { + applicationId: { type: 'string' } + } + }, + response: { + 200: { + type: 'object', + properties: { + meta: { $ref: 'PaginationMeta' }, + count: { type: 'number' }, + groups: { type: 'array', items: { $ref: 'DeviceGroupSummary' } } + } + }, + '4xx': { + $ref: 'APIError' + } + } + } }, async (request, reply) => { const paginationOptions = app.db.controllers.Device.getDevicePaginationOptions(request) @@ -109,38 +108,33 @@ module.exports = async function (app) { * @memberof forge.routes.api.application */ app.post('/', { - preHandler: app.needsPermission('application:devicegroup:create') - // TODO: When mature and ready to be exposed - // schema: { - // summary: 'Add a new Device Group to an Application', - // tags: ['Applications'], - // body: { - // type: 'object', - // properties: { - // name: { type: 'string' }, - // description: { type: 'string' }, - // devices: { type: 'array', items: { type: 'DeviceGroup' } } - // }, - // required: ['name'] - // }, - // params: { - // type: 'object', - // properties: { - // applicationId: { type: 'string' } - // } - // }, - // response: { - // 201: { - // type: 'object', - // properties: { - // group: { $ref: 'DeviceGroup' } - // } - // }, - // '4xx': { - // $ref: 'APIError' - // } - // } - // } + preHandler: app.needsPermission('application:devicegroup:create'), + schema: { + summary: 'Add a new Device Group to an Application', + tags: ['Applications'], + body: { + type: 'object', + properties: { + name: { type: 'string' }, + description: { type: 'string' } + }, + required: ['name'] + }, + params: { + type: 'object', + properties: { + applicationId: { type: 'string' } + } + }, + response: { + 201: { + $ref: 'DeviceGroupSummary' + }, + '4xx': { + $ref: 'APIError' + } + } + } }, async (request, reply) => { const application = request.application const name = request.body.name @@ -158,39 +152,34 @@ module.exports = async function (app) { * @memberof forge.routes.api.application */ app.put('/:groupId', { - preHandler: app.needsPermission('application:devicegroup:update') - // TODO: When mature and ready to be exposed - // schema: { - // summary: 'Update a Device Group', - // tags: ['Applications'], - // body: { - // type: 'object', - // properties: { - // name: { type: 'string' }, - // description: { type: 'string' }, - // color: { type: 'string' }, - // icon: { type: 'string' } - // } - // }, - // params: { - // type: 'object', - // properties: { - // applicationId: { type: 'string' }, - // groupId: { type: 'string' } - // } - // }, - // response: { - // 200: { - // type: 'object', - // properties: { - // group: { $ref: 'DeviceGroup' } - // } - // }, - // '4xx': { - // $ref: 'APIError' - // } - // } - // } + preHandler: app.needsPermission('application:devicegroup:update'), + schema: { + summary: 'Update a Device Group', + tags: ['Applications'], + body: { + type: 'object', + properties: { + name: { type: 'string' }, + description: { type: 'string' } + } + }, + params: { + type: 'object', + properties: { + applicationId: { type: 'string' }, + groupId: { type: 'string' } + } + }, + response: { + 200: { + type: 'object', + additionalProperties: false + }, + '4xx': { + $ref: 'APIError' + } + } + } }, async (request, reply) => { const group = request.deviceGroup const name = request.body.name @@ -207,30 +196,26 @@ module.exports = async function (app) { * @memberof forge.routes.api.application */ app.get('/:groupId', { - preHandler: app.needsPermission('application:devicegroup:read') - // TODO: When mature and ready to be exposed - // schema: { - // summary: 'Get a specific deviceGroup', - // tags: ['Applications'], - // params: { - // type: 'object', - // properties: { - // applicationId: { type: 'string' }, - // groupId: { type: 'string' } - // } - // }, - // response: { - // 200: { - // type: 'object', - // properties: { - // group: { $ref: 'DeviceGroup' } - // } - // }, - // '4xx': { - // $ref: 'APIError' - // } - // } - // } + preHandler: app.needsPermission('application:devicegroup:read'), + schema: { + summary: 'Get a specific deviceGroup', + tags: ['Applications'], + params: { + type: 'object', + properties: { + applicationId: { type: 'string' }, + groupId: { type: 'string' } + } + }, + response: { + 200: { + $ref: 'DeviceGroup' + }, + '4xx': { + $ref: 'APIError' + } + } + } }, async (request, reply) => { const group = request.deviceGroup // already loaded in preHandler const groupView = app.db.views.DeviceGroup.deviceGroup(group) @@ -244,38 +229,35 @@ module.exports = async function (app) { * @memberof forge.routes.api.application */ app.patch('/:groupId', { - preHandler: app.needsPermission('application:devicegroup:membership:update') - // TODO: When mature and ready to be exposed - // schema: { - // summary: 'Update Device Group membership', - // tags: ['Applications'], - // body: { - // type: 'object', - // properties: { - // add: { type: 'array', items: { type: 'string' } }, - // remove: { type: 'array', items: { type: 'string' } }, - // set: { type: 'array', items: { type: 'string' } } - // } - // }, - // params: { - // type: 'object', - // properties: { - // applicationId: { type: 'string' }, - // groupId: { type: 'string' } - // } - // }, - // response: { - // 200: { - // type: 'object', - // properties: { - // group: { $ref: 'DeviceGroup' } - // } - // }, - // '4xx': { - // $ref: 'APIError' - // } - // } - // } + preHandler: app.needsPermission('application:devicegroup:membership:update'), + schema: { + summary: 'Update Device Group membership', + tags: ['Applications'], + body: { + type: 'object', + properties: { + add: { type: 'array', items: { type: 'string' } }, + remove: { type: 'array', items: { type: 'string' } }, + set: { type: 'array', items: { type: 'string' } } + } + }, + params: { + type: 'object', + properties: { + applicationId: { type: 'string' }, + groupId: { type: 'string' } + } + }, + response: { + 200: { + type: 'object', + additionalProperties: false + }, + '4xx': { + $ref: 'APIError' + } + } + } }, async (request, reply) => { const group = request.deviceGroup const addDevices = request.body.add @@ -299,30 +281,27 @@ module.exports = async function (app) { * @memberof forge.routes.api.application */ app.delete('/:groupId', { - preHandler: app.needsPermission('application:devicegroup:delete') - // TODO: When mature and ready to be exposed - // schema: { - // summary: 'Delete a Device Group', - // tags: ['Applications'], - // params: { - // type: 'object', - // properties: { - // applicationId: { type: 'string' }, - // groupId: { type: 'string' } - // } - // }, - // response: { - // 200: { - // type: 'object', - // properties: { - // group: { $ref: 'DeviceGroup' } - // } - // }, - // '4xx': { - // $ref: 'APIError' - // } - // } - // } + preHandler: app.needsPermission('application:devicegroup:delete'), + schema: { + summary: 'Delete a Device Group', + tags: ['Applications'], + params: { + type: 'object', + properties: { + applicationId: { type: 'string' }, + groupId: { type: 'string' } + } + }, + response: { + 200: { + type: 'object', + additionalProperties: false + }, + '4xx': { + $ref: 'APIError' + } + } + } }, async (request, reply) => { const group = request.deviceGroup await group.destroy() From 46fa018338222f97921a5563cfaa0a2e49b586b6 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 4 Dec 2023 16:20:39 +0000 Subject: [PATCH 20/85] remove helpful stuff --- forge/routes/api/applicationDeviceGroup.js | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/forge/routes/api/applicationDeviceGroup.js b/forge/routes/api/applicationDeviceGroup.js index 2ccbcadd3..d20664399 100644 --- a/forge/routes/api/applicationDeviceGroup.js +++ b/forge/routes/api/applicationDeviceGroup.js @@ -14,24 +14,6 @@ const { Roles } = require('../../lib/roles.js') * @param {import('../../forge.js').ForgeApplication} app The application instance */ module.exports = async function (app) { - // ### Routes in this file - // GET /api/v1/applications/:applicationId/devicegroups - // - get a list of devicegroups in this application - // POST /api/v1/applications/:applicationId/devicegroups - // - add a new Device Group to an Application - // > body: { name, [description] } - // PUT /api/v1/applications/:applicationId/devicegroups/:groupId - // - update a device group settings - // > body: { name, [description] } - // GET /api/v1/applications/:applicationId/devicegroups/:groupId - // - get a specific deviceGroup (must be assigned to this application) - // PATCH /api/v1/applications/:applicationId/devicegroups/:groupId - // - update Device Group membership - // > OPTION1: body: { add: [deviceIds], remove: [deviceIds] } - // > OPTION2: body: { set: [deviceIds] } - // DELETE /api/v1/applications/:applicationId/devicegroups/:groupId - // - delete app owned deviceGroup - registerPermissions({ 'application:devicegroup:create': { description: 'Create a device group', role: Roles.Owner }, 'application:devicegroup:list': { description: 'List device groups', role: Roles.Member }, From 765a826dc841c77272fed7f3e013d789c9d17d0e Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Fri, 8 Dec 2023 15:11:29 +0000 Subject: [PATCH 21/85] Add frontend APIs --- frontend/src/api/application.js | 80 ++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/frontend/src/api/application.js b/frontend/src/api/application.js index 13a59d2d6..89d1c7ed8 100644 --- a/frontend/src/api/application.js +++ b/frontend/src/api/application.js @@ -262,6 +262,78 @@ const getSnapshots = async (applicationId, cursor, limit, options) => { return res.data } +/** + * Get the specified device group for an application + * @param {string} applicationId - The ID of application to get device groups for + * @param {string} groupId - The ID of the group to get + */ +const getDeviceGroup = async (applicationId, groupId) => { + return client.get(`/api/v1/applications/${applicationId}/devicegroups/${groupId}`).then(res => { + return res.data + }) +} + +/** + * Get all device groups for an application + * @param {string} applicationId - The ID of application to get device groups for + */ +const getDeviceGroups = async (applicationId) => { + return client.get(`/api/v1/applications/${applicationId}/devicegroups`).then(res => { + return res.data + }) +} + +/** + * Create a new device group for an application + * @param {string} applicationId - The ID of application + * @param {string} name + * @param {string} [description] + */ +const createDeviceGroup = async (applicationId, name, description) => { + return client.post(`/api/v1/applications/${applicationId}/devicegroups`, { name, description }).then(res => { + const props = { + 'devicegroup-id': res.data.id, + 'created-at': res.data.createdAt + } + product.capture('$ff-devicegroup-created', props, { + application: applicationId + }) + return res.data + }) +} + +/** + * Delete a device group + * @param {string} applicationId - The ID of application + * @param {string} groupId - The ID of the group + */ +const deleteDeviceGroup = async (applicationId, groupId) => { + return client.delete(`/api/v1/applications/${applicationId}/devicegroups/${groupId}`) +} + +/** + * Update a device group + * @param {string} applicationId - The ID of application + * @param {string} groupId - The ID of the group + * @param {object} group + */ +const updateDeviceGroup = async (applicationId, groupId, name, description) => { + return client.put(`/api/v1/applications/${applicationId}/devicegroups/${groupId}`, { name, description }) +} + +/** + * Update the members of a device group + * @param {string} applicationId - The ID of application + * @param {string} groupId - The ID of the group + * @param {{}} members + * @param {string[]} members.add - Array of device IDs to add to the group + * @param {string[]} members.remove - Array of device IDs to remove from the group + * @param {string[]} members.set - Array of device IDs to set as the only group members + */ +const updateDeviceGroupMembership = async (applicationId, groupId, { add, remove, set } = {}) => { + return client.patch(`/api/v1/applications/${applicationId}/devicegroups/${groupId}`, { add, remove, set }) +} + export default { createApplication, updateApplication, @@ -276,5 +348,11 @@ export default { getPipelines, createPipeline, deletePipeline, - updatePipeline + updatePipeline, + getDeviceGroup, + getDeviceGroups, + createDeviceGroup, + deleteDeviceGroup, + updateDeviceGroup, + updateDeviceGroupMembership } From 94902b1f518157567ea38863cee17721ac695b66 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Fri, 8 Dec 2023 15:11:51 +0000 Subject: [PATCH 22/85] add graphics/icons --- .../src/components/icons/DeviceGroupSolid.js | 46 ++++++++++++++++++ .../components/icons/device-group-solid.svg | 13 +++++ .../application-device-groups.png | Bin 0 -> 4340 bytes .../images/pictograms/device_group_red.png | Bin 0 -> 16901 bytes 4 files changed, 59 insertions(+) create mode 100644 frontend/src/components/icons/DeviceGroupSolid.js create mode 100644 frontend/src/components/icons/device-group-solid.svg create mode 100644 frontend/src/images/empty-states/application-device-groups.png create mode 100644 frontend/src/images/pictograms/device_group_red.png diff --git a/frontend/src/components/icons/DeviceGroupSolid.js b/frontend/src/components/icons/DeviceGroupSolid.js new file mode 100644 index 000000000..2f91523ef --- /dev/null +++ b/frontend/src/components/icons/DeviceGroupSolid.js @@ -0,0 +1,46 @@ +const { createVNode: _createVNode, openBlock: _openBlock, createBlock: _createBlock } = require('vue') + +module.exports = function render (_ctx, _cache) { + return (_openBlock(), _createBlock('svg', { + width: 24, + height: 24, + fill: 'currentFill', + viewBox: '0 0 24 24' + }, [ + (_openBlock(), _createBlock('g', { + 'clip-path': 'url(#clip0_3717_9251)' + }, [ + _createVNode('path', { + d: 'M20 0H4C1.79086 0 0 1.79086 0 4V20C0 22.2091 1.79086 24 4 24H20C22.2091 24 24 22.2091 24 20V4C24 1.79086 22.2091 0 20 0Z', + fill: '#397B7E' + }), + _createVNode('path', { + d: 'M16.2 11.63H11.63V16.2H16.2V11.63Z', + fill: 'white' + }), + _createVNode('path', { + 'fill-rule': 'evenodd', + 'clip-rule': 'evenodd', + d: 'M8.27997 16.18V18.13C8.27997 18.5 8.42997 18.87 8.69997 19.14C8.96997 19.41 9.32997 19.56 9.70997 19.56H11.66V20.67C11.66 20.82 11.72 20.97 11.83 21.08C12.05 21.3 12.43 21.3 12.65 21.08C12.76 20.97 12.82 20.83 12.82 20.67V19.56H15.03V20.67C15.03 20.82 15.09 20.97 15.2 21.08C15.42 21.3 15.8 21.3 16.02 21.08C16.13 20.97 16.19 20.83 16.19 20.67V19.56H18.14C18.51 19.56 18.88 19.41 19.15 19.14C19.42 18.87 19.57 18.51 19.57 18.13V16.18H20.68C20.83 16.18 20.98 16.12 21.09 16.01C21.2 15.9 21.26 15.75 21.26 15.6C21.26 15.45 21.2 15.3 21.09 15.19C20.98 15.08 20.84 15.02 20.68 15.02H19.57V12.81H20.68C20.83 12.81 20.98 12.75 21.09 12.64C21.2 12.53 21.26 12.38 21.26 12.23C21.26 12.08 21.2 11.93 21.09 11.82C20.98 11.71 20.83 11.65 20.68 11.65H19.57V9.69996C19.57 9.32996 19.42 8.95996 19.15 8.68996C18.88 8.41996 18.52 8.26996 18.14 8.26996H12.82V7.15996C12.82 7.00996 12.76 6.85996 12.65 6.74996C12.43 6.52996 12.05 6.52996 11.83 6.74996C11.72 6.85996 11.66 6.99996 11.66 7.15996V8.26996H9.70997C9.32997 8.26996 8.96997 8.41996 8.69997 8.68996C8.43997 8.94996 8.27997 9.31996 8.27997 9.69996V11.65H7.16997C7.01997 11.65 6.86997 11.71 6.75997 11.82C6.64997 11.93 6.58997 12.08 6.58997 12.23C6.58997 12.38 6.64997 12.53 6.75997 12.64C6.86997 12.75 7.01997 12.81 7.16997 12.81H8.27997V16.18ZM18.4 18.39H9.43997V9.42996H18.4V18.39Z', + fill: 'white' + }), + _createVNode('path', { + 'fill-rule': 'evenodd', + 'clip-rule': 'evenodd', + d: 'M8.28997 15.02H6.07997V6.05996H15.04V8.26996H16.2V6.31996C16.2 5.94996 16.05 5.57996 15.78 5.30996C15.52 5.04996 15.15 4.88996 14.77 4.88996H12.82V3.77996C12.82 3.62996 12.76 3.47996 12.65 3.36996C12.43 3.14996 12.05 3.14996 11.83 3.36996C11.72 3.47996 11.66 3.61996 11.66 3.77996V4.88996H9.44997V3.77996C9.44997 3.62996 9.38997 3.47996 9.27997 3.36996C9.05997 3.14996 8.67997 3.14996 8.45997 3.36996C8.34997 3.47996 8.28997 3.61996 8.28997 3.77996V4.88996H6.33997C5.95997 4.88996 5.59997 5.03996 5.32997 5.30996C5.06997 5.56996 4.90997 5.93996 4.90997 6.31996V8.26996H3.79997C3.64997 8.26996 3.49997 8.32996 3.38997 8.43996C3.27997 8.54996 3.21997 8.69996 3.21997 8.84996C3.21997 8.99996 3.27997 9.14996 3.38997 9.25996C3.49997 9.36996 3.64997 9.42996 3.79997 9.42996H4.90997V11.64H3.79997C3.64997 11.64 3.49997 11.7 3.38997 11.81C3.27997 11.92 3.21997 12.06 3.21997 12.22C3.21997 12.38 3.27997 12.52 3.38997 12.63C3.49997 12.74 3.64997 12.8 3.79997 12.8H4.90997V14.75C4.90997 15.12 5.05997 15.49 5.32997 15.76C5.59997 16.03 5.95997 16.18 6.33997 16.18H8.27997V15.02H8.28997Z', + fill: 'white' + }) + ])), + _createVNode('defs', null, [ + _createVNode('clipPath', { + id: 'clip0_3717_9251' + }, [ + _createVNode('rect', { + width: '24', + height: '24', + fill: 'white' + }) + ]) + ]) + ])) +} diff --git a/frontend/src/components/icons/device-group-solid.svg b/frontend/src/components/icons/device-group-solid.svg new file mode 100644 index 000000000..199e43392 --- /dev/null +++ b/frontend/src/components/icons/device-group-solid.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/src/images/empty-states/application-device-groups.png b/frontend/src/images/empty-states/application-device-groups.png new file mode 100644 index 0000000000000000000000000000000000000000..a70580d3c4c08a75bda922667e770f315bb34c49 GIT binary patch literal 4340 zcmYkAcQ~70*vF$P6rtKuBmL1(t7s4u4Yl{E6)}n;RzgK>wNk`hMXXY4ub)+d+Dc;8 z=+Gi<1*K}Ww$_`z*L%I!@42q?ob&wk+~<4U_x=5x6K`s)17hZ71^@scJzY&R+Wdhw z=9qxAw|dwgUD|Zvp)S%70ARWFchF5Qgr5Qc7t8fD)hvPvek_bzE{$+8Zl87i++T`! z98|w4Nlhs>I#18h5)Z%^Uq7dDzFjafrFWAFE!i?%BFB*=5`Dns9}{E4k7&|=H+&Yz zHXO-91Y}uScKEXhK>qXa7E#u(u=Rgq?!EK#$Ez)(PnC=Tz<46nZFKqCs5TL|MKl4C zB>@swmRA7v#$t?)KSDR3mw*9x%E*d<>;$YVAW$UR_T(AgP4cUwXPph^#^gmhg1@O$ z=!E;Ir0<;y)jNpgo{!OL+&|JH9=rxj-#RqI5+32gF~oW1BM=|pnOPA9((^@tskDu% zd2YM)#r)#IJZ9bBM4Zu)T|g`_@oVg5dh5|ugDU5A#^5e5;=H96k*Zmg^Yr9$FKGLZ z_DgZm1D;;j#O0T1ewK^H3FIMB#R?oWfzGai&!5Ae9;X=04n6xEVNRLs9)UA2a>_V3 zlzt|^kO&+Jwl^ZW0aSO%WxNPWB4))uvEDHc+dE7-;_0O@EH5&vOooUbc6kstzAGhQ zLjkFnte@~ez9;vlh-H#(CRvGc%bI4JfdmD4#$I8jQj=W2m8-2CcujK=dkSbQ|HyU4 z=qcWq^U0K9M@b!c!x)Iuw8%uUX0x6jJ^rTobV+r!9x!#0!KsD0ztWv+_ZlDfJ=s9f z?#Y7`P|^rQ9tuhP(2JLt9)sMsX<(SrwUmc0Rtb}{Kz*Z@B3$YC6qQ!&Q-P#F^VWMk6@pG#vXKk&t~tz zA$y0t4oSiP)m4&zayjfWTS@v1dGV*}ap?tai;U%O3h!C$?oD<{3DRO?&AS6LzubU8`Sjqho}u&z^l6enIn<{#lsye1bd zaBb{GxnW&4+K#k@JcqY}Db!>X1g;ZnFh@$^NkQhTy5Lasg4Hi$NX=WtVGU1SY$ykC z#EXxVEO`-(t|tsa`P9)f6e?$}HS16BU*r9aMW$U*=l0U(*}c!6K`Ykc@pycB`RdQr--e z`gvtlP{4i~6poo^IRZp#T+~*0+_5_6JZ~SP5;WB4i}8ErF!J-(zZ|nlD%A^?;(XTA zMXM@KxYdy*|3yx5Ie%4_e)qn!Kt&9~MwXO5?xH`C{i*fKE2#4AMBQ0HAmVzHL2D;7 zA9uPI+EJ6sH!nQIn*{+E zU9H*VF1K6JHvB+^sWifuHJ$tuceWMKm>2}?UbTtes@0c#{d%VQ9Yk>j;G=n|%;OVo zg*jWI=JrVfs^q!TRcVdg@Rn|FEM(t2elX{TO4fq6JoLK#7X-9yPcIt!aloa9_ayCW z%^j7eoDb*+PSgio>SpPL9fo>VYi9tz*)r=q*_x}5qi24j9pjY=uQ>-?ka?%^Q<@jG zQ=!fQj4M9J4=0X)(P@+=Q5WC9`4E1)Z3f#!%3TWG9&as2}P+JA`Kn^@f?EHs{x_1J#gZ<%G8gdXI6%}(VT_3d#jO2fx`U7=8vdWF6c z!87^{6h8{ArtLeGdbrdgo{q6ZmKopnPRjV8Y}=p=Ori>S-o>^RuZzEyruD3bUF$qd zGN@KC!=ggMRgP1+cTkDVn{^oYE=nNjzT>5SQ$GhBwBrCC_;@p6whxFxmiyM-`{#QGc|duBL%Gij+xDlo{1o^_3TOG=GSe%G$SEq;^XPoAmRYfB_AT7_ z2PGk2dD3LZ{tgttVj^@ItaqP%HOW%XPJ?6oIf*1qzZjtgQQ!OB@C3!WZ~hMtNv$w* zvslnUSdIOsKoa0v=41hjpkig43u9)3qDh^Z>;7$xf>QYmoHKO&MsC!&mmShi6zgog zo!|`9xxUC)+O3arbiRTf;dnB|EQ~3bv2R`f!kKg|CAIcvFFdhIs#(9z^j6I*9ZIlm zy1SKHQS(Wwy@BkeGaD-a>erdX)+>0=D_$imq21WoDMviKduTzDHPLxCQ!( zzH7@tnrcT|Fw#NG#XF|+Pmd6U%0dL->T2Cout&Y4ZIa8U$@KQDVFikx&+%+~IfGn82`1mdnlmyi1$T#MK(Oe}an zu9QnLl@eFv&v)W43)$Ck?&h$M)1jlGAvh#~H_*EEy59b{vk)WCZc1mr?<7?Q0bOWx z1DK2@&anzA3e%V)ml{A(d*6OZmasEtT%uDcEdOtMX2A_r-;euxY-5M$f$fSAnEfEx z^<-AHDowm%Xh6!b9_2;Y;pt4~q*YxA9Da*_{}VZye*v+4k`}mbkIV&sQqg=hLU|!0 zhI-A_o0_ru7>rBIfhPah@QuahJH>@~aUIwfzIP^238jne=gjVB)~!c|oVB;3;AoHO zmTZbQl zc2Cn%n`8*>Ox)*g68$DOs<7Vb>h^@T`# zQA0PaUe@L^I>Fvk4GaFo^d0nkzP+}?l$qrr<`5A_vPZsqTdiErSLY(%x_$?2bJZ8* zr%uzhV^i;}ClyRc9%!z2;?-lFVN$4Cy^kfEH{R36&M$HE5ij(MzN-5s7UG-bosK(g zoX*-JUhO^E_yixb#Grd3d|0caj-G`w^?$pBdg&Fv9;N7d-l?T;$LD1XPv{U7Az!}g zHCN2by{%H^OaEE>xDQis#Xm57ty3;x*l8^^mIdsCwCjI#xj7;ZJ{BbD=2~PV|ao!xG7*EO_TAJ;`4+Q9USXyL_m3q-IU*8naR%`+YO%0n&cFppaSy&w8 z?TrZlUAabs1Yza0YnPG153iH7eV%vfb;S@pF?E(`Dy*~Sv=8)VSfgJ3Dil!$k&fh> zL%MnOSS<}neLYV)+yJ7YmgRr`5{xu~Pu`<2nwT0wq%%tIUeuN)eA`vp2~drN8K=*^ zHqhIVkDHslrQ%iQ>3JCO(MwP|p@oOkm`?u;ehoPco~6*w(QUs8&_ZW=f8HN^>rPht zeKrFo|9)j$MvRt3BT7=kCKni;EV4==H(y#@6MtBLmPu==_Jfz;$riT(1O_LVGs=F+ z@T@{8q4d$Yv`nxzwnS62vSS~DcxSd;chxlI zeDj`F(k%54%`uCrv0xmB+<-0{Z($#%V*-dmNTfPUAmgRe-hLu^*Wi#Y)gF$F(7e(r zmkK=*rqUdjc>1@3$=|iQ?btoC;DU-08Rz8`pcg!|0ok`^zsAN ze)kFFn^J+K0MhMRR=yAIwE*--Zx}p1`=!Fycs{B=0Y`M*gp5m!K?aAVCa* zO?VL6uDj1TA0r(AUl0W*jx3sr zQcQy7tTdL91sYshyfatJG{yaY8q2G3W2(u2KXPNrFE-G)xG{u5%trp_LGZ1xG;CuTHt=?=pX;c-a19-;p6T zakGUtn+}}khq|zAQ=Tk!Xg5K0g#-Npn&8k@u&oqsFEZrIZMv$Dgp{3 zIY>rCa?Y?U@8I|9-CMWn{drFnC+(aGJ>5OszwY6c`h8^@N>)k$0FBD+TMqz0!na6( zCW9|qUVR7f<*b{co|~qlwVS88ixrT6=xAYuQL#6FWc9$x{NYpQ?^eM5s&TK2>5_Vn;)|_fRV59S-ubEO#3EgP=kxkJHoWu|_6@Iii){=m zf3%MFy8a#K4vE{W_Wb)Z`Ebv7DP7#t+UwEJnnx?EKK`?wKN(I2^1pmwZn2mXU2yMj zXsGL(P`=UFXFMn&wv~RL*gHztTB}}P49>EIw;bOx~1HTEt0?CXKA^ z)r6afKj+g7T{JyTU=tb+JKP!U*qKBm-as>|8 zgcptUf+w6wDC2P~;1OQrV?F7nv`rEr*d(2upwW@NNJ(div243~oS&jGDaSNZmstd<@Agqgg#DVm^;z#Mw_H{C%pd~O^LSb=js=kxhFjT=e0ud@6rCHIrud3_ zlHCgp)=<*D0oGZArXLsOp?5WmPp#^S*o3S;q8c%X5E%U$Q`6uTEJ2fHAvZ z!3V;mNW}x=t{-C?P?uF-A^-%PosVHovqPHkds;l$C61x?7*J@HQ+DNcz^UsUgm>@; zLgfqFY(ktNN5Lx8psR@-qUGEALe({xIgT?~!i;>2|3=n%_GyEm?ed#%k1eHJ z{NbG#ta7cAQqC?W!FFEH46k&0Vll|w#D7oO1hD8dU%3($9*8AjNZZxpJRZwTY%jkK z0GedS*5BKY4flM{gw_DKH8&mJsA1gvVzAMr?N~F;33go5ab<3S?N@5>0<*3=>(^Si0QeFnHWWkS?<-}@{cUxn4v4U_1V~z> z{jI)M;pUoF@*w~gRl`|+w1k?-MwB{H1AyII58wdb zPhEgjg!x6qGw=oTPg@{P&ghd9_TTA>IV%Rf;r#b-a)Lz%KIl(Q(8&p)PELVLSqS)t z$$y{!dxJT74gP0!kN;bBuz1lcL{KLQ&Jo7HR9GO}5_I*IGT-vAQtEPnHGn-ubNoq_ zD>uYdYQB{}yt#iZ-%VF0sj5A{-TRf!Nhfbx6d%7HV=MZ|W!m!vV{O$jq$IcKWkb#I z??+bV#Cj&l^lN|VrS3CCWnKr+DiXiOjc|Kr-9V^5`!&w6J_7C`ti1tH33LY-h9!o# zrAX-Fl2^j>PxZF_XRq!H`9GBRvo7OWbGcVr)YI=nH5jXK4xYJhMAX^lBQ${S)8Hfy zn6+3be(AQqURLmudRddP(XNXA!2`g;Sw%j+%b;A0FkHQmHhHOox`*@C+3m55TCzof z`2G|M5DYM3?6=j6_h9-$9%08X?Fr%xg3p< zjUFw3Y+%;c&ibIkgK;YZYZz=*m_=E1Mkx$z3|&_d>f5N>k*$D5 zv5m4zk&;6Au)l}`q&HbY4o(>P>2Y$FY4V)V1%$5W)*b%+B1#~?N-}pwQwMT-tKzk9 zS|o!()1Uf0ZtQ1A!M_;XmJkznN&Yr}5bd%50?bg~O_(OX;q@m@%gRGh=mT0#LLw?= zTOsD^XnZa1nS}(N_R;287CWn3-UT-BwT=poexqychgT z2aClux3!eF*>$HhbYy0_ML4HmxsZnC$LnD7SX;?i?G&}e!F;wHeQw_)8k5+=MwdNt z0~Guj^bieQOeGjurC$6{aOgkRZkHc9FgHVT!Y?vDo|+~AVtt@>B4nKZVPr4xoE!l^ z!~KMhe^NS0j{1*i;z^>;x42#`_L7bX6MLoqH%Xu6w*c zyvx?nX?aC1WPUHndLq5s^RZ5mcdNtVr*TnKTdC^qQY&wAW7S@ZSU(aCUee&+-pCF2 zlb5MCWAmRN#ROCT#)}_4DhJhhJ^8BCr*j#wp!^CLB{qF4&Kqy66%(&qUu6}W-0T@n z3w@}~+N<&kAqcYQkr0^Uq2}Ie$F(K@xODERO6D7k_xzZYXZq%EX9(*0Qe#H-Oe2^a zL%YX`dh8aDm-CnLd=NQ1p}j42N{eF}0in>(p7NrCb1AW4PXN#(h-+*$?}q0(*>s#t z#}@kp>9A3=nGIOFE?n+qRg}?3epoqu_$}#rQAtbUoY6}G9L*(o=IEs)%z|xUrN#-V zuNl@bHs#Si3hQ`fJ5qe7>N5$Tu)OsOQprPnME3&AuaYhoH#{-|It1J;4b^_-OuR7B z=#1-|KC`{&&C>oigdBq8AAeymS3J^v$9%!f2_ufetRo>#eYN+$uKyKYG&wUe)S0){ zmYK=Mn@5YkdDc}BC~4JRV*T3HC*O)<*XA(`Vhzmc>SqDz;g+adK6n4#pG(8}+H*M2 zIq_!y?xi4di;G`^0%CW z#K)Y5C45LFT#ef_VO4l6EgRWogf7UrQpB0U9NSuOrm1olR@qp3GZB{9Ix)NQdNP-- z-B^bo4=h1SAY?-8^(_+}pVopYK?3hDq|9Gz7d+z}EC`5UMud0HlhI6Oj6%5lX$)HA zH(_z~RYBXvc9FupKLIu_^MB7*kaoR6$7{D7B}r`D8;>n9Relok?!IM;iL98Dgv9QH zEQC_(lsEG3z!ovHE`$G3W=+#$!SiKB+1>y$26|%zvu@t9D75 z>?hOL0k2HrdS^>@fxh{@kiyD+{kp2bm3^_DeWCJS>8ZN2L(U|XK4zK=b%MP&eS(P` z=bdZ+UjBKpRs@CdyFJ-F$Eb;Lr;ay?aroKTJ%1%VC9#UeaI`ItW_v85biv+xHE(v) zKE-iJc7k`R_y&&u!Ir1xgf{()=Zrc_B=)QUljF?YliFx11go9t8eEopLN*vddZ*u^V>028Z2{?U>_|p zmRfx*xN&W;$%4Aq*5@y&UWp>!+(T>&v2WSVLb4f*F}PWjg>h; z@>35_(_#Lt7lR~ACeF`Wns*sVPE<7sof(^AFs!go920sFeFi?k<_-7?TVZM_$m}jH z0yHe2feQmK@b(;1kAhwz{9o0KbO7~)Fe5S5tO|Xa#S=TVcU0b0J^7kbU|N1@k+~cV zFAF8hMC4$@45%5URq9o-SL6{#YfbRT zEwu;SEK4klysoF7LX@eZ=0e6eY2$TlRDC!fm%#L(H=2yp@ZHEI4>n)QvpdQZXw557 zdnhZ~GheH~`rrt+J0CVXMQezfi-$yGe&$B+&M(?p7Iz*}It1WJfNY;+?>qAwK6J9V z;l9@ytYiq~M8sa>aO#+NYWX`8a@bf`1~2Hm06l#lGZv*mR)0$np7Ix``4ISRc>YmX zYHl>VYTtwjZggUEql1NIO|MEgwm&eadfXJn8PRNc$R!S28QU~~o;$;4!O&$`V(y?s zi_FC9n#A&HZ91Sa51a20iV||UUcm==;Dw9@4Vq$&w_V`s(Cs8qc{N9>AvRCk+3gXN zSMK*ri+Y{28cdtYQCM&ZEgAh@Q10P<FXZ>zb!oo#D9XOpc+k)+`C3aQgcG!fJ6;jB zD~y<)L7?5|Eq^1R%0qUNI{3InRJ6Us2*E?FWS`y+PG6z|O(Cf9g&Y0n)n0!& ztQb}COHa_4`PmfVGgh|gsOXkoNsvvRT}D(tznGQ@ zx`d9j$oYnmX}`h;Iz%NO9h2%B?T3%PQW@Kn3>+0*&C;(~M}qR8@l)@ofMnI5Jo+_n z_D{f!)r{J}@YH_C=qmSwU51l)Ulc}BSZeD6md9Ior zxGcYkK?>zwQQhu$1>crA?6Tz@-G5xcu4zpOK3DqfCF6g?^Je6sY2HMY8oVv90oI;RJQlD3(-ifX>Q}#dAZV>DCFU zC51u{?SJWNNgb2lkqj0xCm7G_;J^XITDAQypM*Vh>V_s&?>uWxx0nemvA<${Fygy zcQe6yGHrwve|>ObNT0cFul<9g(AcH!n?U*%bS7qN&%e9F@eQkM8qMBL^p1-_hZrZo zf-8KhP8=^**TBo}3B%e~>*SA9H~9<8z0`Kz6U9mM@^Z;BGc$T$n@Q9i@0^!xMZ@xU z?t<$@H@^R&Lov6se@oC|_wHS)L$sukCt<_{7tDj05R<>w7WWB^ACJ}vZ=^csHw$xI7opMh z3^d}#%VI`E6z#^L7>x)S5+f=PJ*d13Ccp137M2&kTtRJGJ#ls#+z~ntV|2Cj>e8`D zYgRKSW)YRk2qfg#a;*Ad+($AHZ^Uy8U#Sn4GhC!kjDi=(oXh}(tf!v-EHqJ7`$QRdfKEW~ASabL_1i9IqaQ-2vdZZQqAhc|;2))8g89t=$Ch4MBD# zMxX3Lz*;?R6s)XGP|#>4n@IyZNE<*`dHLyN`fwy2KY!d9i+wT;9@a?FsJ||3)T_Aj zcOgCq!pr2M&ISxKLBi#)TLKifyF!;!jJDTInZ}tJU&9k-PX`rTvx}oZpCrKk z`4j>&qJ#Ycidej=TAy>n{0P9UE)p{esJtNJ$8goEqe-_GbY&6&O$#sjbqr9twLb}+a(w8=%WxC@&u00VosZXr|}#0}?K&X*hj zT?204SX%hYE~nf2fNYqrm82^{K5>#)2rPA;kziF|+5KFj@V3U$ONaGd;fNFZ+ z_v#D~t*0<$YB0W0r|5TwS{h(4q3}w}5AZF2aVwGO4IH1YCUx6T(!ut*VEz-W%$P~T zcTe=sstnV{7e*p5eMqzPqzAcbnZ`RhxVm}{$MZgDOqirIFS3a9>@~yFg>hY5UxHQh zza;#v5|``{-jUt@T00_j=ZiI##S5UT$kSL!Fom2A8f62x#A(Q0OaZJY!VdcBCfn2R zN*U;Ha3QhPq|g$h;pHQJ6QinD*k^uDRYC$ZP6IYRs~?B94tIXW5DhpOQRqHNdn&*) zhq!(DU8CK|z4jx1*eXktX^IbH0pFX+=8~+sef{1M-aIQyPLg}q_u7ijY)+;48ij*1 z3?Mv1LwB?^5|L(2d?3lVB({|r0k-drAm>d(8HXE4o`-t z2wvAJB^bYufG+n6$|$3jpsUnTG;89@_UjCG2=$Wu7aP#yhGu!rOLQ&k06c%6`HCGNAw{BzEMc*{>bcT}?I)3P_b2OOAxxVK}WyP-TP! z>Y{==EN2%5BNXw@PrZP}_~i)3TLnmLGz!ah8Bxk10#0V<5|B!63xPop7z=sB2&}`& z@rz3M0nxI5pa{JT9i)E_JfU(N@)Yuf^*-bQhXxS9`!e9?gv#x^suU0Eq?G{Zdx61% zmk{{0oY&B|8XGy$Aqt!PTWbi6i`F3G6WP|XHgOwe?b-Sq?%-oXKrc@R(ITS_z_k?5 zfFJY#>xaPPq5!%WboBzO=9g@h<`Ybd6ALUbEJvQkN7Wi(r5(%s;BO{}ynvnesh;k9 zC)r0jF6nO*M^Z-ihjqkm{aQErwcc22KGf+L;J4_tC>=gAeNDo{Kd#hi*QkQM)Zu!S ziL0UGuzCK*8Jj{^9AT`vD%K`^k{;yJF7v~k4M#y7S2ZQopeZhmU(K)ASGSb64%ruj}#9^A03zq!|a(z)d1X zt?e-tKvn?6B%5xam6L6IQFwc_Y8gquwk}Hor&HFkqTsidKKJ*Z0`b<^4@_i?3sYW6 zZHLXX`GNu7k;@}Zr#bE|(?ce|Iz=ko3KK0YV3vEAvCiLrD4bKEJ|ZqrzYRV774Jx7Dq=5E&;ji8ep~7+58`}D(#pE&sn|CYTejCWS=Y0IK8Kw`Fl)(P zb(+2ZhKY-RnP%l{k4{&{pcPaIvmD+skum(pbu6Zj?Kf1!*q4Fo6vR35dy@B=VgQMQg#@%tsicxXWC>M9e z=eDbIVpaSoc>Pa1XB|Nk784ZTC-t_WlWZ-QINb8-nP`Lhpius?&2sy*O$X4>z zyp$tet2J?cW~-6CJ3c*Gs+#;9N8jOy#XDHi%E7h5@v7~VWoLT*?co^DNUl^L$+-5- zJ7^XeRF{CtJE_-~ya46_5fMDJAS?KD3ocG~SiEi|I>ZeE4Pbhf|PtVQ%b^PmMa49TR&%io5S;#s8t-0 z@Gr;lQ(WdyullmwYFNzmDYYzeW<^+rsCck8rFID0d{oKKxZ<~5-lkmn8wQbdoCk9A*(u%u ztG(}CF4KXnVAJ+w_FqvOZ^Z~_%PjU3o5yh1QI;>yXui%htuC7kmG|Fi*tL#9SLaa^ z-}EIhlc3aLu)jHjBhT866FL9Z2eHgP7I)YN#x5+rO|JFYh{# zNeQlg?RmR0liK*EJ$xQ3KNT0Cz)*6Z%NLgI(o48*WYM2=x z9n%5THp$J)CGTe+WzR0r_>^i}8eAn*!jXXaIoRiqr*86;t*Ng5*i$WtdYy<1O8)J) zlKlDe*&>%moPYy8bUtW*k-6qM8oKYr%SYN! z?}XPF)Ag40&%Kq=7*Ozd8b=~9n-J~NME4A=Tpv8HPw~8-evYt6eM2Ds%0X%z+K)UZEt{r?377c`>#o1)v?{#YP^vj64JNr}R0GT3F!z5qs#P{)xf~oF*T23M zpwI7K*m~>`aGbGFfiq%Y3of}{=(t(^w|R*IpB2Au__Bne%#1}Pid$N@-wZox`+k0?xW_|sBKGUkr=*2 zF3-5sr2zSD;!OP20Y|i5u4K(eTbH@WI8|f&+10-;x`C?kMaZoGKmz31T2eplNdDn$kYYdpYBP41fs8bzi6W0SD^>DqTG@;i;LTL2TZa1{9@ z2M+ety)Sr}bnF~OFXBMnq8J>I=zOS$gi9)YWP7I}%-shCebW>uNhubLaTW?v^s#H9wv+U>5n zryT8s3jf6s2Ml>b(D6EC#~+6pTY`wRPXw20OD>%gPS_h8qJpt@aBD85h}yzE;yCyO zuue>^aw2#Uch;v#x7TJCTrWzA5EHokW|OT-Wb`UDqH(=LsUJzb*1+68amJ-qLul4fp|V8o1=PhkC4d{k41+G?8Eul@2z0VM_=D8(}x=y?wErBGtd=wwblP@98N3(xMZ~ z(eJtaH5eT>r$>wiNj)-VF;2wV7-%k+ev#UJZKC2d*|5v6Kn;`idmgC%HBmgffE!+s z0$JvdpJ~+TDv!BS#b*xW8VF=tZKkrw$)NyNgcDMF`o!A^_DWX@($-pA$;+~$EU%^n ze}I=?+i5B{?0BI26#tFgaCd&Pd`_}iYN$}sB2~&Z(mFo~Itfxh5DM(gB63P?SzO_D zO`>4eZ()qw?X6CuX?$5Eg{W-VHC*AKanbw8Vo7@M^`AZ?VH;(4?qqtp*393h4&d1G zWSPzLxCg`dHazg)FBG`1xU;BG<&Ldp=c^GyraL3FB+it^GMR{mA1LkDWM-~=9v*I& zH&Iy}5_}_LRf=-PkL>tUj}C6EUNNeS=Z0uWp)(|1OHwCv1YYP`O_Ad})+@fYFuEgj z@41QI#q>sPVw_8MFdb!CweI%ZJ-&;1e+N#tXiYUV2g5BiH-9wC*EyLbaLRs?L}c%N z+YQIx#lDW~G`eK_zQ>zV`C-%9LGi!nm6Qgy%KFpae)m2^!{81!E+9z@v85AjwICfg z!TU!!;Hf}Wz3I1npCI3gYEM3CMZ`1Jhf zFH-~;&gXD%ouSA9HIhC~l~%gt_@e#^&q)%3W3Acu{blDk3N9<|te)69IXmNX?<=@& zFcZ2vJ4V}P+z9xb6dLTZVhc&v;LV_o6IQ~WdT&%)B2ekrR`z|{GREXMh*dQ05$%tL zkqJ4XQAi@9M#9YKBTTSgx@Tt}2}48f4`7;MsGF*t9!@^_(0#AanghDn_JuuHw5Yw~ zr_aJqpW$zvLV7n4KZjBPhop#W8Sc36RkLE}=B}xnpFwK=q!GubmAq&CF@uAq_v6}) z{&?^?ZmHxZzI=;tcR7ivXDy7Zc~R4oyK{&t)f>NR6L#OHiz}*IXr`;*HCgu%@*HMD zG*vFJ)QO5jX*^<;tN8Tk_h0FWj@d`i{b$$3wyg4YvuYZXy+BV7<<`5MxA>pzJg-;f zq3!2!^>Dm#jxRRIdAyyAT1c=^x~|;K#37X@CwzZ&`}1etd>qbWp)W)7!-M0LuYN80 zV5sY}zn^tYeZEQ2DV`H`us6McCi>@j_fH3#S5y8F57OIz*Suf(VlTzefYq-GcUDy8wAQ;6l zb*OIGAL@aPo8~mxC78kH6G=#hyL6|Kf%X~7<4;#=tQz8@u7oY}3+B(pE;}FH6aGB- zclP3#|G;AQ{WIQzpFS=lE|s07M*@JCyDuPc+L4-it{ODxV6C2-A7&Pa^%$I{KD5A# z?7`gSM%AoJs(hLWiG)&|UChmA`j$s(B?Gj!*OY@QjMpfyGG0KK?dV$`(YU;0`Ij_e z{}$H1Y)ASxUR9|jeH-C?cC%yT!mxyemh(kUNVZ!V4#S)bV=aW)VZ4A0$&}w|e8b(= zHGXotH+$1G(UbY*%k@=t5bw>4wQ0WJ2fH6|w6i<%9Y6FZEreWYsdaPWDem!f|6y1hnsQL}TRHjpMMP9(>ml0=48y~`yL-*&=%BQ1s{qkp z0-ilbc!PwC9wcG>FkXgzxObTOmI;yW;nHlHA}hlug~r^K9{wiD3Cx5~KHgIj*&J}E zlhSle%9Q=^fjo`K3DD5XCfFMmb5`oYu^W<@o2hrhiy8@~1=M zul%~p0CUjSm%1FS-q*}p%n7rBS3Ob+&rtG@^?$);-u)JiBl^ej_mWi6kr>Tv_-ud- z!}HQ7lsem=XxNan-Z?ksq_~@1n499SvE&wHCyWnMJ!>`E%Fcq7Uf>hS(~50wf?G_%;P(@P>ZfZMN?a+%l^%EE z&+sD4<5_5$K0#sxeZ!=%J^?LFE5%okvrwHU9X7v?z9Nv^%%`~Q9SRuF`h+@0-%6f$ z3pS??T^2{>S(sOT&ZVrBG%MA$;%nTqP|8#>>FZM6d4(9+<|6+fi{PbLDP&SJ0O0{>jUG_l=UyP8>D?U`!Mf3rFy8$7F>a za8}M zXck3Dh1};#?@#aiBWV-ZU%{dJ2H&zIhxlp4V|8bjL;C1JOL$pC&VT+CPfPj4jF7z$ zzMMas6;vVM*zoeeR}!~qnsoi0h6>xj)9!0JA955s+8rCN9#}dy%owhY&_-<4=9yUq zE9#M4Fr_>&FvD2FwqFEHxc!TLk&Bj5!n?}sSaC`l_D$^ z6S{W>r9s=8uFu4|?FH3}XKkJj;H>0h3+Bcz&9uaw2j|%=YQ%n~GFe-#r*d59gywEi z1MF>@0QHuMEQiU|(2~UxDYA*(m>nYF$Jw#o)Z8NhA75N$QC2i>g3#X3bn&^jrS8%+ z9hXqtVYS1Yg)Bh9pu<@i=6ftGZW+$30Wt%oZszpKD{Bw(ahqRKlVuXG3N#h#ZZZDE z$ob@%TDgDuAUR>Q;=-{kGMQJPAh>X8|z=kgi(&K0oP!4sFC$O+1dgM=v&^crxQFSyrW*0@lLvvl{aJZJF; zj2Cb<9LpVda~)Zkhi~f2>JIGe6j}=2Rtg}5OWw~9Twkpv$KO@al(?gH3f71pUyF@; zkNQ{syc4s%74vzJ>ge>7Y15}a*BfK*`y5Xk?Wf9ZE~g&M<3xJq9baoL+!+b$8c^Vf z_R=~_)Gso?mg}K!^GXATAZ`G1UPBsIqpPnDdPleV8n163lnYWOiIQk83wgL6vN4hm z%9-ppS47IPN(>IBUXdKYMM-UpiV0{>fAl#>ms@XPYd6Y;WI)f8agAtdgw`tqMGTxQ z>d~}Zi0-N*%#T-`@v3^~ZMmW7V+_;?+W|+@GBawj`)@W{xsHvz-M5`48vE6v!H|7X zZh1*bUBQdayP8yQy92yBaQK^RONiS+m7kGFAQHB|ptM!VW!|~URmxn{T||5mD(5_N zaQu5yyJUT>w{=jKu$J3TPS;p}F!jQ}rqN%os8B|0zNB1?INx8kDDt*L3|aF@8q2cm8>@7 zU#|};2is)yTD?(?3vX;}?z4;>`+l?`EU1{qZ+&CW?}yUH@AfwL-jQ3O-Zf6*D6@>M zRX?1nTHcmbOrya?KgCRASwA~Lt&=XK_)p zQDVpqv=a-0J`G?nCs_{I^xk4 z-kY(+jzvDakJhm9#ffFz?9)w=;5MZL>p|e4Nkaqsl2|`g*B3bA9sIIPvZB^QAw=5$ zx4z8SrntdfFEccp+nD0_J?Mi_G#PnoPGQbgwZ{cGdWn4n8rsZj6I$;A7C}ZBlzmkH z`?pLSyiG*8CaOmfiLN&;DqduPWNS(snn8|qFcOm|4?*CijJK>y`<&mlcyb~ayMVee zAet8yv;937c5uCyxm{t*KfzeW)6=93uzKGo8VkdZXLK4KnG3%Dpol+TK{ojg=oyL& zOyf*MiBgeqBv@9#Q{gb9ajzi-Bt?l8cP}wG>1Hy-=0ZMa~0_LGcv=YMzQ{JEiu@(uBVoG76Fe&!$H;ca_3kq+4y*-RY_ zWt!HzbEu-cr3J!d8t0t>L)0R>!l?(1I{bw>oH))eH5P2g{O1ma`54lQXWeaBGL>c@ zONfoc46=5ZbUjF%)zjE11oepo93wg zpexemGfc=XN<@s(veh%Hojb~b0VBsAp*O$W!fW#)vtmBDI{b+)2?AGcvf1=suX2C> zv)v=_$-TmdO|{-u#(E5pN5V8KaGdnh###Q%W>Z;LBKu_>`STbuIHQqpJ2xZl z>4o@S7;JiS){d`|&4GJa*N&!k^esAO+rFxRkfko-;UJysbL7ngT^Y=@Ax55)I}~r% zl0}!ey2BYm^2Ir@)%(#yQ!NkO)q>kg_HD!zutY2j{3K;nQlP`WFkFUnZVDga3s?@@ z)}EO=$l_)YPAPv$Vj7E6h-_8_$D=j5!`6H)X(imd=`?v5l2S*jGlDKY*v$a|wjSW) z*AaGnc%z#b3mdYpnI?qav|;9J-c`M=-dNgC-8w_n6- z004cZfZx7kcss}TpYloq46wfgqOh>f4**3VH+V^libtMcTJZ6&j2FZW-#$i@IG$u9 zoK%cm`@{~{^Z(xtgazuPa7m&?xikA#0@Ym-xT3i?-K{50{nsG=`!1r%v(^Z#o5)c7 zY0O$`a`b?snyP;s3@nY~9U#zfu>O6=9Xdbl_RsU7h)>`ac=X)3*EmI zl&Ccef_rB`OLLE`_a%oURt1>49bQz^ri$bu9hVtIZpI+4TqWqo{I&HV3f{ucz()2% zGVE7#UhK<{%xPMz>`6@SJjBHUmDqk6R$kZECNDjGJ0$=0MUNkAoxWf?gpR z-{Q)4oufpF!ALrvF~`HYwE@4Z9nap4^s0ae38KOlkpVPby$wm!gUgwpKXY%d6{)_L z7&cb=>NaooVuIVP_+EI~hN&+sP&|T2H^*D0tY>-HpVK4K+x9viFv8^e^b(A*b*BJt z-m48)0arKDryN>WhPRA`_Zsp*if~Rablehvo{oh1qYSO5F<-xu`*I+WYzhG$+wZxj zEq5a=i6udB@}MMlnh$>&OjYmKlZxhw$1wLhMG|T5@ipP@YGYtm;1X&}dd7wqwdIyg z^(A382`!2j0FTqFibFcLT4bA%ngp2FMH!I8ez}@f+gL;ThfC4f!5EuM&#DEM&Bd&i zLi^D09I8A|qm+LEqV&*6n0rr90RWciE6!f~Ck3bABJlA~O8%cz0<5$d6Ttr1BOG!q zF$WJB?<(^B@H4$maDLKuK%7`%;IlMsl8kOoT{`?w9Bp$3$fLoF!rQ=GWxt6$M5_|l zy_i}rbG&v(I4L4XL*RI_NjP`OsYx>aLEI*z0qMzppFJw*a5q)wm*&0kjYd_j=8S^K z{H1*O0gj44<4&)jB8R)~fSiOa*Sw|nd*h`S;rwV`Ae*KLFff++8OFFDTc7r9%_%5E z;Z1vaPViBc4d8o*4ja^Ja{}5xfnGrNb`&{0HV7VJzOJvFJm-lffCu~}eNW!S0?z+t z_$S6)Po#6uknlx$IH6m1{9^%32$=80yviH92fuvFw$K`I3oXt1Pp!As-iutieA0=x zElnuF)}yG*Rs`HQ1gGb~my=xklg7_eK&P!pPpavGnd#)0S6cwyBLM#fzqBHSFeoVF zK0@R*oE{HrI208OAi>?lpCtUB^jIUK;NOHs+w1=`jrB-V%(Dk zKo`q%fcVXazHDo*9Ukl!D(L#Fl9 zjzuQbc4%kz@XD_E+QyAW&xZ@RJFfOt`0cUH#nqWjlK2S$ANAP}g~yyfF2*4SJ#!WU z+KF?&Z2f(*O{wayjY*Y=4Q+nBqj~T!l34=_Gv!GT1BLxaik0-*fcw&L>Y;P(bt*Ge zxFguhSuZ4r@Kz#tU|U{FZx-SI6VMeEW8xlP*FXzQ;1W=a(Td_KZV`z$VFbb_pB#-J zyA{hyzl;H$BSA3LAlAd7{P6$`aT77y&Q`VPp+!YQoaM02Qqz@pJox zno%iXJJ4RB2)LUEezpEk_PMT3s Mynn0crrGoV1JCZ}&;S4c literal 0 HcmV?d00001 From 65a47240e4492fb0e6312b4fd306aa8c0fee461b Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Fri, 8 Dec 2023 15:12:42 +0000 Subject: [PATCH 23/85] add device group management pages --- .../pages/application/DeviceGroup/devices.vue | 324 ++++++++++++++++++ .../pages/application/DeviceGroup/index.vue | 170 +++++++++ .../application/DeviceGroup/settings.vue | 135 ++++++++ .../src/pages/application/DeviceGroups.vue | 202 +++++++++++ frontend/src/pages/application/index.vue | 1 + frontend/src/pages/application/routes.js | 38 ++ 6 files changed, 870 insertions(+) create mode 100644 frontend/src/pages/application/DeviceGroup/devices.vue create mode 100644 frontend/src/pages/application/DeviceGroup/index.vue create mode 100644 frontend/src/pages/application/DeviceGroup/settings.vue create mode 100644 frontend/src/pages/application/DeviceGroups.vue diff --git a/frontend/src/pages/application/DeviceGroup/devices.vue b/frontend/src/pages/application/DeviceGroup/devices.vue new file mode 100644 index 000000000..b141787bc --- /dev/null +++ b/frontend/src/pages/application/DeviceGroup/devices.vue @@ -0,0 +1,324 @@ + + + + + diff --git a/frontend/src/pages/application/DeviceGroup/index.vue b/frontend/src/pages/application/DeviceGroup/index.vue new file mode 100644 index 000000000..f15087b79 --- /dev/null +++ b/frontend/src/pages/application/DeviceGroup/index.vue @@ -0,0 +1,170 @@ + + + diff --git a/frontend/src/pages/application/DeviceGroup/settings.vue b/frontend/src/pages/application/DeviceGroup/settings.vue new file mode 100644 index 000000000..03acc38ef --- /dev/null +++ b/frontend/src/pages/application/DeviceGroup/settings.vue @@ -0,0 +1,135 @@ + + + diff --git a/frontend/src/pages/application/DeviceGroups.vue b/frontend/src/pages/application/DeviceGroups.vue new file mode 100644 index 000000000..77c7d735b --- /dev/null +++ b/frontend/src/pages/application/DeviceGroups.vue @@ -0,0 +1,202 @@ + + + + + diff --git a/frontend/src/pages/application/index.vue b/frontend/src/pages/application/index.vue index 375ec9bb5..28d70eeda 100644 --- a/frontend/src/pages/application/index.vue +++ b/frontend/src/pages/application/index.vue @@ -109,6 +109,7 @@ export default { const routes = [ { label: 'Instances', to: `/application/${this.application.id}/instances`, tag: 'application-overview', icon: ProjectsIcon }, { label: 'Devices', to: `/application/${this.application.id}/devices`, tag: 'application-devices-overview', icon: ChipIcon }, + { label: 'Devices Groups', to: `/application/${this.application.id}/devicegroups`, tag: 'application-devices-groups-overview', icon: ChipIcon }, { label: 'Snapshots', to: `/application/${this.application.id}/snapshots`, tag: 'application-snapshots', icon: ClockIcon }, { label: 'DevOps Pipelines', diff --git a/frontend/src/pages/application/routes.js b/frontend/src/pages/application/routes.js index 8c6e16a87..ae14bf2d3 100644 --- a/frontend/src/pages/application/routes.js +++ b/frontend/src/pages/application/routes.js @@ -5,6 +5,10 @@ * No new functionality should be added here. */ import ApplicationActivity from './Activity.vue' +import ApplicationDeviceGroupDevices from './DeviceGroup/devices.vue' +import ApplicationDeviceGroupIndex from './DeviceGroup/index.vue' +import ApplicationDeviceGroupSettings from './DeviceGroup/settings.vue' +import ApplicationDeviceGroups from './DeviceGroups.vue' import ApplicationDevices from './Devices.vue' import ApplicationLogs from './Logs.vue' import ApplicationOverview from './Overview.vue' @@ -46,6 +50,14 @@ export default [ title: 'Application - Devices' } }, + { + path: 'devicegroups', + name: 'ApplicationDeviceGroups', + component: ApplicationDeviceGroups, + meta: { + title: 'Application - Devices Groups' + } + }, { path: 'snapshots', name: 'ApplicationSnapshots', @@ -135,6 +147,32 @@ export default [ ] } ] + }, + { + path: '/application/:applicationId/devicegroup/:deviceGroupId', + name: 'ApplicationDeviceGroupIndex', + component: ApplicationDeviceGroupIndex, + meta: { + title: 'Application - Device Group' + }, + children: [ + { + path: 'settings', + name: 'ApplicationDeviceGroupSettings', + component: ApplicationDeviceGroupSettings, + meta: { + title: 'Application - Device Group - Settings' + } + }, + { + path: 'devices', + name: 'ApplicationDeviceGroupDevices', + component: ApplicationDeviceGroupDevices, + meta: { + title: 'Application - Device Group - Members' + } + } + ] } ] From 2abfd622a6b44d5959a4bb3eed84229f22bb4526 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Fri, 8 Dec 2023 15:30:53 +0000 Subject: [PATCH 24/85] hyphenate devicegroup --- forge/routes/api/application.js | 2 +- forge/routes/api/applicationDeviceGroup.js | 38 +++++++-------- .../unit/forge/routes/api/application_spec.js | 48 +++++++++---------- 3 files changed, 44 insertions(+), 44 deletions(-) diff --git a/forge/routes/api/application.js b/forge/routes/api/application.js index 9e72ce98b..c50d4629b 100644 --- a/forge/routes/api/application.js +++ b/forge/routes/api/application.js @@ -28,7 +28,7 @@ module.exports = async function (app) { } }) - app.register(applicationDeviceGroup, { prefix: '/:applicationId/devicegroups' }) + app.register(applicationDeviceGroup, { prefix: '/:applicationId/device-groups' }) /** * Create an application diff --git a/forge/routes/api/applicationDeviceGroup.js b/forge/routes/api/applicationDeviceGroup.js index d20664399..935a2956f 100644 --- a/forge/routes/api/applicationDeviceGroup.js +++ b/forge/routes/api/applicationDeviceGroup.js @@ -1,7 +1,7 @@ /** * Application DeviceGroup api routes * - * - /api/v1/applications/:applicationId/devicegroups + * - /api/v1/applications/:applicationId/device-groups * * @namespace application * @memberof forge.routes.api @@ -15,12 +15,12 @@ const { Roles } = require('../../lib/roles.js') */ module.exports = async function (app) { registerPermissions({ - 'application:devicegroup:create': { description: 'Create a device group', role: Roles.Owner }, - 'application:devicegroup:list': { description: 'List device groups', role: Roles.Member }, - 'application:devicegroup:update': { description: 'Update a device group', role: Roles.Owner }, - 'application:devicegroup:delete': { description: 'Delete a device group', role: Roles.Owner }, - 'application:devicegroup:read': { description: 'View a device group', role: Roles.Member }, - 'application:devicegroup:membership:update': { description: 'Update a device group membership', role: Roles.Owner } + 'application:device-group:create': { description: 'Create a device group', role: Roles.Owner }, + 'application:device-group:list': { description: 'List device groups', role: Roles.Member }, + 'application:device-group:update': { description: 'Update a device group', role: Roles.Owner }, + 'application:device-group:delete': { description: 'Delete a device group', role: Roles.Owner }, + 'application:device-group:read': { description: 'View a device group', role: Roles.Member }, + 'application:device-group:membership:update': { description: 'Update a device group membership', role: Roles.Owner } }) // pre-handler for all routes in this file @@ -38,11 +38,11 @@ module.exports = async function (app) { /** * Get a list of device groups in an application * @method GET - * @name /api/v1/applications/:applicationId/devicegroups + * @name /api/v1/applications/:applicationId/device-groups * @memberof forge.routes.api.application */ app.get('/', { - preHandler: app.needsPermission('application:devicegroup:list'), + preHandler: app.needsPermission('application:device-group:list'), schema: { summary: 'Get a list of device groups in an application', tags: ['Applications'], @@ -86,11 +86,11 @@ module.exports = async function (app) { /** * Add a new Device Group to an Application * @method POST - * @name /api/v1/applications/:applicationId/devicegroups + * @name /api/v1/applications/:applicationId/device-groups * @memberof forge.routes.api.application */ app.post('/', { - preHandler: app.needsPermission('application:devicegroup:create'), + preHandler: app.needsPermission('application:device-group:create'), schema: { summary: 'Add a new Device Group to an Application', tags: ['Applications'], @@ -130,11 +130,11 @@ module.exports = async function (app) { /** * Update a Device Group * @method PUT - * @name /api/v1/applications/:applicationId/devicegroups/:groupId + * @name /api/v1/applications/:applicationId/device-groups/:groupId * @memberof forge.routes.api.application */ app.put('/:groupId', { - preHandler: app.needsPermission('application:devicegroup:update'), + preHandler: app.needsPermission('application:device-group:update'), schema: { summary: 'Update a Device Group', tags: ['Applications'], @@ -174,11 +174,11 @@ module.exports = async function (app) { /** * Get a specific deviceGroup * @method GET - * @name /api/v1/applications/:applicationId/devicegroups/:groupId + * @name /api/v1/applications/:applicationId/device-groups/:groupId * @memberof forge.routes.api.application */ app.get('/:groupId', { - preHandler: app.needsPermission('application:devicegroup:read'), + preHandler: app.needsPermission('application:device-group:read'), schema: { summary: 'Get a specific deviceGroup', tags: ['Applications'], @@ -207,11 +207,11 @@ module.exports = async function (app) { /** * Update Device Group membership * @method PATCH - * @name /api/v1/applications/:applicationId/devicegroups/:groupId + * @name /api/v1/applications/:applicationId/device-groups/:groupId * @memberof forge.routes.api.application */ app.patch('/:groupId', { - preHandler: app.needsPermission('application:devicegroup:membership:update'), + preHandler: app.needsPermission('application:device-group:membership:update'), schema: { summary: 'Update Device Group membership', tags: ['Applications'], @@ -259,11 +259,11 @@ module.exports = async function (app) { /** * Delete a Device Group * @method DELETE - * @name /api/v1/applications/:applicationId/devicegroups/:groupId + * @name /api/v1/applications/:applicationId/device-groups/:groupId * @memberof forge.routes.api.application */ app.delete('/:groupId', { - preHandler: app.needsPermission('application:devicegroup:delete'), + preHandler: app.needsPermission('application:device-group:delete'), schema: { summary: 'Delete a Device Group', tags: ['Applications'], diff --git a/test/unit/forge/routes/api/application_spec.js b/test/unit/forge/routes/api/application_spec.js index e4ac64e55..355184c92 100644 --- a/test/unit/forge/routes/api/application_spec.js +++ b/test/unit/forge/routes/api/application_spec.js @@ -731,7 +731,7 @@ describe('Application API', function () { const response = await app.inject({ method: 'POST', - url: `/api/v1/applications/${application.hashid}/devicegroups`, + url: `/api/v1/applications/${application.hashid}/device-groups`, cookies: { sid }, payload: { name: 'my device group', @@ -753,7 +753,7 @@ describe('Application API', function () { const response = await app.inject({ method: 'POST', - url: `/api/v1/applications/${application.hashid}/devicegroups`, + url: `/api/v1/applications/${application.hashid}/device-groups`, cookies: { sid }, payload: { name: 'my device group', @@ -774,7 +774,7 @@ describe('Application API', function () { const response = await app.inject({ method: 'POST', - url: `/api/v1/applications/${application.hashid}/devicegroups`, + url: `/api/v1/applications/${application.hashid}/device-groups`, cookies: { sid }, payload: { name: 'my device group', @@ -793,7 +793,7 @@ describe('Application API', function () { const response = await app.inject({ method: 'GET', - url: `/api/v1/applications/${application.hashid}/devicegroups`, + url: `/api/v1/applications/${application.hashid}/device-groups`, cookies: { sid } }) @@ -815,7 +815,7 @@ describe('Application API', function () { const response = await app.inject({ method: 'GET', - url: `/api/v1/applications/${application.hashid}/devicegroups?limit=1`, + url: `/api/v1/applications/${application.hashid}/device-groups?limit=1`, cookies: { sid } }) @@ -833,7 +833,7 @@ describe('Application API', function () { const response = await app.inject({ method: 'GET', - url: `/api/v1/applications/${application.hashid}/devicegroups`, + url: `/api/v1/applications/${application.hashid}/device-groups`, cookies: { sid } }) @@ -851,7 +851,7 @@ describe('Application API', function () { const response = await app.inject({ method: 'GET', - url: `/api/v1/applications/${application.hashid}/devicegroups/${deviceGroup.hashid}`, + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup.hashid}`, cookies: { sid } }) @@ -878,7 +878,7 @@ describe('Application API', function () { const response = await app.inject({ method: 'GET', - url: `/api/v1/applications/${application.hashid}/devicegroups/doesNotExist`, + url: `/api/v1/applications/${application.hashid}/device-groups/doesNotExist`, cookies: { sid } }) @@ -895,7 +895,7 @@ describe('Application API', function () { const response = await app.inject({ method: 'GET', - url: `/api/v1/applications/${application.hashid}/devicegroups`, + url: `/api/v1/applications/${application.hashid}/device-groups`, cookies: { sid } }) @@ -915,7 +915,7 @@ describe('Application API', function () { // now call the API to update name and desc const response = await app.inject({ method: 'PUT', - url: `/api/v1/applications/${application.hashid}/devicegroups/${deviceGroup.hashid}`, + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup.hashid}`, cookies: { sid }, payload: { name: 'updated name', @@ -938,7 +938,7 @@ describe('Application API', function () { const response = await app.inject({ method: 'PUT', - url: `/api/v1/applications/${application.hashid}/devicegroups/${deviceGroup.hashid}`, + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup.hashid}`, cookies: { sid }, payload: { name: 'updated name', @@ -960,7 +960,7 @@ describe('Application API', function () { const response = await app.inject({ method: 'PUT', - url: `/api/v1/applications/${application.hashid}/devicegroups/${deviceGroup.hashid}`, + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup.hashid}`, cookies: { sid }, payload: { name: 'updated name', @@ -980,7 +980,7 @@ describe('Application API', function () { const response = await app.inject({ method: 'DELETE', - url: `/api/v1/applications/${application.hashid}/devicegroups/${deviceGroup.hashid}`, + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup.hashid}`, cookies: { sid } }) @@ -993,7 +993,7 @@ describe('Application API', function () { const response = await app.inject({ method: 'DELETE', - url: `/api/v1/applications/${application.hashid}/devicegroups/${deviceGroup.hashid}`, + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup.hashid}`, cookies: { sid } }) @@ -1010,7 +1010,7 @@ describe('Application API', function () { const response = await app.inject({ method: 'DELETE', - url: `/api/v1/applications/${application.hashid}/devicegroups/${deviceGroup.hashid}`, + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup.hashid}`, cookies: { sid } }) @@ -1026,7 +1026,7 @@ describe('Application API', function () { const device = await factory.createDevice({ name: generateName('device') }, TestObjects.BTeam, null, application) const response = await app.inject({ method: 'PATCH', - url: `/api/v1/applications/${application.hashid}/devicegroups/${deviceGroup.hashid}`, + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup.hashid}`, cookies: { sid }, payload: { add: [device.hashid] @@ -1056,7 +1056,7 @@ describe('Application API', function () { const response = await app.inject({ method: 'PATCH', - url: `/api/v1/applications/${application.hashid}/devicegroups/${deviceGroup.hashid}`, + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup.hashid}`, cookies: { sid }, payload: { add: [device2.hashid] @@ -1085,7 +1085,7 @@ describe('Application API', function () { const response = await app.inject({ method: 'PATCH', - url: `/api/v1/applications/${application.hashid}/devicegroups/${deviceGroup.hashid}`, + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup.hashid}`, cookies: { sid }, payload: { remove: [device2.hashid] @@ -1113,7 +1113,7 @@ describe('Application API', function () { const response = await app.inject({ method: 'PATCH', - url: `/api/v1/applications/${application.hashid}/devicegroups/${deviceGroup.hashid}`, + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup.hashid}`, cookies: { sid }, payload: { add: [device3.hashid], @@ -1144,7 +1144,7 @@ describe('Application API', function () { const response = await app.inject({ method: 'PATCH', - url: `/api/v1/applications/${application.hashid}/devicegroups/${deviceGroup.hashid}`, + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup.hashid}`, cookies: { sid }, payload: { set: [device3.hashid] @@ -1167,7 +1167,7 @@ describe('Application API', function () { const response = await app.inject({ method: 'PATCH', - url: `/api/v1/applications/${application.hashid}/devicegroups/${deviceGroup.hashid}`, + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup.hashid}`, cookies: { sid }, payload: { add: [device.hashid] @@ -1189,7 +1189,7 @@ describe('Application API', function () { const response = await app.inject({ method: 'PATCH', - url: `/api/v1/applications/${application.hashid}/devicegroups/${deviceGroup.hashid}`, + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup.hashid}`, cookies: { sid }, payload: { add: [device.hashid] @@ -1211,7 +1211,7 @@ describe('Application API', function () { const response = await app.inject({ method: 'PATCH', - url: `/api/v1/applications/${application.hashid}/devicegroups/${deviceGroup.hashid}`, + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup.hashid}`, cookies: { sid }, payload: { set: [device.hashid] @@ -1232,7 +1232,7 @@ describe('Application API', function () { const response = await app.inject({ method: 'PATCH', - url: `/api/v1/applications/${application.hashid}/devicegroups/${deviceGroup.hashid}`, + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup.hashid}`, cookies: { sid }, payload: { set: [device.hashid] From 9ee7c2316407e0633c322afd036ab5d42ed7aee6 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Fri, 8 Dec 2023 16:50:12 +0000 Subject: [PATCH 25/85] Let the model validate fields and bubble up --- forge/db/controllers/DeviceGroup.js | 12 ++-- forge/db/models/DeviceGroup.js | 10 ++- forge/routes/api/applicationDeviceGroup.js | 56 +++++++++++---- .../unit/forge/routes/api/application_spec.js | 71 +++++++++++++++++++ 4 files changed, 127 insertions(+), 22 deletions(-) diff --git a/forge/db/controllers/DeviceGroup.js b/forge/db/controllers/DeviceGroup.js index c20e82ebe..df80d9507 100644 --- a/forge/db/controllers/DeviceGroup.js +++ b/forge/db/controllers/DeviceGroup.js @@ -29,11 +29,8 @@ module.exports = { // Create a Device Group that devices can be linked to // * name is required // * application, description are optional - // * FUTURE: colors (background, border, text) and icon will optional + // * FUTURE: colors (background, border, text) and icon will be optional - if (typeof name !== 'string' || name.length === 0) { - throw new Error('Tag name is required') - } return await app.db.models.DeviceGroup.create({ name, description, @@ -49,9 +46,6 @@ module.exports = { } let changed = false if (typeof name !== 'undefined') { - if (typeof name !== 'string' || name.length === 0) { - throw new Error('Tag name is required') - } deviceGroup.name = name changed = true } @@ -143,7 +137,9 @@ module.exports = { const deviceIds = await validateDeviceList(app, deviceGroup, null, deviceList) // null every device.DeviceGroupId row in device table where the id === deviceGroupId and device.id is in the deviceList await app.db.models.Device.update({ DeviceGroupId: null }, { where: { id: deviceIds.removeList, DeviceGroupId: deviceGroup.id } }) - } + }, + + DeviceGroupMembershipValidationError } /** diff --git a/forge/db/models/DeviceGroup.js b/forge/db/models/DeviceGroup.js index 2a4db2f5d..f5f688386 100644 --- a/forge/db/models/DeviceGroup.js +++ b/forge/db/models/DeviceGroup.js @@ -7,11 +7,19 @@ 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 }, + name: { + type: DataTypes.STRING, + allowNull: false, + validate: { + notEmpty: nameValidator, + notNull: nameValidator + } + }, description: { type: DataTypes.TEXT } }, associations: function (M) { diff --git a/forge/routes/api/applicationDeviceGroup.js b/forge/routes/api/applicationDeviceGroup.js index 935a2956f..81cd71b07 100644 --- a/forge/routes/api/applicationDeviceGroup.js +++ b/forge/routes/api/applicationDeviceGroup.js @@ -7,6 +7,9 @@ * @memberof forge.routes.api */ +const { ValidationError } = require('sequelize') + +const { DeviceGroupMembershipValidationError } = require('../../db/controllers/DeviceGroup.js') const { registerPermissions } = require('../../lib/permissions') const { Roles } = require('../../lib/roles.js') @@ -121,10 +124,13 @@ module.exports = async function (app) { const application = request.application const name = request.body.name const description = request.body.description - - const newGroup = await app.db.controllers.DeviceGroup.createDeviceGroup(name, { application, description }) - const newGroupView = app.db.views.DeviceGroup.deviceGroupSummary(newGroup) - reply.code(201).send(newGroupView) + try { + const newGroup = await app.db.controllers.DeviceGroup.createDeviceGroup(name, { application, description }) + const newGroupView = app.db.views.DeviceGroup.deviceGroupSummary(newGroup) + reply.code(201).send(newGroupView) + } catch (error) { + return handleError(error, reply) + } }) /** @@ -166,13 +172,16 @@ module.exports = async function (app) { const group = request.deviceGroup const name = request.body.name const description = request.body.description - - await app.db.controllers.DeviceGroup.updateDeviceGroup(group, { name, description }) - reply.send({}) + try { + await app.db.controllers.DeviceGroup.updateDeviceGroup(group, { name, description }) + reply.send({}) + } catch (error) { + return handleError(error, reply) + } }) /** - * Get a specific deviceGroup + * Get a specific Device Group * @method GET * @name /api/v1/applications/:applicationId/device-groups/:groupId * @memberof forge.routes.api.application @@ -180,7 +189,7 @@ module.exports = async function (app) { app.get('/:groupId', { preHandler: app.needsPermission('application:device-group:read'), schema: { - summary: 'Get a specific deviceGroup', + summary: 'Get a specific Device Group', tags: ['Applications'], params: { type: 'object', @@ -249,10 +258,7 @@ module.exports = async function (app) { await app.db.controllers.DeviceGroup.updateDeviceGroupMembership(group, { addDevices, removeDevices, setDevices }) reply.send({}) } catch (err) { - return reply.code(err.statusCode || 500).send({ - code: err.code || 'unexpected_error', - error: err.error || err.message - }) + return handleError(err, reply) } }) @@ -289,4 +295,28 @@ module.exports = async function (app) { await group.destroy() reply.send({}) }) + + function handleError (err, reply) { + let statusCode = 500 + let code = 'unexpected_error' + let error = err.error || err.message || 'Unexpected error' + if (err instanceof ValidationError) { + statusCode = 400 + if (err.errors[0]) { + code = err.errors[0].path ? `invalid_${err.errors[0].path}` : 'invalid_input' + error = err.errors[0].message || error + } else { + code = 'invalid_input' + error = err.message || error + } + } else if (err instanceof DeviceGroupMembershipValidationError) { + statusCode = err.statusCode || 400 + code = err.code || 'invalid_device_group_membership' + error = err.message || error + } else { + app.log.error('API error in application device groups:') + app.log.error(err) + } + return reply.code(statusCode).type('application/json').send({ code, error }) + } } diff --git a/test/unit/forge/routes/api/application_spec.js b/test/unit/forge/routes/api/application_spec.js index 355184c92..b5fd84200 100644 --- a/test/unit/forge/routes/api/application_spec.js +++ b/test/unit/forge/routes/api/application_spec.js @@ -747,6 +747,27 @@ describe('Application API', function () { result.should.have.property('description', 'my device group description') }) + it('Cannot create a device group with empty name', async function () { + const sid = await login('bob', 'bbPassword') + const application = await factory.createApplication({ name: generateName('app') }, TestObjects.BTeam) + + const response = await app.inject({ + method: 'POST', + url: `/api/v1/applications/${application.hashid}/device-groups`, + cookies: { sid }, + payload: { + name: '', + description: 'my device group description' + } + }) + + response.statusCode.should.equal(400) + + const result = response.json() + result.should.have.property('code', 'invalid_name') + result.should.have.property('error') + }) + it('Non Owner can not create a device group', async function () { const sid = await login('chris', 'ccPassword') const application = await factory.createApplication({ name: generateName('app') }, TestObjects.BTeam) @@ -931,6 +952,32 @@ describe('Application API', function () { updatedDeviceGroup.should.have.property('description', 'updated description') }) + it('Cannot update a device group with empty name', async function () { + 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') + + // now call the API to update name and desc + const response = await app.inject({ + method: 'PUT', + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup.hashid}`, + cookies: { sid }, + payload: { + name: '', + description: 'updated description' + } + }) + + response.statusCode.should.equal(400) + + const result = response.json() + result.should.have.property('code', 'invalid_name') + result.should.have.property('error') + }) + + it('Non Owner can not update a device group', async function () { const sid = await login('chris', 'ccPassword') const application = await factory.createApplication({ name: generateName('app') }, TestObjects.BTeam) @@ -1203,6 +1250,30 @@ describe('Application API', function () { updatedDeviceGroup.should.have.property('Devices').and.have.length(0) }) + it('Can not add a device to a group if already in a group', async function () { + const sid = await login('bob', 'bbPassword') + const application = TestObjects.application // BTeam application + const deviceGroup = await factory.createApplicationDeviceGroup({ name: generateName('device-group') }, application) + const deviceGroup2 = await factory.createApplicationDeviceGroup({ name: generateName('device-group') }, application) + const device = await factory.createDevice({ name: generateName('device') }, TestObjects.BTeam, null, application) + await app.db.controllers.DeviceGroup.updateDeviceGroupMembership(deviceGroup, { addDevices: [device] }) + + const response = await app.inject({ + method: 'PATCH', + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup2.hashid}`, + cookies: { sid }, + payload: { + add: [device.hashid] + } + }) + + response.statusCode.should.equal(400) + response.json().should.have.property('code', 'invalid_input') + // double check the device did not get added to the group + const updatedDeviceGroup = await app.db.models.DeviceGroup.byId(deviceGroup2.hashid) + updatedDeviceGroup.should.have.property('Devices').and.have.length(0) + }) + it('Non Owner can not update a device group membership', async function () { const sid = await login('chris', 'ccPassword') const application = await factory.createApplication({ name: generateName('app') }, TestObjects.BTeam) From d9d7a5adfaae043aa9fc2a2db7fd71ee4f0db209 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Fri, 8 Dec 2023 16:53:25 +0000 Subject: [PATCH 26/85] corrections to device group view schemas --- forge/db/views/DeviceGroup.js | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/forge/db/views/DeviceGroup.js b/forge/db/views/DeviceGroup.js index 448dd6d0a..7fd9937d5 100644 --- a/forge/db/views/DeviceGroup.js +++ b/forge/db/views/DeviceGroup.js @@ -6,13 +6,10 @@ module.exports = function (app) { id: { type: 'string' }, name: { type: 'string' }, description: { type: 'string' }, - model: { type: 'string' } + deviceCount: { type: 'number' } } }) function deviceGroupSummary (group) { - // if (Object.hasOwn(group, 'get')) { - // group = group.get({ plain: true }) - // } if (group.toJSON) { group = group.toJSON() } @@ -32,7 +29,8 @@ module.exports = function (app) { properties: { createdAt: { type: 'string' }, updatedAt: { type: 'string' }, - DeviceGroup: { type: 'object', additionalProperties: true } + application: { $ref: 'ApplicationSummary' }, + devices: { type: 'array', items: { $ref: 'Device' } } }, additionalProperties: true }) @@ -46,7 +44,7 @@ module.exports = function (app) { id: item.hashid, name: item.name, description: item.description, - Application: item.Application ? app.db.views.Application.applicationSummary(item.Application) : null, + 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) : [] } From 177cce0b02a6a48598d291f850630b86004ff8d0 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Fri, 8 Dec 2023 16:57:11 +0000 Subject: [PATCH 27/85] fix double blank line lint err --- test/unit/forge/routes/api/application_spec.js | 1 - 1 file changed, 1 deletion(-) diff --git a/test/unit/forge/routes/api/application_spec.js b/test/unit/forge/routes/api/application_spec.js index b5fd84200..b983d124e 100644 --- a/test/unit/forge/routes/api/application_spec.js +++ b/test/unit/forge/routes/api/application_spec.js @@ -977,7 +977,6 @@ describe('Application API', function () { result.should.have.property('error') }) - it('Non Owner can not update a device group', async function () { const sid = await login('chris', 'ccPassword') const application = await factory.createApplication({ name: generateName('app') }, TestObjects.BTeam) From 687a9e06ab0f401b8c709655853a0f7dd9fb16e2 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Fri, 8 Dec 2023 17:57:02 +0000 Subject: [PATCH 28/85] updates for devicegroup hyphenation --- frontend/src/api/application.js | 16 ++++++++-------- .../pages/application/DeviceGroup/devices.vue | 4 ++-- .../src/pages/application/DeviceGroup/index.vue | 14 ++------------ .../pages/application/DeviceGroup/settings.vue | 4 ++-- frontend/src/pages/application/index.vue | 2 +- frontend/src/pages/application/routes.js | 4 ++-- 6 files changed, 17 insertions(+), 27 deletions(-) diff --git a/frontend/src/api/application.js b/frontend/src/api/application.js index 89d1c7ed8..0b37a41f2 100644 --- a/frontend/src/api/application.js +++ b/frontend/src/api/application.js @@ -268,7 +268,7 @@ const getSnapshots = async (applicationId, cursor, limit, options) => { * @param {string} groupId - The ID of the group to get */ const getDeviceGroup = async (applicationId, groupId) => { - return client.get(`/api/v1/applications/${applicationId}/devicegroups/${groupId}`).then(res => { + return client.get(`/api/v1/applications/${applicationId}/device-groups/${groupId}`).then(res => { return res.data }) } @@ -278,7 +278,7 @@ const getDeviceGroup = async (applicationId, groupId) => { * @param {string} applicationId - The ID of application to get device groups for */ const getDeviceGroups = async (applicationId) => { - return client.get(`/api/v1/applications/${applicationId}/devicegroups`).then(res => { + return client.get(`/api/v1/applications/${applicationId}/device-groups`).then(res => { return res.data }) } @@ -290,12 +290,12 @@ const getDeviceGroups = async (applicationId) => { * @param {string} [description] */ const createDeviceGroup = async (applicationId, name, description) => { - return client.post(`/api/v1/applications/${applicationId}/devicegroups`, { name, description }).then(res => { + return client.post(`/api/v1/applications/${applicationId}/device-groups`, { name, description }).then(res => { const props = { - 'devicegroup-id': res.data.id, + deviceGroupId: res.data.id, 'created-at': res.data.createdAt } - product.capture('$ff-devicegroup-created', props, { + product.capture('$ff-device-group-created', props, { application: applicationId }) return res.data @@ -308,7 +308,7 @@ const createDeviceGroup = async (applicationId, name, description) => { * @param {string} groupId - The ID of the group */ const deleteDeviceGroup = async (applicationId, groupId) => { - return client.delete(`/api/v1/applications/${applicationId}/devicegroups/${groupId}`) + return client.delete(`/api/v1/applications/${applicationId}/device-groups/${groupId}`) } /** @@ -318,7 +318,7 @@ const deleteDeviceGroup = async (applicationId, groupId) => { * @param {object} group */ const updateDeviceGroup = async (applicationId, groupId, name, description) => { - return client.put(`/api/v1/applications/${applicationId}/devicegroups/${groupId}`, { name, description }) + return client.put(`/api/v1/applications/${applicationId}/device-groups/${groupId}`, { name, description }) } /** @@ -331,7 +331,7 @@ const updateDeviceGroup = async (applicationId, groupId, name, description) => { * @param {string[]} members.set - Array of device IDs to set as the only group members */ const updateDeviceGroupMembership = async (applicationId, groupId, { add, remove, set } = {}) => { - return client.patch(`/api/v1/applications/${applicationId}/devicegroups/${groupId}`, { add, remove, set }) + return client.patch(`/api/v1/applications/${applicationId}/device-groups/${groupId}`, { add, remove, set }) } export default { diff --git a/frontend/src/pages/application/DeviceGroup/devices.vue b/frontend/src/pages/application/DeviceGroup/devices.vue index b141787bc..2150fc5b4 100644 --- a/frontend/src/pages/application/DeviceGroup/devices.vue +++ b/frontend/src/pages/application/DeviceGroup/devices.vue @@ -100,7 +100,7 @@ export default { required: true } }, - emits: ['devicegroup-members-updated'], + emits: ['device-group-members-updated'], data: function () { return { localMemberDevices: [], @@ -299,7 +299,7 @@ export default { .then(() => { Alerts.emit('Device Group updated.', 'confirmation') this.hasChanges = false - this.$emit('devicegroup-members-updated') + this.$emit('device-group-members-updated') }) .catch((err) => { this.$toast.error('Failed to update Device Group') diff --git a/frontend/src/pages/application/DeviceGroup/index.vue b/frontend/src/pages/application/DeviceGroup/index.vue index f15087b79..3566a1a73 100644 --- a/frontend/src/pages/application/DeviceGroup/index.vue +++ b/frontend/src/pages/application/DeviceGroup/index.vue @@ -35,8 +35,8 @@ :is-visiting-admin="isVisitingAdmin" :team="team" :team-membership="teamMembership" - @devicegroup-updated="load" - @devicegroup-members-updated="load" + @device-group-updated="load" + @device-group-members-updated="load" /> @@ -66,16 +66,6 @@ export default { TeamTrialBanner }, mixins: [permissionsMixin], - // props: { - // applicationId: { - // type: String, - // required: true - // }, - // devicegroupId: { - // type: String, - // required: true - // } - // }, data: function () { return { mounted: false, diff --git a/frontend/src/pages/application/DeviceGroup/settings.vue b/frontend/src/pages/application/DeviceGroup/settings.vue index 03acc38ef..acce599d2 100644 --- a/frontend/src/pages/application/DeviceGroup/settings.vue +++ b/frontend/src/pages/application/DeviceGroup/settings.vue @@ -47,7 +47,7 @@ export default { required: true } }, - emits: ['devicegroup-updated'], + emits: ['device-group-updated'], data () { return { input: { @@ -94,7 +94,7 @@ export default { } const response = await ApplicationApi.updateDeviceGroup(this.application.id, this.deviceGroup.id, this.input.name, this.input.description) if (response.status === 200) { - this.$emit('devicegroup-updated') + this.$emit('device-group-updated') Alerts.emit('Device Group settings saved', 'confirmation') } else { Alerts.emit('Failed to update device group settings', 'warning', 5000) diff --git a/frontend/src/pages/application/index.vue b/frontend/src/pages/application/index.vue index 28d70eeda..6d5e31ab1 100644 --- a/frontend/src/pages/application/index.vue +++ b/frontend/src/pages/application/index.vue @@ -109,7 +109,7 @@ export default { const routes = [ { label: 'Instances', to: `/application/${this.application.id}/instances`, tag: 'application-overview', icon: ProjectsIcon }, { label: 'Devices', to: `/application/${this.application.id}/devices`, tag: 'application-devices-overview', icon: ChipIcon }, - { label: 'Devices Groups', to: `/application/${this.application.id}/devicegroups`, tag: 'application-devices-groups-overview', icon: ChipIcon }, + { label: 'Devices Groups', to: `/application/${this.application.id}/device-groups`, tag: 'application-devices-groups-overview', icon: ChipIcon }, { label: 'Snapshots', to: `/application/${this.application.id}/snapshots`, tag: 'application-snapshots', icon: ClockIcon }, { label: 'DevOps Pipelines', diff --git a/frontend/src/pages/application/routes.js b/frontend/src/pages/application/routes.js index ae14bf2d3..8033aecea 100644 --- a/frontend/src/pages/application/routes.js +++ b/frontend/src/pages/application/routes.js @@ -51,7 +51,7 @@ export default [ } }, { - path: 'devicegroups', + path: 'device-groups', name: 'ApplicationDeviceGroups', component: ApplicationDeviceGroups, meta: { @@ -149,7 +149,7 @@ export default [ ] }, { - path: '/application/:applicationId/devicegroup/:deviceGroupId', + path: '/application/:applicationId/device-group/:deviceGroupId', name: 'ApplicationDeviceGroupIndex', component: ApplicationDeviceGroupIndex, meta: { From f6f48cddb71191732939d5f6dfad98c2c0016c53 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Fri, 8 Dec 2023 18:05:27 +0000 Subject: [PATCH 29/85] include device.type for devicegroup manage tables --- forge/db/models/DeviceGroup.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/forge/db/models/DeviceGroup.js b/forge/db/models/DeviceGroup.js index f5f688386..fc17277ba 100644 --- a/forge/db/models/DeviceGroup.js +++ b/forge/db/models/DeviceGroup.js @@ -49,7 +49,7 @@ module.exports = { { model: M.Application, attributes: ['hashid', 'id', 'name', 'TeamId'] }, { model: M.Device, - attributes: ['hashid', 'id', 'name', 'TeamId', 'ApplicationId', 'ProjectId', 'ownerType'], + attributes: ['hashid', 'id', 'name', 'type', 'TeamId', 'ApplicationId', 'ProjectId', 'ownerType'], where: { ApplicationId: literal('"Devices"."ApplicationId" = "Application"."id"') }, From 57672c27ce1dba8835347149f4efca45a4236380 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Fri, 8 Dec 2023 18:06:36 +0000 Subject: [PATCH 30/85] include Device Group in Device for group management --- forge/db/models/Device.js | 8 +++++++- forge/db/views/Device.js | 6 ++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/forge/db/models/Device.js b/forge/db/models/Device.js index 2af77d825..13f2f5587 100644 --- a/forge/db/models/Device.js +++ b/forge/db/models/Device.js @@ -209,7 +209,7 @@ module.exports = { ] }) }, - getAll: async (pagination = {}, where = {}, { includeInstanceApplication = false } = {}) => { + getAll: async (pagination = {}, where = {}, { includeInstanceApplication = false, includeDeviceGroup = false } = {}) => { // Pagination const limit = Math.min(parseInt(pagination.limit) || 100, 100) if (pagination.cursor) { @@ -308,6 +308,12 @@ module.exports = { } ] + if (includeDeviceGroup) { + includes.push({ + model: M.DeviceGroup, + attributes: ['hashid', 'id', 'name', 'description', 'ApplicationId'] + }) + } const statusOnlyIncludes = projectInclude.include?.where ? [projectInclude] : [] const [rows, count] = await Promise.all([ diff --git a/forge/db/views/Device.js b/forge/db/views/Device.js index 1f97b11af..1ecc3d2f6 100644 --- a/forge/db/views/Device.js +++ b/forge/db/views/Device.js @@ -27,7 +27,8 @@ module.exports = function (app) { team: { $ref: 'TeamSummary' }, instance: { $ref: 'InstanceSummary' }, application: { $ref: 'ApplicationSummary' }, - editor: { type: 'object', additionalProperties: true } + editor: { type: 'object', additionalProperties: true }, + deviceGroupId: { type: 'string' } } }) @@ -63,7 +64,8 @@ module.exports = function (app) { agentVersion: result.agentVersion, mode: result.mode || 'autonomous', ownerType: result.ownerType, - isDeploying: app.db.controllers.Device.isDeploying(device) + isDeploying: app.db.controllers.Device.isDeploying(device), + deviceGroupId: device.DeviceGroup ? device.DeviceGroup.hashid : null } if (device.Team) { filtered.team = app.db.views.Team.teamSummary(device.Team) From 6d001bfacc851e4aa4817e6aa2f184a42c12e8b0 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Fri, 8 Dec 2023 18:48:49 +0000 Subject: [PATCH 31/85] include deviceGroupSummary for group management --- forge/db/views/Device.js | 7 +++++-- forge/routes/api/application.js | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/forge/db/views/Device.js b/forge/db/views/Device.js index 1ecc3d2f6..ef842efb9 100644 --- a/forge/db/views/Device.js +++ b/forge/db/views/Device.js @@ -28,7 +28,10 @@ module.exports = function (app) { instance: { $ref: 'InstanceSummary' }, application: { $ref: 'ApplicationSummary' }, editor: { type: 'object', additionalProperties: true }, - deviceGroupId: { type: 'string' } + deviceGroup: { + nullable: true, + allOf: [{ $ref: 'DeviceGroupSummary' }] + } } }) @@ -65,7 +68,7 @@ module.exports = function (app) { mode: result.mode || 'autonomous', ownerType: result.ownerType, isDeploying: app.db.controllers.Device.isDeploying(device), - deviceGroupId: device.DeviceGroup ? device.DeviceGroup.hashid : null + deviceGroup: device.DeviceGroup && app.db.views.DeviceGroup.deviceGroupSummary(device.DeviceGroup) } if (device.Team) { filtered.team = app.db.views.Team.teamSummary(device.Team) diff --git a/forge/routes/api/application.js b/forge/routes/api/application.js index c50d4629b..b5834a99c 100644 --- a/forge/routes/api/application.js +++ b/forge/routes/api/application.js @@ -344,7 +344,7 @@ module.exports = async function (app) { ApplicationId: request.application.hashid } - const devices = await app.db.models.Device.getAll(paginationOptions, where, { includeInstanceApplication: false }) + const devices = await app.db.models.Device.getAll(paginationOptions, where, { includeInstanceApplication: false, includeDeviceGroup: true }) devices.devices = devices.devices.map(d => app.db.views.Device.device(d, { statusOnly: paginationOptions.statusOnly })) reply.send(devices) From bca23aaf715c60560fe2137bdf8637116d071a9e Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Fri, 8 Dec 2023 18:50:33 +0000 Subject: [PATCH 32/85] improve table layout --- frontend/src/pages/application/DeviceGroup/devices.vue | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/application/DeviceGroup/devices.vue b/frontend/src/pages/application/DeviceGroup/devices.vue index 2150fc5b4..4c915eeb0 100644 --- a/frontend/src/pages/application/DeviceGroup/devices.vue +++ b/frontend/src/pages/application/DeviceGroup/devices.vue @@ -61,8 +61,8 @@ - {{ device.name }} - {{ device.type }} + {{ device.name }} + {{ device.type }} @@ -127,13 +127,14 @@ export default { { label: 'Name', key: 'name', - sortable: true + sortable: true, + class: 'w-1/3' }, { label: 'Type', key: 'type', sortable: true, - class: 'w-full' + class: 'w-2/3' } ] } From 51ab25f8fcdb743a5960e417bccfa51349f1a98f Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Fri, 8 Dec 2023 18:50:57 +0000 Subject: [PATCH 33/85] use deviceGroup for filtering group management --- frontend/src/pages/application/DeviceGroup/devices.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/application/DeviceGroup/devices.vue b/frontend/src/pages/application/DeviceGroup/devices.vue index 4c915eeb0..be409034a 100644 --- a/frontend/src/pages/application/DeviceGroup/devices.vue +++ b/frontend/src/pages/application/DeviceGroup/devices.vue @@ -172,7 +172,7 @@ export default { selected: false } }) || [] - const ungrouped = this.applicationDevices.filter((device) => !device.deviceGroupId) + const ungrouped = this.applicationDevices.filter((device) => !device.deviceGroup) this.localAvailableDevices = ungrouped?.map((device) => { return { id: device.id, From d34b22fc2daae695c5564d4a05bd383718eb2331 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 11 Dec 2023 10:25:28 +0000 Subject: [PATCH 34/85] update concepts with Device Groups info --- docs/user/concepts.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/user/concepts.md b/docs/user/concepts.md index 84fdcb063..e3c4844cb 100644 --- a/docs/user/concepts.md +++ b/docs/user/concepts.md @@ -157,3 +157,12 @@ the remote device. To further simplify device registration, Provisioning Tokens can be created to allow devices to automatically connect to a team without having to manually register them first. The token can also be configured to assign a device directly to a Node-RED instance within the team. + +### Device Groups + +**Introduced in FlowFuse 1.15** + +Device groups allow you to organise your Application devices into logical groups. +For now, this is simply an organisational tool, but in the future, we will be +adding new features like the ability to deploy snapshots to a group of devices via +a [pipeline](#devops-pipeline). From d3a826bad25264d6ef83f0ea5c1686dc5e63afd0 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 11 Dec 2023 11:27:58 +0000 Subject: [PATCH 35/85] add application device groups FE API tests --- test/unit/frontend/api/application.spec.js | 91 ++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 test/unit/frontend/api/application.spec.js diff --git a/test/unit/frontend/api/application.spec.js b/test/unit/frontend/api/application.spec.js new file mode 100644 index 000000000..b4ed49515 --- /dev/null +++ b/test/unit/frontend/api/application.spec.js @@ -0,0 +1,91 @@ +import { expect, vi } from 'vitest' + +/* + Mock Values & Imports +*/ +const mockGet = vi.fn().mockImplementation().mockReturnValue(Promise.resolve({ + data: { + teams: [], + devices: [], + deviceGroups: [] + } +})) +const mockPost = vi.fn().mockImplementation().mockReturnValue(Promise.resolve({ data: {} })) +const mockPut = vi.fn().mockImplementation().mockReturnValue(Promise.resolve({ data: {} })) +const mockDelete = vi.fn().mockImplementation().mockReturnValue(Promise.resolve({ data: {} })) +const mockPatch = vi.fn().mockImplementation().mockReturnValue(Promise.resolve({ data: {} })) + +vi.mock('@/api/client', () => { + return { + default: { + get: mockGet, + post: mockPost, + put: mockPut, + delete: mockDelete, + patch: mockPatch + } + } +}) + +/* + Tests +*/ +describe('Application API', async () => { + const ApplicationAPI = await import('../../../../frontend/src/api/application.js') + + afterEach(() => { + mockGet.mockClear() + mockPost.mockClear() + mockPut.mockClear() + mockDelete.mockClear() + }) + describe('Device Groups', async () => { + test('getDeviceGroup calls the correct API endpoint when provided an applicationId and groupId', () => { + const applicationId = '1234' + const groupId = '5678' + ApplicationAPI.default.getDeviceGroup(applicationId, groupId) + expect(mockGet).toHaveBeenCalledOnce() + expect(mockGet).toHaveBeenCalledWith(`/api/v1/applications/${applicationId}/device-groups/${groupId}`) + }) + + test('createDeviceGroup calls the correct API endpoint, with the relevant options', () => { + const applicationId = '1234' + const name = 'My Group' + const description = 'My Group Description' + ApplicationAPI.default.createDeviceGroup(applicationId, name, description) + expect(mockPost).toHaveBeenCalledOnce() + expect(mockPost).toHaveBeenCalledWith(`/api/v1/applications/${applicationId}/device-groups`, { name, description }) + }) + + test('deleteDeviceGroup calls the correct API endpoint', () => { + const applicationId = '1234' + const groupId = '5678' + ApplicationAPI.default.deleteDeviceGroup(applicationId, groupId) + expect(mockDelete).toHaveBeenCalledOnce() + expect(mockDelete).toHaveBeenCalledWith(`/api/v1/applications/${applicationId}/device-groups/${groupId}`) + }) + + test('updateDeviceGroup makes a PUT request to update a device group', () => { + const applicationId = '1234' + const groupId = '4321' + const name = 'My Group' + const description = 'My Group Description' + ApplicationAPI.default.updateDeviceGroup(applicationId, groupId, name, description) + expect(mockPut).toHaveBeenCalledOnce() + expect(mockPut).toHaveBeenCalledWith(`/api/v1/applications/${applicationId}/device-groups/4321`, { name, description }) + }) + + test('updateDeviceGroupMembership makes a PATCH request with `set` data', () => { + const applicationId = '1234' + const groupId = '4321' + const data = { + set: { + devices: ['1234', '5678'] + } + } + ApplicationAPI.default.updateDeviceGroupMembership(applicationId, groupId, data) + expect(mockPatch).toHaveBeenCalledOnce() + expect(mockPatch).toHaveBeenCalledWith(`/api/v1/applications/${applicationId}/device-groups/4321`, data) + }) + }) +}) From 65016ee74f33b0034b32511f282c61152850ae74 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 11 Dec 2023 11:30:31 +0000 Subject: [PATCH 36/85] add missing FE test `getDeviceGroups` --- test/unit/frontend/api/application.spec.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/unit/frontend/api/application.spec.js b/test/unit/frontend/api/application.spec.js index b4ed49515..f34dd5524 100644 --- a/test/unit/frontend/api/application.spec.js +++ b/test/unit/frontend/api/application.spec.js @@ -40,6 +40,13 @@ describe('Application API', async () => { mockDelete.mockClear() }) describe('Device Groups', async () => { + test('getDeviceGroups calls the correct API endpoint when provided an applicationId', () => { + const applicationId = '1234' + ApplicationAPI.default.getDeviceGroups(applicationId) + expect(mockGet).toHaveBeenCalledOnce() + expect(mockGet).toHaveBeenCalledWith(`/api/v1/applications/${applicationId}/device-groups`) + }) + test('getDeviceGroup calls the correct API endpoint when provided an applicationId and groupId', () => { const applicationId = '1234' const groupId = '5678' From 493ae0c8970318e0ca8f605edab58a5821a82f68 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Fri, 15 Dec 2023 18:08:21 +0000 Subject: [PATCH 37/85] Change doc tag from "Applications" to "Application Device Group" --- forge/routes/api-docs.js | 1 + forge/routes/api/applicationDeviceGroup.js | 12 ++++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/forge/routes/api-docs.js b/forge/routes/api-docs.js index 286d5f068..d3bacb56c 100644 --- a/forge/routes/api-docs.js +++ b/forge/routes/api-docs.js @@ -22,6 +22,7 @@ module.exports = fp(async function (app, opts, done) { { name: 'Team Invitations', description: '' }, { name: 'Team Devices', description: '' }, { name: 'Applications', description: '' }, + { name: 'Application Device Groups', description: '' }, { name: 'Instances', description: '' }, { name: 'Instance Types', description: '' }, { name: 'Instance Actions', description: '' }, diff --git a/forge/routes/api/applicationDeviceGroup.js b/forge/routes/api/applicationDeviceGroup.js index 81cd71b07..6f7964abd 100644 --- a/forge/routes/api/applicationDeviceGroup.js +++ b/forge/routes/api/applicationDeviceGroup.js @@ -48,7 +48,7 @@ module.exports = async function (app) { preHandler: app.needsPermission('application:device-group:list'), schema: { summary: 'Get a list of device groups in an application', - tags: ['Applications'], + tags: ['Application Device Groups'], query: { $ref: 'PaginationParams' }, params: { type: 'object', @@ -96,7 +96,7 @@ module.exports = async function (app) { preHandler: app.needsPermission('application:device-group:create'), schema: { summary: 'Add a new Device Group to an Application', - tags: ['Applications'], + tags: ['Application Device Groups'], body: { type: 'object', properties: { @@ -143,7 +143,7 @@ module.exports = async function (app) { preHandler: app.needsPermission('application:device-group:update'), schema: { summary: 'Update a Device Group', - tags: ['Applications'], + tags: ['Application Device Groups'], body: { type: 'object', properties: { @@ -190,7 +190,7 @@ module.exports = async function (app) { preHandler: app.needsPermission('application:device-group:read'), schema: { summary: 'Get a specific Device Group', - tags: ['Applications'], + tags: ['Application Device Groups'], params: { type: 'object', properties: { @@ -223,7 +223,7 @@ module.exports = async function (app) { preHandler: app.needsPermission('application:device-group:membership:update'), schema: { summary: 'Update Device Group membership', - tags: ['Applications'], + tags: ['Application Device Groups'], body: { type: 'object', properties: { @@ -272,7 +272,7 @@ module.exports = async function (app) { preHandler: app.needsPermission('application:device-group:delete'), schema: { summary: 'Delete a Device Group', - tags: ['Applications'], + tags: ['Application Device Groups'], params: { type: 'object', properties: { From 3cbb137f7ba5a96efc8e305539d571f4095e7b95 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Fri, 15 Dec 2023 18:08:40 +0000 Subject: [PATCH 38/85] Move Device Groups tests to own file --- .../api/applicationDeviceGroups_spec.js | 663 ++++++++++++++++++ 1 file changed, 663 insertions(+) create mode 100644 test/unit/forge/routes/api/applicationDeviceGroups_spec.js diff --git a/test/unit/forge/routes/api/applicationDeviceGroups_spec.js b/test/unit/forge/routes/api/applicationDeviceGroups_spec.js new file mode 100644 index 000000000..f132ff0f0 --- /dev/null +++ b/test/unit/forge/routes/api/applicationDeviceGroups_spec.js @@ -0,0 +1,663 @@ +const should = require('should') // eslint-disable-line + +const setup = require('../setup') + +const FF_UTIL = require('flowforge-test-utils') +const { Roles } = FF_UTIL.require('forge/lib/roles') + +describe('Application Device Groups API', function () { + let app + const TestObjects = { + /** admin - owns ateam */ + alice: {}, + /** owner of bteam */ + bob: {}, + /** member of b team */ + chris: {}, + /** not connected to any teams */ + dave: {}, + ATeam: {}, + BTeam: {}, + /** B-team Application */ + application: {} + } + /** @type {import('../../../../lib/TestModelFactory')} */ + let factory = null + let objectCount = 0 + const generateName = (root = 'object') => `${root}-${objectCount++}` + + before(async function () { + app = await setup() + factory = app.factory + + // ATeam ( alice (owner), bob ) + // BTeam ( bob (owner), chris ) + + // Alice create in setup() + TestObjects.alice = await app.db.models.User.byUsername('alice') + TestObjects.bob = await app.db.models.User.create({ username: 'bob', name: 'Bob Solo', email: 'bob@example.com', email_verified: true, password: 'bbPassword' }) + TestObjects.chris = await app.db.models.User.create({ username: 'chris', name: 'Chris Crackers', email: 'chris@example.com', email_verified: true, password: 'ccPassword' }) + TestObjects.dave = await app.db.models.User.create({ username: 'dave', name: 'Dave Smith', email: 'dave@example.com', email_verified: true, password: 'ddPassword' }) + + // ATeam create in setup() + TestObjects.ATeam = await app.db.models.Team.byName('ATeam') + TestObjects.BTeam = await app.db.models.Team.create({ name: 'BTeam', TeamTypeId: app.defaultTeamType.id }) + + // alice : admin - owns ateam (setup) + // bob - owner of bteam + // chris - member of b team + // dave - not connected to any teams + await TestObjects.BTeam.addUser(TestObjects.bob, { through: { role: Roles.Owner } }) + await TestObjects.BTeam.addUser(TestObjects.chris, { through: { role: Roles.Member } }) + + TestObjects.application = await app.db.models.Application.create({ + name: 'B-team Application', + description: 'B-team Application description', + TeamId: TestObjects.BTeam.id + }) + }) + + async function login (username, password) { + const response = await app.inject({ + method: 'POST', + url: '/account/login', + payload: { username, password, remember: false } + }) + response.cookies.should.have.length(1) + response.cookies[0].should.have.property('name', 'sid') + return response.cookies[0].value + } + + after(async function () { + await app.close() + }) + + describe('Create Device Group', async function () { + it('Owner can create a device group', async function () { + const sid = await login('bob', 'bbPassword') + const application = await factory.createApplication({ name: generateName('app') }, TestObjects.BTeam) + + const response = await app.inject({ + method: 'POST', + url: `/api/v1/applications/${application.hashid}/device-groups`, + cookies: { sid }, + payload: { + name: 'my device group', + description: 'my device group description' + } + }) + + response.statusCode.should.equal(201) + + const result = response.json() + result.should.have.property('id') + result.should.have.property('name', 'my device group') + result.should.have.property('description', 'my device group description') + }) + + it('Cannot create a device group with empty name', async function () { + const sid = await login('bob', 'bbPassword') + const application = await factory.createApplication({ name: generateName('app') }, TestObjects.BTeam) + + const response = await app.inject({ + method: 'POST', + url: `/api/v1/applications/${application.hashid}/device-groups`, + cookies: { sid }, + payload: { + name: '', + description: 'my device group description' + } + }) + + response.statusCode.should.equal(400) + + const result = response.json() + result.should.have.property('code', 'invalid_name') + result.should.have.property('error') + }) + + it('Non Owner can not create a device group', async function () { + const sid = await login('chris', 'ccPassword') + const application = await factory.createApplication({ name: generateName('app') }, TestObjects.BTeam) + + const response = await app.inject({ + method: 'POST', + url: `/api/v1/applications/${application.hashid}/device-groups`, + cookies: { sid }, + payload: { + name: 'my device group', + description: 'my device group description' + } + }) + + 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 create a device group', async function () { + const sid = await login('dave', 'ddPassword') + const application = TestObjects.application + + const response = await app.inject({ + method: 'POST', + url: `/api/v1/applications/${application.hashid}/device-groups`, + cookies: { sid }, + payload: { + name: 'my device group', + description: 'my device group description' + } + }) + response.statusCode.should.be.oneOf([400, 403, 404]) + }) + }) + + describe('Read Device Groups', async function () { + it('Owner can read device groups', async function () { + 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'), description: 'a description' }, application) + + const response = await app.inject({ + method: 'GET', + url: `/api/v1/applications/${application.hashid}/device-groups`, + cookies: { sid } + }) + + response.statusCode.should.equal(200) + + const result = response.json() + result.should.have.property('groups') + result.groups.should.have.length(1) + result.groups[0].should.have.property('id', deviceGroup.hashid) + result.groups[0].should.have.property('name', deviceGroup.name) + result.groups[0].should.have.property('description', deviceGroup.description) + }) + + it('Paginates device groups', async function () { + const sid = await login('bob', 'bbPassword') + const application = await factory.createApplication({ name: generateName('app') }, TestObjects.BTeam) + await factory.createApplicationDeviceGroup({ name: generateName('device-group'), description: 'a description' }, application) + await factory.createApplicationDeviceGroup({ name: generateName('device-group'), description: 'a description' }, application) + + const response = await app.inject({ + method: 'GET', + url: `/api/v1/applications/${application.hashid}/device-groups?limit=1`, + cookies: { sid } + }) + + response.statusCode.should.equal(200) + + const result = response.json() + result.should.have.property('groups') + result.groups.should.have.length(1) + }) + + it('Non Owner can read device groups', async function () { + const sid = await login('chris', 'ccPassword') + const application = await factory.createApplication({ name: generateName('app') }, TestObjects.BTeam) + await factory.createApplicationDeviceGroup({ name: generateName('device-group') }, application) + + const response = await app.inject({ + method: 'GET', + url: `/api/v1/applications/${application.hashid}/device-groups`, + cookies: { sid } + }) + + response.statusCode.should.equal(200) + }) + + it('Can get a specific group and associated devices', async function () { + const sid = await login('chris', 'ccPassword') + const application = await factory.createApplication({ name: generateName('app') }, TestObjects.BTeam) + await factory.createApplicationDeviceGroup({ name: generateName('device-group') }, application) + const deviceGroup = await factory.createApplicationDeviceGroup({ name: generateName('device-group') }, application) + const device1 = await factory.createDevice({ name: generateName('device-1') }, TestObjects.BTeam, null, application) + const device2 = await factory.createDevice({ name: generateName('device-2') }, TestObjects.BTeam, null, application) + await app.db.controllers.DeviceGroup.updateDeviceGroupMembership(deviceGroup, { addDevices: [device1, device2] }) + + const response = await app.inject({ + method: 'GET', + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup.hashid}`, + cookies: { sid } + }) + + response.statusCode.should.equal(200) + + // check the response + const result = response.json() + result.should.have.property('id', deviceGroup.hashid) + result.should.have.property('name', deviceGroup.name) + result.should.have.property('description', deviceGroup.description) + result.should.have.property('devices') + result.devices.should.have.length(2) + // ensure one of the 2 devices matches device1.hashid + const device1Result = result.devices.find((device) => device.id === device1.hashid) + should(device1Result).be.an.Object().and.not.be.null() + // ensure one of the 2 devices matches device2.hashid + const device2Result = result.devices.find((device) => device.id === device2.hashid) + should(device2Result).be.an.Object().and.not.be.null() + }) + + it('404s when getting a specific group that does not exist', async function () { + const sid = await login('chris', 'ccPassword') + const application = await factory.createApplication({ name: generateName('app') }, TestObjects.BTeam) + + const response = await app.inject({ + method: 'GET', + url: `/api/v1/applications/${application.hashid}/device-groups/doesNotExist`, + cookies: { sid } + }) + + response.statusCode.should.equal(404) + const result = response.json() + result.should.have.property('error') + result.should.have.property('code', 'not_found') + }) + + it('Non Member can not read device groups', async function () { + const sid = await login('dave', 'ddPassword') + const application = await factory.createApplication({ name: generateName('app') }, TestObjects.BTeam) + await factory.createApplicationDeviceGroup({ name: generateName('device-group') }, application) + + const response = await app.inject({ + method: 'GET', + url: `/api/v1/applications/${application.hashid}/device-groups`, + cookies: { sid } + }) + + response.statusCode.should.be.oneOf([400, 403, 404]) + }) + }) + + describe('Update Device Group', async function () { + it('Owner can update a device group', async function () { + 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 originalId = deviceGroup.id + + // now call the API to update name and desc + const response = await app.inject({ + method: 'PUT', + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup.hashid}`, + cookies: { sid }, + payload: { + name: 'updated name', + description: 'updated description' + } + }) + + // ensure success + response.statusCode.should.equal(200) + const updatedDeviceGroup = await app.db.models.DeviceGroup.byId(deviceGroup.hashid) + updatedDeviceGroup.should.have.property('id', originalId) + updatedDeviceGroup.should.have.property('name', 'updated name') + updatedDeviceGroup.should.have.property('description', 'updated description') + }) + + it('Cannot update a device group with empty name', async function () { + 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') + + // now call the API to update name and desc + const response = await app.inject({ + method: 'PUT', + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup.hashid}`, + cookies: { sid }, + payload: { + name: '', + description: 'updated description' + } + }) + + response.statusCode.should.equal(400) + + const result = response.json() + result.should.have.property('code', 'invalid_name') + result.should.have.property('error') + }) + + it('Non Owner can not update a device group', 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 app.inject({ + method: 'PUT', + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup.hashid}`, + cookies: { sid }, + payload: { + name: 'updated name', + description: 'updated description' + } + }) + + 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', 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 app.inject({ + method: 'PUT', + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup.hashid}`, + cookies: { sid }, + payload: { + name: 'updated name', + description: 'updated description' + } + }) + + response.statusCode.should.be.oneOf([400, 403, 404]) + }) + }) + + describe('Delete Device Group', async function () { + it('Owner can delete a device group', async function () { + 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') }, application) + + const response = await app.inject({ + method: 'DELETE', + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup.hashid}`, + cookies: { sid } + }) + + response.statusCode.should.equal(200) + }) + it('Non Owner can not delete a device group', 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 app.inject({ + method: 'DELETE', + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup.hashid}`, + cookies: { sid } + }) + + 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 delete a device group', 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 app.inject({ + method: 'DELETE', + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup.hashid}`, + cookies: { sid } + }) + + response.statusCode.should.be.oneOf([400, 403, 404]) + }) + }) + + describe('Update Device Group Membership', async function () { + it('Owner can add a device to a new group', async function () { + 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') }, application) + const device = await factory.createDevice({ name: generateName('device') }, TestObjects.BTeam, null, application) + const response = await app.inject({ + method: 'PATCH', + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup.hashid}`, + cookies: { sid }, + payload: { + add: [device.hashid] + } + }) + + response.statusCode.should.equal(200) + + // call the various db accessors and verify the group contains 1 device + const updatedDeviceGroup = await app.db.models.DeviceGroup.byId(deviceGroup.hashid) + updatedDeviceGroup.should.have.property('Devices').and.have.length(1) + const deviceCount = await updatedDeviceGroup.deviceCount() + deviceCount.should.equal(1) + const devices = await updatedDeviceGroup.getDevices() + devices.should.have.length(1) + }) + it('Owner can add a device to an existing group', async function () { + 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') }, application) + const device1 = await factory.createDevice({ name: generateName('device') }, TestObjects.BTeam, null, application) + const device2 = await factory.createDevice({ name: generateName('device') }, TestObjects.BTeam, null, application) + await app.db.controllers.DeviceGroup.updateDeviceGroupMembership(deviceGroup, { addDevices: [device1] }) + // verify the group contains 1 device + const updatedDeviceGroup = await app.db.models.DeviceGroup.byId(deviceGroup.hashid) + updatedDeviceGroup.should.have.property('Devices').and.have.length(1) + + const response = await app.inject({ + method: 'PATCH', + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup.hashid}`, + cookies: { sid }, + payload: { + add: [device2.hashid] + } + }) + + response.statusCode.should.equal(200) + updatedDeviceGroup.reload() + const deviceCount = await updatedDeviceGroup.deviceCount() + deviceCount.should.equal(2) + const devices = await updatedDeviceGroup.getDevices() + devices.should.have.length(2) + const updatedDeviceGroup2 = await app.db.models.DeviceGroup.byId(deviceGroup.hashid) + updatedDeviceGroup2.should.have.property('Devices').and.have.length(2) + }) + it('Owner can remove a device from an existing group', async function () { + 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') }, application) + const device1 = await factory.createDevice({ name: generateName('device') }, TestObjects.BTeam, null, application) + const device2 = await factory.createDevice({ name: generateName('device') }, TestObjects.BTeam, null, application) + await app.db.controllers.DeviceGroup.updateDeviceGroupMembership(deviceGroup, { addDevices: [device1, device2] }) + // verify the group contains 2 devices + const updatedDeviceGroup = await app.db.models.DeviceGroup.byId(deviceGroup.hashid) + updatedDeviceGroup.should.have.property('Devices').and.have.length(2) + + const response = await app.inject({ + method: 'PATCH', + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup.hashid}`, + cookies: { sid }, + payload: { + remove: [device2.hashid] + } + }) + + response.statusCode.should.equal(200) + + // get the group from DB and verify it contains 1 device (device1) + const updatedDeviceGroup2 = await app.db.models.DeviceGroup.byId(deviceGroup.hashid) + updatedDeviceGroup2.should.have.property('Devices').and.have.length(1) + updatedDeviceGroup2.Devices[0].should.have.property('id', device1.id) + }) + it('Owner can add and remove devices from an existing group', async function () { + 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') }, application) + const device1 = await factory.createDevice({ name: generateName('device') }, TestObjects.BTeam, null, application) + const device2 = await factory.createDevice({ name: generateName('device') }, TestObjects.BTeam, null, application) + const device3 = await factory.createDevice({ name: generateName('device') }, TestObjects.BTeam, null, application) + await app.db.controllers.DeviceGroup.updateDeviceGroupMembership(deviceGroup, { addDevices: [device1, device2] }) + // verify the group contains 2 devices + const updatedDeviceGroup = await app.db.models.DeviceGroup.byId(deviceGroup.hashid) + updatedDeviceGroup.should.have.property('Devices').and.have.length(2) + + const response = await app.inject({ + method: 'PATCH', + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup.hashid}`, + cookies: { sid }, + payload: { + add: [device3.hashid], + remove: [device2.hashid] + } + }) + + response.statusCode.should.equal(200) + + // get the group from DB and verify it contains 2 devices (device1, device3) + const updatedDeviceGroup2 = await app.db.models.DeviceGroup.byId(deviceGroup.hashid) + updatedDeviceGroup2.should.have.property('Devices').and.have.length(2) + updatedDeviceGroup2.Devices[0].should.have.property('id', device1.id) + updatedDeviceGroup2.Devices[1].should.have.property('id', device3.id) + }) + + it('Owner can set the list of devices in existing group', async function () { + 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') }, application) + const device1 = await factory.createDevice({ name: generateName('device') }, TestObjects.BTeam, null, application) + const device2 = await factory.createDevice({ name: generateName('device') }, TestObjects.BTeam, null, application) + const device3 = await factory.createDevice({ name: generateName('device') }, TestObjects.BTeam, null, application) + await app.db.controllers.DeviceGroup.updateDeviceGroupMembership(deviceGroup, { addDevices: [device1, device2] }) + // verify the group contains 2 devices + const updatedDeviceGroup = await app.db.models.DeviceGroup.byId(deviceGroup.hashid) + await updatedDeviceGroup.should.have.property('Devices').and.have.length(2) + + const response = await app.inject({ + method: 'PATCH', + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup.hashid}`, + cookies: { sid }, + payload: { + set: [device3.hashid] + } + }) + + response.statusCode.should.equal(200) + + // get the group from DB and verify it contains 1 device (device3) + const updatedDeviceGroup2 = await app.db.models.DeviceGroup.byId(deviceGroup.hashid) + updatedDeviceGroup2.should.have.property('Devices').and.have.length(1) + updatedDeviceGroup2.Devices[0].should.have.property('id', device3.id) + }) + + it('Can not add a device to a group in a different team', async function () { + const sid = await login('bob', 'bbPassword') + const application = TestObjects.application // BTeam application + const deviceGroup = await factory.createApplicationDeviceGroup({ name: generateName('device-group') }, application) + const device = await factory.createDevice({ name: generateName('device') }, TestObjects.ATeam, null, application) + + const response = await app.inject({ + method: 'PATCH', + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup.hashid}`, + cookies: { sid }, + payload: { + add: [device.hashid] + } + }) + + response.statusCode.should.equal(400) + response.json().should.have.property('code', 'invalid_input') + // double check the device did not get added to the group + const updatedDeviceGroup = await app.db.models.DeviceGroup.byId(deviceGroup.hashid) + updatedDeviceGroup.should.have.property('Devices').and.have.length(0) + }) + it('Can not add a device to a group if they belong to different applications', async function () { + const sid = await login('bob', 'bbPassword') + const application = TestObjects.application // BTeam application + const application2 = await factory.createApplication({ name: generateName('application') }, TestObjects.BTeam) + const deviceGroup = await factory.createApplicationDeviceGroup({ name: generateName('device-group') }, application) + const device = await factory.createDevice({ name: generateName('device') }, TestObjects.ATeam, null, application2) + + const response = await app.inject({ + method: 'PATCH', + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup.hashid}`, + cookies: { sid }, + payload: { + add: [device.hashid] + } + }) + + response.statusCode.should.equal(400) + response.json().should.have.property('code', 'invalid_input') + // double check the device did not get added to the group + const updatedDeviceGroup = await app.db.models.DeviceGroup.byId(deviceGroup.hashid) + updatedDeviceGroup.should.have.property('Devices').and.have.length(0) + }) + + it('Can not add a device to a group if already in a group', async function () { + const sid = await login('bob', 'bbPassword') + const application = TestObjects.application // BTeam application + const deviceGroup = await factory.createApplicationDeviceGroup({ name: generateName('device-group') }, application) + const deviceGroup2 = await factory.createApplicationDeviceGroup({ name: generateName('device-group') }, application) + const device = await factory.createDevice({ name: generateName('device') }, TestObjects.BTeam, null, application) + await app.db.controllers.DeviceGroup.updateDeviceGroupMembership(deviceGroup, { addDevices: [device] }) + + const response = await app.inject({ + method: 'PATCH', + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup2.hashid}`, + cookies: { sid }, + payload: { + add: [device.hashid] + } + }) + + response.statusCode.should.equal(400) + response.json().should.have.property('code', 'invalid_input') + // double check the device did not get added to the group + const updatedDeviceGroup = await app.db.models.DeviceGroup.byId(deviceGroup2.hashid) + updatedDeviceGroup.should.have.property('Devices').and.have.length(0) + }) + + it('Non Owner can not update a device group membership', 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 device = await factory.createDevice({ name: generateName('device') }, TestObjects.BTeam, null, application) + + const response = await app.inject({ + method: 'PATCH', + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup.hashid}`, + cookies: { sid }, + payload: { + set: [device.hashid] + } + }) + + 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 membership', 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 device = await factory.createDevice({ name: generateName('device') }, TestObjects.BTeam, null, application) + + const response = await app.inject({ + method: 'PATCH', + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup.hashid}`, + cookies: { sid }, + payload: { + set: [device.hashid] + } + }) + + response.statusCode.should.be.oneOf([400, 403, 404]) + }) + }) +}) From fdefa11c001c7c25dc27aeef9e9f19d140aa0635 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Fri, 15 Dec 2023 20:03:31 +0000 Subject: [PATCH 39/85] move to Enterprise tier As per https://github.com/FlowFuse/flowfuse/issues/2997#issuecomment-1857711941 --- forge/db/controllers/index.js | 1 - forge/{ => ee}/db/controllers/DeviceGroup.js | 454 ++++++------ forge/ee/db/controllers/index.js | 3 +- .../routes/applicationDeviceGroups/index.js} | 666 +++++++++--------- forge/ee/routes/index.js | 1 + forge/routes/api/application.js | 4 - .../api/applicationDeviceGroups_spec.js | 4 +- 7 files changed, 576 insertions(+), 557 deletions(-) rename forge/{ => ee}/db/controllers/DeviceGroup.js (96%) rename forge/{routes/api/applicationDeviceGroup.js => ee/routes/applicationDeviceGroups/index.js} (90%) rename test/unit/forge/{ => ee}/routes/api/applicationDeviceGroups_spec.js (99%) diff --git a/forge/db/controllers/index.js b/forge/db/controllers/index.js index ecc595b97..657f88070 100644 --- a/forge/db/controllers/index.js +++ b/forge/db/controllers/index.js @@ -24,7 +24,6 @@ const modelTypes = [ 'ProjectTemplate', 'ProjectSnapshot', 'Device', - 'DeviceGroup', 'BrokerClient', 'StorageCredentials', 'StorageFlows', diff --git a/forge/db/controllers/DeviceGroup.js b/forge/ee/db/controllers/DeviceGroup.js similarity index 96% rename from forge/db/controllers/DeviceGroup.js rename to forge/ee/db/controllers/DeviceGroup.js index df80d9507..6e37ea961 100644 --- a/forge/db/controllers/DeviceGroup.js +++ b/forge/ee/db/controllers/DeviceGroup.js @@ -1,227 +1,227 @@ -const { Op } = require('sequelize') - -const { ControllerError } = require('../../lib/errors') -class DeviceGroupMembershipValidationError extends ControllerError { - /** - * @param {string} code - * @param {string} message - * @param {number} statusCode - * @param {Object} options - */ - constructor (code, message, statusCode, options) { - super(code, message, statusCode, options) - this.name = 'DeviceGroupMembershipValidationError' - } -} - -module.exports = { - - /** - * Create a Device Group - * @param {import("../../forge").ForgeApplication} app The application object - * @param {string} name The name of the Device Group - * @param {Object} options - * @param {Object} [options.application] The application this Device Group will belong to - * @param {string} [options.description] The description of the Device Group - * @returns {Promise} The created Device Group - */ - createDeviceGroup: async function (app, name, { application = null, description } = {}) { - // Create a Device Group that devices can be linked to - // * name is required - // * application, description are optional - // * FUTURE: colors (background, border, text) and icon will be optional - - return await app.db.models.DeviceGroup.create({ - name, - description, - ApplicationId: application?.id - }) - }, - - updateDeviceGroup: async function (app, deviceGroup, { name = undefined, description = undefined } = {}) { - // * deviceGroup is required. - // * name, description, color are optional - if (!deviceGroup) { - throw new Error('DeviceGroup is required') - } - let changed = false - if (typeof name !== 'undefined') { - deviceGroup.name = name - changed = true - } - if (typeof description !== 'undefined') { - deviceGroup.description = description - changed = true - } - if (changed) { - await deviceGroup.save() - await deviceGroup.reload() - } - return deviceGroup - }, - - updateDeviceGroupMembership: async function (app, deviceGroup, { addDevices, removeDevices, setDevices } = {}) { - // * deviceGroup is required. The object must be a Sequelize model instance and must include the Devices - // * addDevices, removeDevices, setDevices are optional - // * if setDevices is provided, this will be used to set the devices assigned to the group, removing any devices that are not in the set - // * if addDevices is provided, these devices will be added to the group - // * if removeDevices is provided, these devices will be removed from the group - // if a device appears in both addDevices and removeDevices, it will be removed from the group (remove occurs after add) - if (!setDevices && !addDevices && !removeDevices) { - return // nothing to do - } - if (!deviceGroup || typeof deviceGroup !== 'object') { - throw new Error('DeviceGroup is required') - } - let actualRemoveDevices = [] - let actualAddDevices = [] - const currentMembers = await deviceGroup.getDevices() - // from this point on, all IDs need to be numeric (convert as needed) - const currentMemberIds = deviceListToIds(currentMembers, app.db.models.Device.decodeHashid) - setDevices = setDevices && deviceListToIds(setDevices, app.db.models.Device.decodeHashid) - addDevices = addDevices && deviceListToIds(addDevices, app.db.models.Device.decodeHashid) - removeDevices = removeDevices && deviceListToIds(removeDevices, app.db.models.Device.decodeHashid) - - // setDevices is an atomic operation, it will replace the current list of devices with the specified list - if (typeof setDevices !== 'undefined') { - // create a list of devices that are currently assigned to the group, minus the devices in the set, these are the ones to remove - actualRemoveDevices = currentMemberIds.filter(d => !setDevices.includes(d)) - // create a list of devices that are in the set, minus the devices that are currently assigned to the group, these are the ones to add - actualAddDevices = setDevices.filter(d => !currentMemberIds.includes(d)) - } else { - if (typeof removeDevices !== 'undefined') { - actualRemoveDevices = currentMemberIds.filter(d => removeDevices.includes(d)) - } - if (typeof addDevices !== 'undefined') { - actualAddDevices = addDevices.filter(d => !currentMemberIds.includes(d)) - } - } - - // wrap the dual operation in a transaction to avoid inconsistent state - const t = await app.db.sequelize.transaction() - try { - // add devices - if (actualAddDevices.length > 0) { - await this.assignDevicesToGroup(app, deviceGroup, actualAddDevices) - } - // remove devices - if (actualRemoveDevices.length > 0) { - await this.removeDevicesFromGroup(app, deviceGroup, actualRemoveDevices) - } - // commit the transaction - await t.commit() - } catch (err) { - // Rollback transaction if any errors were encountered - await t.rollback() - // if the error is a DeviceGroupMembershipValidationError, rethrow it - if (err instanceof DeviceGroupMembershipValidationError) { - throw err - } - // otherwise, throw a friendly error message along with the original error - throw new Error(`Failed to update device group membership: ${err.message}`) - } - }, - - assignDevicesToGroup: async function (app, deviceGroup, deviceList) { - const deviceIds = await validateDeviceList(app, deviceGroup, deviceList, null) - await app.db.models.Device.update({ DeviceGroupId: deviceGroup.id }, { where: { id: deviceIds.addList } }) - }, - - /** - * Remove 1 or more devices from the specified DeviceGroup - * @param {*} app The application object - * @param {*} deviceGroupId The device group id - * @param {*} deviceList A list of devices to remove from the group - */ - removeDevicesFromGroup: async function (app, deviceGroup, deviceList) { - const deviceIds = await validateDeviceList(app, deviceGroup, null, deviceList) - // null every device.DeviceGroupId row in device table where the id === deviceGroupId and device.id is in the deviceList - await app.db.models.Device.update({ DeviceGroupId: null }, { where: { id: deviceIds.removeList, DeviceGroupId: deviceGroup.id } }) - }, - - DeviceGroupMembershipValidationError -} - -/** - * Convert a list of devices to a list of device ids - * @param {Object[]|String[]|Number[]} deviceList List of devices to convert to ids - * @param {Function} decoderFn The decoder function to use on hashes - * @returns {Number[]} Array of device IDs - */ -function deviceListToIds (deviceList, decoderFn) { - // Convert a list of devices (object|id|hash) to a list of device ids - const ids = deviceList?.map(device => { - let id = device - if (typeof device === 'string') { - [id] = decoderFn(device) - } else if (typeof device === 'object') { - id = device.id - } - return id - }) - return ids -} - -/** - * Verify devices are suitable for the specified group: - * - * * All devices in the list must either have DeviceGroupId===null or DeviceGroupId===deviceGroupId - * * All devices in the list must belong to the same Application as the DeviceGroup - * * All devices in the list must belong to the same Team as the DeviceGroup - * @param {*} app The application object - * @param {*} deviceGroupId The device group id - * @param {*} deviceList A list of devices to verify - */ -async function validateDeviceList (app, deviceGroup, addList, removeList) { - // check to ensure all devices in deviceList are not assigned to any group before commencing - // Assign 1 or more devices to a DeviceGroup - if (!deviceGroup || typeof deviceGroup !== 'object') { - throw new Error('DeviceGroup is required') - } - - // reload with the Application association if not already loaded - if (!deviceGroup.Application) { - await deviceGroup.reload({ include: [{ model: app.db.models.Application }] }) - } - - const teamId = deviceGroup.Application.TeamId - if (!teamId) { - throw new Error('DeviceGroup must belong to an Application that belongs to a Team') - } - - const deviceIds = { - addList: addList && deviceListToIds(addList, app.db.models.Device.decodeHashid), - removeList: removeList && deviceListToIds(removeList, app.db.models.Device.decodeHashid) - } - const deviceGroupId = deviceGroup.id - if (deviceIds.addList) { - const okCount = await app.db.models.Device.count({ - where: { - id: deviceIds.addList, - [Op.or]: [ - { DeviceGroupId: null }, - { DeviceGroupId: deviceGroupId } - ], - ApplicationId: deviceGroup.ApplicationId, - TeamId: teamId - } - }) - if (okCount !== deviceIds.addList.length) { - throw new DeviceGroupMembershipValidationError('invalid_input', 'One or more devices cannot be added to the group', 400) - } - } - if (deviceIds.removeList) { - const okCount = await app.db.models.Device.count({ - where: { - id: deviceIds.removeList, - DeviceGroupId: deviceGroupId, - ApplicationId: deviceGroup.ApplicationId, - TeamId: teamId - } - }) - if (okCount !== deviceIds.removeList.length) { - throw new DeviceGroupMembershipValidationError('invalid_input', 'One or more devices cannot be removed from the group', 400) - } - } - return deviceIds -} +const { Op } = require('sequelize') + +const { ControllerError } = require('../../../lib/errors') +class DeviceGroupMembershipValidationError extends ControllerError { + /** + * @param {string} code + * @param {string} message + * @param {number} statusCode + * @param {Object} options + */ + constructor (code, message, statusCode, options) { + super(code, message, statusCode, options) + this.name = 'DeviceGroupMembershipValidationError' + } +} + +module.exports = { + + /** + * Create a Device Group + * @param {import("../../../forge").ForgeApplication} app The application object + * @param {string} name The name of the Device Group + * @param {Object} options + * @param {Object} [options.application] The application this Device Group will belong to + * @param {string} [options.description] The description of the Device Group + * @returns {Promise} The created Device Group + */ + createDeviceGroup: async function (app, name, { application = null, description } = {}) { + // Create a Device Group that devices can be linked to + // * name is required + // * application, description are optional + // * FUTURE: colors (background, border, text) and icon will be optional + + return await app.db.models.DeviceGroup.create({ + name, + description, + ApplicationId: application?.id + }) + }, + + updateDeviceGroup: async function (app, deviceGroup, { name = undefined, description = undefined } = {}) { + // * deviceGroup is required. + // * name, description, color are optional + if (!deviceGroup) { + throw new Error('DeviceGroup is required') + } + let changed = false + if (typeof name !== 'undefined') { + deviceGroup.name = name + changed = true + } + if (typeof description !== 'undefined') { + deviceGroup.description = description + changed = true + } + if (changed) { + await deviceGroup.save() + await deviceGroup.reload() + } + return deviceGroup + }, + + updateDeviceGroupMembership: async function (app, deviceGroup, { addDevices, removeDevices, setDevices } = {}) { + // * deviceGroup is required. The object must be a Sequelize model instance and must include the Devices + // * addDevices, removeDevices, setDevices are optional + // * if setDevices is provided, this will be used to set the devices assigned to the group, removing any devices that are not in the set + // * if addDevices is provided, these devices will be added to the group + // * if removeDevices is provided, these devices will be removed from the group + // if a device appears in both addDevices and removeDevices, it will be removed from the group (remove occurs after add) + if (!setDevices && !addDevices && !removeDevices) { + return // nothing to do + } + if (!deviceGroup || typeof deviceGroup !== 'object') { + throw new Error('DeviceGroup is required') + } + let actualRemoveDevices = [] + let actualAddDevices = [] + const currentMembers = await deviceGroup.getDevices() + // from this point on, all IDs need to be numeric (convert as needed) + const currentMemberIds = deviceListToIds(currentMembers, app.db.models.Device.decodeHashid) + setDevices = setDevices && deviceListToIds(setDevices, app.db.models.Device.decodeHashid) + addDevices = addDevices && deviceListToIds(addDevices, app.db.models.Device.decodeHashid) + removeDevices = removeDevices && deviceListToIds(removeDevices, app.db.models.Device.decodeHashid) + + // setDevices is an atomic operation, it will replace the current list of devices with the specified list + if (typeof setDevices !== 'undefined') { + // create a list of devices that are currently assigned to the group, minus the devices in the set, these are the ones to remove + actualRemoveDevices = currentMemberIds.filter(d => !setDevices.includes(d)) + // create a list of devices that are in the set, minus the devices that are currently assigned to the group, these are the ones to add + actualAddDevices = setDevices.filter(d => !currentMemberIds.includes(d)) + } else { + if (typeof removeDevices !== 'undefined') { + actualRemoveDevices = currentMemberIds.filter(d => removeDevices.includes(d)) + } + if (typeof addDevices !== 'undefined') { + actualAddDevices = addDevices.filter(d => !currentMemberIds.includes(d)) + } + } + + // wrap the dual operation in a transaction to avoid inconsistent state + const t = await app.db.sequelize.transaction() + try { + // add devices + if (actualAddDevices.length > 0) { + await this.assignDevicesToGroup(app, deviceGroup, actualAddDevices) + } + // remove devices + if (actualRemoveDevices.length > 0) { + await this.removeDevicesFromGroup(app, deviceGroup, actualRemoveDevices) + } + // commit the transaction + await t.commit() + } catch (err) { + // Rollback transaction if any errors were encountered + await t.rollback() + // if the error is a DeviceGroupMembershipValidationError, rethrow it + if (err instanceof DeviceGroupMembershipValidationError) { + throw err + } + // otherwise, throw a friendly error message along with the original error + throw new Error(`Failed to update device group membership: ${err.message}`) + } + }, + + assignDevicesToGroup: async function (app, deviceGroup, deviceList) { + const deviceIds = await validateDeviceList(app, deviceGroup, deviceList, null) + await app.db.models.Device.update({ DeviceGroupId: deviceGroup.id }, { where: { id: deviceIds.addList } }) + }, + + /** + * Remove 1 or more devices from the specified DeviceGroup + * @param {*} app The application object + * @param {*} deviceGroupId The device group id + * @param {*} deviceList A list of devices to remove from the group + */ + removeDevicesFromGroup: async function (app, deviceGroup, deviceList) { + const deviceIds = await validateDeviceList(app, deviceGroup, null, deviceList) + // null every device.DeviceGroupId row in device table where the id === deviceGroupId and device.id is in the deviceList + await app.db.models.Device.update({ DeviceGroupId: null }, { where: { id: deviceIds.removeList, DeviceGroupId: deviceGroup.id } }) + }, + + DeviceGroupMembershipValidationError +} + +/** + * Convert a list of devices to a list of device ids + * @param {Object[]|String[]|Number[]} deviceList List of devices to convert to ids + * @param {Function} decoderFn The decoder function to use on hashes + * @returns {Number[]} Array of device IDs + */ +function deviceListToIds (deviceList, decoderFn) { + // Convert a list of devices (object|id|hash) to a list of device ids + const ids = deviceList?.map(device => { + let id = device + if (typeof device === 'string') { + [id] = decoderFn(device) + } else if (typeof device === 'object') { + id = device.id + } + return id + }) + return ids +} + +/** + * Verify devices are suitable for the specified group: + * + * * All devices in the list must either have DeviceGroupId===null or DeviceGroupId===deviceGroupId + * * All devices in the list must belong to the same Application as the DeviceGroup + * * All devices in the list must belong to the same Team as the DeviceGroup + * @param {*} app The application object + * @param {*} deviceGroupId The device group id + * @param {*} deviceList A list of devices to verify + */ +async function validateDeviceList (app, deviceGroup, addList, removeList) { + // check to ensure all devices in deviceList are not assigned to any group before commencing + // Assign 1 or more devices to a DeviceGroup + if (!deviceGroup || typeof deviceGroup !== 'object') { + throw new Error('DeviceGroup is required') + } + + // reload with the Application association if not already loaded + if (!deviceGroup.Application) { + await deviceGroup.reload({ include: [{ model: app.db.models.Application }] }) + } + + const teamId = deviceGroup.Application.TeamId + if (!teamId) { + throw new Error('DeviceGroup must belong to an Application that belongs to a Team') + } + + const deviceIds = { + addList: addList && deviceListToIds(addList, app.db.models.Device.decodeHashid), + removeList: removeList && deviceListToIds(removeList, app.db.models.Device.decodeHashid) + } + const deviceGroupId = deviceGroup.id + if (deviceIds.addList) { + const okCount = await app.db.models.Device.count({ + where: { + id: deviceIds.addList, + [Op.or]: [ + { DeviceGroupId: null }, + { DeviceGroupId: deviceGroupId } + ], + ApplicationId: deviceGroup.ApplicationId, + TeamId: teamId + } + }) + if (okCount !== deviceIds.addList.length) { + throw new DeviceGroupMembershipValidationError('invalid_input', 'One or more devices cannot be added to the group', 400) + } + } + if (deviceIds.removeList) { + const okCount = await app.db.models.Device.count({ + where: { + id: deviceIds.removeList, + DeviceGroupId: deviceGroupId, + ApplicationId: deviceGroup.ApplicationId, + TeamId: teamId + } + }) + if (okCount !== deviceIds.removeList.length) { + throw new DeviceGroupMembershipValidationError('invalid_input', 'One or more devices cannot be removed from the group', 400) + } + } + return deviceIds +} diff --git a/forge/ee/db/controllers/index.js b/forge/ee/db/controllers/index.js index 045a92ab8..459e4830b 100644 --- a/forge/ee/db/controllers/index.js +++ b/forge/ee/db/controllers/index.js @@ -1,7 +1,8 @@ const modelTypes = [ 'Subscription', 'UserBillingCode', - 'Pipeline' + 'Pipeline', + 'DeviceGroup' ] async function init (app) { diff --git a/forge/routes/api/applicationDeviceGroup.js b/forge/ee/routes/applicationDeviceGroups/index.js similarity index 90% rename from forge/routes/api/applicationDeviceGroup.js rename to forge/ee/routes/applicationDeviceGroups/index.js index 6f7964abd..78e3e4152 100644 --- a/forge/routes/api/applicationDeviceGroup.js +++ b/forge/ee/routes/applicationDeviceGroups/index.js @@ -1,322 +1,344 @@ -/** - * Application DeviceGroup api routes - * - * - /api/v1/applications/:applicationId/device-groups - * - * @namespace application - * @memberof forge.routes.api - */ - -const { ValidationError } = require('sequelize') - -const { DeviceGroupMembershipValidationError } = require('../../db/controllers/DeviceGroup.js') -const { registerPermissions } = require('../../lib/permissions') -const { Roles } = require('../../lib/roles.js') - -/** - * @param {import('../../forge.js').ForgeApplication} app The application instance - */ -module.exports = async function (app) { - registerPermissions({ - 'application:device-group:create': { description: 'Create a device group', role: Roles.Owner }, - 'application:device-group:list': { description: 'List device groups', role: Roles.Member }, - 'application:device-group:update': { description: 'Update a device group', role: Roles.Owner }, - 'application:device-group:delete': { description: 'Delete a device group', role: Roles.Owner }, - 'application:device-group:read': { description: 'View a device group', role: Roles.Member }, - 'application:device-group:membership:update': { description: 'Update a device group membership', role: Roles.Owner } - }) - - // pre-handler for all routes in this file - app.addHook('preHandler', async (request, reply) => { - // get the device group - const groupId = request.params.groupId - if (groupId) { - request.deviceGroup = await app.db.models.DeviceGroup.byId(groupId) - if (!request.deviceGroup || request.deviceGroup.ApplicationId !== request.application.id) { - reply.code(404).send({ code: 'not_found', error: 'Not Found' }) - } - } - }) - - /** - * Get a list of device groups in an application - * @method GET - * @name /api/v1/applications/:applicationId/device-groups - * @memberof forge.routes.api.application - */ - app.get('/', { - preHandler: app.needsPermission('application:device-group:list'), - schema: { - summary: 'Get a list of device groups in an application', - tags: ['Application Device Groups'], - query: { $ref: 'PaginationParams' }, - params: { - type: 'object', - properties: { - applicationId: { type: 'string' } - } - }, - response: { - 200: { - type: 'object', - properties: { - meta: { $ref: 'PaginationMeta' }, - count: { type: 'number' }, - groups: { type: 'array', items: { $ref: 'DeviceGroupSummary' } } - } - }, - '4xx': { - $ref: 'APIError' - } - } - } - }, async (request, reply) => { - const paginationOptions = app.db.controllers.Device.getDevicePaginationOptions(request) - - const where = { - ApplicationId: request.application.hashid - } - - const groupData = await app.db.models.DeviceGroup.getAll(paginationOptions, where) - const result = { - count: groupData.count, - meta: groupData.meta, - groups: (groupData.groups || []).map(d => app.db.views.DeviceGroup.deviceGroupSummary(d, { includeApplication: false })) - } - reply.send(result) - }) - - /** - * Add a new Device Group to an Application - * @method POST - * @name /api/v1/applications/:applicationId/device-groups - * @memberof forge.routes.api.application - */ - app.post('/', { - preHandler: app.needsPermission('application:device-group:create'), - schema: { - summary: 'Add a new Device Group to an Application', - tags: ['Application Device Groups'], - body: { - type: 'object', - properties: { - name: { type: 'string' }, - description: { type: 'string' } - }, - required: ['name'] - }, - params: { - type: 'object', - properties: { - applicationId: { type: 'string' } - } - }, - response: { - 201: { - $ref: 'DeviceGroupSummary' - }, - '4xx': { - $ref: 'APIError' - } - } - } - }, async (request, reply) => { - const application = request.application - const name = request.body.name - const description = request.body.description - try { - const newGroup = await app.db.controllers.DeviceGroup.createDeviceGroup(name, { application, description }) - const newGroupView = app.db.views.DeviceGroup.deviceGroupSummary(newGroup) - reply.code(201).send(newGroupView) - } catch (error) { - return handleError(error, reply) - } - }) - - /** - * Update a Device Group - * @method PUT - * @name /api/v1/applications/:applicationId/device-groups/:groupId - * @memberof forge.routes.api.application - */ - app.put('/:groupId', { - preHandler: app.needsPermission('application:device-group:update'), - schema: { - summary: 'Update a Device Group', - tags: ['Application Device Groups'], - body: { - type: 'object', - properties: { - name: { type: 'string' }, - description: { type: 'string' } - } - }, - params: { - type: 'object', - properties: { - applicationId: { type: 'string' }, - groupId: { type: 'string' } - } - }, - response: { - 200: { - type: 'object', - additionalProperties: false - }, - '4xx': { - $ref: 'APIError' - } - } - } - }, async (request, reply) => { - const group = request.deviceGroup - const name = request.body.name - const description = request.body.description - try { - await app.db.controllers.DeviceGroup.updateDeviceGroup(group, { name, description }) - reply.send({}) - } catch (error) { - return handleError(error, reply) - } - }) - - /** - * Get a specific Device Group - * @method GET - * @name /api/v1/applications/:applicationId/device-groups/:groupId - * @memberof forge.routes.api.application - */ - app.get('/:groupId', { - preHandler: app.needsPermission('application:device-group:read'), - schema: { - summary: 'Get a specific Device Group', - tags: ['Application Device Groups'], - params: { - type: 'object', - properties: { - applicationId: { type: 'string' }, - groupId: { type: 'string' } - } - }, - response: { - 200: { - $ref: 'DeviceGroup' - }, - '4xx': { - $ref: 'APIError' - } - } - } - }, async (request, reply) => { - const group = request.deviceGroup // already loaded in preHandler - const groupView = app.db.views.DeviceGroup.deviceGroup(group) - reply.send(groupView) - }) - - /** - * Update Device Group membership - * @method PATCH - * @name /api/v1/applications/:applicationId/device-groups/:groupId - * @memberof forge.routes.api.application - */ - app.patch('/:groupId', { - preHandler: app.needsPermission('application:device-group:membership:update'), - schema: { - summary: 'Update Device Group membership', - tags: ['Application Device Groups'], - body: { - type: 'object', - properties: { - add: { type: 'array', items: { type: 'string' } }, - remove: { type: 'array', items: { type: 'string' } }, - set: { type: 'array', items: { type: 'string' } } - } - }, - params: { - type: 'object', - properties: { - applicationId: { type: 'string' }, - groupId: { type: 'string' } - } - }, - response: { - 200: { - type: 'object', - additionalProperties: false - }, - '4xx': { - $ref: 'APIError' - } - } - } - }, async (request, reply) => { - const group = request.deviceGroup - const addDevices = request.body.add - const removeDevices = request.body.remove - const setDevices = request.body.set - try { - await app.db.controllers.DeviceGroup.updateDeviceGroupMembership(group, { addDevices, removeDevices, setDevices }) - reply.send({}) - } catch (err) { - return handleError(err, reply) - } - }) - - /** - * Delete a Device Group - * @method DELETE - * @name /api/v1/applications/:applicationId/device-groups/:groupId - * @memberof forge.routes.api.application - */ - app.delete('/:groupId', { - preHandler: app.needsPermission('application:device-group:delete'), - schema: { - summary: 'Delete a Device Group', - tags: ['Application Device Groups'], - params: { - type: 'object', - properties: { - applicationId: { type: 'string' }, - groupId: { type: 'string' } - } - }, - response: { - 200: { - type: 'object', - additionalProperties: false - }, - '4xx': { - $ref: 'APIError' - } - } - } - }, async (request, reply) => { - const group = request.deviceGroup - await group.destroy() - reply.send({}) - }) - - function handleError (err, reply) { - let statusCode = 500 - let code = 'unexpected_error' - let error = err.error || err.message || 'Unexpected error' - if (err instanceof ValidationError) { - statusCode = 400 - if (err.errors[0]) { - code = err.errors[0].path ? `invalid_${err.errors[0].path}` : 'invalid_input' - error = err.errors[0].message || error - } else { - code = 'invalid_input' - error = err.message || error - } - } else if (err instanceof DeviceGroupMembershipValidationError) { - statusCode = err.statusCode || 400 - code = err.code || 'invalid_device_group_membership' - error = err.message || error - } else { - app.log.error('API error in application device groups:') - app.log.error(err) - } - return reply.code(statusCode).type('application/json').send({ code, error }) - } -} +/** + * Application DeviceGroup api routes + * + * - /api/v1/applications/:applicationId/device-groups + * + * @namespace application + * @memberof forge.routes.api + */ + +const { ValidationError } = require('sequelize') + +const { registerPermissions } = require('../../../lib/permissions.js') +const { Roles } = require('../../../lib/roles.js') +const { DeviceGroupMembershipValidationError } = require('../../db/controllers/DeviceGroup.js') + +/** + * @param {import('../../../forge.js').ForgeApplication} app The application instance + */ +module.exports = async function (app) { + registerPermissions({ + 'application:device-group:create': { description: 'Create a device group', role: Roles.Owner }, + 'application:device-group:list': { description: 'List device groups', role: Roles.Member }, + 'application:device-group:update': { description: 'Update a device group', role: Roles.Owner }, + 'application:device-group:delete': { description: 'Delete a device group', role: Roles.Owner }, + 'application:device-group:read': { description: 'View a device group', role: Roles.Member }, + 'application:device-group:membership:update': { description: 'Update a device group membership', role: Roles.Owner } + }) + + // pre-handler for all routes in this file + app.addHook('preHandler', async (request, reply) => { + // Get the application + const applicationId = request.params.applicationId + if (!applicationId) { + return reply.code(404).send({ code: 'not_found', error: 'Not Found' }) + } + + try { + request.application = await app.db.models.Application.byId(applicationId) + if (!request.application) { + return reply.code(404).send({ code: 'not_found', error: 'Not Found' }) + } + + if (request.session.User) { + request.teamMembership = await request.session.User.getTeamMembership(request.application.Team.id) + if (!request.teamMembership && !request.session.User.admin) { + return reply.code(404).send({ code: 'not_found', error: 'Not Found' }) + } + } + } catch (err) { + return reply.code(500).send({ code: 'unexpected_error', error: err.toString() }) + } + + // Get the device group + const groupId = request.params.groupId + if (groupId) { + request.deviceGroup = await app.db.models.DeviceGroup.byId(groupId) + if (!request.deviceGroup || request.deviceGroup.ApplicationId !== request.application.id) { + reply.code(404).send({ code: 'not_found', error: 'Not Found' }) + } + } + }) + + /** + * Get a list of device groups in an application + * @method GET + * @name /api/v1/applications/:applicationId/device-groups + * @memberof forge.routes.api.application + */ + app.get('/', { + preHandler: app.needsPermission('application:device-group:list'), + schema: { + summary: 'Get a list of device groups in an application', + tags: ['Application Device Groups'], + query: { $ref: 'PaginationParams' }, + params: { + type: 'object', + properties: { + applicationId: { type: 'string' } + } + }, + response: { + 200: { + type: 'object', + properties: { + meta: { $ref: 'PaginationMeta' }, + count: { type: 'number' }, + groups: { type: 'array', items: { $ref: 'DeviceGroupSummary' } } + } + }, + '4xx': { + $ref: 'APIError' + } + } + } + }, async (request, reply) => { + const paginationOptions = app.db.controllers.Device.getDevicePaginationOptions(request) + + const where = { + ApplicationId: request.application.hashid + } + + const groupData = await app.db.models.DeviceGroup.getAll(paginationOptions, where) + const result = { + count: groupData.count, + meta: groupData.meta, + groups: (groupData.groups || []).map(d => app.db.views.DeviceGroup.deviceGroupSummary(d, { includeApplication: false })) + } + reply.send(result) + }) + + /** + * Add a new Device Group to an Application + * @method POST + * @name /api/v1/applications/:applicationId/device-groups + * @memberof forge.routes.api.application + */ + app.post('/', { + preHandler: app.needsPermission('application:device-group:create'), + schema: { + summary: 'Add a new Device Group to an Application', + tags: ['Application Device Groups'], + body: { + type: 'object', + properties: { + name: { type: 'string' }, + description: { type: 'string' } + }, + required: ['name'] + }, + params: { + type: 'object', + properties: { + applicationId: { type: 'string' } + } + }, + response: { + 201: { + $ref: 'DeviceGroupSummary' + }, + '4xx': { + $ref: 'APIError' + } + } + } + }, async (request, reply) => { + const application = request.application + const name = request.body.name + const description = request.body.description + try { + const newGroup = await app.db.controllers.DeviceGroup.createDeviceGroup(name, { application, description }) + const newGroupView = app.db.views.DeviceGroup.deviceGroupSummary(newGroup) + reply.code(201).send(newGroupView) + } catch (error) { + return handleError(error, reply) + } + }) + + /** + * Update a Device Group + * @method PUT + * @name /api/v1/applications/:applicationId/device-groups/:groupId + * @memberof forge.routes.api.application + */ + app.put('/:groupId', { + preHandler: app.needsPermission('application:device-group:update'), + schema: { + summary: 'Update a Device Group', + tags: ['Application Device Groups'], + body: { + type: 'object', + properties: { + name: { type: 'string' }, + description: { type: 'string' } + } + }, + params: { + type: 'object', + properties: { + applicationId: { type: 'string' }, + groupId: { type: 'string' } + } + }, + response: { + 200: { + type: 'object', + additionalProperties: false + }, + '4xx': { + $ref: 'APIError' + } + } + } + }, async (request, reply) => { + const group = request.deviceGroup + const name = request.body.name + const description = request.body.description + try { + await app.db.controllers.DeviceGroup.updateDeviceGroup(group, { name, description }) + reply.send({}) + } catch (error) { + return handleError(error, reply) + } + }) + + /** + * Get a specific Device Group + * @method GET + * @name /api/v1/applications/:applicationId/device-groups/:groupId + * @memberof forge.routes.api.application + */ + app.get('/:groupId', { + preHandler: app.needsPermission('application:device-group:read'), + schema: { + summary: 'Get a specific Device Group', + tags: ['Application Device Groups'], + params: { + type: 'object', + properties: { + applicationId: { type: 'string' }, + groupId: { type: 'string' } + } + }, + response: { + 200: { + $ref: 'DeviceGroup' + }, + '4xx': { + $ref: 'APIError' + } + } + } + }, async (request, reply) => { + const group = request.deviceGroup // already loaded in preHandler + const groupView = app.db.views.DeviceGroup.deviceGroup(group) + reply.send(groupView) + }) + + /** + * Update Device Group membership + * @method PATCH + * @name /api/v1/applications/:applicationId/device-groups/:groupId + * @memberof forge.routes.api.application + */ + app.patch('/:groupId', { + preHandler: app.needsPermission('application:device-group:membership:update'), + schema: { + summary: 'Update Device Group membership', + tags: ['Application Device Groups'], + body: { + type: 'object', + properties: { + add: { type: 'array', items: { type: 'string' } }, + remove: { type: 'array', items: { type: 'string' } }, + set: { type: 'array', items: { type: 'string' } } + } + }, + params: { + type: 'object', + properties: { + applicationId: { type: 'string' }, + groupId: { type: 'string' } + } + }, + response: { + 200: { + type: 'object', + additionalProperties: false + }, + '4xx': { + $ref: 'APIError' + } + } + } + }, async (request, reply) => { + const group = request.deviceGroup + const addDevices = request.body.add + const removeDevices = request.body.remove + const setDevices = request.body.set + try { + await app.db.controllers.DeviceGroup.updateDeviceGroupMembership(group, { addDevices, removeDevices, setDevices }) + reply.send({}) + } catch (err) { + return handleError(err, reply) + } + }) + + /** + * Delete a Device Group + * @method DELETE + * @name /api/v1/applications/:applicationId/device-groups/:groupId + * @memberof forge.routes.api.application + */ + app.delete('/:groupId', { + preHandler: app.needsPermission('application:device-group:delete'), + schema: { + summary: 'Delete a Device Group', + tags: ['Application Device Groups'], + params: { + type: 'object', + properties: { + applicationId: { type: 'string' }, + groupId: { type: 'string' } + } + }, + response: { + 200: { + type: 'object', + additionalProperties: false + }, + '4xx': { + $ref: 'APIError' + } + } + } + }, async (request, reply) => { + const group = request.deviceGroup + await group.destroy() + reply.send({}) + }) + + function handleError (err, reply) { + let statusCode = 500 + let code = 'unexpected_error' + let error = err.error || err.message || 'Unexpected error' + if (err instanceof ValidationError) { + statusCode = 400 + if (err.errors[0]) { + code = err.errors[0].path ? `invalid_${err.errors[0].path}` : 'invalid_input' + error = err.errors[0].message || error + } else { + code = 'invalid_input' + error = err.message || error + } + } else if (err instanceof DeviceGroupMembershipValidationError) { + statusCode = err.statusCode || 400 + code = err.code || 'invalid_device_group_membership' + error = err.message || error + } else { + app.log.error('API error in application device groups:') + app.log.error(err) + } + return reply.code(statusCode).type('application/json').send({ code, error }) + } +} diff --git a/forge/ee/routes/index.js b/forge/ee/routes/index.js index ff4df9b38..fdbe3fcaf 100644 --- a/forge/ee/routes/index.js +++ b/forge/ee/routes/index.js @@ -16,6 +16,7 @@ module.exports = async function (app) { await app.register(require('./flowBlueprints'), { prefix: '/api/v1/flow-blueprints', logLevel: app.config.logging.http }) if (app.license.get('tier') === 'enterprise') { + await app.register(require('./applicationDeviceGroups'), { prefix: '/api/v1/applications/:applicationId/device-groups', logLevel: app.config.logging.http }) await app.register(require('./ha'), { prefix: '/api/v1/projects/:projectId/ha', logLevel: app.config.logging.http }) await app.register(require('./mfa'), { prefix: '/api/v1', logLevel: app.config.logging.http }) diff --git a/forge/routes/api/application.js b/forge/routes/api/application.js index b5834a99c..19f867491 100644 --- a/forge/routes/api/application.js +++ b/forge/routes/api/application.js @@ -1,5 +1,3 @@ -const applicationDeviceGroup = require('./applicationDeviceGroup.js') - module.exports = async function (app) { app.addHook('preHandler', async (request, reply) => { const applicationId = request.params.applicationId @@ -28,8 +26,6 @@ module.exports = async function (app) { } }) - app.register(applicationDeviceGroup, { prefix: '/:applicationId/device-groups' }) - /** * Create an application * @name /api/v1/applications diff --git a/test/unit/forge/routes/api/applicationDeviceGroups_spec.js b/test/unit/forge/ee/routes/api/applicationDeviceGroups_spec.js similarity index 99% rename from test/unit/forge/routes/api/applicationDeviceGroups_spec.js rename to test/unit/forge/ee/routes/api/applicationDeviceGroups_spec.js index f132ff0f0..e3a4d6fb0 100644 --- a/test/unit/forge/routes/api/applicationDeviceGroups_spec.js +++ b/test/unit/forge/ee/routes/api/applicationDeviceGroups_spec.js @@ -1,6 +1,6 @@ const should = require('should') // eslint-disable-line -const setup = require('../setup') +const setup = require('../../setup.js') const FF_UTIL = require('flowforge-test-utils') const { Roles } = FF_UTIL.require('forge/lib/roles') @@ -21,7 +21,7 @@ describe('Application Device Groups API', function () { /** B-team Application */ application: {} } - /** @type {import('../../../../lib/TestModelFactory')} */ + /** @type {import('../../../../../lib/TestModelFactory')} */ let factory = null let objectCount = 0 const generateName = (root = 'object') => `${root}-${objectCount++}` From 9197daecc2f6d60fc066d2872e2c355d3332eb00 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Fri, 15 Dec 2023 21:20:17 +0000 Subject: [PATCH 40/85] move Device Groups to enterprise tier Ref: https://github.com/FlowFuse/flowfuse/issues/2997#issuecomment-1857711941 --- forge/ee/lib/index.js | 2 ++ frontend/src/pages/application/DeviceGroups.vue | 15 ++++++++++----- frontend/src/pages/application/index.vue | 8 +++++++- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/forge/ee/lib/index.js b/forge/ee/lib/index.js index 077dccddc..073eb3ed7 100644 --- a/forge/ee/lib/index.js +++ b/forge/ee/lib/index.js @@ -12,6 +12,8 @@ module.exports = fp(async function (app, opts, done) { app.decorate('sso', await require('./sso').init(app)) // Set the MFA Feature Flag app.config.features.register('mfa', true, true) + // Set the Device Groups Feature Flag + app.config.features.register('device-groups', true, true) } // Set the Team Library Feature Flag diff --git a/frontend/src/pages/application/DeviceGroups.vue b/frontend/src/pages/application/DeviceGroups.vue index 77c7d735b..0d19aa80e 100644 --- a/frontend/src/pages/application/DeviceGroups.vue +++ b/frontend/src/pages/application/DeviceGroups.vue @@ -17,14 +17,14 @@
- + -