diff --git a/.eslintrc b/.eslintrc index 5f678417bd..e1f5da1104 100644 --- a/.eslintrc +++ b/.eslintrc @@ -253,8 +253,6 @@ "frontend/src/components/tables/cells/TeamTypeCell.vue", "frontend/src/components/tables/cells/UserCell.vue", "frontend/src/components/tables/cells/UserRoleCell.vue", - "frontend/src/pages/application/components/ExportProjectComponents.vue", - "frontend/src/pages/application/components/ImportProjectComponents.vue", "frontend/src/pages/application/Debug.vue", "frontend/src/pages/device/components/DeviceLastSeenBadge.vue", "frontend/src/pages/device/Overview.vue", diff --git a/config/cypress-shared.config.js b/config/cypress-shared.config.js index d7f8b56f8e..350360ffc7 100644 --- a/config/cypress-shared.config.js +++ b/config/cypress-shared.config.js @@ -28,14 +28,17 @@ module.exports = { // fileName looks like a regex debug = debug + JSON.stringify(files) const filteredList = files.filter(file => !!file.match(re)) - return filteredList.length === 1 + if (filteredList.length === 1) { + return filteredList[0] + } + return false } return false }).then(result => { if (!result) { throw new Error(`${fileName} not found in ${baseDir} ${debug}`) } - return true + return result }) }, // Clear all files in the downloads folder diff --git a/docs/user/snapshots.md b/docs/user/snapshots.md index 550d992d40..4479206c19 100644 --- a/docs/user/snapshots.md +++ b/docs/user/snapshots.md @@ -44,9 +44,17 @@ A snapshot can be downloaded to your local machine for backup or sharing. To download a snapshot: -1. Go to the instance's page and select the **Snapshots** tab. +1. Go to the desired application, instance or device overview page and select the **Snapshots** tab. 2. Open the dropdown menu to the right of the snapshot you want to download and - select the **Download Snapshot** option. + click the **Download Snapshot** option to open the download dialog. +3. Select the required components to download. + - **Flows**: Include the snapshot flows + - **Credentials**: Include the snapshot flows credentials + - **Environment Variables**: Include environment variables in the snapshot + - **Keys and Values**: Include the keys and values of the environment variables + - **Keys Only**: Include only the keys of the environment variables +4. Enter a secret to encrypt any credentials in the snapshot (optional, depends on components selected). +5. Click **Download** ## Upload a snapshot @@ -54,13 +62,19 @@ A snapshot can be uploaded to a Node-RED instance in FlowFuse. To upload a snapshot: -1. Go to the instance's page and select the **Snapshots** tab. +1. Go to the desired instance or device overview page and select the **Snapshots** tab. 2. Click the **Upload Snapshot** button. 3. Select the snapshot file from your local machine. -4. If the snapshot contains credentials, you will be asked to enter the credentials secret. - This is the secret that was used to encrypt the credentials in the snapshot. -5. Update the name and description if required. -6. Click **Upload** +4. Update the name and description if required. +5. Select the components to upload: + - **Flows**: Include the snapshots flows + - **Credentials**: Include the snapshots flows credentials (visible only if the snapshot contains credentials) + - **Environment Variables**: Include environment variables in the snapshot + - **Keys and Values**: Include the keys and values of the environment variables + - **Keys Only**: Include only the keys of the environment variables +6. If the snapshot contains credentials and the `Credentials` component is checked, + you will be asked to enter a Secret. This will be used to later decrypt any credentials in the snapshots flows. +7. Click **Upload** ## Delete a snapshot diff --git a/forge/db/controllers/Snapshot.js b/forge/db/controllers/Snapshot.js index 3ed3dfa7c6..7bc7f13ce1 100644 --- a/forge/db/controllers/Snapshot.js +++ b/forge/db/controllers/Snapshot.js @@ -20,9 +20,22 @@ module.exports = { * @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. + * @param {Object} [options.components] (Optional) Components to include in the snapshot. Defaults to all components. + * @param {Boolean} [options.components.flows] (Optional) Include flows in the snapshot. Defaults to true. + * @param {Boolean} [options.components.credentials] (Optional) Include credentials in the snapshot. Defaults to true if `options.components.flows` is true, otherwise false. + * @param {'all' | 'keys' | false} [options.components.envVars='all'] (Optional) Env Var options: `'All'` will keep keys and values, `'keys'` will keep only keys, `false` will remove them. */ exportSnapshot: async function (app, snapshot, options) { - if (!options.credentialSecret) { + const components = { + flows: options.components?.flows ?? true, + credentials: options.components?.credentials ?? true, + envVars: ['all', 'keys', false].includes(options.components?.envVars) ? options.components.envVars : 'all' + } + if (components.flows === false) { + components.credentials = false // no flows, no credentials! + } + + if (components.credentials !== false && !options.credentialSecret) { return null } @@ -54,6 +67,28 @@ module.exports = { ...snapshot.toJSON() } + // include/exclude components of the snapshot + if (components.flows === false) { + result.flows = { + flows: [], + credentials: {} // no flows, no credentials! + } + } else if (components.credentials === false) { + result.flows = { + flows: snapshot.flows.flows || [], + credentials: {} // no credentials! + } + } + if (components.envVars === false) { + result.settings.env = {} + } else if (components.envVars === 'keys' && snapshot.settings?.env && Object.keys(snapshot.settings.env).length > 0) { + const keysOnly = Object.keys(snapshot.settings.env).reduce((acc, key) => { + acc[key] = '' + return acc + }, {}) + result.settings = Object.assign({}, snapshot.settings, { env: keysOnly }) + } + // loop keys of result.settings.env and remove any that match FF_* Object.keys(result.settings.env).forEach((key) => { if (key.startsWith('FF_')) { @@ -61,15 +96,19 @@ module.exports = { } }) - // 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 (components.flows === false || components.credentials === false) { + result.flows.credentials = {} + } else { + // 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) + // 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 }, @@ -143,8 +182,13 @@ module.exports = { * @param {*} snapshot - snapshot data * @param {String} credentialSecret - secret to encrypt credentials with. Can be null if the snapshot does not contain credentials. * @param {*} user - user who uploaded the snapshot + * @param {Object} [options] - additional options + * @param {Object} [options.components] - Include flows in the snapshot. Defaults to true. + * @param {Boolean} [options.components.flows] - Include flows in the snapshot. Defaults to true. + * @param {Boolean} [options.components.credentials] - Include credentials in the snapshot. Defaults to true if `options.components.flows` is true, otherwise false. + * @param {'all' | 'keys' | false} [options.components.envVars='all'] - Env Var options: `'All'` will keep keys and values, `'keys'` will keep only keys, `false` will remove them. */ - async uploadSnapshot (app, owner, snapshot, credentialSecret, user) { + async uploadSnapshot (app, owner, snapshot, credentialSecret, user, options) { // Validate the owner let ownerType if (owner.constructor.name === 'Project') { @@ -155,33 +199,70 @@ module.exports = { throw new Error('Invalid owner type') } + const components = { + flows: options?.components?.flows ?? true, + credentials: options?.components?.credentials ?? true, + envVars: ['all', 'keys', false].includes(options?.components?.envVars) ? options?.components.envVars : 'all' + } + if (components.flows === false) { + components.credentials = false // no flows, no credentials! + } + + // shallow clone the snapshot before modifying it + const importSnapshot = Object.assign({}, snapshot) + + // include/exclude components of the snapshot + if (components.flows === false) { + importSnapshot.flows = { + flows: [], + credentials: {} // no flows, no credentials! + } + } else if (components.credentials === false) { + importSnapshot.flows = { + flows: snapshot.flows.flows || [], + credentials: {} // no credentials! + } + } + if (components.envVars === false) { + importSnapshot.settings = { + ...snapshot.settings, + env: {} // no env vars! + } + } else if (components.envVars === 'keys' && snapshot.settings?.env && Object.keys(snapshot.settings.env).length > 0) { + const keysOnly = Object.keys(snapshot.settings.env).reduce((acc, key) => { + acc[key] = '' + return acc + }, {}) + importSnapshot.settings = Object.assign({}, snapshot.settings, { env: keysOnly }) + } + const targetCredentialSecret = owner.credentialSecret || (owner.getCredentialSecret && await owner.getCredentialSecret()) || credentialSecret // 1. If the snapshot includes credentials but no credentialSecret, we should reject it // 2. if the snapshot includes credentials and a credentialSecret, we should reencrypt for the owner - if (snapshot.flows.credentials?.$) { + if (importSnapshot.flows.credentials?.$) { if (!credentialSecret) { throw new Error('Missing credentialSecret') } // Need to re-encrypt the credentials for the target - snapshot.flows.credentials = app.db.controllers.Project.exportCredentials(snapshot.flows.credentials, credentialSecret, targetCredentialSecret) + importSnapshot.flows.credentials = app.db.controllers.Project.exportCredentials(importSnapshot.flows.credentials, credentialSecret, targetCredentialSecret) } const ProjectId = ownerType === 'instance' ? owner.id : null const DeviceId = ownerType === 'device' ? owner.id : null const snapshotOptions = { - name: snapshot.name, - description: snapshot.description || '', + name: importSnapshot.name, + description: importSnapshot.description || '', credentialSecret: targetCredentialSecret, settings: { - settings: snapshot.settings?.settings || {}, - env: snapshot.settings?.env || {}, - modules: snapshot.settings?.modules || {} + settings: importSnapshot.settings?.settings || {}, + env: importSnapshot.settings?.env || {}, + modules: importSnapshot.settings?.modules || {} }, flows: { - flows: snapshot.flows.flows || [], - credentials: snapshot.flows.credentials || { } + flows: importSnapshot.flows.flows || [], + credentials: importSnapshot.flows.credentials || { } }, ProjectId, DeviceId, diff --git a/forge/routes/api/snapshot.js b/forge/routes/api/snapshot.js index 4ab2de5314..7f2631f0dc 100644 --- a/forge/routes/api/snapshot.js +++ b/forge/routes/api/snapshot.js @@ -227,7 +227,20 @@ module.exports = async function (app) { body: { type: 'object', properties: { - credentialSecret: { type: 'string' } + credentialSecret: { type: 'string' }, + components: { + type: 'object', + properties: { + flows: { type: 'boolean', default: true }, + credentials: { type: 'boolean', default: true }, + envVars: { + anyOf: [ + { type: 'string', enum: ['all', 'keys'] }, + { type: 'boolean', enum: [false] } + ] + } + } + } } }, response: { @@ -243,7 +256,8 @@ module.exports = async function (app) { const options = { credentialSecret: request.body.credentialSecret, credentials: request.body.credentials, - owner: request.owner // the instance or device that owns the snapshot + owner: request.owner, // the instance or device that owns the snapshot + components: request.body.components } if (!options.credentialSecret) { @@ -304,7 +318,20 @@ module.exports = async function (app) { }, required: ['name', 'flows', 'settings'] }, - credentialSecret: { type: 'string' } + credentialSecret: { type: 'string' }, + components: { + type: 'object', + properties: { + flows: { type: 'boolean', default: true }, + credentials: { type: 'boolean', default: true }, + envVars: { + anyOf: [ + { type: 'string', enum: ['all', 'keys'] }, + { type: 'boolean', enum: [false] } + ] + } + } + } } }, response: { @@ -323,12 +350,17 @@ module.exports = async function (app) { reply.code(400).send({ code: 'bad_request', error: 'owner and snapshot are mandatory in the body' }) return } - if (snapshot.flows.credentials?.$ && !request.body.credentialSecret) { - reply.code(400).send({ code: 'bad_request', error: 'Credential secret is required when importing a snapshot with credentials' }) - return + if (request.body.components?.credentials !== false) { + if (snapshot.flows.credentials?.$ && !request.body.credentialSecret) { + reply.code(400).send({ code: 'bad_request', error: 'Credential secret is required when importing a snapshot with credentials' }) + return + } } try { - const newSnapshot = await snapshotController.uploadSnapshot(owner, snapshot, request.body.credentialSecret, request.session.User) + const options = { + components: request.body.components || null + } + const newSnapshot = await snapshotController.uploadSnapshot(owner, snapshot, request.body.credentialSecret, request.session.User, options) if (!newSnapshot) { throw new Error('Failed to upload snapshot') } diff --git a/frontend/src/api/snapshots.js b/frontend/src/api/snapshots.js index 8cd5986b5d..b438388268 100644 --- a/frontend/src/api/snapshots.js +++ b/frontend/src/api/snapshots.js @@ -45,12 +45,13 @@ const exportSnapshot = (snapshotId, options) => { * @param {Object} snapshot - snapshot object to import * @param {String} [credentialSecret] - secret to use when decrypting credentials in the snapshot object (optional/only required when the snapshot contains credentials) */ -const importSnapshot = async (ownerId, ownerType, snapshot, credentialSecret) => { +const importSnapshot = async (ownerId, ownerType, snapshot, credentialSecret, options) => { return client.post('/api/v1/snapshots/import', { ownerId, ownerType, snapshot, - credentialSecret + credentialSecret, + components: options?.components }).then(res => { const props = { 'snapshot-id': res.data.id diff --git a/frontend/src/components/dialogs/SnapshotImportDialog.vue b/frontend/src/components/dialogs/SnapshotImportDialog.vue index 91fbeafd82..3cbad24878 100644 --- a/frontend/src/components/dialogs/SnapshotImportDialog.vue +++ b/frontend/src/components/dialogs/SnapshotImportDialog.vue @@ -15,7 +15,6 @@ - Credentials Secret Name Description @@ -23,6 +22,14 @@ + + Credentials Secret @@ -32,6 +39,7 @@ import { DocumentIcon } from '@heroicons/vue/outline' import snapshotsApi from '../../api/snapshots.js' +import ImportInstanceComponents from '../../pages/instance/components/ExportImportComponents.vue' import alerts from '../../services/alerts.js' import { isSnapshot } from '../../utils/snapshot.js' import FormRow from '../FormRow.vue' @@ -40,7 +48,8 @@ export default { name: 'SnapshotImportDialog', components: { FormRow, - DocumentIcon + DocumentIcon, + ImportInstanceComponents }, props: { owner: { @@ -60,11 +69,10 @@ export default { setup () { return { show () { - this.setKeys(this.input, '') - this.setKeys(this.errors, '') - this.setKeys(this.validateField, false) + this.clear() this.$refs.dialog.show() setTimeout(() => { + this.clear() this.validate() this.shown = true }, 5) @@ -84,21 +92,31 @@ export default { errors: { file: '', secret: '', - name: '' + name: '', + parts: '' }, validateField: { file: false, secret: false, - name: false + name: false, + parts: false + }, + parts: { + flows: true, + credentials: true, + envVars: 'all' } } }, computed: { formValid () { - return !Object.values(this.errors).some(error => !!error) && this.input.file && this.input.name && (!this.snapshotNeedsSecret || this.input.secret) + return !Object.values(this.errors).some(error => !!error) && this.input.file && this.input.name && (this.parts.flows || this.parts.envVars) }, snapshotNeedsSecret () { - return !!(this.input.snapshot && this.input.snapshot.flows?.credentials) + if (!this.input.snapshot?.flows?.credentials) { + return false + } + return Object.keys(this.input.snapshot.flows.credentials).length > 0 } }, watch: { @@ -119,16 +137,29 @@ export default { this.validateField.secret = true this.validate() } + }, + parts: { + handler () { + if (this.shown) { + this.validateField.parts = true + this.validate() + } + }, + deep: true } }, mounted () { this.$refs.fileUpload.addEventListener('change', (e) => { + if (!e.target.files?.length) { + return + } const file = e.target.files[0] this.input.snapshot = null this.input.file = '' this.errors.file = null this.validateField.file = true this.validateField.secret = true + this.validateField.parts = true const reader = new FileReader() reader.onload = () => { const data = reader.result @@ -157,7 +188,12 @@ export default { validate () { this.errors.file = !this.input.file ? 'Snapshot file is required' : '' this.errors.name = !this.input.name ? 'Name is required' : '' - this.errors.secret = this.snapshotNeedsSecret ? (this.input.secret ? '' : 'Secret is required') : '' + if (this.parts.flows && this.parts.credentials) { + this.errors.secret = this.snapshotNeedsSecret ? (this.input.secret ? '' : 'Secret is required') : '' + } else { + this.errors.secret = '' + } + this.errors.parts = this.parts.flows === false && this.parts.envVars === false ? 'At least one component must be selected' : '' return this.formValid }, confirm () { @@ -177,7 +213,34 @@ export default { if (this.snapshotNeedsSecret) { secret = this.input.secret } - snapshotsApi.importSnapshot(this.owner.id, this.ownerType, importSnapshot, secret).then((response) => { + const components = { + flows: this.parts.flows, + credentials: this.parts.credentials, + envVars: this.parts.envVars + } + if (components.flows === false) { + importSnapshot.flows = { + flows: [], + credentials: {} + } + } else if (components.credentials === false) { + importSnapshot.flows = { + flows: importSnapshot.flows.flows, + credentials: {} + } + } + + importSnapshot.settings = importSnapshot.settings || {} + if (components.envVars === false) { + importSnapshot.settings.env = {} + } else if (components.envVars === 'keys') { + importSnapshot.settings.env = Object.keys(importSnapshot.settings.env || {}).reduce((acc, key) => { + acc[key] = '' + return acc + }, {}) + } + + snapshotsApi.importSnapshot(this.owner.id, this.ownerType, importSnapshot, secret, { components }).then((response) => { this.$emit('snapshot-import-success', response) this.$refs.dialog.close() this.shown = false @@ -188,10 +251,21 @@ export default { }, cancel () { this.shown = false + this.$refs.fileUpload.value = '' this.$refs.dialog.close() this.$emit('canceled') }, + clear () { + this.$refs.fileUpload.value = '' + this.setKeys(this.input, '') + this.setKeys(this.errors, '') + this.setKeys(this.validateField, false) + this.parts.flows = true + this.parts.credentials = true + this.parts.envVars = 'all' + }, selectSnapshot () { + this.$refs.fileUpload.value = '' const fileUpload = this.$refs.fileUpload fileUpload.click() }, diff --git a/frontend/src/pages/application/Snapshots/components/dialogs/SnapshotExportDialog.vue b/frontend/src/pages/application/Snapshots/components/dialogs/SnapshotExportDialog.vue index 5ea1a7f80d..b2aedb29f3 100644 --- a/frontend/src/pages/application/Snapshots/components/dialogs/SnapshotExportDialog.vue +++ b/frontend/src/pages/application/Snapshots/components/dialogs/SnapshotExportDialog.vue @@ -1,29 +1,36 @@ - - Please make a note of the secret used to encrypt the snapshot credentials. It will be required when importing the snapshot. - - Secret - - A key used to encrypt any credentials in the snapshot. - - - - - - - - - - - - - - - - - + + + + + Secret + + A key used to encrypt any credentials in the snapshot's flow. + + + + + + + + + + + + + + + + + + Please make a note of the secret used to encrypt the snapshot credentials. It will be required when importing the snapshot. + @@ -32,16 +39,17 @@ import { ClipboardCopyIcon, RefreshIcon } from '@heroicons/vue/outline' import snapshotsApi from '../../../../../api/snapshots.js' - import FormRow from '../../../../../components/FormRow.vue' import { downloadData } from '../../../../../composables/Download.js' import clipboardMixin from '../../../../../mixins/Clipboard.js' import alerts from '../../../../../services/alerts.js' +import ExportInstanceComponents from '../../../../instance/components/ExportImportComponents.vue' export default { name: 'SnapshotExportDialog', components: { ClipboardCopyIcon, + ExportInstanceComponents, FormRow, RefreshIcon }, @@ -53,9 +61,8 @@ export default { this.input.secret = this.generateRandomKey() this.snapshot = snapshot this.submitted = false - this.errors = { - secret: '' - } + this.errors.secret = '' + this.errors.parts = '' this.$refs.secret.focus() } } @@ -67,34 +74,59 @@ export default { secret: '' }, snapshot: null, - errors: {} + errors: { + secret: '', + parts: '' + }, + parts: { + flows: true, + credentials: true, + envVars: 'all' + } } }, computed: { formValid () { return this.validate() + }, + needsSecret () { + return this.parts.flows && this.parts.credentials } }, mounted () { }, methods: { validate () { - if (!this.input.secret) { - this.errors.secret = 'Secret is required' - } else if (this.input.secret.length < 8) { - this.errors.secret = 'Secret must be at least 8 characters' - } else if (/^\s/.test(this.input.secret) || /\s$/.test(this.input.secret)) { - this.errors.secret = 'Secret cannot start or end with a space' + if (this.needsSecret) { + if (!this.input.secret) { + this.errors.secret = 'Secret is required' + } else if (this.input.secret.length < 8) { + this.errors.secret = 'Secret must be at least 8 characters' + } else if (/^\s/.test(this.input.secret) || /\s$/.test(this.input.secret)) { + this.errors.secret = 'Secret cannot start or end with a space' + } else { + this.errors.secret = '' + } } else { this.errors.secret = '' } - return !this.submitted && !!(this.input.secret) && !this.errors.secret + if (this.parts.flows === false && this.parts.envVars === false) { + this.errors.parts = 'At least one component must be selected' + } else { + this.errors.parts = '' + } + return !this.submitted && !this.errors.parts && !this.errors.secret }, confirm () { if (this.validate()) { this.submitted = true const opts = { - credentialSecret: this.input.secret + credentialSecret: this.input.secret, + components: { + flows: this.parts.flows, + credentials: this.parts.credentials, + envVars: this.parts.envVars + } } snapshotsApi.exportSnapshot(this.snapshot.id, opts).then((data) => { return data diff --git a/frontend/src/pages/application/components/ExportProjectComponents.vue b/frontend/src/pages/application/components/ExportProjectComponents.vue deleted file mode 100644 index 6a9c7479d0..0000000000 --- a/frontend/src/pages/application/components/ExportProjectComponents.vue +++ /dev/null @@ -1,101 +0,0 @@ - - - - Flows - - - - Credentials - - - - Template - - - Secret - Provide a Secret to encrypt the exported Credentials - - - Application Settings - - - Environment Variables - - - - - - - - diff --git a/frontend/src/pages/application/components/ImportProjectComponents.vue b/frontend/src/pages/application/components/ImportProjectComponents.vue deleted file mode 100644 index 29d10e3d6f..0000000000 --- a/frontend/src/pages/application/components/ImportProjectComponents.vue +++ /dev/null @@ -1,133 +0,0 @@ - - - - Flow File - - - Credentials File - - - Credentials Secret - - - - - diff --git a/frontend/src/pages/instance/components/ExportImportComponents.vue b/frontend/src/pages/instance/components/ExportImportComponents.vue new file mode 100644 index 0000000000..92eb53ba6b --- /dev/null +++ b/frontend/src/pages/instance/components/ExportImportComponents.vue @@ -0,0 +1,158 @@ + + + + {{ header }} + + + Flows + + + + Credentials + + + + Template + + + Secret + Provide a Secret to encrypt the exported Credentials + + + Instance Settings + + + Environment Variables + + + + + {{ error }} + + + + diff --git a/frontend/src/pages/instance/components/ExportInstanceComponents.vue b/frontend/src/pages/instance/components/ExportInstanceComponents.vue deleted file mode 100644 index 6f29e78dc3..0000000000 --- a/frontend/src/pages/instance/components/ExportInstanceComponents.vue +++ /dev/null @@ -1,101 +0,0 @@ - - - - Flows - - - - Credentials - - - - Template - - - Secret - Provide a Secret to encrypt the exported Credentials - - - Instance Settings - - - Environment Variables - - - - - - - - diff --git a/frontend/src/pages/instance/components/InstanceForm.vue b/frontend/src/pages/instance/components/InstanceForm.vue index 1ba9fa6afb..a0d00d8e7a 100644 --- a/frontend/src/pages/instance/components/InstanceForm.vue +++ b/frontend/src/pages/instance/components/InstanceForm.vue @@ -255,7 +255,7 @@ import NameGenerator from '../../../utils/name-generator/index.js' import BlueprintTileSmall from '../Blueprints/BlueprintTileSmall.vue' -import ExportInstanceComponents from './ExportInstanceComponents.vue' +import ExportInstanceComponents from './ExportImportComponents.vue' import InstanceChargesTable from './InstanceChargesTable.vue' import InstanceCreditBanner from './InstanceCreditBanner.vue' diff --git a/test/e2e/frontend/cypress/fixtures/snapshots/instance-snapshots.json b/test/e2e/frontend/cypress/fixtures/snapshots/instance-snapshots.json index e4e9ccb4d1..5414c7795e 100644 --- a/test/e2e/frontend/cypress/fixtures/snapshots/instance-snapshots.json +++ b/test/e2e/frontend/cypress/fixtures/snapshots/instance-snapshots.json @@ -15,7 +15,7 @@ "mode": "autonomous", "isDeploying": false }, - "id": "2", + "id": "KR6Ov5pj5z", "name": "instance-2 snapshot-2", "description": "an instance snapshot" } diff --git a/test/e2e/frontend/cypress/fixtures/snapshots/instance2-full-snapshot2.json b/test/e2e/frontend/cypress/fixtures/snapshots/instance2-full-snapshot2.json index c9bcc9fc88..a190d4a613 100644 --- a/test/e2e/frontend/cypress/fixtures/snapshots/instance2-full-snapshot2.json +++ b/test/e2e/frontend/cypress/fixtures/snapshots/instance2-full-snapshot2.json @@ -2,7 +2,7 @@ "flows": { "flows": [{"id":"8a47f02e3a227d35","type":"tab","label":"Device info flows","disabled":false,"info":"","env":[]},{"id":"36113d72fe3d5757","type":"inject","z":"8a47f02e3a227d35","name":"","props":[{"p":"payload"},{"p":"payload.FF_INSTANCE_NAME","v":"FF_INSTANCE_NAME","vt":"env"},{"p":"payload.FF_DEVICE_NAME","v":"FF_DEVICE_NAME","vt":"env"}],"repeat":"6","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"{}","payloadType":"json","x":140,"y":140,"wires":[["5493f53d471a35ba"]]},{"id":"5493f53d471a35ba","type":"debug","z":"8a47f02e3a227d35","name":"debug 1","active":true,"tosidebar":true,"console":true,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":340,"y":140,"wires":[]},{"id":"970460dbdcb949f7","type":"comment","z":"8a47f02e3a227d35","name":"System info","info":"","x":150,"y":80,"wires":[]}] }, - "id": "2", + "id": "KR6Ov5pj5z", "name": "instance-2 snapshot-2", "description": "an instance snapshot", "createdAt": "2024-01-01T11:22:33.444Z", diff --git a/test/e2e/frontend/cypress/fixtures/snapshots/instance2-snapshot2.json b/test/e2e/frontend/cypress/fixtures/snapshots/instance2-snapshot2.json index 54137468ad..6decc88da5 100644 --- a/test/e2e/frontend/cypress/fixtures/snapshots/instance2-snapshot2.json +++ b/test/e2e/frontend/cypress/fixtures/snapshots/instance2-snapshot2.json @@ -1,5 +1,5 @@ { - "id": "2", + "id": "KR6Ov5pj5z", "name": "instance-2 snapshot-2", "description": "an instance snapshot", "createdAt": "2024-01-01T11:22:33.444Z", diff --git a/test/e2e/frontend/cypress/fixtures/snapshots/snapshot-with-credentials.json b/test/e2e/frontend/cypress/fixtures/snapshots/snapshot-with-credentials.json index 8ee9bc5a22..e1abe9a941 100644 --- a/test/e2e/frontend/cypress/fixtures/snapshots/snapshot-with-credentials.json +++ b/test/e2e/frontend/cypress/fixtures/snapshots/snapshot-with-credentials.json @@ -13,7 +13,10 @@ "ownerType": "device", "settings": { "settings": {}, - "env": {}, + "env": { + "key1": "value1", + "key2": "value2" + }, "modules": { "@flowfuse/nr-file-nodes": "0.0.5", "@flowfuse/nr-project-nodes": "file:../../../Users/Stephen/repos/github/flowfuse/dev-env/packages/nr-project-nodes", diff --git a/test/e2e/frontend/cypress/fixtures/snapshots/upload-for-download.json b/test/e2e/frontend/cypress/fixtures/snapshots/upload-for-download.json new file mode 100644 index 0000000000..ec2ef155cc --- /dev/null +++ b/test/e2e/frontend/cypress/fixtures/snapshots/upload-for-download.json @@ -0,0 +1,26 @@ +{ + "flows": { + "flows": [{"id":"8a47f02e3a227d35","type":"tab","label":"Device info flows","disabled":false,"info":"","env":[]},{"id":"36113d72fe3d5757","type":"inject","z":"8a47f02e3a227d35","name":"","props":[{"p":"payload"},{"p":"payload.FF_INSTANCE_NAME","v":"FF_INSTANCE_NAME","vt":"env"},{"p":"payload.FF_DEVICE_NAME","v":"FF_DEVICE_NAME","vt":"env"}],"repeat":"6","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"{}","payloadType":"json","x":140,"y":140,"wires":[["5493f53d471a35ba"]]},{"id":"5493f53d471a35ba","type":"debug","z":"8a47f02e3a227d35","name":"debug 1","active":true,"tosidebar":true,"console":true,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":340,"y":140,"wires":[]},{"id":"970460dbdcb949f7","type":"comment","z":"8a47f02e3a227d35","name":"System info","info":"","x":150,"y":80,"wires":[]}], + "credentials": { + "$": "132e70164b076039152304df8407ad19IsI=" + } + }, + "id": "KR6Ov5pj5z", + "name": "upload-for-download.json", + "description": "a device snapshot", + "createdAt": "2024-01-01T11:22:33.444Z", + "updatedAt": "2024-01-01T11:22:33.444Z", + "ownerType": "device", + "settings": { + "settings": {}, + "env": { + "key1": "value1", + "key2": "value2" + }, + "modules": { + "@flowfuse/nr-file-nodes": "0.0.5", + "@flowfuse/nr-project-nodes": "file:../../../Users/Stephen/repos/github/flowfuse/dev-env/packages/nr-project-nodes", + "node-red": "3.1.9" + } + } +} \ No newline at end of file diff --git a/test/e2e/frontend/cypress/fixtures/snapshots/upload1.json b/test/e2e/frontend/cypress/fixtures/snapshots/upload1.json new file mode 100644 index 0000000000..e503d44dcd --- /dev/null +++ b/test/e2e/frontend/cypress/fixtures/snapshots/upload1.json @@ -0,0 +1,26 @@ +{ + "flows": { + "flows": [{"id":"8a47f02e3a227d35","type":"tab","label":"Device info flows","disabled":false,"info":"","env":[]},{"id":"36113d72fe3d5757","type":"inject","z":"8a47f02e3a227d35","name":"","props":[{"p":"payload"},{"p":"payload.FF_INSTANCE_NAME","v":"FF_INSTANCE_NAME","vt":"env"},{"p":"payload.FF_DEVICE_NAME","v":"FF_DEVICE_NAME","vt":"env"}],"repeat":"6","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"{}","payloadType":"json","x":140,"y":140,"wires":[["5493f53d471a35ba"]]},{"id":"5493f53d471a35ba","type":"debug","z":"8a47f02e3a227d35","name":"debug 1","active":true,"tosidebar":true,"console":true,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":340,"y":140,"wires":[]},{"id":"970460dbdcb949f7","type":"comment","z":"8a47f02e3a227d35","name":"System info","info":"","x":150,"y":80,"wires":[]}], + "credentials": { + "$": "132e70164b076039152304df8407ad19IsI=" + } + }, + "id": "KR6Ov5pj5z", + "name": "upload1.json", + "description": "a device snapshot", + "createdAt": "2024-01-01T11:22:33.444Z", + "updatedAt": "2024-01-01T11:22:33.444Z", + "ownerType": "device", + "settings": { + "settings": {}, + "env": { + "key1": "value1", + "key2": "value2" + }, + "modules": { + "@flowfuse/nr-file-nodes": "0.0.5", + "@flowfuse/nr-project-nodes": "file:../../../Users/Stephen/repos/github/flowfuse/dev-env/packages/nr-project-nodes", + "node-red": "3.1.9" + } + } +} \ No newline at end of file diff --git a/test/e2e/frontend/cypress/fixtures/snapshots/upload2.json b/test/e2e/frontend/cypress/fixtures/snapshots/upload2.json new file mode 100644 index 0000000000..bcce75998a --- /dev/null +++ b/test/e2e/frontend/cypress/fixtures/snapshots/upload2.json @@ -0,0 +1,26 @@ +{ + "flows": { + "flows": [{"id":"8a47f02e3a227d35","type":"tab","label":"Device info flows","disabled":false,"info":"","env":[]},{"id":"36113d72fe3d5757","type":"inject","z":"8a47f02e3a227d35","name":"","props":[{"p":"payload"},{"p":"payload.FF_INSTANCE_NAME","v":"FF_INSTANCE_NAME","vt":"env"},{"p":"payload.FF_DEVICE_NAME","v":"FF_DEVICE_NAME","vt":"env"}],"repeat":"6","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"{}","payloadType":"json","x":140,"y":140,"wires":[["5493f53d471a35ba"]]},{"id":"5493f53d471a35ba","type":"debug","z":"8a47f02e3a227d35","name":"debug 1","active":true,"tosidebar":true,"console":true,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":340,"y":140,"wires":[]},{"id":"970460dbdcb949f7","type":"comment","z":"8a47f02e3a227d35","name":"System info","info":"","x":150,"y":80,"wires":[]}], + "credentials": { + "$": "132e70164b076039152304df8407ad19IsI=" + } + }, + "id": "KR6Ov5pj5z", + "name": "upload2.json", + "description": "a device snapshot", + "createdAt": "2024-01-01T11:22:33.444Z", + "updatedAt": "2024-01-01T11:22:33.444Z", + "ownerType": "device", + "settings": { + "settings": {}, + "env": { + "key1": "value1", + "key2": "value2" + }, + "modules": { + "@flowfuse/nr-file-nodes": "0.0.5", + "@flowfuse/nr-project-nodes": "file:../../../Users/Stephen/repos/github/flowfuse/dev-env/packages/nr-project-nodes", + "node-red": "3.1.9" + } + } +} \ No newline at end of file diff --git a/test/e2e/frontend/cypress/fixtures/snapshots/upload3.json b/test/e2e/frontend/cypress/fixtures/snapshots/upload3.json new file mode 100644 index 0000000000..37b5aba231 --- /dev/null +++ b/test/e2e/frontend/cypress/fixtures/snapshots/upload3.json @@ -0,0 +1,26 @@ +{ + "flows": { + "flows": [{"id":"8a47f02e3a227d35","type":"tab","label":"Device info flows","disabled":false,"info":"","env":[]},{"id":"36113d72fe3d5757","type":"inject","z":"8a47f02e3a227d35","name":"","props":[{"p":"payload"},{"p":"payload.FF_INSTANCE_NAME","v":"FF_INSTANCE_NAME","vt":"env"},{"p":"payload.FF_DEVICE_NAME","v":"FF_DEVICE_NAME","vt":"env"}],"repeat":"6","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"{}","payloadType":"json","x":140,"y":140,"wires":[["5493f53d471a35ba"]]},{"id":"5493f53d471a35ba","type":"debug","z":"8a47f02e3a227d35","name":"debug 1","active":true,"tosidebar":true,"console":true,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":340,"y":140,"wires":[]},{"id":"970460dbdcb949f7","type":"comment","z":"8a47f02e3a227d35","name":"System info","info":"","x":150,"y":80,"wires":[]}], + "credentials": { + "$": "132e70164b076039152304df8407ad19IsI=" + } + }, + "id": "KR6Ov5pj5z", + "name": "upload3.json", + "description": "a device snapshot", + "createdAt": "2024-01-01T11:22:33.444Z", + "updatedAt": "2024-01-01T11:22:33.444Z", + "ownerType": "device", + "settings": { + "settings": {}, + "env": { + "key1": "value1", + "key2": "value2" + }, + "modules": { + "@flowfuse/nr-file-nodes": "0.0.5", + "@flowfuse/nr-project-nodes": "file:../../../Users/Stephen/repos/github/flowfuse/dev-env/packages/nr-project-nodes", + "node-red": "3.1.9" + } + } +} \ No newline at end of file diff --git a/test/e2e/frontend/cypress/fixtures/snapshots/upload4.json b/test/e2e/frontend/cypress/fixtures/snapshots/upload4.json new file mode 100644 index 0000000000..6071a076a7 --- /dev/null +++ b/test/e2e/frontend/cypress/fixtures/snapshots/upload4.json @@ -0,0 +1,26 @@ +{ + "flows": { + "flows": [{"id":"8a47f02e3a227d35","type":"tab","label":"Device info flows","disabled":false,"info":"","env":[]},{"id":"36113d72fe3d5757","type":"inject","z":"8a47f02e3a227d35","name":"","props":[{"p":"payload"},{"p":"payload.FF_INSTANCE_NAME","v":"FF_INSTANCE_NAME","vt":"env"},{"p":"payload.FF_DEVICE_NAME","v":"FF_DEVICE_NAME","vt":"env"}],"repeat":"6","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"{}","payloadType":"json","x":140,"y":140,"wires":[["5493f53d471a35ba"]]},{"id":"5493f53d471a35ba","type":"debug","z":"8a47f02e3a227d35","name":"debug 1","active":true,"tosidebar":true,"console":true,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":340,"y":140,"wires":[]},{"id":"970460dbdcb949f7","type":"comment","z":"8a47f02e3a227d35","name":"System info","info":"","x":150,"y":80,"wires":[]}], + "credentials": { + "$": "132e70164b076039152304df8407ad19IsI=" + } + }, + "id": "KR6Ov5pj5z", + "name": "upload4.json", + "description": "a device snapshot", + "createdAt": "2024-01-01T11:22:33.444Z", + "updatedAt": "2024-01-01T11:22:33.444Z", + "ownerType": "device", + "settings": { + "settings": {}, + "env": { + "key1": "value1", + "key2": "value2" + }, + "modules": { + "@flowfuse/nr-file-nodes": "0.0.5", + "@flowfuse/nr-project-nodes": "file:../../../Users/Stephen/repos/github/flowfuse/dev-env/packages/nr-project-nodes", + "node-red": "3.1.9" + } + } +} \ No newline at end of file diff --git a/test/e2e/frontend/cypress/fixtures/snapshots/upload5.json b/test/e2e/frontend/cypress/fixtures/snapshots/upload5.json new file mode 100644 index 0000000000..a787def8b5 --- /dev/null +++ b/test/e2e/frontend/cypress/fixtures/snapshots/upload5.json @@ -0,0 +1,26 @@ +{ + "flows": { + "flows": [{"id":"8a47f02e3a227d35","type":"tab","label":"Device info flows","disabled":false,"info":"","env":[]},{"id":"36113d72fe3d5757","type":"inject","z":"8a47f02e3a227d35","name":"","props":[{"p":"payload"},{"p":"payload.FF_INSTANCE_NAME","v":"FF_INSTANCE_NAME","vt":"env"},{"p":"payload.FF_DEVICE_NAME","v":"FF_DEVICE_NAME","vt":"env"}],"repeat":"6","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"{}","payloadType":"json","x":140,"y":140,"wires":[["5493f53d471a35ba"]]},{"id":"5493f53d471a35ba","type":"debug","z":"8a47f02e3a227d35","name":"debug 1","active":true,"tosidebar":true,"console":true,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":340,"y":140,"wires":[]},{"id":"970460dbdcb949f7","type":"comment","z":"8a47f02e3a227d35","name":"System info","info":"","x":150,"y":80,"wires":[]}], + "credentials": { + "$": "132e70164b076039152304df8407ad19IsI=" + } + }, + "id": "KR6Ov5pj5z", + "name": "upload5.json", + "description": "a device snapshot", + "createdAt": "2024-01-01T11:22:33.444Z", + "updatedAt": "2024-01-01T11:22:33.444Z", + "ownerType": "device", + "settings": { + "settings": {}, + "env": { + "key1": "value1", + "key2": "value2" + }, + "modules": { + "@flowfuse/nr-file-nodes": "0.0.5", + "@flowfuse/nr-project-nodes": "file:../../../Users/Stephen/repos/github/flowfuse/dev-env/packages/nr-project-nodes", + "node-red": "3.1.9" + } + } +} \ No newline at end of file diff --git a/test/e2e/frontend/cypress/fixtures/snapshots/upload6.json b/test/e2e/frontend/cypress/fixtures/snapshots/upload6.json new file mode 100644 index 0000000000..fd5729c60a --- /dev/null +++ b/test/e2e/frontend/cypress/fixtures/snapshots/upload6.json @@ -0,0 +1,23 @@ +{ + "flows": { + "flows": [{"id":"8a47f02e3a227d35","type":"tab","label":"Device info flows","disabled":false,"info":"","env":[]},{"id":"36113d72fe3d5757","type":"inject","z":"8a47f02e3a227d35","name":"","props":[{"p":"payload"},{"p":"payload.FF_INSTANCE_NAME","v":"FF_INSTANCE_NAME","vt":"env"},{"p":"payload.FF_DEVICE_NAME","v":"FF_DEVICE_NAME","vt":"env"}],"repeat":"6","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"{}","payloadType":"json","x":140,"y":140,"wires":[["5493f53d471a35ba"]]},{"id":"5493f53d471a35ba","type":"debug","z":"8a47f02e3a227d35","name":"debug 1","active":true,"tosidebar":true,"console":true,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":340,"y":140,"wires":[]},{"id":"970460dbdcb949f7","type":"comment","z":"8a47f02e3a227d35","name":"System info","info":"","x":150,"y":80,"wires":[]}] + }, + "id": "KR6Ov5pj5z", + "name": "upload6.json", + "description": "a device snapshot", + "createdAt": "2024-01-01T11:22:33.444Z", + "updatedAt": "2024-01-01T11:22:33.444Z", + "ownerType": "device", + "settings": { + "settings": {}, + "env": { + "key1": "value1", + "key2": "value2" + }, + "modules": { + "@flowfuse/nr-file-nodes": "0.0.5", + "@flowfuse/nr-project-nodes": "file:../../../Users/Stephen/repos/github/flowfuse/dev-env/packages/nr-project-nodes", + "node-red": "3.1.9" + } + } +} \ No newline at end of file diff --git a/test/e2e/frontend/cypress/tests/instances/snapshots.spec.js b/test/e2e/frontend/cypress/tests/instances/snapshots.spec.js index 694d642403..ab01f0ff3a 100644 --- a/test/e2e/frontend/cypress/tests/instances/snapshots.spec.js +++ b/test/e2e/frontend/cypress/tests/instances/snapshots.spec.js @@ -1,7 +1,10 @@ /// +import should from 'should' + import instanceSnapshots from '../../fixtures/snapshots/instance-snapshots.json' import instanceFullSnapshot from '../../fixtures/snapshots/instance2-full-snapshot2.json' import instanceSnapshot from '../../fixtures/snapshots/instance2-snapshot2.json' +// import instanceFullSnapshot from '../../fixtures/snapshots/snapshot-with-credentials.json' let idx = 0 const IDX_DEPLOY_SNAPSHOT = idx++ const IDX_EDIT_SNAPSHOT = idx++ @@ -168,19 +171,44 @@ describe('FlowForge - Instance Snapshots', () => { cy.get('[data-el="dialog-compare-snapshot"] .ff-dialog-content svg').should('exist') }) - it('download snapshot', () => { + function prepareDownloadSnapshot (projectId, name) { + // if (!testSnapshotUploaded) { + // first upload a known snapshot so that download tests can be run against it + // directly POST to api/v1/snapshots/import the upload1.json snapshot + cy.fixture('snapshots/upload-for-download.json').then((snapshot) => { + snapshot.name = name + cy.request('POST', '/api/v1/snapshots/import', { + ownerId: projectId, + ownerType: 'instance', + snapshot, + credentialSecret: 'correct secret', + components: { flows: true, credentials: true, env: true } + }) + }) // ensure the downloads folder is empty before the test cy.task('clearDownloads') - - cy.intercept('POST', '/api/*/snapshots/*/export').as('exportSnapshot') - + cy.intercept('GET', '/api/*/projects/*/snapshots').as('snapshotData') + cy.visit(`/instance/${projectId}/snapshots`) + cy.wait('@snapshotData') // click kebab menu in row 1 cy.get('[data-el="snapshots"] tbody').find('.ff-kebab-menu').eq(0).click() // click the Download Snapshot option cy.get('[data-el="snapshots"] tbody .ff-kebab-menu .ff-kebab-options').find('.ff-list-item').eq(IDX_DOWNLOAD_SNAPSHOT).click() - // wait for SnapshotExportDialog dialog to appear cy.get('[data-el="dialog-export-snapshot"]').should('be.visible') + } + + it('download snapshot options and validation work as expected', () => { + // Premise: the snapshot has components and these can be included or excluded from the download as per users choice + // Rules: + // - By default, all components are included + // - The download button is enabled, the secret field is visible and populated with a random string + // - Excluding flows and/or credentials should hide the secret field + // - Excluding flows component disables the credentials component + // - Excluding env should disable the radio buttons + // - Excluding all components should disable the download & a validation message should appear + + prepareDownloadSnapshot(projectId, 'a-snapshot') // by default, the secret should be populated with a random string and the download button should be enabled cy.get('[data-el="dialog-export-snapshot"] [data-form="snapshot-secret"] input').invoke('val').should('not.be.empty') @@ -214,27 +242,144 @@ describe('FlowForge - Instance Snapshots', () => { cy.get('[data-el="dialog-export-snapshot"] [data-form="snapshot-secret"] input').type(' ') cy.get('[data-el="dialog-export-snapshot"] [data-el="form-row-error"]').contains('Secret cannot start or end with a space') + // download button should be enabled & secret field should be visible and populated cy.get('[data-el="dialog-export-snapshot"] [data-form="snapshot-secret"] input').clear() cy.get('[data-el="dialog-export-snapshot"] [data-form="snapshot-secret"] input').type('a valid secret') + cy.get('[data-el="dialog-export-snapshot"] button').contains('Download').should('not.be.disabled') + cy.get('[data-el="dialog-export-snapshot"] [data-form="snapshot-secret"]').should('exist') + cy.get('[data-el="dialog-export-snapshot"] [data-form="snapshot-secret"] input').invoke('val').should('not.be.empty') + + // check the component options are present & default state + cy.get('[data-el="dialog-export-snapshot"] [data-form="export-snapshot-components"]').should('exist') + cy.get('[data-el="dialog-export-snapshot"] [data-form="export-snapshot-components"]').within(() => { + cy.get('[data-form="component-flows"]').contains('Flows') + cy.get('[data-form="component-flows"] input[type="checkbox"]').should('be.checked') + + cy.get('[data-form="component-credentials"]').contains('Credentials') + cy.get('[data-form="component-credentials"] input[type="checkbox"]').should('be.checked') + cy.get('[data-form="component-credentials"] input[type="checkbox"]').should('not.be.disabled') + + cy.get('[data-form="component-environment-variables"]').contains('Environment Variables') + cy.get('[data-form="component-environment-variables"] input[type="checkbox"]').should('be.checked') + + cy.get('[data-form="component-environment-variables-options"]').should('exist') + cy.get('[data-form="component-environment-variables-options"] .ff-radio-btn').should('have.length', 2) + + // by default, the first radio button should be checked + cy.get('[data-form="component-environment-variables-options"] .ff-radio-btn').eq(0).should('not.be.disabled') + cy.get('[data-form="component-environment-variables-options"] .ff-radio-btn').eq(0).contains('Keys and Values') + cy.get('[data-form="component-environment-variables-options"] .ff-radio-btn span.checkbox').eq(0).should('have.attr', 'checked', 'checked') + cy.get('[data-form="component-environment-variables-options"] .ff-radio-btn').eq(1).should('not.be.disabled') + cy.get('[data-form="component-environment-variables-options"] .ff-radio-btn').eq(1).contains('Keys Only') + cy.get('[data-form="component-environment-variables-options"] .ff-radio-btn').eq(1).contains('Keys Only').should('not.have.attr', 'checked') + }) + + // now, click the `flows` check which should disable the `credentials` field (no flows, no creds!). Also, the secret field should be hidden + cy.get('[data-el="dialog-export-snapshot"] [data-form="export-snapshot-components"]').within(() => { + cy.get('[data-form="component-flows"] .ff-checkbox').click() + cy.get('[data-form="component-credentials"] input[type="checkbox"]').should('be.disabled') + cy.get('[data-el="dialog-export-snapshot"] [data-form="snapshot-secret"]').should('not.exist') + }) + + // now, click the `environment variables`, the `key and values` & `keys only` radio buttons should be disabled + cy.get('[data-el="dialog-export-snapshot"] [data-form="export-snapshot-components"]').within(() => { + cy.get('[data-form="component-environment-variables"] .ff-checkbox').click() + cy.get('[data-form="component-environment-variables-options"] .ff-radio-btn').eq(0).should('have.attr', 'disabled') + cy.get('[data-form="component-environment-variables-options"] .ff-radio-btn').eq(1).should('have.attr', 'disabled') + }) + + // ensure the validation message appears when all components are excluded & the download button is disabled + cy.get('[data-el="dialog-export-snapshot"] [data-form="export-snapshot-components"] [data-el="form-row-error"]').contains('At least one component must be selected') + cy.get('[data-el="dialog-export-snapshot"] button').contains('Download').should('be.disabled') + + // re-check the flows (the validation should clear), then uncheck credentials + cy.get('[data-el="dialog-export-snapshot"] [data-form="export-snapshot-components"]').within(() => { + cy.get('[data-form="component-flows"] .ff-checkbox').click() + cy.get('[data-form="component-credentials"] input[type="checkbox"]').should('not.be.disabled') + cy.get('[data-form="component-credentials"] .ff-checkbox').click() // exclude credentials + }) + // validation message should be gone, secret field should be hidden and download button should be enabled + cy.get('[data-el="dialog-export-snapshot"] [data-form="export-snapshot-components"] [data-el="form-row-error"]').should('not.exist') + cy.get('[data-el="dialog-export-snapshot"] [data-form="snapshot-secret"]').should('not.exist') + cy.get('[data-el="dialog-export-snapshot"] button').contains('Download').should('not.be.disabled') + }) - // operate the data-action="dialog-confirm" button - cy.get('[data-el="dialog-export-snapshot"] [data-action="dialog-confirm"]').click() + function downloadSnapshotWithComponentOptionsTest (excludeFlows, excludeCredentials, excludeEnv, envKeysOnly) { + const nameBuilder = ['for-download'] + nameBuilder.push(excludeFlows ? 'exclude-flows' : 'include-flows') + nameBuilder.push(excludeCredentials ? 'exclude-creds' : 'include-creds') + nameBuilder.push(excludeEnv ? 'exclude-env' : 'include-env') + if (!excludeEnv) { + nameBuilder.push(envKeysOnly ? 'keys-only' : 'keys-and-values') + } + const name = nameBuilder.join(' ') + + prepareDownloadSnapshot(projectId, name) + cy.intercept('POST', '/api/*/snapshots/*/export').as('exportSnapshot') - // wait for `api/v1/snapshots/*/export` to respond + if (excludeFlows) { + cy.get('[data-el="dialog-export-snapshot"] [data-form="export-snapshot-components"] [data-form="component-flows"] .ff-checkbox').click() + } + if (excludeCredentials) { + cy.get('[data-el="dialog-export-snapshot"] [data-form="export-snapshot-components"] [data-form="component-credentials"] .ff-checkbox').click() + } + if (excludeEnv) { + cy.get('[data-el="dialog-export-snapshot"] [data-form="export-snapshot-components"] [data-form="component-environment-variables"] .ff-checkbox').click() + } else if (envKeysOnly) { + cy.get('[data-el="dialog-export-snapshot"] [data-form="export-snapshot-components"] [data-form="component-environment-variables-options"] .ff-radio-btn').eq(1).click() + } + // click the `Download` button + cy.get('[data-el="dialog-export-snapshot"] button').contains('Download').click() + const downloadsFolder = Cypress.config('downloadsFolder') cy.wait('@exportSnapshot').then(interception => { - // At this point, the endpoint has returned but occasionally, the test fails as the file is not yet written to the filesystem. - // To counter this, there is a short 250ms wait to allow time for the file to be written to the filesystem. - // A better solution would be to use a cy.command (named waitForFileDownload) that polls the downloads folder - // and calls `cy.wait` with timeout and retry. This would allow the test to wait for the file in a more reliable way. - // For now, a small delay here gets the job done. cy.wait(250) // eslint-disable-line cypress/no-unnecessary-waiting - const response = interception.response.body - // check the downloaded file - const downloadsFolder = Cypress.config('downloadsFolder') - // generate the expected snapshot filename structure - cy.task('fileExists', { dir: downloadsFolder, fileRE: `snapshot-${response.id}-\\d{8}-\\d{6}\\.json` }) + return cy.task('fileExists', { dir: downloadsFolder, fileRE: `snapshot-${response.id}-\\d{8}-\\d{6}\\.json` }) + }).then((filename) => { + return cy.readFile(`${downloadsFolder}/${filename}`) + }).then((packageObject) => { + expect(packageObject).to.have.property('name', name) // ensure we get the correct snapshot + expect(packageObject.flows).to.have.keys('flows', 'credentials') + if (excludeFlows) { + expect(packageObject.flows).to.have.property('flows').and.to.be.an('array').and.to.have.length(0) + } else { + expect(packageObject.flows).to.have.property('flows').and.to.be.an('array').and.to.have.length(4) + } + expect(packageObject.flows).to.have.property('credentials').and.to.be.an('object') + if (excludeFlows || excludeCredentials) { + expect(packageObject.flows.credentials).to.deep.equal({}) + } else { + expect(packageObject.flows.credentials).to.have.property('$') + } + expect(packageObject.settings).to.have.property('env') + if (excludeEnv) { + expect(packageObject.settings.env || {}).to.deep.equal({}) + } else if (envKeysOnly) { + expect(packageObject.settings.env).to.deep.equal({ key1: '', key2: '' }) + } else { + expect(packageObject.settings.env).to.deep.equal({ key1: 'value1', key2: 'value2' }) + } }) + } + + it('download full snapshot, include all', () => { + downloadSnapshotWithComponentOptionsTest(false, false, false, false) + }) + + it('download snapshot, exclude flows', () => { + downloadSnapshotWithComponentOptionsTest(true, false, false, false) + }) + + it('download snapshot, exclude credentials', () => { + downloadSnapshotWithComponentOptionsTest(false, true, false, false) + }) + + it('download snapshot, exclude env', () => { + downloadSnapshotWithComponentOptionsTest(false, false, true, false) + }) + + it('download snapshot, env keys only', () => { + downloadSnapshotWithComponentOptionsTest(false, false, false, true) }) it('download snapshot package.json', () => { @@ -293,7 +438,16 @@ describe('FlowForge - Instance Snapshots', () => { }) }) - it('upload snapshot with credentials', () => { + it('upload snapshot options and validation work as expected', () => { + // Premise: the snapshot has components and these can be included or excluded from the download as per users choice + // Rules: + // - By default, all components are included + // - The upload button is enabled, the secret field is visible + // - excluding flows and/or credentials should hide the secret field + // - excluding flows component disables the credentials component + // - Excluding all components should disable the upload & a validation message should appear + // - Excluding env should disable the radio buttons + cy.fixture('snapshots/snapshot-with-credentials.json', null).as('snapshot') cy.intercept('POST', '/api/*/snapshots/import').as('importSnapshot') @@ -327,21 +481,72 @@ describe('FlowForge - Instance Snapshots', () => { cy.get('[data-el="dialog-import-snapshot"] [data-form="import-snapshot-secret"] input').type('correct secret') cy.get('[data-el="dialog-import-snapshot"] [data-form="import-snapshot-secret"]').should('not.contain', '[data-el="form-row-error"]') - // set a description - cy.get('[data-el="dialog-import-snapshot"] [data-form="import-snapshot-description"] textarea').type('snapshot1 description') - - // click import button - cy.get('[data-el="dialog-import-snapshot"] [data-action="dialog-confirm"]').click() - - cy.wait('@importSnapshot') + // Upload button should be enabled & secret field should be visible and populated + cy.get('[data-el="dialog-import-snapshot"] button').contains('Upload').should('not.be.disabled') + cy.get('[data-el="dialog-import-snapshot"] [data-form="import-snapshot-secret"]').should('exist') + cy.get('[data-el="dialog-import-snapshot"] [data-form="import-snapshot-secret"] input').invoke('val').should('not.be.empty') + + // check the component options are present & default state + cy.get('[data-el="dialog-import-snapshot"] [data-form="import-snapshot-components"]').should('exist') + cy.get('[data-el="dialog-import-snapshot"] [data-form="import-snapshot-components"]').within(() => { + cy.get('[data-form="component-flows"]').contains('Flows') + cy.get('[data-form="component-flows"] input[type="checkbox"]').should('be.checked') + + cy.get('[data-form="component-credentials"]').contains('Credentials') + cy.get('[data-form="component-credentials"] input[type="checkbox"]').should('be.checked') + cy.get('[data-form="component-credentials"] input[type="checkbox"]').should('not.be.disabled') + + cy.get('[data-form="component-environment-variables"]').contains('Environment Variables') + cy.get('[data-form="component-environment-variables"] input[type="checkbox"]').should('be.checked') + + cy.get('[data-form="component-environment-variables-options"]').should('exist') + cy.get('[data-form="component-environment-variables-options"] .ff-radio-btn').should('have.length', 2) + + // by default, the first radio button should be checked + cy.get('[data-form="component-environment-variables-options"] .ff-radio-btn').eq(0).should('not.be.disabled') + cy.get('[data-form="component-environment-variables-options"] .ff-radio-btn').eq(0).contains('Keys and Values') + cy.get('[data-form="component-environment-variables-options"] .ff-radio-btn span.checkbox').eq(0).should('have.attr', 'checked', 'checked') + cy.get('[data-form="component-environment-variables-options"] .ff-radio-btn').eq(1).should('not.be.disabled') + cy.get('[data-form="component-environment-variables-options"] .ff-radio-btn').eq(1).contains('Keys Only') + cy.get('[data-form="component-environment-variables-options"] .ff-radio-btn').eq(1).contains('Keys Only').should('not.have.attr', 'checked') + }) - // check the snapshot is now in the table - cy.get('[data-el="snapshots"] tbody').find('tr').contains('uploaded snapshot1') - cy.get('[data-el="snapshots"] tbody').find('tr').contains('snapshot1 description') + // now, click the `flows` check which should disable the `credentials` field (no flows, no creds!). Also, the secret field should be hidden + cy.get('[data-el="dialog-import-snapshot"] [data-form="import-snapshot-components"]').within(() => { + cy.get('[data-form="component-flows"] .ff-checkbox').click() + cy.get('[data-form="component-credentials"] input[type="checkbox"]').should('be.disabled') + }) + cy.get('[data-form="import-snapshot-secret"]').should('not.exist') + + // now, click the `environment variables`, the `key and values` & `keys only` radio buttons should be disabled + cy.get('[data-el="dialog-import-snapshot"] [data-form="import-snapshot-components"]').within(() => { + cy.get('[data-form="component-environment-variables"] .ff-checkbox').click() + cy.get('[data-form="component-environment-variables-options"] .ff-radio-btn').eq(0).should('have.attr', 'disabled') + cy.get('[data-form="component-environment-variables-options"] .ff-radio-btn').eq(1).should('have.attr', 'disabled') + cy.get('[data-el="form-row-error"]').contains('At least one component must be selected') + }) + cy.get('[data-el="dialog-import-snapshot"] button').contains('Upload').should('be.disabled') + + // re-check the flows (the validation should clear), then disable credentials, the secret field should not be shown + cy.get('[data-el="dialog-import-snapshot"] [data-form="import-snapshot-components"]').within(() => { + cy.get('[data-form="component-flows"] .ff-checkbox').click() + cy.get('[data-form="component-credentials"] input[type="checkbox"]').should('not.be.disabled') + cy.get('[data-form="component-credentials"] .ff-checkbox').click() // exclude credentials + cy.get('[data-el="form-row-error"]').should('not.exist') // validation message should be gone + cy.get('[data-form="import-snapshot-secret"]').should('not.exist') // secret field should be hidden + }) + cy.get('[data-el="dialog-import-snapshot"] button').contains('Upload').should('not.be.disabled') // Upload button should be re-enabled now }) - it('upload snapshot without credentials', () => { - cy.fixture('snapshots/instance2-full-snapshot2.json', null).as('snapshot') + function uploadWithComponentOptionsTest (excludeFlows, excludeCredentials, excludeEnv, envKeysOnly, fixture) { + const summary = [excludeFlows, excludeCredentials, excludeEnv, envKeysOnly].map((v) => v ? 'exclude' : 'include').join('-') + const fixtureFilename = fixture.split('/').pop() + let fixtureHasCredentials = true + cy.fixture(fixture, 'utf8', { timeout: 5000 }).as('snapshot-' + summary).then((snapshot) => { + if (!snapshot.flows.credentials || Object.hasOwnProperty.call(snapshot.flows.credentials, '$') === false) { + fixtureHasCredentials = false + } + }) cy.intercept('POST', '/api/*/snapshots/import').as('importSnapshot') // click data-action="import-snapshot" to open the dialog @@ -352,34 +557,114 @@ describe('FlowForge - Instance Snapshots', () => { // check the dialog header cy.get('[data-el="dialog-import-snapshot"] .ff-dialog-header').contains('Upload Snapshot') - // upload the snapshot file that has credentials (the credentials secret field should become visible) - cy.get('[data-el="dialog-import-snapshot"] [data-form="import-snapshot-filename"] input[type="file"]').selectFile({ contents: '@snapshot' }, { force: true }) // force because the input is hidden + // upload the snapshot file that has credentials (the credentials secret field be visible since the snapshot has credentials) + cy.get('[data-el="dialog-import-snapshot"] [data-form="import-snapshot-filename"] input[type="file"]').selectFile( + { contents: '@snapshot-' + summary, filename: fixture }, + { force: true } // force because the input is hidden + ) // check file field input text is the filename - cy.get('[data-el="dialog-import-snapshot"] [data-form="import-snapshot-filename"] input[type="text"]').should('have.value', 'instance2-full-snapshot2.json') + cy.get('[data-el="dialog-import-snapshot"] [data-form="import-snapshot-filename"] input[type="text"]').should('have.value', fixtureFilename) // check name field is the name from within the snapshot file - cy.get('[data-el="dialog-import-snapshot"] [data-form="import-snapshot-name"] input').should('have.value', 'instance-2 snapshot-2') - - // check credentials secret field is not visible - cy.get('[data-el="dialog-import-snapshot"] [data-form="import-snapshot-secret"]').should('not.exist') + cy.get('[data-el="dialog-import-snapshot"] [data-form="import-snapshot-name"] input').should('have.value', fixtureFilename) - // check validation of name field + // set a unique name and description + const time = new Date().toISOString() + const name = `name @ ${time}` + const desc = `description @ ${time}` cy.get('[data-el="dialog-import-snapshot"] [data-form="import-snapshot-name"] input').clear() - cy.get('[data-el="dialog-import-snapshot"] [data-form="import-snapshot-name"] [data-el="form-row-error"]').should('contain.text', 'Name is required') - cy.get('[data-el="dialog-import-snapshot"] [data-form="import-snapshot-name"] input').type('uploaded snapshot2') - cy.get('[data-el="dialog-import-snapshot"] [data-form="import-snapshot-name"]').should('not.contain', '[data-el="form-row-error"]') - - // set a description - cy.get('[data-el="dialog-import-snapshot"] [data-form="import-snapshot-description"] textarea').type('snapshot2 description') + cy.get('[data-el="dialog-import-snapshot"] [data-form="import-snapshot-name"] input').type(name) + cy.get('[data-el="dialog-import-snapshot"] [data-form="import-snapshot-description"] textarea').clear() + cy.get('[data-el="dialog-import-snapshot"] [data-form="import-snapshot-description"] textarea').type(desc) + + // if the fixture doesnt contain credentials, the secret field should not be visible + if (fixtureHasCredentials === false) { + cy.get('[data-el="dialog-import-snapshot"] [data-form="import-snapshot-secret"]').should('not.exist') + } + + // set the component options + if (excludeFlows) { + cy.get('[data-el="dialog-import-snapshot"] [data-form="import-snapshot-components"] [data-form="component-flows"] .ff-checkbox').click() + } + if (fixtureHasCredentials && excludeCredentials === true) { + cy.get('[data-el="dialog-import-snapshot"] [data-form="import-snapshot-components"] [data-form="component-credentials"] .ff-checkbox').click() + } + if (excludeEnv) { + cy.get('[data-el="dialog-import-snapshot"] [data-form="import-snapshot-components"] [data-form="component-environment-variables"] .ff-checkbox').click() + } else if (envKeysOnly) { + cy.get('[data-el="dialog-import-snapshot"] [data-form="import-snapshot-components"] [data-form="component-environment-variables-options"] .ff-radio-btn').eq(1).click() + } + if (fixtureHasCredentials && excludeFlows === false && excludeCredentials === false) { + cy.get('[data-el="dialog-import-snapshot"] [data-form="import-snapshot-secret"] input').type('correct secret') + } // click import button cy.get('[data-el="dialog-import-snapshot"] [data-action="dialog-confirm"]').click() - cy.wait('@importSnapshot') + cy.wait('@importSnapshot').then(interception => { + const body = interception.request.body + should(body.snapshot.name).equal(name) + should(body.snapshot.description).equal(desc) + if (fixtureHasCredentials && excludeFlows === false && excludeCredentials === false) { + should(body.credentialSecret).equal('correct secret') + } + should(body.components).have.property('credentials', !excludeCredentials) + should(body.components).have.property('flows', !excludeFlows) + should(body.components).have.property('envVars', excludeEnv ? false : (envKeysOnly ? 'keys' : 'all')) + should(body.snapshot.flows.flows).be.an.Array() + if (excludeFlows) { + should(body.snapshot.flows.flows).have.length(0) + } else { + should(body.snapshot.flows.flows).have.length(4) + } + if (fixtureHasCredentials) { + should(body.snapshot.flows.credentials).be.an.Object() + if (excludeFlows || excludeCredentials) { + should(body.snapshot.flows.credentials).not.have.property('$') + } else { + should(body.snapshot.flows.credentials).have.property('$') + } + } + should(body.snapshot.settings.env).be.an.Object() + if (excludeEnv) { + should(body.snapshot.settings.env).not.have.property('key1') + should(body.snapshot.settings.env).not.have.property('key2') + } else if (envKeysOnly) { + should(body.snapshot.settings.env).have.property('key1', '') + should(body.snapshot.settings.env).have.property('key2', '') + } else { + should(body.snapshot.settings.env).have.property('key1', 'value1') + should(body.snapshot.settings.env).have.property('key2', 'value2') + } + }) + + // check the topmost snapshot is the one just uploaded + cy.get('[data-el="snapshots"] tbody').find('tr').eq(0).contains(name) + cy.get('[data-el="snapshots"] tbody').find('tr').eq(0).contains(desc) + } + + it('upload full snapshot, include all', () => { + uploadWithComponentOptionsTest(false, false, false, false, 'snapshots/upload1.json') + }) + + it('upload full snapshot, exclude flows', () => { + uploadWithComponentOptionsTest(true, false, false, false, 'snapshots/upload2.json') + }) + + it('upload full snapshot, exclude credentials', () => { + uploadWithComponentOptionsTest(false, true, false, false, 'snapshots/upload3.json') + }) + + it('upload full snapshot, exclude env', () => { + uploadWithComponentOptionsTest(false, false, true, false, 'snapshots/upload4.json') + }) + + it('upload full snapshot, env keys only', () => { + uploadWithComponentOptionsTest(false, false, false, true, 'snapshots/upload5.json') + }) - // check the snapshot is now in the table - cy.get('[data-el="snapshots"] tbody').find('tr').contains('uploaded snapshot2') - cy.get('[data-el="snapshots"] tbody').find('tr').contains('snapshot2 description') + it('upload snapshot which has no credentials', () => { + uploadWithComponentOptionsTest(false, null, false, false, 'snapshots/upload6.json') }) it('Can rollback a snapshot', () => { @@ -431,15 +716,15 @@ describe('FlowForge shows audit logs', () => { }) it('for when a snapshot is created', () => { - cy.get('.ff-audit-entry').contains('Instance Snapshot Created') + cy.get('.ff-audit-entry').contains('Instance Snapshot Created', { includeShadowDom: true, force: true }) // force check to inspect items off screen }) it('for when a snapshot is deleted', () => { - cy.get('.ff-audit-entry').contains('Instance Snapshot Deleted') + cy.get('.ff-audit-entry').contains('Instance Snapshot Deleted', { includeShadowDom: true, force: true }) // force check to inspect items off screen }) it('for when a snapshot is exported', () => { - cy.get('.ff-audit-entry').contains('Instance Snapshot Exported') + cy.get('.ff-audit-entry').contains('Instance Snapshot Exported', { includeShadowDom: true, force: true }) // force check to inspect items off screen }) it('for when a snapshot is imported', () => { - cy.get('.ff-audit-entry').contains('Snapshot Imported') + cy.get('.ff-audit-entry').contains('Snapshot Imported', { includeShadowDom: true, force: true }) // force check to inspect items off screen }) }) diff --git a/test/unit/forge/db/controllers/Snapshot_spec.js b/test/unit/forge/db/controllers/Snapshot_spec.js new file mode 100644 index 0000000000..f54b66312e --- /dev/null +++ b/test/unit/forge/db/controllers/Snapshot_spec.js @@ -0,0 +1,515 @@ +const crypto = require('crypto') + +const should = require('should') // eslint-disable-line +const { decryptCreds } = require('../../../../lib/credentials.js') +const setup = require('../setup') + +describe('Snapshot controller', function () { + // Use standard test data. + let app + let projectInstanceCount = 0 + /** @type {import('../../../../lib/TestModelFactory')} */ + let factory + /** @type {import('../../../../../forge/db/controllers/Snapshot.js')} */ + let snapshotController + + async function createProject (application = null, team = null) { + const options = { name: 'project-' + (projectInstanceCount++), type: '', url: '' } + if (application) { + options.ApplicationId = application.id + } + if (team) { + options.TeamId = team.id + } + let project = await app.db.models.Project.create(options) + await project.updateSetting('credentialSecret', 'c13f09839cb9072bdc61a3c1530629dad3da26b131d9434c7322bc2f7921f1cd') + // Reload to ensure all models are attached + project = await app.db.models.Project.byId(project.id) + await app.db.models.StorageFlow.create({ + // a flow JSON containing a HTTP Request node with basic auth + flow: JSON.stringify([{"id":"90f317c74dab03c1","type":"http request","z":"d55adbd49eb9c0a7","name":"","method":"GET","ret":"txt","paytoqs":"ignore","url":"http://localhost:1880/auth-test","tls":"","persist":false,"proxy":"","insecureHTTPParser":false,"authType":"basic","senderr":false,"headers":[],"x":460,"y":180,"wires":[["db4f0470eee740fb"]]}]), // eslint-disable-line + ProjectId: project.id + }) + await app.db.models.StorageCredentials.create({ + credentials: JSON.stringify({ $: '8f89788aada6a40fc7cc06f12de7ea53veCKFVBe7rpZpzwnLNH0iNXnf0oG0Ttclqq5g8gtBZX+pjnaD6eozWkV6Sps5d48ja2A+pK1' }), + ProjectId: project.id + }) + await app.db.models.StorageSettings.create({ + settings: JSON.stringify({}), + ProjectId: project.id + }) + await app.db.models.StorageSession.create({ + sessions: JSON.stringify({}), + ProjectId: project.id + }) + await project.updateSetting('settings', { env: [{ name: 'env1', value: 'a' }, { name: 'env2', value: 'b' }] }) + await project.reload() + return project + } + + before(async function () { + app = await setup({ + limits: { + instances: 50 + } + }) + factory = app.factory + app.TestObjects.application1 = await factory.createApplication({ name: 'application-1' }, app.TestObjects.team1) + snapshotController = app.db.controllers.Snapshot + }) + + after(async function () { + await app.close() + }) + + afterEach(async function () { + await app.db.models.ProjectSnapshot.destroy({ where: {} }) + }) + + describe.skip('getSnapshot', function () { + // TODO: Implement test + }) + + describe.skip('updateSnapshot', function () { + // TODO: Implement test + }) + + describe('exportSnapshot', function () { + let instanceSnapshot + let instance + before(async function () { + instance = await createProject() + const user = await app.TestObjects.userAlice + const options = { + name: 'snapshot-with-options', + description: 'a full snapshot' + } + instanceSnapshot = await factory.createSnapshot(options, instance, user) + }) + + it('should export a full snapshot by default', async function () { + const reencryptSecret = 'abc' + const options = { + credentialSecret: reencryptSecret + // components: // excluded to use defaults + } + + const snapshotExported = await snapshotController.exportSnapshot(instanceSnapshot, options) + should.exist(snapshotExported) + snapshotExported.should.be.an.Object() + snapshotExported.should.have.properties('id', 'name', 'description', 'settings', 'credentialSecret', 'flows', 'ownerType', 'ProjectId') + snapshotExported.id.should.equal(instanceSnapshot.id) + snapshotExported.name.should.equal(instanceSnapshot.name) + snapshotExported.description.should.equal(instanceSnapshot.description) + snapshotExported.settings.should.only.have.keys('settings', 'env', 'modules') + snapshotExported.settings.settings.should.deepEqual(instanceSnapshot.settings.settings) + snapshotExported.settings.modules.should.deepEqual(instanceSnapshot.settings.modules) + + // ensure flows are exported + snapshotExported.flows.should.have.only.keys('flows', 'credentials') + snapshotExported.flows.flows.should.deepEqual(instanceSnapshot.flows.flows) + + // ensure credentials are exported + snapshotExported.flows.credentials.should.be.an.Object() + snapshotExported.flows.credentials.should.have.only.keys('$') + const keyHash = crypto.createHash('sha256').update(reencryptSecret).digest() + const creds = decryptCreds(keyHash, snapshotExported.flows.credentials) + creds.should.only.have.keys('90f317c74dab03c1').and.be.an.Object() + creds['90f317c74dab03c1'].should.only.have.keys('user', 'password') + creds['90f317c74dab03c1'].user.should.equal('auth') + creds['90f317c74dab03c1'].password.should.equal('test') + + // ensure exported env var are present and correct + snapshotExported.settings.should.have.properties('env') + snapshotExported.settings.env.should.deepEqual({ env1: 'a', env2: 'b' }) + }) + + it('should export a snapshot without creds', async function () { + const options = { + components: { + credentials: false + } + } + + const snapshotExported = await snapshotController.exportSnapshot(instanceSnapshot, options) + should.exist(snapshotExported) + snapshotExported.should.be.an.Object() + snapshotExported.should.have.properties('id', 'name', 'description', 'settings', 'flows', 'ownerType', 'ProjectId') + snapshotExported.id.should.equal(instanceSnapshot.id) + snapshotExported.name.should.equal(instanceSnapshot.name) + snapshotExported.description.should.equal(instanceSnapshot.description) + snapshotExported.settings.should.only.have.keys('settings', 'env', 'modules') + snapshotExported.settings.settings.should.deepEqual(instanceSnapshot.settings.settings) + snapshotExported.settings.modules.should.deepEqual(instanceSnapshot.settings.modules) + + // ensure flows are exported and credentials are excluded + snapshotExported.flows.should.have.only.keys('flows', 'credentials') + snapshotExported.flows.flows.should.deepEqual(instanceSnapshot.flows.flows) + snapshotExported.flows.credentials.should.deepEqual({}) + + // ensure exported env var are present and correct + snapshotExported.settings.should.have.properties('env') + snapshotExported.settings.env.should.deepEqual({ env1: 'a', env2: 'b' }) + }) + + it('should export a snapshot without flows', async function () { + const options = { + components: { + flows: false + } + } + // since credentials are for flows, they should be excluded as well + const snapshotExported = await snapshotController.exportSnapshot(instanceSnapshot, options) + should.exist(snapshotExported) + snapshotExported.should.be.an.Object() + snapshotExported.should.have.properties('id', 'name', 'description', 'settings', 'credentialSecret', 'flows', 'ownerType', 'ProjectId') + snapshotExported.id.should.equal(instanceSnapshot.id) + snapshotExported.name.should.equal(instanceSnapshot.name) + snapshotExported.description.should.equal(instanceSnapshot.description) + snapshotExported.settings.should.only.have.keys('settings', 'env', 'modules') + snapshotExported.settings.should.only.have.keys('settings', 'env', 'modules') + snapshotExported.settings.settings.should.deepEqual(instanceSnapshot.settings.settings) + snapshotExported.settings.modules.should.deepEqual(instanceSnapshot.settings.modules) + + // ensure flows and credentials are excluded + snapshotExported.flows.should.have.only.keys('flows', 'credentials') + snapshotExported.flows.flows.should.deepEqual([]) + snapshotExported.flows.credentials.should.deepEqual({}) + + // ensure exported env var are present and correct + snapshotExported.settings.should.have.properties('env') + snapshotExported.settings.env.should.deepEqual({ env1: 'a', env2: 'b' }) + }) + + it('should export a snapshot without env (envVars: false)', async function () { + const options = { + credentialSecret: 'abc', + components: { + envVars: false + } + } + + const snapshotExported = await snapshotController.exportSnapshot(instanceSnapshot, options) + should.exist(snapshotExported) + snapshotExported.should.be.an.Object() + snapshotExported.should.have.properties('id', 'name', 'description', 'settings', 'credentialSecret', 'flows', 'ownerType', 'ProjectId') + snapshotExported.id.should.equal(instanceSnapshot.id) + snapshotExported.name.should.equal(instanceSnapshot.name) + snapshotExported.description.should.equal(instanceSnapshot.description) + snapshotExported.settings.should.only.have.keys('settings', 'env', 'modules') + // ensure flows are exported + snapshotExported.flows.should.have.only.keys('flows', 'credentials') + snapshotExported.flows.flows.should.deepEqual(instanceSnapshot.flows.flows) + // ensure credentials are exported + snapshotExported.flows.credentials.should.be.an.Object() + snapshotExported.flows.credentials.should.have.only.keys('$') + // ensure env are excluded + snapshotExported.settings.env.should.deepEqual({}) + }) + + it('should export a snapshot without env values (envVars: "keys")', async function () { + const options = { + credentialSecret: 'abc', + components: { + envVars: 'keys' + } + } + + const snapshotExported = await snapshotController.exportSnapshot(instanceSnapshot, options) + should.exist(snapshotExported) + snapshotExported.should.be.an.Object() + snapshotExported.should.have.properties('id', 'name', 'description', 'settings', 'credentialSecret', 'flows', 'ownerType', 'ProjectId') + snapshotExported.id.should.equal(instanceSnapshot.id) + snapshotExported.name.should.equal(instanceSnapshot.name) + snapshotExported.description.should.equal(instanceSnapshot.description) + snapshotExported.settings.should.only.have.keys('settings', 'env', 'modules') + // ensure flows are exported + snapshotExported.flows.should.have.only.keys('flows', 'credentials') + snapshotExported.flows.flows.should.deepEqual(instanceSnapshot.flows.flows) + // ensure credentials are exported + snapshotExported.flows.credentials.should.be.an.Object() + snapshotExported.flows.credentials.should.have.only.keys('$') + // ensure env are exported with keys only + snapshotExported.settings.should.have.property('env') + snapshotExported.settings.env.should.deepEqual({ env1: '', env2: '' }) + }) + }) + + describe('uploadSnapshot', function () { + let alice + let instance + let counter = 0 + + function generateSnapshot (name, description, flows, creds, env, ownerType) { + counter++ + const fullSnapshot = { + flows: { + flows: flows || [ + { + id: '90f317c74dab03c1', + type: 'http request', + z: 'd55adbd49eb9c0a7', + name: '', + method: 'GET', + ret: 'txt', + paytoqs: 'ignore', + url: 'http://192.168.86.45:12095/auth-test', + tls: '', + persist: false, + proxy: '', + insecureHTTPParser: false, + authType: 'basic', + senderr: false, + headers: [], + x: 460, + y: 180, + wires: [['db4f0470eee740fb']] + } + ], + credentials: creds || { + $: '92c3a4152076d6d8489b3de60f97f551LnSwcIJl9Q+l6Xz6WFCPQLMVRiGbyjkwR2MOuvi8HOV41oBO6MiBepsZ1lbhDwLxv2uHWmVA' + } + }, + id: '6KbgK9BO4a', + name: name || 'full snapshot ' + counter, + description: description || 'full snapshot ' + counter, + createdAt: '2024-10-02T16:56:15.175Z', + updatedAt: '2024-10-02T16:56:15.175Z', + user: { + id: 'EexY4j17B2', + username: 'steve', + name: 'steve-mcl', + avatar: 'http://localhost:3000/avatar/c3RldmU', + admin: true, + createdAt: '2023-12-15T18:51:43.578Z', + suspended: false + }, + exportedBy: { + id: 'EexY4j17B2', + username: 'steve', + name: 'steve-mcl', + avatar: 'http://localhost:3000/avatar/c3RldmU', + admin: true, + createdAt: '2023-12-15T18:51:43.578Z', + suspended: false + }, + ownerType: ownerType || 'instance', + settings: { + settings: { + disableEditor: false, + disableTours: false, + codeEditor: 'monaco', + theme: 'forge-light', + page: { + title: 'FlowFuse', + favicon: '' + }, + header: { + title: 'snapshot-import-export-options', + url: '' + }, + timeZone: 'UTC', + palette: { + allowInstall: true, + catalogue: [ + 'https://catalogue.nodered.org/catalogue.json' + ], + npmrc: '', + denyList: [], + modules: {} + }, + modules: { + allowInstall: true, + denyList: [] + }, + httpNodeAuth: { + type: 'none', + user: '', + pass: '' + }, + emailAlerts: { + crash: false, + safe: false, + recipients: 'owners' + }, + debugMaxLength: 1000, + apiMaxLength: '5mb', + httpAdminRoot: '', + dashboardUI: '/ui' + }, + env: env || { + ev1: 'ev1', + ev2: 'ev2', + ev3: 'ev3', + ev4: 'ev4' + }, + modules: { + 'node-red': '4.0.3' + } + } + } + return fullSnapshot + } + + before(async function () { + alice = await app.TestObjects.userAlice + instance = await createProject() + }) + + it('should upload a snapshot', async function () { + const fullSnapshot = generateSnapshot() + const options = { + // components: {} // excluded to use defaults + } + const importedSnapshot = await snapshotController.uploadSnapshot(instance, fullSnapshot, 'the secret', alice, options) + should.exist(importedSnapshot) + importedSnapshot.should.be.an.Object() + // should be a squelize model + importedSnapshot.should.be.an.instanceOf(app.db.models.ProjectSnapshot) + importedSnapshot.should.have.properties('id', 'name', 'description', 'settings', 'flows', 'credentialSecret', 'ownerType', 'ProjectId') + importedSnapshot.id.should.not.equal(fullSnapshot.id) + importedSnapshot.name.should.equal(fullSnapshot.name) + importedSnapshot.description.should.equal(fullSnapshot.description) + importedSnapshot.settings.should.deepEqual(fullSnapshot.settings) + + // ensure flows are imported correctly + importedSnapshot.flows.should.only.have.keys('flows', 'credentials') + importedSnapshot.flows.flows.should.deepEqual(fullSnapshot.flows.flows) + + // ensure credentials are imported correctly + importedSnapshot.flows.credentials.should.be.an.Object() + const keyHash = crypto.createHash('sha256').update(importedSnapshot.credentialSecret).digest() + const creds = decryptCreds(keyHash, importedSnapshot.flows.credentials) + creds.should.only.have.keys('90f317c74dab03c1').and.be.an.Object() + creds['90f317c74dab03c1'].should.only.have.keys('user', 'password') + creds['90f317c74dab03c1'].user.should.equal('auth') + creds['90f317c74dab03c1'].password.should.equal('test') + + // ensure env vars and values are imported correctly + importedSnapshot.settings.should.have.properties('env') + importedSnapshot.settings.env.should.deepEqual(fullSnapshot.settings.env) + }) + + it('should upload a snapshot without creds', async function () { + const fullSnapshot = generateSnapshot() + const options = { + components: { + credentials: false + } + } + const importedSnapshot = await snapshotController.uploadSnapshot(instance, fullSnapshot, 'the secret', alice, options) + should.exist(importedSnapshot) + importedSnapshot.should.be.an.Object() + // should be a squelize model + importedSnapshot.should.be.an.instanceOf(app.db.models.ProjectSnapshot) + importedSnapshot.should.have.properties('id', 'name', 'description', 'settings', 'flows', 'credentialSecret', 'ownerType', 'ProjectId') + importedSnapshot.id.should.not.equal(fullSnapshot.id) + importedSnapshot.name.should.equal(fullSnapshot.name) + importedSnapshot.description.should.equal(fullSnapshot.description) + importedSnapshot.settings.should.deepEqual(fullSnapshot.settings) + + // ensure flows are imported correctly + importedSnapshot.flows.should.only.have.keys('flows', 'credentials') + importedSnapshot.flows.flows.should.deepEqual(fullSnapshot.flows.flows) + + // ensure credentials are excluded + importedSnapshot.flows.credentials.should.deepEqual({}) + + // ensure env vars and values are imported correctly + importedSnapshot.settings.should.have.properties('env') + importedSnapshot.settings.env.should.deepEqual(fullSnapshot.settings.env) + }) + + it('should upload a snapshot without flows', async function () { + const fullSnapshot = generateSnapshot() + const options = { + components: { + flows: false + } + } + const importedSnapshot = await snapshotController.uploadSnapshot(instance, fullSnapshot, 'the secret', alice, options) + should.exist(importedSnapshot) + importedSnapshot.should.be.an.Object() + // should be a squelize model + importedSnapshot.should.be.an.instanceOf(app.db.models.ProjectSnapshot) + importedSnapshot.should.have.properties('id', 'name', 'description', 'settings', 'flows', 'credentialSecret', 'ownerType', 'ProjectId') + importedSnapshot.id.should.not.equal(fullSnapshot.id) + importedSnapshot.name.should.equal(fullSnapshot.name) + importedSnapshot.description.should.equal(fullSnapshot.description) + importedSnapshot.settings.should.deepEqual(fullSnapshot.settings) + + // ensure flows are excluded + importedSnapshot.flows.should.only.have.keys('flows', 'credentials') + importedSnapshot.flows.flows.should.deepEqual([]) + importedSnapshot.flows.credentials.should.deepEqual({}) // no flows, no creds! + + // ensure env vars and values are imported correctly + importedSnapshot.settings.should.have.properties('env') + importedSnapshot.settings.env.should.deepEqual(fullSnapshot.settings.env) + }) + + it('should upload a snapshot without env (envVars: false)', async function () { + const fullSnapshot = generateSnapshot() + const options = { + components: { + envVars: false + } + } + const importedSnapshot = await snapshotController.uploadSnapshot(instance, fullSnapshot, 'the secret', alice, options) + should.exist(importedSnapshot) + importedSnapshot.should.be.an.Object() + // should be a squelize model + importedSnapshot.should.be.an.instanceOf(app.db.models.ProjectSnapshot) + importedSnapshot.should.have.properties('id', 'name', 'description', 'settings', 'flows', 'credentialSecret', 'ownerType', 'ProjectId') + importedSnapshot.id.should.not.equal(fullSnapshot.id) + importedSnapshot.name.should.equal(fullSnapshot.name) + importedSnapshot.description.should.equal(fullSnapshot.description) + + // ensure flows and creds are imported + importedSnapshot.flows.should.only.have.keys('flows', 'credentials') + importedSnapshot.flows.flows.should.deepEqual(fullSnapshot.flows.flows) + + // ensure credentials are imported correctly + importedSnapshot.flows.credentials.should.be.an.Object() + importedSnapshot.flows.credentials.should.have.only.keys('$') + + // ensure env vars are excluded + importedSnapshot.settings.should.have.properties('env') + importedSnapshot.settings.env.should.deepEqual({}) + }) + + it('should upload a snapshot without env values (envVars: "keys")', async function () { + const fullSnapshot = generateSnapshot() + const options = { + components: { + envVars: 'keys' + } + } + const importedSnapshot = await snapshotController.uploadSnapshot(instance, fullSnapshot, 'the secret', alice, options) + should.exist(importedSnapshot) + importedSnapshot.should.be.an.Object() + // should be a squelize model + importedSnapshot.should.be.an.instanceOf(app.db.models.ProjectSnapshot) + importedSnapshot.should.have.properties('id', 'name', 'description', 'settings', 'flows', 'credentialSecret', 'ownerType', 'ProjectId') + importedSnapshot.id.should.not.equal(fullSnapshot.id) + importedSnapshot.name.should.equal(fullSnapshot.name) + importedSnapshot.description.should.equal(fullSnapshot.description) + + // ensure flows and creds are imported + importedSnapshot.flows.should.only.have.keys('flows', 'credentials') + importedSnapshot.flows.flows.should.deepEqual(fullSnapshot.flows.flows) + + // ensure credentials are imported correctly + importedSnapshot.flows.credentials.should.be.an.Object() + importedSnapshot.flows.credentials.should.have.only.keys('$') + + // ensure env vars are imported with keys only + importedSnapshot.settings.should.have.properties('env') + importedSnapshot.settings.env.should.deepEqual({ ev1: '', ev2: '', ev3: '', ev4: '' }) + }) + }) + + describe.skip('deleteSnapshot', function () { + // TODO: Implement test + }) +})
Please make a note of the secret used to encrypt the snapshot credentials. It will be required when importing the snapshot.
A key used to encrypt any credentials in the snapshot.
A key used to encrypt any credentials in the snapshot's flow.
+ {{ header }} +