diff --git a/forge/auditLog/application.js b/forge/auditLog/application.js index 0b064454fb..b4bb99052f 100644 --- a/forge/auditLog/application.js +++ b/forge/auditLog/application.js @@ -29,6 +29,9 @@ module.exports = { async deleted (actionedBy, error, application, device, snapshot) { await log('application.device.snapshot.deleted', actionedBy, application?.id, generateBody({ error, device, snapshot })) }, + async exported (actionedBy, error, application, device, snapshot) { + await log('application.device.snapshot.exported', actionedBy, application?.id, generateBody({ error, device, snapshot })) + }, async deviceTargetSet (actionedBy, error, application, device, snapshot) { await log('application.device.snapshot.device-target-set', actionedBy, application?.id, generateBody({ error, device, snapshot })) } diff --git a/forge/db/controllers/ProjectSnapshot.js b/forge/db/controllers/ProjectSnapshot.js index 8e311791d5..f34306cf65 100644 --- a/forge/db/controllers/ProjectSnapshot.js +++ b/forge/db/controllers/ProjectSnapshot.js @@ -500,33 +500,8 @@ module.exports = { * @param {Object} [options.credentials] (Optional) credentials to export. If omitted, credentials of the current project will be re-encrypted, with credentialSecret. */ exportSnapshot: async function (app, project, snapshot, options) { - if (!options.credentialSecret) { - return null - } - if (snapshot.UserId && !snapshot.User) { - await snapshot.reload({ include: [app.db.models.User] }) - } - - const result = { - ...snapshot.toJSON() - } - - const serviceEnv = ['FF_INSTANCE_ID', 'FF_INSTANCE_NAME', 'FF_PROJECT_ID', 'FF_PROJECT_NAME'] - serviceEnv.forEach((key) => { - delete result.settings.env[key] - }) - - // use the secret stored in the snapshot, if available... - const projectSecret = result.credentialSecret || await project.getCredentialSecret() - const credentials = options.credentials ? options.credentials : result.flows.credentials - - // if provided credentials already encrypted: "exportCredentials" will just return the same credentials - // if provided credentials are raw: "exportCredentials" will encrypt them with the secret provided - // if credentials are not provided: project's flows credentials will be used, they will be encrypted with either the provided secret or a new one - const keyToDecrypt = (options.credentials && options.credentials.$) ? options.credentialSecret : projectSecret - result.flows.credentials = app.db.controllers.Project.exportCredentials(credentials || {}, keyToDecrypt, options.credentialSecret) - - return result + options.owner = project + return app.db.controllers.Snapshot.exportSnapshot(snapshot, options) }, getDeviceAutoSnapshots: deviceAutoSnapshotUtils.getAutoSnapshots, diff --git a/forge/db/controllers/Snapshot.js b/forge/db/controllers/Snapshot.js new file mode 100644 index 0000000000..450aa2df09 --- /dev/null +++ b/forge/db/controllers/Snapshot.js @@ -0,0 +1,110 @@ +module.exports = { + /** + * Get a snapshot by ID + * @param {*} app - app instance + * @param {*} snapshotId + */ + async getSnapshot (app, snapshotId) { + return await app.db.models.Snapshot.byId(snapshotId) + }, + + /** + * Export specific snapshot. + * @param {*} app - app instance + * @param {*} owner project/device-originator of this snapshot + * @param {*} snapshot snapshot object to export + * @param {Object} options + * @param {String} [options.credentialSecret] secret to encrypt credentials with. + * @param {Object} [options.credentials] (Optional) credentials to export. If omitted, credentials of the current owner will be re-encrypted with the provided `credentialSecret`. + * @param {Object} [options.owner] (Optional) The owner project or device. If omitted, the snapshot's owner will be obtained from the database. + */ + exportSnapshot: async function (app, snapshot, options) { + if (!options.credentialSecret) { + return null + } + + let owner = options.owner + if (!owner) { + if (snapshot.ownerType === 'device') { + owner = await snapshot.getDevice() + } else if (snapshot.ownerType === 'instance') { + owner = await snapshot.getProject() + } else { + return null + } + } + + // ensure the owner is of the correct model type + if (snapshot.ownerType === 'device' && owner?.constructor.name !== 'Device') { + return null + } + if (snapshot.ownerType === 'instance' && owner?.constructor.name !== 'Project') { + return null + } + + // ensure the snapshot has the User association loaded + if (snapshot.UserId && !snapshot.User) { + await snapshot.reload({ include: [app.db.models.User] }) + } + + const result = { + ...snapshot.toJSON() + } + + // loop keys of result.settings.env and remove any that match FF_* + Object.keys(result.settings.env).forEach((key) => { + if (key.startsWith('FF_')) { + delete result.settings.env[key] + } + }) + + // use the secret stored in the snapshot, if available... + const currentSecret = result.credentialSecret || owner.credentialSecret || (owner.getCredentialSecret && await owner.getCredentialSecret()) + const credentials = options.credentials ? options.credentials : result.flows.credentials + + // if provided credentials already encrypted: "exportCredentials" will just return the same credentials + // if provided credentials are raw: "exportCredentials" will encrypt them with the secret provided + // if credentials are not provided: project's flows credentials will be used, they will be encrypted with the provided secret + const keyToDecrypt = (options.credentials && options.credentials.$) ? options.credentialSecret : currentSecret + result.flows.credentials = app.db.controllers.Project.exportCredentials(credentials || {}, keyToDecrypt, options.credentialSecret) + + return result + }, + + /** + * Delete a snapshot + * @param {*} app - app instance + * @param {*} snapshot - snapshot object + */ + async deleteSnapshot (app, snapshot) { + let owner + + if (snapshot.ownerType === 'device') { + owner = await snapshot.getDevice() + } else if (snapshot.ownerType === 'instance') { + owner = await snapshot.getProject() + } else { + return false + } + + const deviceSettings = await owner.getSetting('deviceSettings') || { + targetSnapshot: null + } + if (deviceSettings.targetSnapshot === snapshot.id) { + // We're about to delete the active snapshot for this device + await owner.updateSetting('deviceSettings', { + targetSnapshot: null + }) + // The cascade relationship will ensure owner.targetSnapshotId is cleared + if (app.comms) { + const team = await owner.getTeam() + app.comms.devices.sendCommandToProjectDevices(team.hashid, owner.id, 'update', { + snapshot: null + }) + } + } + + await snapshot.destroy() + return true + } +} diff --git a/forge/db/controllers/index.js b/forge/db/controllers/index.js index 657f880707..d48777f3b2 100644 --- a/forge/db/controllers/index.js +++ b/forge/db/controllers/index.js @@ -23,6 +23,7 @@ const modelTypes = [ 'ProjectStack', 'ProjectTemplate', 'ProjectSnapshot', + 'Snapshot', 'Device', 'BrokerClient', 'StorageCredentials', diff --git a/forge/db/views/ProjectSnapshot.js b/forge/db/views/ProjectSnapshot.js index c3991e6ecd..8215c13d58 100644 --- a/forge/db/views/ProjectSnapshot.js +++ b/forge/db/views/ProjectSnapshot.js @@ -1,23 +1,99 @@ -module.exports = function (app) { - app.addSchema({ - $id: 'Snapshot', - type: 'object', - properties: { - id: { type: 'string' }, - name: { type: 'string' }, - description: { type: 'string' }, - createdAt: { type: 'string' }, - updatedAt: { type: 'string' }, - user: { $ref: 'UserSummary' }, - modules: { type: 'object', additionalProperties: true }, - ownerType: { type: 'string' }, - deviceId: { type: 'string' }, - projectId: { type: 'string' }, - device: { $ref: 'DeviceSummary' }, - project: { $ref: 'InstanceSummary' } +let app + +module.exports = { + init: (appInstance) => { + app = appInstance + app.addSchema({ + $id: 'SnapshotSummary', + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + description: { type: 'string' } + } + }) + app.addSchema({ + $id: 'Snapshot', + type: 'object', + allOf: [{ $ref: 'SnapshotSummary' }], + properties: { + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, + user: { $ref: 'UserSummary' }, + modules: { type: 'object', additionalProperties: true }, + ownerType: { type: 'string' }, + deviceId: { type: 'string' }, + projectId: { type: 'string' }, + device: { $ref: 'DeviceSummary' }, + project: { $ref: 'InstanceSummary' } + } + }) + app.addSchema({ + $id: 'SnapshotAndSettings', + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + description: { type: 'string' }, + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, + user: { $ref: 'UserSummary' }, + exportedBy: { $ref: 'UserSummary' }, + ownerType: { type: 'string' }, + settings: { + type: 'object', + properties: { + settings: { type: 'object', additionalProperties: true }, + env: { type: 'object', additionalProperties: true }, + modules: { type: 'object', additionalProperties: true } + } + } + } + }) + app.addSchema({ + $id: 'FullSnapshot', + type: 'object', + allOf: [{ $ref: 'SnapshotAndSettings' }], + properties: { + flows: { + type: 'object', + properties: { + flows: { type: 'array', items: { type: 'object', additionalProperties: true } } + } + } + } + }) + app.addSchema({ + $id: 'ExportedSnapshot', + type: 'object', + allOf: [{ $ref: 'SnapshotAndSettings' }], + properties: { + flows: { + type: 'object', + properties: { + flows: { type: 'array', items: { type: 'object', additionalProperties: true } }, + credentials: { type: 'object', additionalProperties: true } + } + } + } + }) + }, + + snapshotSummary (snapshot) { + if (snapshot) { + const result = snapshot.toJSON ? snapshot.toJSON() : snapshot + const filtered = { + id: result.hashid, + name: result.name, + description: result.description || '' + } + return filtered + } else { + return null } - }) - function snapshot (snapshot) { + }, + + snapshot (snapshot) { if (snapshot) { const result = snapshot.toJSON ? snapshot.toJSON() : snapshot const filtered = { @@ -46,45 +122,9 @@ module.exports = function (app) { } else { return null } - } - app.addSchema({ - $id: 'SnapshotSummary', - type: 'object', - properties: { - id: { type: 'string' }, - name: { type: 'string' }, - description: { type: 'string' } - } - }) - function snapshotSummary (snapshot) { - if (snapshot) { - const result = snapshot.toJSON ? snapshot.toJSON() : snapshot - const filtered = { - id: result.hashid, - name: result.name, - description: result.description || '' - } - return filtered - } else { - return null - } - } - app.addSchema({ - $id: 'ExportedSnapshot', - type: 'object', - properties: { - id: { type: 'string' }, - name: { type: 'string' }, - description: { type: 'string' }, - createdAt: { type: 'string' }, - updatedAt: { type: 'string' }, - user: { $ref: 'UserSummary' }, - exportedBy: { $ref: 'UserSummary' }, - flows: { type: 'object', additionalProperties: true }, - settings: { type: 'object', additionalProperties: true } - } - }) - function snapshotExport (snapshot, exportedBy) { + }, + + snapshotExport (snapshot, exportedBy) { if (snapshot) { const result = snapshot.toJSON ? snapshot.toJSON() : snapshot const filtered = { @@ -94,7 +134,8 @@ module.exports = function (app) { createdAt: result.createdAt, updatedAt: result.updatedAt, flows: result.flows, - settings: result.settings + settings: result.settings, + ownerType: result.ownerType } if (snapshot.User) { filtered.user = app.db.views.User.userSummary(snapshot.User) @@ -108,10 +149,4 @@ module.exports = function (app) { return null } } - - return { - snapshot, - snapshotSummary, - snapshotExport - } } diff --git a/forge/db/views/index.js b/forge/db/views/index.js index 0b6157e3e4..30d0f69fe2 100644 --- a/forge/db/views/index.js +++ b/forge/db/views/index.js @@ -29,8 +29,16 @@ const modelTypes = [ async function register (app, viewType, viewModule) { module.exports[viewType] = {} - if (typeof viewModule === 'function') { + if (typeof viewModule === 'object' && typeof viewModule.init === 'function') { // New style: + // - views export an object with an init function + // - allows the views to contain their own schema definitions + // - allows type inference to work via /** import('path_to_view') */ for + // static analysis, type checking and DX improvements + viewModule.init(app) + module.exports[viewType] = viewModule + } else if (typeof viewModule === 'function') { + // Current style: // - views export a function that is called to get the view functions back // - allows the views to contain their own schema definitions as well module.exports[viewType] = viewModule(app) diff --git a/forge/lib/permissions.js b/forge/lib/permissions.js index 1a5a42d81b..fd7ee20256 100644 --- a/forge/lib/permissions.js +++ b/forge/lib/permissions.js @@ -79,6 +79,12 @@ const Permissions = { 'device:snapshot:set-target': { description: 'Set Device Target Snapshot', role: Roles.Member }, 'device:audit-log': { description: 'View a Device Audit Log', role: Roles.Viewer }, + // Snapshots (common) + 'snapshot:meta': { description: 'View a Snapshot', role: Roles.Viewer }, + 'snapshot:full': { description: 'View full snapshot details excluding credentials', role: Roles.Member }, + 'snapshot:export': { description: 'Export a snapshot including credentials', role: Roles.Member }, + 'snapshot:delete': { description: 'Delete a Snapshot', role: Roles.Owner }, + // Project Types 'project-type:create': { description: 'Create a ProjectType', role: Roles.Admin }, 'project-type:list': { description: 'List all ProjectTypes' }, diff --git a/forge/routes/api/deviceSnapshots.js b/forge/routes/api/deviceSnapshots.js index 42913b80b2..b467222f64 100644 --- a/forge/routes/api/deviceSnapshots.js +++ b/forge/routes/api/deviceSnapshots.js @@ -12,6 +12,9 @@ */ module.exports = async function (app) { + /** @type {typeof import('../../db/controllers/Snapshot.js')} */ + const controller = app.db.controllers.Snapshot + app.addHook('preHandler', async (request, reply) => { if (request.params.snapshotId !== undefined) { if (request.params.snapshotId) { @@ -126,24 +129,7 @@ module.exports = async function (app) { } } }, async (request, reply) => { - const device = await request.snapshot.getDevice() - const deviceSettings = await device.getSetting('deviceSettings') || { - targetSnapshot: null - } - if (deviceSettings.targetSnapshot === request.snapshot.id) { - // We're about to delete the active snapshot for this device - await device.updateSetting('deviceSettings', { - targetSnapshot: null - }) - // The cascade relationship will ensure Device.targetSnapshotId is cleared - if (app.comms) { - const team = await device.getTeam() - app.comms.devices.sendCommandToProjectDevices(team.hashid, device.id, 'update', { - snapshot: null - }) - } - } - await request.snapshot.destroy() + await controller.deleteSnapshot(request.snapshot) await app.auditLog.Application.application.device.snapshot.deleted(request.session.User, null, request.device.Application, request.device, request.snapshot) reply.send({ status: 'okay' }) }) diff --git a/forge/routes/api/index.js b/forge/routes/api/index.js index ae5c275e8f..129784e319 100644 --- a/forge/routes/api/index.js +++ b/forge/routes/api/index.js @@ -11,6 +11,7 @@ const Device = require('./device.js') const Project = require('./project.js') const ProjectType = require('./projectType.js') const Settings = require('./settings.js') +const Snapshot = require('./snapshot.js') const Stack = require('./stack.js') const Team = require('./team.js') const TeamType = require('./teamType.js') @@ -32,6 +33,7 @@ module.exports = async function (app) { app.register(Template, { prefix: '/templates' }) app.register(Device, { prefix: '/devices' }) app.register(ProjectType, { prefix: '/project-types' }) + app.register(Snapshot, { prefix: '/snapshots' }) app.get('*', function (request, reply) { reply.code(404).send({ code: 'not_found', error: 'Not Found' }) }) diff --git a/forge/routes/api/projectSnapshots.js b/forge/routes/api/projectSnapshots.js index a8e3f275fc..bf1974949f 100644 --- a/forge/routes/api/projectSnapshots.js +++ b/forge/routes/api/projectSnapshots.js @@ -12,6 +12,11 @@ const { createSnapshot } = require('../../services/snapshots') module.exports = async function (app) { + /** @type {typeof import('../../db/controllers/Snapshot.js')} */ + const snapshotController = app.db.controllers.Snapshot + /** @type {typeof import('../../db/views/ProjectSnapshot.js')} */ + const projectSnapshotView = app.db.views.ProjectSnapshot + app.addHook('preHandler', async (request, reply) => { if (request.params.snapshotId !== undefined) { if (request.params.snapshotId) { @@ -122,24 +127,7 @@ module.exports = async function (app) { } } }, async (request, reply) => { - const project = await request.snapshot.getProject() - const deviceSettings = await project.getSetting('deviceSettings') || { - targetSnapshot: null - } - if (deviceSettings.targetSnapshot === request.snapshot.id) { - // We're about to delete the active snapshot for this project - await project.updateSetting('deviceSettings', { - targetSnapshot: null - }) - // The cascade relationship will ensure Device.targetSnapshotId is cleared - if (app.comms) { - const team = await project.getTeam() - app.comms.devices.sendCommandToProjectDevices(team.hashid, project.id, 'update', { - snapshot: null - }) - } - } - await request.snapshot.destroy() + await snapshotController.deleteSnapshot(request.snapshot) await app.auditLog.Project.project.snapshot.deleted(request.session.User, null, request.project, request.snapshot) reply.send({ status: 'okay' }) }) @@ -248,13 +236,15 @@ module.exports = async function (app) { reply.code(400).send({ code: 'bad_request', error: 'credentialSecret is mandatory in the body' }) } - const snapshot = await app.db.controllers.ProjectSnapshot.exportSnapshot( - request.project, - request.snapshot, - request.body - ) + const options = { + credentialSecret: request.body.credentialSecret, + credentials: request.body.credentials, + owner: request.project // the instance owns the snapshot + } + + const snapshot = await snapshotController.exportSnapshot(request.snapshot, options) if (snapshot) { - const snapshotExport = app.db.views.ProjectSnapshot.snapshotExport(snapshot, request.session.User) + const snapshotExport = projectSnapshotView.snapshotExport(snapshot, request.session.User) await app.auditLog.Project.project.snapshot.exported(request.session.User, null, request.project, snapshot) reply.send(snapshotExport) } else { diff --git a/forge/routes/api/snapshot.js b/forge/routes/api/snapshot.js new file mode 100644 index 0000000000..97839a6400 --- /dev/null +++ b/forge/routes/api/snapshot.js @@ -0,0 +1,201 @@ +/** + * Snapshot api routes + * + * - /api/v1/snapshots/ + * + * @namespace project + * @memberof forge.routes.api + */ + +module.exports = async function (app) { + /** @type {typeof import('../../db/controllers/Snapshot.js')} */ + const snapshotController = app.db.controllers.Snapshot + /** @type {typeof import('../../db/views/ProjectSnapshot.js')} */ + const projectSnapshotView = app.db.views.ProjectSnapshot + const applicationLogger = require('../../../forge/auditLog/application').getLoggers(app) + const projectLogger = require('../../../forge/auditLog/project').getLoggers(app) + + app.addHook('preHandler', async (request, reply) => { + try { + request.ownerType = null + request.owner = null + if (request.params.id) { + request.snapshot = await app.db.models.ProjectSnapshot.byId(request.params.id) + if (!request.snapshot) { + return reply.code(404).send({ code: 'not_found', error: 'Not Found' }) + } + request.ownerType = request.snapshot.ownerType + if (request.ownerType === 'instance') { + request.owner = await request.snapshot.getProject() + if (!request.owner) { + return reply.code(404).send({ code: 'not_found', error: 'Not Found' }) + } + } else if (request.ownerType === 'device') { + request.owner = await request.snapshot.getDevice() + if (!request.owner) { + return reply.code(404).send({ code: 'not_found', error: 'Not Found' }) + } + } else { + return reply.code(404).send({ code: 'not_found', error: 'Not Found' }) + } + } + if (request.session.User) { + request.teamMembership = await request.session.User.getTeamMembership(request.owner.TeamId) + if (!request.teamMembership && !request.session.User.admin) { + return reply.code(404).send({ code: 'not_found', error: 'Not Found' }) + } + } else { + return reply.code(401).send({ code: 'unauthorized', error: 'Unauthorized' }) + } + } catch (err) { + reply.code(404).send({ code: 'not_found', error: 'Not Found' }) + } + }) + + /** + * Get a snapshot - metadata only + */ + app.get('/:id', { + preHandler: app.needsPermission('snapshot:meta'), + schema: { + summary: 'Get summary of a snapshot', + tags: ['Snapshots'], + params: { + type: 'object', + properties: { + id: { type: 'string' } + } + }, + response: { + 200: { + $ref: 'Snapshot' + }, + '4xx': { + $ref: 'APIError' + } + } + } + }, async (request, reply) => { + reply.send(projectSnapshotView.snapshotSummary(request.snapshot)) + }) + + /** + * Get details of a snapshot - full details + */ + app.get('/:id/full', { + preHandler: app.needsPermission('snapshot:full'), + schema: { + summary: 'Get details of a snapshot', + tags: ['Snapshots'], + params: { + type: 'object', + properties: { + id: { type: 'string' } + } + }, + response: { + 200: { + $ref: 'FullSnapshot' // identical to ExportedSnapshot but excludes credentials + }, + '4xx': { + $ref: 'APIError' + } + } + } + }, async (request, reply) => { + const snapshot = { + ...(request.snapshot.toJSON ? request.snapshot.toJSON() : request.snapshot) + } + reply.send(projectSnapshotView.snapshotExport(snapshot)) + }) + + /** + * Delete a snapshot + */ + app.delete('/:id', { + preHandler: app.needsPermission('snapshot:delete'), + schema: { + summary: 'Delete a snapshot', + tags: ['Snapshots'], + params: { + type: 'object', + properties: { + id: { type: 'string' } + } + }, + response: { + 200: { + $ref: 'APIStatus' + }, + '4xx': { + $ref: 'APIError' + } + } + } + }, async (request, reply) => { + await snapshotController.deleteSnapshot(request.snapshot) + if (request.ownerType === 'device') { + const application = await request.owner.getApplication() + await applicationLogger.application.device.snapshot.deleted(request.session.User, null, application, request.owner, request.snapshot) + } else if (request.ownerType === 'instance') { + await projectLogger.project.snapshot.deleted(request.session.User, null, request.owner, request.snapshot) + } + reply.send({ status: 'okay' }) + }) + + /** + * Export a snapshot for later import in another project or platform + */ + app.post('/:id/export', { + preHandler: app.needsPermission('snapshot:export'), + schema: { + summary: 'Export a snapshot', + tags: ['Snapshots'], + params: { + type: 'object', + properties: { + id: { type: 'string' } + } + }, + body: { + type: 'object', + properties: { + credentialSecret: { type: 'string' } + } + }, + response: { + 200: { + $ref: 'ExportedSnapshot' // // identical to FullSnapshot but includes credentials + }, + '4xx': { + $ref: 'APIError' + } + } + } + }, async (request, reply) => { + const options = { + credentialSecret: request.body.credentialSecret, + credentials: request.body.credentials, + owner: request.owner // the instance or device that owns the snapshot + } + + if (!options.credentialSecret) { + reply.code(400).send({ code: 'bad_request', error: 'credentialSecret is mandatory in the body' }) + return + } + + const snapshot = await snapshotController.exportSnapshot(request.snapshot, options) + if (snapshot) { + const snapshotExport = projectSnapshotView.snapshotExport(snapshot, request.session.User) + if (request.ownerType === 'device') { + const application = await request.owner.getApplication() + await applicationLogger.application.device.snapshot.exported(request.session.User, null, application, request.owner, request.snapshot) + } else if (request.ownerType === 'instance') { + await projectLogger.project.snapshot.exported(request.session.User, null, request.owner, request.snapshot) + } + reply.send(snapshotExport) + } else { + reply.send({}) + } + }) +} diff --git a/test/unit/forge/auditLog/application_spec.js b/test/unit/forge/auditLog/application_spec.js index 34fcbc332c..e2129554cd 100644 --- a/test/unit/forge/auditLog/application_spec.js +++ b/test/unit/forge/auditLog/application_spec.js @@ -14,6 +14,7 @@ describe('Audit Log > Application', async function () { let APPLICATION let DEVICE let DEVICEGROUP + let SNAPSHOT // temporarily assign the logger purely for type info & intellisense // so that xxxxxLogger.yyy.zzz function parameters are offered @@ -35,6 +36,14 @@ describe('Audit Log > Application', async function () { DEVICE = await app.db.models.Device.create({ name: 'deviceOne', type: 'something', credentialSecret: 'deviceKey' }) DEVICEGROUP = await app.db.models.DeviceGroup.create({ name: 'deviceGroupOne', ApplicationId: APPLICATION.id }) + + SNAPSHOT = await app.db.models.ProjectSnapshot.create({ + name: 'snapshot', + description: '', + settings: {}, + flows: {}, + DeviceId: DEVICE.id + }) }) after(async () => { await app.close() @@ -127,6 +136,51 @@ describe('Audit Log > Application', async function () { logEntry.body.device.id.should.equal(DEVICE.hashid) }) + it('Provides a logger for application device snapshot created', async function () { + await logger.application.device.snapshot.created(ACTIONED_BY, null, APPLICATION, DEVICE, SNAPSHOT) + // check log stored + const logEntry = await getLog() + logEntry.should.have.property('event', 'application.device.snapshot.created') + logEntry.should.have.property('scope', { id: APPLICATION.hashid, type: 'application' }) + logEntry.should.have.property('trigger', { id: ACTIONED_BY.hashid, type: 'user', name: ACTIONED_BY.username }) + logEntry.should.have.property('body') + logEntry.body.should.only.have.keys('device', 'snapshot') + logEntry.body.device.should.only.have.keys('id', 'name') + logEntry.body.device.id.should.equal(DEVICE.hashid) + logEntry.body.snapshot.should.only.have.keys('id', 'name') + logEntry.body.snapshot.id.should.equal(SNAPSHOT.hashid) + }) + + it('Provides a logger for application device snapshot deleted', async function () { + await logger.application.device.snapshot.deleted(ACTIONED_BY, null, APPLICATION, DEVICE, SNAPSHOT) + // check log stored + const logEntry = await getLog() + logEntry.should.have.property('event', 'application.device.snapshot.deleted') + logEntry.should.have.property('scope', { id: APPLICATION.hashid, type: 'application' }) + logEntry.should.have.property('trigger', { id: ACTIONED_BY.hashid, type: 'user', name: ACTIONED_BY.username }) + logEntry.should.have.property('body') + logEntry.body.should.only.have.keys('device', 'snapshot') + logEntry.body.device.should.only.have.keys('id', 'name') + logEntry.body.device.id.should.equal(DEVICE.hashid) + logEntry.body.snapshot.should.only.have.keys('id', 'name') + logEntry.body.snapshot.id.should.equal(SNAPSHOT.hashid) + }) + + it('Provides a logger for application device snapshot exported', async function () { + await logger.application.device.snapshot.exported(ACTIONED_BY, null, APPLICATION, DEVICE, SNAPSHOT) + // check log stored + const logEntry = await getLog() + logEntry.should.have.property('event', 'application.device.snapshot.exported') + logEntry.should.have.property('scope', { id: APPLICATION.hashid, type: 'application' }) + logEntry.should.have.property('trigger', { id: ACTIONED_BY.hashid, type: 'user', name: ACTIONED_BY.username }) + logEntry.should.have.property('body') + logEntry.body.should.only.have.keys('device', 'snapshot') + logEntry.body.device.should.only.have.keys('id', 'name') + logEntry.body.device.id.should.equal(DEVICE.hashid) + logEntry.body.snapshot.should.only.have.keys('id', 'name') + logEntry.body.snapshot.id.should.equal(SNAPSHOT.hashid) + }) + it('Provides a logger for creating a device group', async function () { await logger.application.deviceGroup.created(ACTIONED_BY, null, APPLICATION, DEVICEGROUP) // check log stored diff --git a/test/unit/forge/routes/api/snapshots_spec.js b/test/unit/forge/routes/api/snapshots_spec.js new file mode 100644 index 0000000000..a95f69c31b --- /dev/null +++ b/test/unit/forge/routes/api/snapshots_spec.js @@ -0,0 +1,582 @@ +/// + +const crypto = require('crypto') + +const should = require('should') // eslint-disable-line no-unused-vars +const sinon = require('sinon') + +const { Roles } = require('../../../../../forge/lib/roles') +const TestModelFactory = require('../../../../lib/TestModelFactory') +const setup = require('../setup') + +function decryptCredentials (key, cipher) { + let flows = cipher.$ + const initVector = Buffer.from(flows.substring(0, 32), 'hex') + flows = flows.substring(32) + const decipher = crypto.createDecipheriv('aes-256-ctr', key, initVector) + const decrypted = decipher.update(flows, 'base64', 'utf8') + decipher.final('utf8') + return JSON.parse(decrypted) +} + +describe('Snapshots API', function () { + let app + /** @type {TestModelFactory} */ let factory = null + + const TestObjects = { + application1: null, + project1: null, + device1: null, + /** ATeam Owner & Admin */ alice: null, + /** ATeam Member */ bob: null, + /** ATeam Viewer */ verity: null, + /** BTeam Owner */ chris: null, + ATeam: null, + BTeam: null, + tokens: { + project1: null, + device1: null, + /** ATeam Owner */ alice: null, + /** ATeam Member */ bob: null, + /** ATeam Viewer */ verity: null, + /** BTeam Owner */ chris: null + }, + template1: null, + stack1: null + } + before(async function () { + app = await setup() + factory = new TestModelFactory(app) + + TestObjects.application1 = app.application + TestObjects.template1 = app.template + TestObjects.stack1 = app.stack + TestObjects.project1 = app.project + + // 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.verity = await app.db.models.User.create({ username: 'verity', name: 'Verity Viewer', email: 'verity@example.com', email_verified: true, password: 'vvPassword' }) + TestObjects.chris = await app.db.models.User.create({ username: 'chris', name: 'Chris Kenobi', email: 'chris@example.com', email_verified: true, password: 'ccPassword' }) + + // Add Bob and Verity to ATeam + TestObjects.ATeam = await app.db.models.Team.byName('ATeam') + await TestObjects.ATeam.addUser(TestObjects.bob, { through: { role: Roles.Member } }) + await TestObjects.ATeam.addUser(TestObjects.verity, { through: { role: Roles.Viewer } }) + + // Add BTeam and add Chris as owner + TestObjects.BTeam = await factory.createTeam({ name: 'BTeam' }) + await TestObjects.BTeam.addUser(TestObjects.chris, { through: { role: Roles.Owner } }) + + // create a device + TestObjects.device1 = await factory.createDevice({ name: 'device-1' }, TestObjects.ATeam, null, TestObjects.application1) + + TestObjects.tokens = {} + await login('alice', 'aaPassword') + await login('bob', 'bbPassword') + await login('chris', 'ccPassword') + await login('verity', 'vvPassword') + + // TestObjects.tokens.alice = (await app.db.controllers.AccessToken.createTokenForPasswordReset(TestObjects.alice)).token + TestObjects.tokens.project1 = (await TestObjects.project1.refreshAuthTokens()).token + // TestObjects.tokens.project2 = (await TestObjects.project2.refreshAuthTokens()).token + + TestObjects.template1 = app.template + TestObjects.stack1 = app.stack + }) + afterEach(async function () { + await app.db.models.ProjectSnapshot.destroy({ where: {} }) + + // if app.comms.devices.sendCommandAwaitReply/sendCommand are stubbed, restore them + if (app.comms.devices.sendCommandAwaitReply.restore) { + app.comms.devices.sendCommandAwaitReply.restore() + } + if (app.comms.devices.sendCommand.restore) { + app.comms.devices.sendCommand.restore() + } + }) + after(async function () { + await app.close() + }) + // after(async function () { + // await TestObjects.project2.destroy() + // }) + const nameGenerator = (name) => `${name} ${Math.random().toString(36).substring(7)}` + async function login (username, password) { + const response = await app.inject({ + method: 'POST', + url: '/account/login', + payload: { username, password, remember: false } + }) + should(response.statusCode).equal(200) + response.cookies.should.have.length(1) + response.cookies[0].should.have.property('name', 'sid') + TestObjects.tokens[username] = response.cookies[0].value + } + + async function createInstanceSnapshot (projectId, token, options) { + projectId = projectId || TestObjects.project1.id + token = token || TestObjects.tokens.alice + options = options || {} + const name = options.name || nameGenerator('instance-snapshot') + const description = options.description || undefined + return await app.inject({ + method: 'POST', + url: `/api/v1/projects/${projectId}/snapshots`, + payload: { + name, + description + }, + cookies: { sid: token } + }) + } + + async function createAppDeviceSnapshot (deviceId, token, options, mockResponse) { + mockResponse = mockResponse || { + flows: [{ id: '123', type: 'newNode' }], + credentials: { testCreds: 'abc' }, + package: {} + } + deviceId = deviceId || TestObjects.device1.hashid + token = token || TestObjects.tokens.alice + options = options || {} + const name = options.name || nameGenerator('device-snapshot') + const description = options.description || undefined + + sinon.stub(app.comms.devices, 'sendCommandAwaitReply').resolves(mockResponse) + sinon.stub(app.comms.devices, 'sendCommand').resolves() + return await app.inject({ + method: 'POST', + url: `/api/v1/devices/${deviceId}/snapshots`, + payload: { + name, + description + }, + cookies: { sid: token } + }) + } + + async function getSnapshot (snapshotId, token) { + return await app.inject({ + method: 'GET', + url: `/api/v1/snapshots/${snapshotId}`, + ...(token ? { cookies: { sid: token } } : {}) + }) + } + + async function getFullSnapshot (snapshotId, token) { + return await app.inject({ + method: 'GET', + url: `/api/v1/snapshots/${snapshotId}/full`, + ...(token ? { cookies: { sid: token } } : {}) + }) + } + + async function exportSnapshot (snapshotId, token, credentialSecret = undefined, credentials = undefined) { + if (typeof credentialSecret === 'undefined') { + credentialSecret = credentialSecret || nameGenerator('test-secret') + } + return await app.inject({ + method: 'POST', + url: `/api/v1/snapshots/${snapshotId}/export`, + ...(token ? { cookies: { sid: token } } : {}), + payload: { + credentialSecret, + ...(credentials ? { credentials } : {}) + } + }) + } + + async function deleteSnapshot (snapshotId, token) { + return await app.inject({ + method: 'DELETE', + url: `/api/v1/snapshots/${snapshotId}`, + ...(token ? { cookies: { sid: token } } : {}) + }) + } + + // Tests for GET /api/v1/snapshots/{snapshotId} + // * Gets the snapshot metadata only. + // * Include content of SnapshotSummary schema: see forge/db/views/ProjectSnapshot.js + + describe('Get snapshot meta', function () { + /** + * get snapshot summary/meta tests + * @param {'instance' | 'device'} kind - 'instance' or 'device' + */ + function tests (kind) { + const createSnapshot = kind === 'instance' ? createInstanceSnapshot : createAppDeviceSnapshot + + it('Returns 404 for non-existent snapshot', async function () { + const response = await getSnapshot('non-existent-snapshot-id', TestObjects.tokens.alice) + response.statusCode.should.equal(404) + response.json().should.have.property('code', 'not_found') + }) + + it('Non-member cannot get snapshot', async function () { + const snapshotResponse = await createSnapshot() + const result = snapshotResponse.json() + + // ensure it really exists before assuming the non-member cannot access it + const ownerResponse = await getSnapshot(result.id, TestObjects.tokens.alice) + ownerResponse.statusCode.should.equal(200) + + const response = await getSnapshot(result.id, TestObjects.tokens.chris) + + // 404 as a non member should not know the resource exists + response.statusCode.should.equal(404) + response.json().should.have.property('code', 'not_found') + }) + + it('Snapshot contains only meta data', async function () { + const snapshotResponse = await createSnapshot() + const result = snapshotResponse.json() + + const response = await getSnapshot(result.id, TestObjects.tokens.alice) + + response.statusCode.should.equal(200) + const data = response.json() + should(data).be.an.Object() + data.should.only.have.keys('id', 'name', 'description') + data.should.have.property('id', result.id) + data.should.have.property('name', result.name) + data.should.have.property('description', result.description) + }) + + it('Owner can get snapshot', async function () { + const snapshotResponse = await createSnapshot() + const result = snapshotResponse.json() + + const response = await getSnapshot(result.id, TestObjects.tokens.alice) + + response.statusCode.should.equal(200) + response.json().should.have.property('name', result.name) + }) + + it('Member can get snapshot', async function () { + const snapshotResponse = await createSnapshot() + const result = snapshotResponse.json() + + const response = await getSnapshot(result.id, TestObjects.tokens.bob) + + response.statusCode.should.equal(200) + response.json().should.have.property('name', result.name) + }) + + it('Viewer can get snapshot', async function () { + const snapshotResponse = await createSnapshot() + const result = snapshotResponse.json() + + const response = await getSnapshot(result.id, TestObjects.tokens.verity) + + response.statusCode.should.equal(200) + response.json().should.have.property('name', result.name) + }) + } + describe('instance', function () { + tests('instance') + }) + describe('device', function () { + tests('device') + }) + }) + + // Tests for GET /api/v1/snapshots/{snapshotId}/full + // * Gets the full snapshot (flows, settings, etc.) + // * Does not include credentials (that is the export endpoint) + // * Does include content of FullSnapshot schema: see forge/db/views/ProjectSnapshot.js + + describe('Get full snapshot', function () { + afterEach(async function () { + await app.db.models.ProjectSnapshot.destroy({ where: {} }) + }) + + /** + * get full snapshot tests + * @param {'instance' | 'device'} kind - 'instance' or 'device' + */ + function tests (kind) { + const createSnapshot = kind === 'instance' ? createInstanceSnapshot : createAppDeviceSnapshot + + it('Returns 404 for non-existent snapshot', async function () { + const response = await getFullSnapshot('non-existent-snapshot-id', TestObjects.tokens.alice) + response.statusCode.should.equal(404) + response.json().should.have.property('code', 'not_found') + }) + + it('Non-member cannot get snapshot', async function () { + const snapshotResponse = await createSnapshot() + const result = snapshotResponse.json() + + // ensure it really exists before assuming the non-member cannot access it + const ownerResponse = await getFullSnapshot(result.id, TestObjects.tokens.alice) + ownerResponse.statusCode.should.equal(200) + + const response = await getFullSnapshot(result.id, TestObjects.tokens.chris) + + // 404 as a non member should not know the resource exists + response.statusCode.should.equal(404) + response.json().should.have.property('code', 'not_found') + }) + + it('Snapshot contains full data', async function () { + const snapshotResponse = await createSnapshot() + const result = snapshotResponse.json() + + const response = await getFullSnapshot(result.id, TestObjects.tokens.alice) + + response.statusCode.should.equal(200) + const data = response.json() + should(data).be.an.Object() + data.should.have.keys('id', 'name', 'description', 'createdAt', 'updatedAt', 'ownerType', 'flows', 'settings') + data.should.not.have.keys('credentialSecret', 'hashid', 'deviceId', 'projectId') + data.should.have.property('id', result.id) + data.should.have.property('name', result.name) + data.should.have.property('description', result.description) + data.settings.should.be.an.Object() + data.settings.should.only.have.keys('settings', 'env', 'modules') + data.settings.settings.should.be.an.Object() + data.settings.env.should.be.an.Object() + data.settings.modules.should.be.an.Object() + data.flows.should.be.an.Object() + data.flows.should.only.have.keys('flows') + data.flows.flows.should.be.an.Array() + }) + + it('Owner can get snapshot', async function () { + const snapshotResponse = await createSnapshot() + const result = snapshotResponse.json() + + const response = await getFullSnapshot(result.id, TestObjects.tokens.alice) + + response.statusCode.should.equal(200) + response.json().should.have.property('name', result.name) + }) + + it('Member can get snapshot', async function () { + const snapshotResponse = await createSnapshot() + const result = snapshotResponse.json() + + const response = await getFullSnapshot(result.id, TestObjects.tokens.bob) + + response.statusCode.should.equal(200) + response.json().should.have.property('name', result.name) + }) + + it('Viewer cannot get snapshot', async function () { + const snapshotResponse = await createSnapshot() + const result = snapshotResponse.json() + + // ensure it really exists before assuming the non-member cannot access it + const ownerResponse = await getFullSnapshot(result.id, TestObjects.tokens.alice) + ownerResponse.statusCode.should.equal(200) + + const response = await getFullSnapshot(result.id, TestObjects.tokens.verity) + + response.statusCode.should.equal(403) + response.json().should.have.property('code', 'unauthorized') + }) + } + describe('instance', function () { + tests('instance') + }) + describe('device', function () { + tests('device') + }) + }) + + // Tests for POST /api/v1/snapshots/{snapshotId}/export + // * Gets the full snapshot (flows, settings, etc.) + // * Does include credentials + // * Does include content of ExportedSnapshot schema: see forge/db/views/ProjectSnapshot.js + + describe('Export snapshot', function () { + afterEach(async function () { + await app.db.models.ProjectSnapshot.destroy({ where: {} }) + }) + + /** + * post export snapshot tests + * @param {'instance' | 'device'} kind - 'instance' or 'device' + */ + function tests (kind) { + const createSnapshot = kind === 'instance' ? createInstanceSnapshot : createAppDeviceSnapshot + + it('Returns 404 for non-existent snapshot', async function () { + const response = await exportSnapshot('non-existent-snapshot-id', TestObjects.tokens.alice) + response.statusCode.should.equal(404) + response.json().should.have.property('code', 'not_found') + }) + + it('Non-member cannot export snapshot', async function () { + const snapshotResponse = await createSnapshot() + const result = snapshotResponse.json() + + // ensure it really exists before assuming the non-member cannot access it + const ownerResponse = await exportSnapshot(result.id, TestObjects.tokens.alice) + ownerResponse.statusCode.should.equal(200) + + const response = await exportSnapshot(result.id, TestObjects.tokens.chris) + + // 404 as a non member should not know the resource exists + response.statusCode.should.equal(404) + response.json().should.have.property('code', 'not_found') + }) + + it('Cannot export snapshot without new credentialSecret', async function () { + const snapshotResponse = await createSnapshot() + const result = snapshotResponse.json() + + const response = await exportSnapshot(result.id, TestObjects.tokens.alice, null) + response.statusCode.should.equal(400) + response.json().should.have.property('code', 'bad_request') + }) + + it('Snapshot contains export data', async function () { + const snapshotResponse = await createSnapshot() + const result = snapshotResponse.json() + + const response = await exportSnapshot(result.id, TestObjects.tokens.alice) + + response.statusCode.should.equal(200) + const data = response.json() + should(data).be.an.Object() + data.should.have.keys('id', 'name', 'description', 'createdAt', 'updatedAt', 'user', 'exportedBy', 'ownerType', 'flows', 'settings') + data.should.not.have.keys('credentialSecret', 'hashid', 'deviceId', 'projectId') + data.should.have.property('id', result.id) + data.should.have.property('name', result.name) + data.should.have.property('description', result.description) + data.settings.should.be.an.Object() + data.settings.should.only.have.keys('settings', 'env', 'modules') + data.settings.settings.should.be.an.Object() + data.settings.env.should.be.an.Object() + data.settings.modules.should.be.an.Object() + data.flows.should.be.an.Object() + data.flows.should.only.have.keys('flows', 'credentials') + data.flows.flows.should.be.an.Array() + }) + + it('Owner can export snapshot', async function () { + const snapshotResponse = await createSnapshot() + const result = snapshotResponse.json() + + const response = await exportSnapshot(result.id, TestObjects.tokens.alice) + + response.statusCode.should.equal(200) + response.json().should.have.property('name', result.name) + }) + + it('Member can export snapshot', async function () { + const snapshotResponse = await createSnapshot() + const result = snapshotResponse.json() + + const response = await exportSnapshot(result.id, TestObjects.tokens.bob) + + response.statusCode.should.equal(200) + response.json().should.have.property('name', result.name) + }) + + it('Viewer cannot export snapshot', async function () { + const snapshotResponse = await createSnapshot() + const result = snapshotResponse.json() + + const response = await exportSnapshot(result.id, TestObjects.tokens.verity) + + response.statusCode.should.equal(403) + response.json().should.have.property('code', 'unauthorized') + }) + + it('Exports provided credentials', async function () { + const snapshotResponse = await createSnapshot() + const result = snapshotResponse.json() + + const response = await exportSnapshot(result.id, TestObjects.tokens.bob, 'test-secret', { testCreds: 'abc' }) + + response.statusCode.should.equal(200) + const exportResult = response.json() + + const keyHash = crypto.createHash('sha256').update('test-secret').digest() + const decrypted = decryptCredentials(keyHash, exportResult.flows.credentials) + JSON.stringify(decrypted).should.equal(JSON.stringify({ testCreds: 'abc' })) + }) + } + describe('instance', function () { + tests('instance') + }) + describe('device', function () { + tests('device') + }) + }) + + // Tests for DELETE /api/v1/snapshots/{snapshotId} + // * Deletes a full snapshot + + describe('Delete snapshot', function () { + afterEach(async function () { + await app.db.models.ProjectSnapshot.destroy({ where: {} }) + }) + + /** + * delete snapshot tests + * @param {'instance' | 'device'} kind - 'instance' or 'device' + */ + function tests (kind) { + const createSnapshot = kind === 'instance' ? createInstanceSnapshot : createAppDeviceSnapshot + + it('Returns 404 for non-existent snapshot', async function () { + const response = await getSnapshot('non-existent-snapshot-id', TestObjects.tokens.alice) + response.statusCode.should.equal(404) + response.json().should.have.property('code', 'not_found') + }) + + it('Non-member cannot delete snapshot', async function () { + const snapshotResponse = await createSnapshot() + const result = snapshotResponse.json() + + // ensure it really exists before assuming the non-member cannot access it + const ownerResponse = await deleteSnapshot(result.id, TestObjects.tokens.alice) + ownerResponse.statusCode.should.equal(200) + + const response = await deleteSnapshot(result.id, TestObjects.tokens.chris) + + // 404 as a non member should not know the resource exists + response.statusCode.should.equal(404) + response.json().should.have.property('code', 'not_found') + }) + + it('Owner can delete snapshot', async function () { + const snapshotResponse = await createSnapshot() + const result = snapshotResponse.json() + + const response = await deleteSnapshot(result.id, TestObjects.tokens.alice) + + response.statusCode.should.equal(200) + }) + + it('Member cannot delete snapshot', async function () { + const snapshotResponse = await createSnapshot() + const result = snapshotResponse.json() + + const response = await deleteSnapshot(result.id, TestObjects.tokens.bob) + + response.statusCode.should.equal(403) + response.json().should.have.property('code', 'unauthorized') + }) + + it('Viewer cannot delete snapshot', async function () { + const snapshotResponse = await createSnapshot() + const result = snapshotResponse.json() + + const response = await deleteSnapshot(result.id, TestObjects.tokens.verity) + + response.statusCode.should.equal(403) + response.json().should.have.property('code', 'unauthorized') + }) + } + describe('instance', function () { + tests('instance') + }) + describe('device', function () { + tests('device') + }) + }) +})