From 9d45d5a18eb3ecfc605a0233c105f9365c568dfb Mon Sep 17 00:00:00 2001 From: Jason Sherman Date: Fri, 26 Jul 2024 09:18:10 -0700 Subject: [PATCH 01/25] Event Stream Service Signed-off-by: Jason Sherman --- .devcontainer/chefs_local/local.json.sample | 10 + app/app.js | 45 ++- app/config/custom-environment-variables.json | 10 + app/config/default.json | 10 + app/package-lock.json | 49 ++++ app/package.json | 1 + app/src/components/eventStreamService.js | 285 +++++++++++++++++++ app/src/components/featureFlags.js | 65 +++++ app/src/forms/form/service.js | 12 +- app/src/forms/submission/service.js | 12 +- event-stream-service/.gitignore | 1 + event-stream-service/config/jetstream.conf | 98 +++++++ event-stream-service/consumer.js | 112 ++++++++ event-stream-service/docker-compose.yml | 65 +++++ event-stream-service/package-lock.json | 44 +++ event-stream-service/package.json | 10 + event-stream-service/publisher.js | 75 +++++ event-stream-service/pullConsumer.js | 94 ++++++ event-stream-service/subscriber.js | 59 ++++ 19 files changed, 1048 insertions(+), 9 deletions(-) create mode 100644 app/src/components/eventStreamService.js create mode 100644 app/src/components/featureFlags.js create mode 100644 event-stream-service/.gitignore create mode 100644 event-stream-service/config/jetstream.conf create mode 100644 event-stream-service/consumer.js create mode 100644 event-stream-service/docker-compose.yml create mode 100644 event-stream-service/package-lock.json create mode 100644 event-stream-service/package.json create mode 100644 event-stream-service/publisher.js create mode 100644 event-stream-service/pullConsumer.js create mode 100644 event-stream-service/subscriber.js diff --git a/.devcontainer/chefs_local/local.json.sample b/.devcontainer/chefs_local/local.json.sample index 676f2edd9..bc6cd6da0 100644 --- a/.devcontainer/chefs_local/local.json.sample +++ b/.devcontainer/chefs_local/local.json.sample @@ -61,6 +61,16 @@ "proxy": "352f7c24819086bf3df5a38c1a40586045f73e0007440c9d27d59ee8560e3fe7" } }, + "features": { + "eventStreamService": true + }, + "eventStreamService": { + "servers": "localhost:4222,localhost:4223,localhost:4224", + "streamName": "CHEFS", + "domain": "forms", + "username": "chefs", + "password": "password" + }, "serviceClient": { "commonServices": { "ches": { diff --git a/app/app.js b/app/app.js index 007d4c69a..2d3c1caad 100644 --- a/app/app.js +++ b/app/app.js @@ -11,15 +11,22 @@ const middleware = require('./src/forms/common/middleware'); const v1Router = require('./src/routes/v1'); const DataConnection = require('./src/db/dataConnection'); +const { featureFlags } = require('./src/components/featureFlags'); const dataConnection = new DataConnection(); +const { eventStreamService } = require('./src/components/eventStreamService'); + const apiRouter = express.Router(); const state = { connections: { data: false, + eventStreamService: false, }, ready: false, shutdown: false, }; + +if (!featureFlags.eventStreamService) delete state.connections.eventStreamService; + let probeId; const app = express(); app.use(compression()); @@ -56,7 +63,8 @@ apiRouter.use('/config', (_req, res, next) => { const frontend = config.get('frontend'); // we will need to pass const uploads = config.get('files.uploads'); - const feConfig = { ...frontend, uploads: uploads }; + const features = config.get('features'); + const feConfig = { ...frontend, uploads: uploads, features: features }; res.status(200).json(feConfig); } catch (err) { next(err); @@ -155,6 +163,7 @@ function cleanup() { log.info('Cleaning up...', { function: 'cleanup' }); clearInterval(probeId); + if (featureFlags.eventStreamService) eventStreamService.closeConnection(); dataConnection.close(() => process.exit()); // Wait 10 seconds max before hard exiting @@ -170,6 +179,10 @@ function initializeConnections() { // Initialize connections and exit if unsuccessful const tasks = [dataConnection.checkAll()]; + if (featureFlags.eventStreamService) { + tasks.push(eventStreamService.checkConnection()); + } + Promise.all(tasks) .then((results) => { state.connections.data = results[0]; @@ -178,9 +191,23 @@ function initializeConnections() { log.info('DataConnection Reachable', { function: 'initializeConnections', }); + + if (featureFlags.eventStreamService) { + state.connections.eventStreamService = results[1]; + if (state.connections.eventStreamService) { + log.info('EventStreamService Reachable', { + function: 'initializeConnections', + }); + } + } else { + log.info('EventStreamService feature is not enabled.'); + } }) .catch((error) => { log.error(`Initialization failed: Database OK = ${state.connections.data}`, { function: 'initializeConnections' }); + if (featureFlags.eventStreamService) + log.error(`Initialization failed: EventStreamService OK = ${state.connections.eventStreamService}`, { function: 'initializeConnections' }); + log.error('Connection initialization failure', error.message, { function: 'initializeConnections', }); @@ -196,7 +223,16 @@ function initializeConnections() { function: 'initializeConnections', }); // Start periodic 10 second connection probe check - probeId = setInterval(checkConnections, 10000); + probeId = setInterval(checkConnections, 30000); + } else { + log.error(`Service not ready to accept traffic`, { + function: 'initializeConnections', + }); + log.error(`Database connected = ${state.connections.data}`, { function: 'initializeConnections' }); + if (featureFlags.eventStreamService) log.error(`EventStreamService connected = ${state.connections.eventStreamService}`, { function: 'initializeConnections' }); + + process.exitCode = 1; + shutdown(); } }); } @@ -210,9 +246,12 @@ function checkConnections() { const wasReady = state.ready; if (!state.shutdown) { const tasks = [dataConnection.checkConnection()]; + if (featureFlags.eventStreamService) tasks.push(eventStreamService.checkConnection()); Promise.all(tasks).then((results) => { state.connections.data = results[0]; + if (featureFlags.eventStreamService) state.connections.eventStreamService = results[1]; + state.ready = Object.values(state.connections).every((x) => x); if (!wasReady && state.ready) log.info('Service ready to accept traffic', { @@ -220,6 +259,8 @@ function checkConnections() { }); log.verbose(state); if (!state.ready) { + log.error(`Database connected = ${state.connections.data}`, { function: 'checkConnections' }); + if (featureFlags.eventStreamService) log.error(`EventStreamService connected = ${state.connections.eventStreamService}`, { function: 'checkConnections' }); process.exitCode = 1; shutdown(); } diff --git a/app/config/custom-environment-variables.json b/app/config/custom-environment-variables.json index 8b2ef444f..0d62dc2d4 100755 --- a/app/config/custom-environment-variables.json +++ b/app/config/custom-environment-variables.json @@ -58,6 +58,16 @@ "proxy": "SERVER_ENCRYPTION_PROXY" } }, + "features": { + "eventStreamService": "FEATURES_EVENTSTREAMSERVICE" + }, + "eventStreamService": { + "servers": "EVENTSTREAMSERVICE_SERVERS", + "streamName": "EVENTSTREAMSERVICE_STREAMNAME", + "domain": "EVENTSTREAMSERVICE_DOMAIN", + "username": "EVENTSTREAMSERVICE_USERNAME", + "password": "EVENTSTREAMSERVICE_PASSWORD" + }, "serviceClient": { "commonServices": { "ches": { diff --git a/app/config/default.json b/app/config/default.json index 931c2592b..677d28ada 100644 --- a/app/config/default.json +++ b/app/config/default.json @@ -61,6 +61,16 @@ "proxy": "352f7c24819086bf3df5a38c1a40586045f73e0007440c9d27d59ee8560e3fe7" } }, + "features": { + "eventStreamService": true + }, + "eventStreamService": { + "servers": "localhost:4222,localhost:4223,localhost:4224", + "streamName": "CHEFS", + "domain": "forms", + "username": "chefs", + "password": "password" + }, "serviceClient": { "commonServices": { "ches": { diff --git a/app/package-lock.json b/app/package-lock.json index 52864794e..a78848312 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -36,6 +36,7 @@ "mime-types": "^2.1.35", "moment": "^2.29.4", "multer": "^1.4.5-lts.1", + "nats": "^2.28.0", "nested-objects-util": "^1.1.2", "objection": "^3.0.1", "pg": "^8.10.0", @@ -8479,6 +8480,17 @@ "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", "dev": true }, + "node_modules/nats": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/nats/-/nats-2.28.0.tgz", + "integrity": "sha512-zXWOTOZEizUQy8UO2lMYFAee0htGrZLmJ2sddfmsDI00nc+dsK5+gS7p7bt26O1omfdCLVU5xymlnAjaqYnOmw==", + "dependencies": { + "nkeys.js": "1.1.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -8509,6 +8521,17 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true }, + "node_modules/nkeys.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/nkeys.js/-/nkeys.js-1.1.0.tgz", + "integrity": "sha512-tB/a0shZL5UZWSwsoeyqfTszONTt4k2YS0tuQioMOD180+MbombYVgzDUYHlx+gejYK6rgf08n/2Df99WY0Sxg==", + "dependencies": { + "tweetnacl": "1.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -10489,6 +10512,11 @@ "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" } }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -17491,6 +17519,14 @@ "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", "dev": true }, + "nats": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/nats/-/nats-2.28.0.tgz", + "integrity": "sha512-zXWOTOZEizUQy8UO2lMYFAee0htGrZLmJ2sddfmsDI00nc+dsK5+gS7p7bt26O1omfdCLVU5xymlnAjaqYnOmw==", + "requires": { + "nkeys.js": "1.1.0" + } + }, "natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -17518,6 +17554,14 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true }, + "nkeys.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/nkeys.js/-/nkeys.js-1.1.0.tgz", + "integrity": "sha512-tB/a0shZL5UZWSwsoeyqfTszONTt4k2YS0tuQioMOD180+MbombYVgzDUYHlx+gejYK6rgf08n/2Df99WY0Sxg==", + "requires": { + "tweetnacl": "1.0.3" + } + }, "node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -18980,6 +19024,11 @@ "tslib": "^1.8.1" } }, + "tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==" + }, "type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/app/package.json b/app/package.json index 2b9cbd218..3553d38fe 100644 --- a/app/package.json +++ b/app/package.json @@ -74,6 +74,7 @@ "mime-types": "^2.1.35", "moment": "^2.29.4", "multer": "^1.4.5-lts.1", + "nats": "^2.28.0", "nested-objects-util": "^1.1.2", "objection": "^3.0.1", "pg": "^8.10.0", diff --git a/app/src/components/eventStreamService.js b/app/src/components/eventStreamService.js new file mode 100644 index 000000000..f44406c6d --- /dev/null +++ b/app/src/components/eventStreamService.js @@ -0,0 +1,285 @@ +const config = require('config'); +const nats = require('nats'); +const log = require('./log')(module.filename); + +const { FormVersion, Form } = require('../forms/common/models'); + +const { featureFlags } = require('./featureFlags'); + +const SERVICE = 'EventStreamService'; + +const FORM_EVENT_TYPES = { + PUBLISHED: 'published', + UNPUBLISHED: 'unpublished', +}; + +const SUBMISSION_EVENT_TYPES = { + CREATED: 'created', + UPDATED: 'updated', + DELETED: 'deleted', +}; + +class DummyEventStreamService { + // this class should not be called if we actually check that this feature is enabled. + // however... if we missed that check these calls will do nothing. + async checkConnection() { + log.warn('EventStreamService.checkConnection - EventStreamService is not enabled.'); + } + async openConnection() { + log.warn('EventStreamService.openConnection - EventStreamService is not enabled.'); + } + closeConnection() { + log.warn('EventStreamService.closeConnection - EventStreamService is not enabled.'); + } + get connected() { + log.warn('EventStreamService.connected - EventStreamService is not enabled.'); + return false; + } + // eslint-disable-next-line no-unused-vars + async onPublish(formId, formVersionId, published) { + log.warn('EventStreamService.onPublish - EventStreamService is not enabled.'); + } + // eslint-disable-next-line no-unused-vars + async onSubmit(eventType, submission, draft) { + log.warn('EventStreamService.onSubmit - EventStreamService is not enabled.'); + } +} + +class EventStreamService { + constructor({ servers, streamName, domain, username, password }) { + if (!servers || !streamName || !domain || !username || !password) { + throw new Error('EventStreamService is not configured. Check configuration.'); + } + + this.servers = servers; + this.streamName = streamName; + this.domain = domain; + this.username = username; + this.password = password; + + this.nc = null; // nats connection + this.js = null; // jet stream + this.jsm = null; // jet stream manager + this.natsOptions = { + servers: this.servers.split(','), + maxReconnectAttempts: 1, + name: this.streamName, + reconnectTimeWait: 5000, // wait 5 seconds before retrying... + waitOnFirstConnect: true, + pingInterval: 2000, + user: this.username, + pass: this.password, + }; + + this.publicSubject = `PUBLIC.${this.domain}`; + this.privateSubject = `PRIVATE.${this.domain}`; + this.firstConnect = true; + } + + async checkConnection() { + // this is for health checks + // it will also open our connection on first check... + try { + await this.openConnection(); + return this.connected; + } catch (e) { + log.error(e.message, { function: 'checkConnection' }); + } + return false; + } + + async openConnection() { + try { + if (this.connected) return this.nc; + + const me = this; + const connect = async function () { + // nats.connect will throw errors only if the server is running. + // nats.connect will NOT timeout if the server isn't reachable/not started. + // so, let's wait twice the reconnect time and create our own timeout. + let timeout; + // eslint-disable-next-line no-unused-vars + const timeoutPromise = new Promise((resolve, reject) => { + timeout = setTimeout(() => { + resolve(null); + }, me.natsOptions.reconnectTimeWait * 2); + }); + + // we either timeout or connect... + const result = await Promise.race([nats.connect(me.natsOptions), timeoutPromise]); + + if (timeout) { + clearTimeout(timeout); + } + return result; + }; + + this.nc = await connect(); + if (this.connected) { + log.info('Connected', { function: 'openConnection' }); + this.js = this.nc.jetstream(); + this.jsm = await this.js.jetstreamManager(); + this.jsm.streams.add({ name: this.streamName, subjects: [`${this.publicSubject}.>`, `${this.privateSubject}.>`] }); + this.nc.closed().then((err) => { + if (err) { + log.warn(`the connection closed with an error ${err.message}`, { function: 'connection.closed' }); + } else { + log.info('the connection closed.', { function: 'connection.closed' }); + } + }); + } + return this.nc; + } catch (e) { + log.error(e.message, { function: 'openConnection' }); + } + } + + closeConnection() { + if (this.connected) { + try { + // make this sync so we can use it in our app shutdown/clean up + this.nc.close().then(() => {}); + log.info('Disconnected', { function: 'closeConnection' }); + } catch (e) { + log.error(e.message, { function: 'closeConnection' }); + } + } + } + + get connected() { + try { + if (this.nc && this.nc.info != undefined) { + return true; + } + } catch (e) { + log.error(e.message, { function: 'connected' }); + } + return false; + } + + async onPublish(formId, formVersionId, published) { + try { + const eventType = published ? FORM_EVENT_TYPES.PUBLISHED : FORM_EVENT_TYPES.UNPUBLISHED; + await this.openConnection(); + if (this.connected) { + // fetch form (don't fetch all versions...) + const form = await Form.query().findById(formId).allowGraph('[identityProviders]').withGraphFetched('identityProviders(orderDefault)').throwIfNotFound(); + // fetch version and place in form.versions[] + const formVersion = await FormVersion.query().findById(formVersionId).throwIfNotFound(); + form['versions'] = [formVersion]; + + // need to fetch the encryption key... + + const sub = `schema.${eventType}.${formId}`; + const publicSubj = `${this.publicSubject}.${sub}`; + const privateSubj = `${this.privateSubject}.${sub}`; + const meta = { + source: 'chefs', + domain: 'forms', + class: 'schema', + type: eventType, + formId: formId, + formVersionId: formVersionId, + }; + const privMsg = { + meta: meta, + payload: { + data: form, // we will encrypt for private + }, + }; + const pubMsg = { + meta: meta, + payload: {}, + }; + + // this will need to change when/if we allow configuration for sending public and/or private (or none!) + await Promise.all([this.js.publish(privateSubj, JSON.stringify(privMsg)), this.js.publish(publicSubj, JSON.stringify(pubMsg))]).then((values) => { + log.info(`form ${eventType} event (private) - formId: ${formId}, version: ${formVersion.version}, seq: ${values[0].seq}`, { function: 'onPublish' }); + log.info(`form ${eventType} event (public) - formId: ${formId}, version: ${formVersion.version}, seq: ${values[1].seq}`, { function: 'onPublish' }); + }); + } else { + // warn, error??? + log.warn(`${SERVICE} is not connected. Cannot publish (form) event. [event: form.'${eventType}', formId: ${formId}, versionId: ${formVersionId}]`, { + function: 'onPublish', + }); + } + } catch (e) { + log.error(`${SERVICE}.onPublish: ${e.message}`, e); + } + } + + async onSubmit(eventType, submission, draft) { + try { + const submissionId = submission.id; + await this.openConnection(); + if (this.connected) { + const formVersion = await FormVersion.query().findById(submission.formVersionId).throwIfNotFound(); + + // need to fetch the encryption key... + + const sub = `submission.${eventType}.${formVersion.formId}`; + const publicSubj = `${this.publicSubject}.${sub}`; + const privateSubj = `${this.privateSubject}.${sub}`; + const meta = { + source: 'chefs', + domain: 'forms', + class: 'submission', + type: eventType, + formId: formVersion.formId, + formVersionId: submission.formVersionId, + submissionId: submission.id, + draft: draft, + }; + const privMsg = { + meta: meta, + payload: { + data: submission, // we will encrypt for private + }, + }; + const pubMsg = { + meta: meta, + payload: {}, + }; + + // this will need to change when/if we allow configuration for sending public and/or private (or none!) + await Promise.all([this.js.publish(privateSubj, JSON.stringify(privMsg)), this.js.publish(publicSubj, JSON.stringify(pubMsg))]).then((values) => { + log.info( + `submission ${eventType} event (private) - formId: ${formVersion.formId}, version: ${formVersion.version}, submissionId: ${submission.id}, seq: ${values[0].seq}`, + { + function: 'onSubmit', + } + ); + log.info( + `submission ${eventType} event (public) - formId: ${formVersion.formId}, version: ${formVersion.version}, submissionId: ${submission.id}, seq: ${values[1].seq}`, + { + function: 'onSubmit', + } + ); + }); + } else { + // warn, error??? + log.warn(`${SERVICE} is not connected. Cannot publish (submission) event. [submission.event: '${eventType}', submissionId: ${submissionId}]`, { + function: 'onSubmit', + }); + } + } catch (e) { + log.error(`${SERVICE}.onSubmit: ${e.message}`, e); + } + } +} + +// we need something to import when feature flag is off... +let eventStreamService = new DummyEventStreamService(); + +if (featureFlags.eventStreamService) { + // feature flag on, let's use the real thing. + eventStreamService = new EventStreamService(config.get('eventStreamService')); +} + +module.exports = eventStreamService; + +module.exports = { + eventStreamService: eventStreamService, + FORM_EVENT_TYPES: Object.freeze(FORM_EVENT_TYPES), + SUBMISSION_EVENT_TYPES: Object.freeze(SUBMISSION_EVENT_TYPES), +}; diff --git a/app/src/components/featureFlags.js b/app/src/components/featureFlags.js new file mode 100644 index 000000000..c8ebdab1e --- /dev/null +++ b/app/src/components/featureFlags.js @@ -0,0 +1,65 @@ +const config = require('config'); +const log = require('./log')(module.filename); +const Problem = require('api-problem'); + +const FEATURES = { + EVENT_STREAM_SERVICE: 'eventStreamService', +}; + +class FeatureFlags { + constructor() { + this._eventStreamService = this.enabled(FEATURES.EVENT_STREAM_SERVICE); + } + + // generic flag check + enabled(feature) { + try { + const flag = config.get(`features.${feature}`); + return flag; + } catch (e) { + log.warn(`feature flag '${feature}' not found.`); + } + return false; + } + + // just add direct access helper functions + get eventStreamService() { + return this._eventStreamService; + } + + // middleware, so we can short-circuit any api calls + async featureEnabled(_req, _res, next, feature) { + try { + const flag = this.enabled(feature); + if (flag) { + next(); // all good, feature enabled... + } else { + throw new Problem(400, { + detail: `Feature '${feature}' is not enabled.`, + }); + } + } catch (error) { + next(error); + } + } + async eventStreamServiceEnabled(_req, _res, next) { + try { + if (this._eventStreamService) { + next(); // all good, feature enabled... + } else { + throw new Problem(400, { + detail: `Feature '${FEATURES.EVENT_STREAM_SERVICE}' is not enabled.`, + }); + } + } catch (error) { + next(error); + } + } +} + +let featureFlags = new FeatureFlags(); + +module.exports = { + featureFlags: featureFlags, + FEATURES: Object.freeze(FEATURES), +}; diff --git a/app/src/forms/form/service.js b/app/src/forms/form/service.js index b65f3316d..0ac1da561 100644 --- a/app/src/forms/form/service.js +++ b/app/src/forms/form/service.js @@ -25,6 +25,7 @@ const { } = require('../common/models'); const { falsey, queryUtils, checkIsFormExpired, validateScheduleObject, typeUtils } = require('../common/utils'); const { Permissions, Roles, Statuses } = require('../common/constants'); +const { eventStreamService, SUBMISSION_EVENT_TYPES } = require('../../components/eventStreamService'); const Rolenames = [Roles.OWNER, Roles.TEAM_MANAGER, Roles.FORM_DESIGNER, Roles.SUBMISSION_REVIEWER, Roles.FORM_SUBMITTER, Roles.SUBMISSION_APPROVER]; const service = { @@ -190,8 +191,7 @@ const service = { if (fIdps && fIdps.length) await FormIdentityProvider.query(trx).insert(fIdps); await trx.commit(); - const result = await service.readForm(obj.id); - return result; + return await service.readForm(obj.id); } catch (err) { if (trx) await trx.rollback(); throw err; @@ -480,7 +480,9 @@ const service = { eventService.publishFormEvent(formId, formVersionId, publish); // return the published form/version... - return await service.readPublishedForm(formId); + const result = await service.readPublishedForm(formId); + await eventStreamService.onPublish(formId, formVersionId, publish); + return result; } catch (err) { if (trx) await trx.rollback(); throw err; @@ -592,7 +594,7 @@ const service = { await trx.commit(); const result = await service.readSubmission(obj.id); - + eventStreamService.onSubmit(SUBMISSION_EVENT_TYPES.CREATED, result, data.draft); return result; } catch (err) { if (trx) await trx.rollback(); @@ -764,6 +766,8 @@ const service = { eventService.publishFormEvent(formId, version.id, version.published); + await eventStreamService.onPublish(formId, version.id, version.published); + // return the published version... return await service.readVersion(version.id); } catch (err) { diff --git a/app/src/forms/submission/service.js b/app/src/forms/submission/service.js index 36c9a642f..6af2ac0c3 100644 --- a/app/src/forms/submission/service.js +++ b/app/src/forms/submission/service.js @@ -9,6 +9,7 @@ const eventService = require('../event/eventService'); const fileService = require('../file/service'); const formService = require('../form/service'); const permissionService = require('../permission/service'); +const { eventStreamService, SUBMISSION_EVENT_TYPES } = require('../../components/eventStreamService'); const service = { // ------------------------------------------------------------------------------------------------------- @@ -95,9 +96,10 @@ const service = { let trx; try { trx = etrx ? etrx : await FormSubmission.startTransaction(); + const restoring = data['deleted'] !== undefined && typeof data.deleted == 'boolean'; // If we're restoring a submission - if (data['deleted'] !== undefined && typeof data.deleted == 'boolean') { + if (restoring) { await FormSubmission.query(trx).patchAndFetchById(formSubmissionId, { deleted: data.deleted, updatedBy: currentUser.usernameIdp }); } else { const statuses = await FormSubmissionStatus.query().modify('filterSubmissionId', formSubmissionId).modify('orderDescending'); @@ -140,7 +142,9 @@ const service = { if (!etrx) await trx.commit(); - return service.read(formSubmissionId); + const result = await service.read(formSubmissionId); + await eventStreamService.onSubmit(SUBMISSION_EVENT_TYPES.UPDATED, result.submission, data.draft); + return result; } catch (err) { if (!etrx && trx) await trx.rollback(); throw err; @@ -184,7 +188,9 @@ const service = { updatedBy: currentUser.usernameIdp, }); await trx.commit(); - return await service.read(formSubmissionId); + const result = await service.read(formSubmissionId); + await eventStreamService.onSubmit(SUBMISSION_EVENT_TYPES.DELETED, result.submission, false); + return result; } catch (err) { if (trx) await trx.rollback(); throw err; diff --git a/event-stream-service/.gitignore b/event-stream-service/.gitignore new file mode 100644 index 000000000..b512c09d4 --- /dev/null +++ b/event-stream-service/.gitignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/event-stream-service/config/jetstream.conf b/event-stream-service/config/jetstream.conf new file mode 100644 index 000000000..2e65d5467 --- /dev/null +++ b/event-stream-service/config/jetstream.conf @@ -0,0 +1,98 @@ +debug: true +trace: false + +# Each server can connect to clients on the internal port 4222 +# (mapped to external ports in our docker-compose) +port: 4222 + +# Persistent JetStream data store +jetstream: { + # Each server persists messages within the docker container + # at /data/nats-server (mounted as ./persistent-data/server-n… + # in our docker-compose) + store_dir: "/data/nats-server/" +} + +# Cluster formation +cluster: { + name: "JSC" + listen: "0.0.0.0:4245" + + # Servers can connect to one another at + # the following routes + routes: [ + "nats://n1:4245" + "nats://n2:4245" + "nats://n3:4245" + ] + +} + +authorization: { + default_permissions = { + publish = ["SANDBOX.*", + "$JS.API.INFO", + "$JS.API.CONSUMER.CREATE.*", + "$JS.API.CONSUMER.CREATE.*.>", + "$JS.API.CONSUMER.DURABLE.CREATE.*.>", + "$JS.API.CONSUMER.DELETE.*.>", + "$JS.API.CONSUMER.INFO.*.>", + "$JS.API.CONSUMER.LIST.*", + "$JS.API.CONSUMER.NAMES.*", + "$JS.API.CONSUMER.MSG.NEXT.*.>", + "$JS.API.CONSUMER.MSG.NEXT.*.NEW", + "$JS.API.STREAM.MSG.GET.*", + "$JS.API.STREAM.INFO.*", + "$JS.API.STREAM.NAMES", + "$JS.ACK.*", + "$JS.ACK.*.>"] + subscribe = [ + "PUBLIC.>", + "PRIVATE.>", + "_INBOX.>"] + } + ADMIN = { + publish = ">" + subscribe = ">" + } + CHEFS = { + publish = [ + "$JS.API.INFO", + "$JS.API.STREAM.CREATE.CHEFS", + "$JS.API.STREAM.UPDATE.CHEFS", + "$JS.API.STREAM.DELETE.CHEFS", + "$JS.API.STREAM.INFO.CHEFS", + "$JS.API.STREAM.PURGE.CHEFS", + "$JS.API.STREAM.LIST", + "$JS.API.STREAM.NAMES", + "$JS.API.STREAM.MSG.DELETE.CHEFS", + "$JS.API.STREAM.MSG.GET.CHEFS", + "$JS.API.STREAM.SNAPSHOT.CHEFS", + "$JS.API.STREAM.RESTORE.CHEFS", + + + "$JS.API.CONSUMER.CREATE.CHEFS", + "$JS.API.CONSUMER.CREATE.CHEFS.>", + "$JS.API.CONSUMER.DURABLE.CREATE.CHEFS.>", + "$JS.API.CONSUMER.DELETE.CHEFS.>", + "$JS.API.CONSUMER.INFO.CHEFS.>", + "$JS.API.CONSUMER.LIST.CHEFS", + "$JS.API.CONSUMER.NAMES.CHEFS", + "$JS.API.CONSUMER.MSG.NEXT.CHEFS.>", + + "$JS.API.CONSUMER.MSG.NEXT.CHEFS.NEW", + "$JS.API.STREAM.MSG.GET.CHEFS", + + "$JS.ACK.CHEFS.>" + + "PUBLIC.forms.>", + "PRIVATE.forms.>"] + subscribe = "_INBOX.>" + } + users = [ + {user: admin, password: password, permissions: $ADMIN} + {user: chefs, password: password, permissions: $CHEFS} + {user: anonymous, password: password} + ] +} +no_auth_user: anonymous \ No newline at end of file diff --git a/event-stream-service/consumer.js b/event-stream-service/consumer.js new file mode 100644 index 000000000..5c1b4011e --- /dev/null +++ b/event-stream-service/consumer.js @@ -0,0 +1,112 @@ +const { AckPolicy, connect, millis } = require("nats"); +// connection info +const servers = ["localhost:4222", "localhost:4223", "localhost:4224"]; + +let nc = undefined; // nats connection +let js = undefined; // jet stream +let jsm = undefined; // jet stream manager + +// stream info +const name = "CHEFS"; + +const printMsg = (m) => { + try { + const ts = new Date(m.info.timestampNanos / 1000000).toISOString(); + console.log( + `msg seq: ${m.seq}, subject: ${m.subject}, timestamp: ${ts}, streamSequence: ${m.info.streamSequence}, deliverySequence: ${m.info.deliverySequence}` + ); + console.log(JSON.stringify(m.json(), null, 2)); + } catch (e) { + console.error(`Error printing message: ${e.message}`); + } +}; + +const consume = async () => { + console.log(`connect to nats server(s) ${servers} as 'anonymous'...`); + nc = await connect({ + servers: servers, + }); + + console.log("access jetstream..."); + js = nc.jetstream(); + console.log("get jetstream manager..."); + jsm = await js.jetstreamManager(); + + // The new consumer API is a pull consumer + // Let's create an ephemeral consumer. An ephemeral consumer + // will be reaped by the server when inactive for some time + console.log( + `\nadd ephemeral consumer of stream '${name}' to jetstream manager...` + ); + let ci = await jsm.consumers.add(name, { ack_policy: AckPolicy.None }); + console.log(`get consumer by name '${ci.name}'...`); + const c = await js.consumers.get(name, ci.name); + console.log( + "ephemeral consumer will live until inactivity of ", + millis((await c.info(true)).config.inactive_threshold), + "millis" + ); + + // you can retrieve messages one at time with next(): + console.log("retrieve messages by calling next on consumer..."); + let m = await c.next(); + if (!m) { + // no messages, publisher may have cleared the stream... + console.log("No messages, publisher may have cleared the stream..."); + console.log("drain nats connection..."); + await nc.drain(); + return; + } + printMsg(m); + + m = await c.next(); + printMsg(m); + + m = await c.next(); + printMsg(m); + + // Let's create another consumer, this time well use fetch + // we'll make this a durable + console.log("\nadd consumer with durable_name 'A'"); + await jsm.consumers.add(name, { + ack_policy: AckPolicy.Explicit, + durable_name: "A", + }); + console.log("get consumer by name 'A'..."); + const c2 = await js.consumers.get(name, "A"); + console.log("fetch 3 messages (max)..."); + let iter = await c2.fetch({ max_messages: 3 }); + console.log("iterate over results and ack each one..."); + for await (const m of iter) { + printMsg(m); + m.ack(); + } + // if you know you don't need to save the state of the consumer, you can + // delete it: + console.log("delete consumer..."); + await c2.delete(); + + // Lastly we'll create another one but this time use consume + // this consumer will be an ordered consumer - this one is an ephemeral + // that guarantees that messages are delivered in order + // These have a special shortcut, we only need the name of the stream + // the underlying consumer is managed under the covers + console.log("\nget ordered consumer (ephemeral)..."); + const c3 = await js.consumers.get(name); + console.log("consume 3 messages (max)..."); + iter = await c3.consume({ max_messages: 3 }); + for await (const m of iter) { + printMsg(m); + // if we don't break, consume would keep waiting for messages + // we know when we have seen all messages when no more are pending + if (m.info.pending === 0) { + console.log("no more messages, break."); + break; + } + } + + console.log("drain nats connection..."); + await nc.drain(); +}; + +consume(); diff --git a/event-stream-service/docker-compose.yml b/event-stream-service/docker-compose.yml new file mode 100644 index 000000000..f9e8af2a5 --- /dev/null +++ b/event-stream-service/docker-compose.yml @@ -0,0 +1,65 @@ +services: + n1: + container_name: n1 + image: nats:2.10.12 + entrypoint: /nats-server + command: "--config /config/jetstream.conf --server_name S1" + networks: + - nats + ports: + - 4222:4222 + volumes: + - ./config:/config + - n1-data:/data/nats-server/jetstream + + n2: + container_name: n2 + image: nats:2.10.12 + entrypoint: /nats-server + command: "--config /config/jetstream.conf --server_name S2" + networks: + - nats + ports: + - 4223:4222 + volumes: + - ./config:/config + - n2-data:/data/nats-server/jetstream + + n3: + container_name: n3 + image: nats:2.10.12 + entrypoint: /nats-server + command: "--config /config/jetstream.conf --server_name S3" + networks: + - nats + ports: + - 4224:4222 + volumes: + - ./config:/config + - n3-data:/data/nats-server/jetstream + + natsbox: + container_name: natsbox + image: natsio/nats-box:latest + tty: true + stdin_open: true + command: sh + networks: + - nats + +networks: + nats: + driver: bridge + ipam: + driver: default + config: + - subnet: "192.168.0.0/24" + gateway: "192.168.0.1" + +volumes: + n1-data: + driver: local + n2-data: + driver: local + n3-data: + driver: local diff --git a/event-stream-service/package-lock.json b/event-stream-service/package-lock.json new file mode 100644 index 000000000..52b4c5de1 --- /dev/null +++ b/event-stream-service/package-lock.json @@ -0,0 +1,44 @@ +{ + "name": "event-stream-service-demos", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "event-stream-service-demos", + "version": "0.0.0", + "license": "Apache-2.0", + "dependencies": { + "nats": "^2.28.0" + }, + "devDependencies": {} + }, + "node_modules/nats": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/nats/-/nats-2.28.0.tgz", + "integrity": "sha512-zXWOTOZEizUQy8UO2lMYFAee0htGrZLmJ2sddfmsDI00nc+dsK5+gS7p7bt26O1omfdCLVU5xymlnAjaqYnOmw==", + "dependencies": { + "nkeys.js": "1.1.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/nkeys.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/nkeys.js/-/nkeys.js-1.1.0.tgz", + "integrity": "sha512-tB/a0shZL5UZWSwsoeyqfTszONTt4k2YS0tuQioMOD180+MbombYVgzDUYHlx+gejYK6rgf08n/2Df99WY0Sxg==", + "dependencies": { + "tweetnacl": "1.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==" + } + } +} diff --git a/event-stream-service/package.json b/event-stream-service/package.json new file mode 100644 index 000000000..4b64f1b4f --- /dev/null +++ b/event-stream-service/package.json @@ -0,0 +1,10 @@ +{ + "name": "event-stream-service-demos", + "version": "0.0.0", + "private": true, + "license": "Apache-2.0", + "scripts": {}, + "dependencies": { + "nats": "^2.28.0" + } +} diff --git a/event-stream-service/publisher.js b/event-stream-service/publisher.js new file mode 100644 index 000000000..88baf4d10 --- /dev/null +++ b/event-stream-service/publisher.js @@ -0,0 +1,75 @@ +const { connect } = require("nats"); + +// connection info +const servers = ["localhost:4222", "localhost:4223", "localhost:4224"]; +const username = "chefs"; +const password = "password"; + +let nc = undefined; // nats connection +let js = undefined; // jet stream +let jsm = undefined; // jet stream manager + +// stream info +const name = "CHEFS"; +const subj = "PUBLIC.forms"; +const privatesubj = "PRIVATE.forms"; + +const publish = async () => { + console.log(`connect to nats server(s) ${servers} as '${username}'...`); + nc = await connect({ + servers: servers, + user: username, + pass: password, + }); + + console.log("access jetstream..."); + js = nc.jetstream(); + console.log("get jetstream manager..."); + jsm = await js.jetstreamManager(); + + console.log(`add stream '${name}' to jetstream manager...`); + + await jsm.streams.add({ + name, + subjects: [`${subj}.>`, `${privatesubj}.>`], + }); + + console.log("publish 3 messages..."); + let ack = await js.publish( + `${subj}.1`, + JSON.stringify({ meta: { id: 1 }, payload: { data: "one" } }) + ); + console.log(`ack stream: ${ack.stream}, seq: ${ack.seq}`); + ack = await js.publish( + `${subj}.2`, + JSON.stringify({ meta: { id: 2 }, payload: { data: "two" } }) + ); + console.log(`ack stream: ${ack.stream}, seq: ${ack.seq}`); + ack = await js.publish( + `${subj}.3`, + JSON.stringify({ meta: { id: 3 }, payload: { data: "three" } }) + ); + console.log(`ack stream: ${ack.stream}, seq: ${ack.seq}`); + + // find a stream that stores a specific subject: + const sname = await jsm.streams.find(`${subj}.1`); + // retrieve info about the stream by its name + const si = await jsm.streams.info(sname); + console.log( + `stream messages: ${si.state.messages}, first_seq: ${si.state.first_seq}, last_seq: ${si.state.last_seq}, last_ts: ${si.state.last_ts}` + ); + + console.log("CTRL-C to close."); +}; + +publish(); + +const close = async () => { + console.log(`\npurge messages from '${name}' stream`); + await jsm.streams.purge(name); + console.log("drain nats connection..."); + await nc.drain(); + console.log("done."); +}; + +process.on("SIGINT", close); diff --git a/event-stream-service/pullConsumer.js b/event-stream-service/pullConsumer.js new file mode 100644 index 000000000..b7678b8ff --- /dev/null +++ b/event-stream-service/pullConsumer.js @@ -0,0 +1,94 @@ +const { AckPolicy, connect } = require("nats"); + +// connection info +const servers = ["localhost:4222", "localhost:4223", "localhost:4224"]; + +let nc = undefined; // nats connection +let js = undefined; // jet stream +let jsm = undefined; // jet stream manager +let consumer = undefined; // pull consumer (ordered, ephemeral) + +// stream info +const STREAM_NAME = "CHEFS"; +const FILTER_SUBJECTS = ["PUBLIC.forms.>", "PRIVATE.forms.>"]; +const MAX_MESSAGES = 2; +const DURABLE_NAME = "pullConsumer"; + +const printMsg = (m) => { + // illustrate grabbing the sequence and timestamp from the nats message... + try { + const ts = new Date(m.info.timestampNanos / 1000000).toISOString(); + console.log( + `msg seq: ${m.seq}, subject: ${m.subject}, timestamp: ${ts}, streamSequence: ${m.info.streamSequence}, deliverySequence: ${m.info.deliverySequence}` + ); + // illustrate (one way of) grabbing message content as json + console.log(JSON.stringify(m.json(), null, 2)); + } catch (e) { + console.error(`Error printing message: ${e.message}`); + } +}; + +const init = async () => { + if (nc && nc.info != undefined) { + // already connected. + return; + } else { + // open a connection... + try { + // no credentials provided. + // anonymous connections have read access to the stream + console.log(`connect to nats server(s) ${servers} as 'anonymous'...`); + nc = await connect({ + servers: servers, + }); + + console.log("access jetstream..."); + js = nc.jetstream(); + console.log("get jetstream manager..."); + jsm = await js.jetstreamManager(); + await jsm.consumers.add(STREAM_NAME, { + ack_policy: AckPolicy.Explicit, + durable_name: DURABLE_NAME, + }); + consumer = await js.consumers.get(STREAM_NAME, DURABLE_NAME); + } catch (e) { + console.error(e); + process.exit(0); + } + } +}; + +const pull = async () => { + console.log("fetch..."); + let iter = await consumer.fetch({ + filterSubjects: FILTER_SUBJECTS, + max_messages: MAX_MESSAGES, + }); + for await (const m of iter) { + printMsg(m); + m.ack(); + } +}; + +const main = async () => { + await init(); + await pull(); + setTimeout(main, 5000); // process a batch every 5 seconds +}; + +main(); + +const shutdown = async () => { + console.log("\nshutdown..."); + console.log("drain connection..."); + await nc.drain(); + process.exit(0); +}; + +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); +process.on("SIGUSR1", shutdown); +process.on("SIGUSR2", shutdown); +process.on("exit", () => { + console.log("exit."); +}); diff --git a/event-stream-service/subscriber.js b/event-stream-service/subscriber.js new file mode 100644 index 000000000..c39a3db19 --- /dev/null +++ b/event-stream-service/subscriber.js @@ -0,0 +1,59 @@ +const { connect, consumerOpts } = require("nats"); +// connection info +const servers = ["localhost:4222", "localhost:4223", "localhost:4224"]; + +let nc = undefined; // nats connection +let js = undefined; // jet stream + +// stream info +const SUBJECTS = ["PUBLIC.forms.>", "PRIVATE.forms.>"]; + +const printMsg = (m) => { + try { + const ts = new Date(m.info.timestampNanos / 1000000).toISOString(); + console.log( + `msg seq: ${m.seq}, subject: ${m.subject}, timestamp: ${ts}, streamSequence: ${m.info.streamSequence}, deliverySequence: ${m.info.deliverySequence}` + ); + console.log(JSON.stringify(m.json(), null, 2)); + } catch (e) { + console.error(`Error printing message: ${e.message}`); + } +}; + +const subscribe = async () => { + console.log(`connect to nats server(s) ${servers} as 'anonymous'...`); + nc = await connect({ + servers: servers, + }); + + console.log("access jetstream..."); + js = nc.jetstream(); + + SUBJECTS.forEach(async (key) => { + const opts = consumerOpts(); + opts.maxMessages(100); + opts.ackExplicit(); + opts.deliverNew(); + opts.deliverTo("_INBOX.push_consumer"); + console.log(`subscribe to ${key}`); + const sub = await js.subscribe(key, opts); + await (async () => { + for await (const m of sub) { + printMsg(m); + m.ack(); + } + })(); + }); + + console.log("CTRL-C to close."); +}; + +subscribe(); + +const close = async () => { + console.log("drain nats connection..."); + await nc.drain(); + console.log("done."); +}; + +process.on("SIGINT", close); From df51bbafca7fe94f273050cefd489b1ba127c21c Mon Sep 17 00:00:00 2001 From: Jason Sherman Date: Mon, 5 Aug 2024 14:15:09 -0700 Subject: [PATCH 02/25] event stream service api for ux (in progress) Signed-off-by: Jason Sherman --- .../forms/manage/EventStreamConfig.vue | 479 ++++++++++++++++++ .../components/forms/manage/ManageForm.vue | 22 + .../trans/chefs/en/en.json | 36 +- .../src/services/encryptionKeyService.js | 86 ++++ .../src/services/eventStreamConfigService.js | 58 +++ app/frontend/src/services/index.js | 2 + app/frontend/src/store/encryptionKey.js | 13 + app/frontend/src/store/eventStreamConfig.js | 13 + app/frontend/src/utils/constants.js | 2 + ...20240801182051_047_event_stream_service.js | 38 ++ .../common/middleware/validateParameter.js | 28 + app/src/forms/common/models/index.js | 2 + .../common/models/tables/formEncryptionKey.js | 48 ++ .../models/tables/formEventStreamConfig.js | 58 +++ .../forms/form/encryptionKey/controller.js | 52 ++ app/src/forms/form/encryptionKey/index.js | 0 app/src/forms/form/encryptionKey/routes.js | 39 ++ app/src/forms/form/encryptionKey/service.js | 65 +++ .../form/eventStreamConfig/controller.js | 36 ++ app/src/forms/form/eventStreamConfig/index.js | 0 .../forms/form/eventStreamConfig/routes.js | 30 ++ .../forms/form/eventStreamConfig/service.js | 56 ++ 22 files changed, 1162 insertions(+), 1 deletion(-) create mode 100644 app/frontend/src/components/forms/manage/EventStreamConfig.vue create mode 100644 app/frontend/src/services/encryptionKeyService.js create mode 100644 app/frontend/src/services/eventStreamConfigService.js create mode 100644 app/frontend/src/store/encryptionKey.js create mode 100644 app/frontend/src/store/eventStreamConfig.js create mode 100644 app/src/db/migrations/20240801182051_047_event_stream_service.js create mode 100644 app/src/forms/common/models/tables/formEncryptionKey.js create mode 100644 app/src/forms/common/models/tables/formEventStreamConfig.js create mode 100644 app/src/forms/form/encryptionKey/controller.js create mode 100644 app/src/forms/form/encryptionKey/index.js create mode 100644 app/src/forms/form/encryptionKey/routes.js create mode 100644 app/src/forms/form/encryptionKey/service.js create mode 100644 app/src/forms/form/eventStreamConfig/controller.js create mode 100644 app/src/forms/form/eventStreamConfig/index.js create mode 100644 app/src/forms/form/eventStreamConfig/routes.js create mode 100644 app/src/forms/form/eventStreamConfig/service.js diff --git a/app/frontend/src/components/forms/manage/EventStreamConfig.vue b/app/frontend/src/components/forms/manage/EventStreamConfig.vue new file mode 100644 index 000000000..6eaeafe73 --- /dev/null +++ b/app/frontend/src/components/forms/manage/EventStreamConfig.vue @@ -0,0 +1,479 @@ + + + + + diff --git a/app/frontend/src/components/forms/manage/ManageForm.vue b/app/frontend/src/components/forms/manage/ManageForm.vue index e6d454edd..38f83ea13 100644 --- a/app/frontend/src/components/forms/manage/ManageForm.vue +++ b/app/frontend/src/components/forms/manage/ManageForm.vue @@ -5,10 +5,12 @@ import { useI18n } from 'vue-i18n'; import ApiKey from '~/components/forms/manage/ApiKey.vue'; import DocumentTemplate from '~/components/forms/manage/DocumentTemplate.vue'; +import EventStreamConfig from '~/components/forms/manage/EventStreamConfig.vue'; import ExternalAPIs from '~/components/forms/manage/ExternalAPIs.vue'; import FormSettings from '~/components/designer/FormSettings.vue'; import ManageVersions from '~/components/forms/manage/ManageVersions.vue'; import Subscription from '~/components/forms/manage/Subscription.vue'; +import { useAppStore } from '~/store/app'; import { useFormStore } from '~/store/form'; import { useNotificationStore } from '~/store/notification'; import { FormPermissions, NotificationTypes } from '~/utils/constants'; @@ -18,6 +20,7 @@ const { locale } = useI18n({ useScope: 'global' }); const apiKeyPanel = ref(1); const cdogsPanel = ref(1); +const eventStreamConfigPanel = ref(1); const externalAPIsPanel = ref(1); const formSettingsDisabled = ref(true); const settingsForm = ref(null); @@ -26,6 +29,7 @@ const subscription = ref(false); const subscriptionsPanel = ref(0); const versionsPanel = ref(0); +const appStore = useAppStore(); const formStore = useFormStore(); const notificationStore = useNotificationStore(); @@ -310,6 +314,24 @@ defineExpose({ + + + + +
+ {{ $t('trans.manageForm.eventStreamConfig') }} +
+
+ + + +
+
+ diff --git a/app/frontend/src/internationalization/trans/chefs/en/en.json b/app/frontend/src/internationalization/trans/chefs/en/en.json index de26b6e95..25000806f 100644 --- a/app/frontend/src/internationalization/trans/chefs/en/en.json +++ b/app/frontend/src/internationalization/trans/chefs/en/en.json @@ -67,7 +67,8 @@ "cancel": "Cancel", "eventSubscription": "Event Subscription", "cdogsTemplate": "CDOGS Template", - "externalAPIs": "External APIs" + "externalAPIs": "External APIs", + "eventStreamConfig": "Event Stream Configuration" }, "documentTemplate": { "uploadTemplate": "Upload CDOGS Template", @@ -83,6 +84,39 @@ "fetchError": "An error occurred while fetching the template.", "info": "Upload a template to use the Common Document Generation Service (CDOGS)" }, + "eventStreamConfig": { + "info": "Configure Event Stream for your form.", + "create": "Create new Event Stream Configuration", + "createError": "Event Stream Configuration could not be created.", + "createSuccess": "Event Stream Configuration created successfully.", + "createTitle": "New Event Stream Configuration", + "delete": "Delete", + "deleteSuccess": "Event Stream Configuration deleted successfully.", + "deleteError": "An error occurred while deleting the Event Stream Configuration.", + "edit": "Edit", + "editError": "Event Stream Configuration could not be updated.", + "editSuccess": "Event Stream Configuration updated successfully.", + "editTitle": "Edit Event Stream Configuration", + "fetchError": "An error occurred while fetching the Event Stream Configuration.", + "save": "Save" + }, + "encryptionKey": { + "info": "Configure Encryption Keys for your form.", + "create": "Create new Encryption Key", + "createError": "Encryption Key could not be created.", + "createSuccess": "Encryption Key created successfully.", + "createTitle": "New Encryption Key Configuration", + "delete": "Delete", + "deleteSuccess": "Encryption Key deleted successfully.", + "deleteError": "An error occurred while deleting the Encryption Key.", + "edit": "Edit", + "editError": "Encryption Key could not be updated.", + "editSuccess": "Encryption Key updated successfully.", + "editTitle": "Edit Encryption Key", + "fetchError": "An error occurred while fetching the Encryption Key.", + "fetchListError": "An error occurred while fetching the Encryption Key list.", + "save": "Save" + }, "externalAPI": { "info": "Configure External APIs for use in your form.", "create": "Create new External API.", diff --git a/app/frontend/src/services/encryptionKeyService.js b/app/frontend/src/services/encryptionKeyService.js new file mode 100644 index 000000000..5b56d7a39 --- /dev/null +++ b/app/frontend/src/services/encryptionKeyService.js @@ -0,0 +1,86 @@ +import { appAxios } from '~/services/interceptors'; +import { ApiRoutes } from '~/utils/constants'; + +export default { + /** + * @function listEncryptionAlgorithms + * Get the Encryption Key Algortithms supported + * @param {string} formId The form uuid + * @returns {Promise} An axios response + */ + listEncryptionAlgorithms(formId, params = {}) { + return appAxios().get( + `${ApiRoutes.FORMS}/${formId}${ApiRoutes.ENCRYPTION_KEY}/algorithms`, + { params } + ); + }, + /** + * @function listEncryptionKeys + * List the encryption keys for the form + * @param {string} formId The form uuid + * @returns {Promise} An axios response + */ + listEncryptionKeys(formId, params = {}) { + return appAxios().get( + `${ApiRoutes.FORMS}/${formId}${ApiRoutes.ENCRYPTION_KEY}`, + { params } + ); + }, + + /** + * @function createEncryptionKey + * Create new encryption key for the form + * @param {string} formId The form uuid + * @param {Object} data An object containing the encryption key details + * @returns {Promise} An axios response + */ + createEncryptionKey(formId, data = {}) { + return appAxios().post( + `${ApiRoutes.FORMS}/${formId}${ApiRoutes.ENCRYPTION_KEY}`, + data + ); + }, + + /** + * @function getEncryptionKey + * Get the encryption key + * @param {string} formId The form uuid + * @param {string} formEncryptionKeyId The form encryption key uuid + * @param {Object} [params={}] The query parameters + * @returns {Promise} An axios response + */ + getEncryptionKey(formId, formEncryptionKeyId, params = {}) { + return appAxios().get( + `${ApiRoutes.FORMS}/${formId}${ApiRoutes.ENCRYPTION_KEY}/${formEncryptionKeyId}`, + { params } + ); + }, + + /** + * @function updateEncryptionKey + * Update an encryption key + * @param {string} formId The form uuid + * @param {string} formEncryptionKeyId The form encryption key uuid + * @param {Object} data An object containing the encryption key details + * @returns {Promise} An axios response + */ + updateEncryptionKey(formId, formEncryptionKeyId, data = {}) { + return appAxios().put( + `${ApiRoutes.FORMS}/${formId}${ApiRoutes.ENCRYPTION_KEY}/${formEncryptionKeyId}`, + data + ); + }, + + /** + * @function deleteEncryptionKey + * Delete an encryption key + * @param {string} formId The form uuid + * @param {string} formEncryptionKeyId The form encryption key uuid + * @returns {Promise} An axios response + */ + deleteEncryptionKey(formId, formEncryptionKeyId) { + return appAxios().delete( + `${ApiRoutes.FORMS}/${formId}${ApiRoutes.ENCRYPTION_KEY}/${formEncryptionKeyId}` + ); + }, +}; diff --git a/app/frontend/src/services/eventStreamConfigService.js b/app/frontend/src/services/eventStreamConfigService.js new file mode 100644 index 000000000..ccb4bc3b3 --- /dev/null +++ b/app/frontend/src/services/eventStreamConfigService.js @@ -0,0 +1,58 @@ +import { appAxios } from '~/services/interceptors'; +import { ApiRoutes } from '~/utils/constants'; + +export default { + /** + * @function createEventStreamConfig + * Create new event stream configuration for the form + * @param {string} formId The form uuid + * @param {Object} data An object containing the event stream configuration details + * @returns {Promise} An axios response + */ + createEventStreamConfig(formId, data = {}) { + return appAxios().post( + `${ApiRoutes.FORMS}/${formId}${ApiRoutes.EVENT_STREAM_CONFIG}`, + data + ); + }, + + /** + * @function getEventStreamConfig + * Get the event stream configuration + * @param {string} formId The form uuid + * @param {Object} [params={}] The query parameters + * @returns {Promise} An axios response + */ + getEventStreamConfig(formId, params = {}) { + return appAxios().get( + `${ApiRoutes.FORMS}/${formId}${ApiRoutes.EVENT_STREAM_CONFIG}`, + { params } + ); + }, + + /** + * @function updateEventStreamConfig + * Update an event stream configuration + * @param {string} formId The form uuid + * @param {Object} data An object containing the event stream configuration details + * @returns {Promise} An axios response + */ + updateEventStreamConfig(formId, data = {}) { + return appAxios().put( + `${ApiRoutes.FORMS}/${formId}${ApiRoutes.EVENT_STREAM_CONFIG}`, + data + ); + }, + + /** + * @function deleteEventStreamConfig + * Delete an event stream configuration + * @param {string} formId The form uuid + * @returns {Promise} An axios response + */ + deleteEventStreamConfig(formId) { + return appAxios().delete( + `${ApiRoutes.FORMS}/${formId}${ApiRoutes.EVENT_STREAM_CONFIG}` + ); + }, +}; diff --git a/app/frontend/src/services/index.js b/app/frontend/src/services/index.js index a0f299000..5dd1ff563 100755 --- a/app/frontend/src/services/index.js +++ b/app/frontend/src/services/index.js @@ -6,3 +6,5 @@ export { default as roleService } from './roleService'; export { default as userService } from './userService'; export { default as fileService } from './fileService'; export { default as utilsService } from './utilsService'; +export { default as encryptionKeyService } from './encryptionKeyService'; +export { default as eventStreamConfigService } from './eventStreamConfigService'; diff --git a/app/frontend/src/store/encryptionKey.js b/app/frontend/src/store/encryptionKey.js new file mode 100644 index 000000000..1c1149ed9 --- /dev/null +++ b/app/frontend/src/store/encryptionKey.js @@ -0,0 +1,13 @@ +import { defineStore } from 'pinia'; + +export const useEncryptionKeyStore = defineStore('encryptionKey', { + state: () => ({ + // + }), + getters: { + // + }, + actions: { + // + }, +}); diff --git a/app/frontend/src/store/eventStreamConfig.js b/app/frontend/src/store/eventStreamConfig.js new file mode 100644 index 000000000..79c380f15 --- /dev/null +++ b/app/frontend/src/store/eventStreamConfig.js @@ -0,0 +1,13 @@ +import { defineStore } from 'pinia'; + +export const useIdpStore = defineStore('eventStream', { + state: () => ({ + // + }), + getters: { + // + }, + actions: { + // + }, +}); diff --git a/app/frontend/src/utils/constants.js b/app/frontend/src/utils/constants.js index 83efe0333..f1fa63c33 100755 --- a/app/frontend/src/utils/constants.js +++ b/app/frontend/src/utils/constants.js @@ -16,6 +16,8 @@ export const ApiRoutes = Object.freeze({ FILES_API_ACCESS: '/filesApiAccess', PROXY: '/proxy', EXTERNAL_APIS: '/externalAPIs', + EVENT_STREAM_CONFIG: '/eventStreamConfig', + ENCRYPTION_KEY: '/encryptionKey', }); /** Roles a user can have on a form. These are defined in the DB and sent from the API */ diff --git a/app/src/db/migrations/20240801182051_047_event_stream_service.js b/app/src/db/migrations/20240801182051_047_event_stream_service.js new file mode 100644 index 000000000..1d0f28902 --- /dev/null +++ b/app/src/db/migrations/20240801182051_047_event_stream_service.js @@ -0,0 +1,38 @@ +const stamps = require('../stamps'); +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return Promise.resolve() + .then(() => + knex.schema.createTable('form_encryption_key', (table) => { + table.uuid('id').primary(); + table.uuid('formId').references('id').inTable('form').notNullable().index(); + table.string('name', 255).notNullable(); + table.string('algorithm'); + table.string('key'); + stamps(knex, table); + }) + ) + .then(() => + knex.schema.createTable('form_event_stream_config', (table) => { + table.uuid('id').primary(); + table.uuid('formId').references('id').inTable('form').notNullable().index(); + table.boolean('enablePublicStream').defaultTo(false); + table.boolean('enablePrivateStream').defaultTo(false); + table.uuid('encryptionKeyId').references('id').inTable('form_encryption_key'); + stamps(knex, table); + }) + ); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return Promise.resolve() + .then(() => knex.schema.dropTableIfExists('form_event_stream_config')) + .then(() => knex.schema.dropTableIfExists('form_encryption_keys')); +}; diff --git a/app/src/forms/common/middleware/validateParameter.js b/app/src/forms/common/middleware/validateParameter.js index 2132b2794..39a2b36f8 100644 --- a/app/src/forms/common/middleware/validateParameter.js +++ b/app/src/forms/common/middleware/validateParameter.js @@ -1,6 +1,7 @@ const Problem = require('api-problem'); const uuid = require('uuid'); +const encryptionKeyService = require('../../form/encryptionKey/service'); const externalApiService = require('../../form/externalApi/service'); const formService = require('../../form/service'); const submissionService = require('../../submission/service'); @@ -174,6 +175,32 @@ const validateFormVersionId = async (req, _res, next, formVersionId) => { } }; +/** + * Validates that the :formEncryptionKeyId route parameter exists and is a UUID. This + * validator requires that the :formId route parameter also exists. + * + * @param {*} req the Express object representing the HTTP request. + * @param {*} _res the Express object representing the HTTP response - unused. + * @param {*} next the Express chaining function. + * @param {*} formEncryptionKeyId the :formEncryptionKeyId value from the route. + */ +const validateFormEncryptionKeyId = async (req, _res, next, formEncryptionKeyId) => { + try { + _validateUuid(formEncryptionKeyId, 'formEncryptionKeyId'); + + const rec = await encryptionKeyService.readEncryptionKey(formEncryptionKeyId); + if (!rec || rec.formId !== req.params.formId) { + throw new Problem(404, { + detail: 'formEncryptionKeyId does not exist on this form', + }); + } + + next(); + } catch (error) { + next(error); + } +}; + module.exports = { validateDocumentTemplateId, validateExternalAPIId, @@ -181,4 +208,5 @@ module.exports = { validateFormId, validateFormVersionDraftId, validateFormVersionId, + validateFormEncryptionKeyId, }; diff --git a/app/src/forms/common/models/index.js b/app/src/forms/common/models/index.js index fc313a9e2..d2e849aaf 100644 --- a/app/src/forms/common/models/index.js +++ b/app/src/forms/common/models/index.js @@ -26,6 +26,8 @@ module.exports = { FormSubscription: require('./tables/formSubscription'), ExternalAPI: require('./tables/externalAPI'), ExternalAPIStatusCode: require('./tables/externalAPIStatusCode'), + FormEncryptionKey: require('./tables/formEncryptionKey'), + FormEventStreamConfig: require('./tables/formEventStreamConfig'), // Views FormSubmissionUserPermissions: require('./views/formSubmissionUserPermissions'), diff --git a/app/src/forms/common/models/tables/formEncryptionKey.js b/app/src/forms/common/models/tables/formEncryptionKey.js new file mode 100644 index 000000000..94492c0d6 --- /dev/null +++ b/app/src/forms/common/models/tables/formEncryptionKey.js @@ -0,0 +1,48 @@ +const { Model } = require('objection'); +const { Timestamps } = require('../mixins'); +const { Regex } = require('../../constants'); +const stamps = require('../jsonSchema').stamps; + +class FormEncryptionKey extends Timestamps(Model) { + static get tableName() { + return 'form_encryption_key'; + } + + static get modifiers() { + return { + filterFormId(query, value) { + if (value) { + query.where('formId', value); + } + }, + findByIdAndFormId(query, id, formId) { + if (id !== undefined && formId !== undefined) { + query.where('id', id).where('formId', formId); + } + }, + findByFormIdAndName(query, formId, name) { + if (name !== undefined && formId !== undefined) { + query.where('name', name).where('formId', formId); + } + }, + }; + } + + static get jsonSchema() { + return { + type: 'object', + required: ['formId', 'name', 'algorithm', 'key'], + properties: { + id: { type: 'string', pattern: Regex.UUID }, + formId: { type: 'string', pattern: Regex.UUID }, + name: { type: 'string', minLength: 1, maxLength: 255 }, + algorithm: { type: 'string' }, + key: { type: 'string' }, + ...stamps, + }, + additionalProperties: false, + }; + } +} + +module.exports = FormEncryptionKey; diff --git a/app/src/forms/common/models/tables/formEventStreamConfig.js b/app/src/forms/common/models/tables/formEventStreamConfig.js new file mode 100644 index 000000000..ad7b51e39 --- /dev/null +++ b/app/src/forms/common/models/tables/formEventStreamConfig.js @@ -0,0 +1,58 @@ +const { Model } = require('objection'); +const { Timestamps } = require('../mixins'); +const { Regex } = require('../../constants'); +const stamps = require('../jsonSchema').stamps; + +class FormEventStreamConfig extends Timestamps(Model) { + static get tableName() { + return 'form_event_stream_config'; + } + + static get relationMappings() { + const FormEncryptionKey = require('./formEncryptionKey'); + + return { + encryptionKey: { + relation: Model.HasOneRelation, + modelClass: FormEncryptionKey, + join: { + from: 'form_event_stream_config.encryptionKeyId', + to: 'form_encryption_key.id', + }, + }, + }; + } + + static get modifiers() { + return { + filterFormId(query, value) { + if (value) { + query.where('formId', value); + } + }, + findByIdAndFormId(query, id, formId) { + if (id !== undefined && formId !== undefined) { + query.where('id', id).where('formId', formId); + } + }, + }; + } + + static get jsonSchema() { + return { + type: 'object', + required: ['formId'], + properties: { + id: { type: 'string', pattern: Regex.UUID }, + formId: { type: 'string', pattern: Regex.UUID }, + enablePublicStream: { type: 'boolean', default: false }, + enablePrivateStream: { type: 'boolean', default: false }, + encryptionKeyId: { type: 'string', pattern: Regex.UUID }, + ...stamps, + }, + additionalProperties: false, + }; + } +} + +module.exports = FormEventStreamConfig; diff --git a/app/src/forms/form/encryptionKey/controller.js b/app/src/forms/form/encryptionKey/controller.js new file mode 100644 index 000000000..73788ac67 --- /dev/null +++ b/app/src/forms/form/encryptionKey/controller.js @@ -0,0 +1,52 @@ +const service = require('./service'); + +module.exports = { + listEncryptionAlgorithms: async (req, res, next) => { + try { + const response = await service.listEncryptionAlgorithms(); + res.status(200).json(response); + } catch (error) { + next(error); + } + }, + listEncryptionKeys: async (req, res, next) => { + try { + const response = await service.listEncryptionKeys(req.params.formId); + res.status(200).json(response); + } catch (error) { + next(error); + } + }, + createEncryptionKey: async (req, res, next) => { + try { + const response = await service.createEncryptionKey(req.params.formId, req.body, req.currentUser); + res.status(201).json(response); + } catch (error) { + next(error); + } + }, + readEncryptionKey: async (req, res, next) => { + try { + const response = await service.readEncryptionKey(req.params.formId, req.params.formEncryptionKeyId, req.body, req.currentUser); + res.status(200).json(response); + } catch (error) { + next(error); + } + }, + updateEncryptionKey: async (req, res, next) => { + try { + const response = await service.updateEncryptionKey(req.params.formId, req.params.formEncryptionKeyId, req.body, req.currentUser); + res.status(200).json(response); + } catch (error) { + next(error); + } + }, + deleteEncryptionKey: async (req, res, next) => { + try { + await service.deleteEncryptionKey(req.params.formId, req.params.formEncryptionKeyId); + res.status(204).send(); + } catch (error) { + next(error); + } + }, +}; diff --git a/app/src/forms/form/encryptionKey/index.js b/app/src/forms/form/encryptionKey/index.js new file mode 100644 index 000000000..e69de29bb diff --git a/app/src/forms/form/encryptionKey/routes.js b/app/src/forms/form/encryptionKey/routes.js new file mode 100644 index 000000000..db4f1f4be --- /dev/null +++ b/app/src/forms/form/encryptionKey/routes.js @@ -0,0 +1,39 @@ +const routes = require('express').Router(); +const { currentUser, hasFormPermissions } = require('../../auth/middleware/userAccess'); +const validateParameter = require('../../common/middleware/validateParameter'); +const featureFlags = require('../../../components/featureFlags'); +const P = require('../../common/constants').Permissions; + +const controller = require('./controller'); + +routes.use(featureFlags.eventStreamServiceEnabled); +routes.use(currentUser); + +routes.param('formId', validateParameter.validateFormId); +routes.param('formEncryptionKeyId', validateParameter.validateFormEncryptionKeyId); + +routes.get('/:formId/encryptionKey/algorithms', hasFormPermissions([P.FORM_READ, P.FORM_UPDATE]), async (req, res, next) => { + await controller.listEncryptionAlgorithms(req, res, next); +}); + +routes.get('/:formId/encryptionKey', hasFormPermissions([P.FORM_READ, P.FORM_UPDATE]), async (req, res, next) => { + await controller.listEncryptionKeys(req, res, next); +}); + +routes.post('/:formId/encryptionKey', hasFormPermissions([P.FORM_READ, P.FORM_UPDATE]), async (req, res, next) => { + await controller.createEncryptionKey(req, res, next); +}); + +routes.get('/:formId/encryptionKey/:formEncryptionKeyId', hasFormPermissions([P.FORM_READ, P.FORM_UPDATE]), async (req, res, next) => { + await controller.readEncryptionKey(req, res, next); +}); + +routes.put('/:formId/encryptionKey/:formEncryptionKeyId', hasFormPermissions([P.FORM_READ, P.FORM_UPDATE]), async (req, res, next) => { + await controller.updateEncryptionKey(req, res, next); +}); + +routes.delete('/:formId/encryptionKey/:formEncryptionKeyId', hasFormPermissions([P.FORM_READ, P.FORM_UPDATE]), async (req, res, next) => { + await controller.deleteEncryptionKey(req, res, next); +}); + +module.exports = routes; diff --git a/app/src/forms/form/encryptionKey/service.js b/app/src/forms/form/encryptionKey/service.js new file mode 100644 index 000000000..3692c9dc6 --- /dev/null +++ b/app/src/forms/form/encryptionKey/service.js @@ -0,0 +1,65 @@ +const Problem = require('api-problem'); + +const { v4: uuidv4 } = require('uuid'); + +const { FormEncryptionKey } = require('../../common/models'); + +const { ENCRYPTION_ALGORITHMS } = require('../../../components/encryptionService'); + +const service = { + listEncryptionAlgorithms: () => { + return Object.values(ENCRYPTION_ALGORITHMS).map((x) => ({ + code: x, + display: x, + })); + }, + + validateEncryptionKey: (data) => { + if (!data) { + throw new Problem(422, `'EncryptionKey record' cannot be empty.`); + } + }, + + listEncryptionKeys: (formId) => { + return FormEncryptionKey.query().modify('filterFormId', formId); + }, + + createExternalAPI: async (formId, data, currentUser) => { + service.validateEncryptionKey(data); + + data.id = uuidv4(); + await FormEncryptionKey.query().insert({ + ...data, + createdBy: currentUser.usernameIdp, + }); + + return FormEncryptionKey.query().findById(data.id); + }, + + updateExternalAPI: async (formId, formEncryptionKeyId, data, currentUser) => { + service.validateEncryptionKey(data); + + const existing = await FormEncryptionKey.query().modify('findByIdAndFormId', formEncryptionKeyId, formId).first().throwIfNotFound(); + // compare to see if we are actually updating any attributes. + data.code = existing.code; + await FormEncryptionKey.query() + .findById(formEncryptionKeyId) + .update({ + ...data, + updatedBy: currentUser.usernameIdp, + }); + + return FormEncryptionKey.query().findById(formEncryptionKeyId); + }, + + deleteExternalAPI: async (formId, formEncryptionKeyId) => { + await FormEncryptionKey.query().modify('findByIdAndFormId', formEncryptionKeyId, formId).first().throwIfNotFound(); + await FormEncryptionKey.query().deleteById(formEncryptionKeyId); + }, + + readEncryptionKey: (formEncryptionKeyId) => { + return FormEncryptionKey.findById(formEncryptionKeyId); + }, +}; + +module.exports = service; diff --git a/app/src/forms/form/eventStreamConfig/controller.js b/app/src/forms/form/eventStreamConfig/controller.js new file mode 100644 index 000000000..754eeb04d --- /dev/null +++ b/app/src/forms/form/eventStreamConfig/controller.js @@ -0,0 +1,36 @@ +const service = require('./service'); + +module.exports = { + createEventStreamConfig: async (req, res, next) => { + try { + const response = await service.createEventStreamConfig(req.params.formId, req.body, req.currentUser); + res.status(201).json(response); + } catch (error) { + next(error); + } + }, + readEventStreamConfig: async (req, res, next) => { + try { + const response = await service.readEventStreamConfig(req.params.formId); + res.status(200).json(response); + } catch (error) { + next(error); + } + }, + updateEventStreamConfig: async (req, res, next) => { + try { + const response = await service.updateEventStreamConfig(req.params.formId, req.body, req.currentUser); + res.status(200).json(response); + } catch (error) { + next(error); + } + }, + deleteEventStreamConfig: async (req, res, next) => { + try { + await service.deleteEventStreamConfig(req.params.formId, req.currentUser); + res.status(204).send(); + } catch (error) { + next(error); + } + }, +}; diff --git a/app/src/forms/form/eventStreamConfig/index.js b/app/src/forms/form/eventStreamConfig/index.js new file mode 100644 index 000000000..e69de29bb diff --git a/app/src/forms/form/eventStreamConfig/routes.js b/app/src/forms/form/eventStreamConfig/routes.js new file mode 100644 index 000000000..f46fd6e73 --- /dev/null +++ b/app/src/forms/form/eventStreamConfig/routes.js @@ -0,0 +1,30 @@ +const routes = require('express').Router(); +const { currentUser, hasFormPermissions } = require('../../auth/middleware/userAccess'); +const validateParameter = require('../../common/middleware/validateParameter'); +const featureFlags = require('../../../components/featureFlags'); +const P = require('../../common/constants').Permissions; + +const controller = require('./controller'); + +routes.use(featureFlags.eventStreamServiceEnabled); +routes.use(currentUser); + +routes.param('formId', validateParameter.validateFormId); + +routes.get('/:formId/eventStreamConfig', hasFormPermissions([P.FORM_READ, P.FORM_UPDATE]), async (req, res, next) => { + await controller.readEventStreamConfig(req, res, next); +}); + +routes.post('/:formId/eventStreamConfig', hasFormPermissions([P.FORM_READ, P.FORM_UPDATE]), async (req, res, next) => { + await controller.createEventStreamConfig(req, res, next); +}); + +routes.put('/:formId/eventStreamConfig', hasFormPermissions([P.FORM_READ, P.FORM_UPDATE]), async (req, res, next) => { + await controller.updateEventStreamConfig(req, res, next); +}); + +routes.delete('/:formId/eventStreamConfig', hasFormPermissions([P.FORM_READ, P.FORM_UPDATE]), async (req, res, next) => { + await controller.deleteEventStreamConfig(req, res, next); +}); + +module.exports = routes; diff --git a/app/src/forms/form/eventStreamConfig/service.js b/app/src/forms/form/eventStreamConfig/service.js new file mode 100644 index 000000000..798e946b5 --- /dev/null +++ b/app/src/forms/form/eventStreamConfig/service.js @@ -0,0 +1,56 @@ +const Problem = require('api-problem'); + +const { v4: uuidv4 } = require('uuid'); + +const { FormEventStreamConfig } = require('../../common/models'); + +const service = { + validateEventStreamConfig: (data) => { + if (!data) { + throw new Problem(422, `'EventStreamConfig record' cannot be empty.`); + } + }, + + createEventStreamConfig: async (formId, data, currentUser) => { + service.validateEventStreamConfig(data); + + const existing = await FormEventStreamConfig.query().modify('filterFormId', formId).first(); + if (existing) { + // if found throw error? or just update? + } + + data.id = uuidv4(); + await FormEventStreamConfig.query().insert({ + ...data, + createdBy: currentUser.usernameIdp, + }); + + return FormEventStreamConfig.query().findById(data.id); + }, + + updateEventStreamConfig: async (formId, data, currentUser) => { + service.validateExternalAPI(data); + + const existing = await FormEventStreamConfig.query().modify('filterFormId', formId).first().throwIfNotFound(); + // compare to see if we are actually updating any attributes. + await FormEventStreamConfig.query() + .findById(existing.id) + .update({ + ...data, + updatedBy: currentUser.usernameIdp, + }); + + return FormEventStreamConfig.query().findById(existing.id); + }, + + deleteEventStreamConfig: async (formId) => { + const existing = await FormEventStreamConfig.query().modify('filterFormId', formId).first().throwIfNotFound(); + await FormEventStreamConfig.query().deleteById(existing.id); + }, + + readEventStreamConfig: (formId) => { + return FormEventStreamConfig.query().modify('findByFormId', formId).first().throwIfNotFound(); + }, +}; + +module.exports = service; From c66b5cd13403f8ef1f8dc51f8de9792277163c37 Mon Sep 17 00:00:00 2001 From: Jason Sherman Date: Thu, 15 Aug 2024 11:47:24 -0700 Subject: [PATCH 03/25] UX code to support Event Stream Service configuration Signed-off-by: Jason Sherman --- .devcontainer/chefs_local/test.json | 11 + app/app.js | 8 + app/config/custom-environment-variables.json | 1 + app/config/default.json | 1 + .../src/components/designer/FormDesigner.vue | 1 + .../src/components/designer/FormSettings.vue | 11 + .../settings/FormEventStreamSettings.vue | 274 ++++++++++ .../forms/manage/EventStreamConfig.vue | 479 ------------------ .../components/forms/manage/ManageForm.vue | 25 +- .../trans/chefs/en/en.json | 21 +- .../src/services/encryptionKeyService.js | 5 +- app/frontend/src/store/form.js | 49 +- app/src/components/eventStreamService.js | 145 +++--- app/src/components/featureFlags.js | 61 ++- .../common/middleware/validateParameter.js | 4 +- .../models/tables/formEventStreamConfig.js | 2 +- .../forms/form/encryptionKey/controller.js | 2 +- app/src/forms/form/encryptionKey/routes.js | 6 +- app/src/forms/form/encryptionKey/service.js | 82 ++- .../forms/form/eventStreamConfig/routes.js | 4 +- .../forms/form/eventStreamConfig/service.js | 60 ++- app/src/forms/form/index.js | 4 +- app/src/forms/form/service.js | 5 + 23 files changed, 640 insertions(+), 621 deletions(-) create mode 100644 app/frontend/src/components/designer/settings/FormEventStreamSettings.vue delete mode 100644 app/frontend/src/components/forms/manage/EventStreamConfig.vue diff --git a/.devcontainer/chefs_local/test.json b/.devcontainer/chefs_local/test.json index 5928abdca..b957b8ae6 100644 --- a/.devcontainer/chefs_local/test.json +++ b/.devcontainer/chefs_local/test.json @@ -61,6 +61,17 @@ "proxy": "5fb2054478353fd8d514056d1745b3a9eef066deadda4b90967af7ca65ce6505" } }, + "features": { + "eventStreamService": false + }, + "eventStreamService": { + "servers": "localhost:4222,localhost:4223,localhost:4224", + "streamName": "CHEFS", + "source": "chefs", + "domain": "forms", + "username": "chefs", + "password": "password" + }, "serviceClient": { "commonServices": { "ches": { diff --git a/app/app.js b/app/app.js index 4ce130e67..848e500fe 100644 --- a/app/app.js +++ b/app/app.js @@ -67,6 +67,14 @@ apiRouter.use('/config', (_req, res, next) => { const uploads = config.get('files.uploads'); const features = config.get('features'); const feConfig = { ...frontend, uploads: uploads, features: features }; + if (featureFlags.eventStreamService) { + let ess = config.util.cloneDeep(config.get('eventStreamService')); + delete ess['username']; + delete ess['password']; + feConfig['eventStreamService'] = { + ...ess, + }; + } res.status(200).json(feConfig); } catch (err) { next(err); diff --git a/app/config/custom-environment-variables.json b/app/config/custom-environment-variables.json index 0d62dc2d4..c0f5c5236 100755 --- a/app/config/custom-environment-variables.json +++ b/app/config/custom-environment-variables.json @@ -64,6 +64,7 @@ "eventStreamService": { "servers": "EVENTSTREAMSERVICE_SERVERS", "streamName": "EVENTSTREAMSERVICE_STREAMNAME", + "source": "EVENTSTREAMSERVICE_SOURCE", "domain": "EVENTSTREAMSERVICE_DOMAIN", "username": "EVENTSTREAMSERVICE_USERNAME", "password": "EVENTSTREAMSERVICE_PASSWORD" diff --git a/app/config/default.json b/app/config/default.json index 677d28ada..9846a221f 100644 --- a/app/config/default.json +++ b/app/config/default.json @@ -67,6 +67,7 @@ "eventStreamService": { "servers": "localhost:4222,localhost:4223,localhost:4224", "streamName": "CHEFS", + "source": "chefs", "domain": "forms", "username": "chefs", "password": "password" diff --git a/app/frontend/src/components/designer/FormDesigner.vue b/app/frontend/src/components/designer/FormDesigner.vue index 7abbab56c..b10ecb1c1 100644 --- a/app/frontend/src/components/designer/FormDesigner.vue +++ b/app/frontend/src/components/designer/FormDesigner.vue @@ -652,6 +652,7 @@ export default { apiIntegration: this.form.apiIntegration, useCase: this.form.useCase, labels: this.form.labels, + eventStreamConfig: this.form.eventStreamConfig, }); // update user labels with any new added labels if ( diff --git a/app/frontend/src/components/designer/FormSettings.vue b/app/frontend/src/components/designer/FormSettings.vue index 634d3c2e9..12c511a4f 100644 --- a/app/frontend/src/components/designer/FormSettings.vue +++ b/app/frontend/src/components/designer/FormSettings.vue @@ -5,6 +5,9 @@ import FormGeneralSettings from '~/components/designer/settings/FormGeneralSetti import FormFunctionalitySettings from '~/components/designer/settings/FormFunctionalitySettings.vue'; import FormScheduleSettings from '~/components/designer/settings/FormScheduleSettings.vue'; import FormSubmissionSettings from '~/components/designer/settings/FormSubmissionSettings.vue'; +import FormEventStreamSettings from '~/components/designer/settings/FormEventStreamSettings.vue'; + +import { useAppStore } from '~/store/app'; import { useFormStore } from '~/store/form'; export default { @@ -14,6 +17,7 @@ export default { FormFunctionalitySettings, FormScheduleSettings, FormSubmissionSettings, + FormEventStreamSettings, }, props: { disabled: { @@ -24,6 +28,10 @@ export default { computed: { ...mapWritableState(useFormStore, ['form']), ...mapState(useFormStore, ['isFormPublished', 'isRTL']), + eventStreamEnabled() { + const appStore = useAppStore(); + return appStore.config?.features?.eventStreamService; + }, }, }; @@ -46,6 +54,9 @@ export default { + + + diff --git a/app/frontend/src/components/designer/settings/FormEventStreamSettings.vue b/app/frontend/src/components/designer/settings/FormEventStreamSettings.vue new file mode 100644 index 000000000..a3e2bf2ef --- /dev/null +++ b/app/frontend/src/components/designer/settings/FormEventStreamSettings.vue @@ -0,0 +1,274 @@ + + + diff --git a/app/frontend/src/components/forms/manage/EventStreamConfig.vue b/app/frontend/src/components/forms/manage/EventStreamConfig.vue deleted file mode 100644 index 6eaeafe73..000000000 --- a/app/frontend/src/components/forms/manage/EventStreamConfig.vue +++ /dev/null @@ -1,479 +0,0 @@ - - - - - diff --git a/app/frontend/src/components/forms/manage/ManageForm.vue b/app/frontend/src/components/forms/manage/ManageForm.vue index 38f83ea13..013b388f1 100644 --- a/app/frontend/src/components/forms/manage/ManageForm.vue +++ b/app/frontend/src/components/forms/manage/ManageForm.vue @@ -5,12 +5,10 @@ import { useI18n } from 'vue-i18n'; import ApiKey from '~/components/forms/manage/ApiKey.vue'; import DocumentTemplate from '~/components/forms/manage/DocumentTemplate.vue'; -import EventStreamConfig from '~/components/forms/manage/EventStreamConfig.vue'; import ExternalAPIs from '~/components/forms/manage/ExternalAPIs.vue'; import FormSettings from '~/components/designer/FormSettings.vue'; import ManageVersions from '~/components/forms/manage/ManageVersions.vue'; import Subscription from '~/components/forms/manage/Subscription.vue'; -import { useAppStore } from '~/store/app'; import { useFormStore } from '~/store/form'; import { useNotificationStore } from '~/store/notification'; import { FormPermissions, NotificationTypes } from '~/utils/constants'; @@ -20,7 +18,6 @@ const { locale } = useI18n({ useScope: 'global' }); const apiKeyPanel = ref(1); const cdogsPanel = ref(1); -const eventStreamConfigPanel = ref(1); const externalAPIsPanel = ref(1); const formSettingsDisabled = ref(true); const settingsForm = ref(null); @@ -29,7 +26,6 @@ const subscription = ref(false); const subscriptionsPanel = ref(0); const versionsPanel = ref(0); -const appStore = useAppStore(); const formStore = useFormStore(); const notificationStore = useNotificationStore(); @@ -103,7 +99,8 @@ function enableSettingsEdit() { async function updateSettings() { try { - if (settingsForm.value.validate()) { + const { valid } = await settingsForm.value.validate(); + if (valid) { await formStore.updateForm(); formSettingsDisabled.value = true; notificationStore.addNotification({ @@ -314,24 +311,6 @@ defineExpose({ - - - - -
- {{ $t('trans.manageForm.eventStreamConfig') }} -
-
- - - -
-
- diff --git a/app/frontend/src/internationalization/trans/chefs/en/en.json b/app/frontend/src/internationalization/trans/chefs/en/en.json index 25000806f..45a5736a7 100644 --- a/app/frontend/src/internationalization/trans/chefs/en/en.json +++ b/app/frontend/src/internationalization/trans/chefs/en/en.json @@ -231,7 +231,26 @@ "eventSubscription": "Event Subscription", "validEndpointRequired": "Please enter a valid endpoint starting with https://", "validBearerTokenRequired": "Enter a valid bearer token example: 89abddfb-2cff-4fda-83e6-13221f0c3d4f", - "wideFormLayout": "Wide Form Layout" + "wideFormLayout": "Wide Form Layout", + "eventStreamTitle": "Event Stream Settings", + "eventStreamMessage": "Event Stream Service will publish notifications about form publishing and submissions. The underlying technology is NATS.io which allows consumers to subscribe or pull the event messages and process them in external systems. Private messages will contain encrypted payloads with the key configured here.", + "natsConfiguration": "NATS Configuration and Message Metadata", + "publishConfiguration": "Event Publishing Configuration", + "enablePublicStream": "Enable Public Stream", + "enablePrivateStream": "Enable Private Stream", + "encryptionKeyAlgorithm": "Encryption Key Algorithm", + "encryptionKey": "Encryption Key", + "encryptionKeyReq": "Encryption Key is required for Private Streams", + "fetchEncryptionAlgorithmListError": "Error fetching Encryption Algorithm list.", + "serversLabel": "Servers", + "streamNameLabel": "Stream Name", + "sourceLabel": "Source", + "domainLabel": "Domain", + "eventStreamUpdatedBy": "Event Stream Settings Updated By", + "encryptionKeyUpdatedBy": "Encryption Key Updated By", + "encryptionKeyCopySnackbar": "Encryption Key copied to clipboard", + "encryptionKeyCopyTooltip": "Copy Encryption Key to clipboard", + "encryptionKeyGenerate": "Generate Encryption Key" }, "formProfile": { "message": "The CHEFS team is collecting and organizing information to serve as crucial input for crafting comprehensive business cases. These cases will play a pivotal role in guiding the strategic operation and ongoing improvement of CHEFS in the coming years. This initiative to gather data is essential for informing critical decisions and molding the trajectory of CHEFS, ensuring its adaptability and effectiveness in addressing evolving needs and challenges.", diff --git a/app/frontend/src/services/encryptionKeyService.js b/app/frontend/src/services/encryptionKeyService.js index 5b56d7a39..9becc8b7b 100644 --- a/app/frontend/src/services/encryptionKeyService.js +++ b/app/frontend/src/services/encryptionKeyService.js @@ -5,12 +5,11 @@ export default { /** * @function listEncryptionAlgorithms * Get the Encryption Key Algortithms supported - * @param {string} formId The form uuid * @returns {Promise} An axios response */ - listEncryptionAlgorithms(formId, params = {}) { + listEncryptionAlgorithms(params = {}) { return appAxios().get( - `${ApiRoutes.FORMS}/${formId}${ApiRoutes.ENCRYPTION_KEY}/algorithms`, + `${ApiRoutes.FORMS}/${ApiRoutes.ENCRYPTION_KEY}/algorithms`, { params } ); }, diff --git a/app/frontend/src/store/form.js b/app/frontend/src/store/form.js index 27ee6ae43..75187a424 100644 --- a/app/frontend/src/store/form.js +++ b/app/frontend/src/store/form.js @@ -7,9 +7,11 @@ import { rbacService, userService, } from '~/services'; +import { useAppStore } from '~/store/app'; import { useNotificationStore } from '~/store/notification'; import { IdentityMode, NotificationTypes } from '~/utils/constants'; import { generateIdps, parseIdps } from '~/utils/transformUtils'; +import { encryptionKeyService, eventStreamConfigService } from '../services'; const genInitialSchedule = () => ({ enabled: null, @@ -58,7 +60,20 @@ const genInitialSubscribeDetails = () => ({ endpointToken: null, key: '', }); - +const genInitialEncryptionKey = () => ({ + id: null, + name: null, + algorithm: null, + key: null, +}); +const genInitialEventStreamConfig = () => ({ + id: null, + formId: null, + enablePublicStream: false, + enablePrivateStream: false, + encryptionKeyId: null, + encryptionKey: genInitialEncryptionKey(), +}); const genInitialForm = () => ({ description: '', enableSubmitterDraft: false, @@ -84,6 +99,7 @@ const genInitialForm = () => ({ apiIntegration: null, useCase: null, wideFormLayout: false, + eventStreamConfig: genInitialEventStreamConfig(), }); export const useFormStore = defineStore('form', { @@ -310,6 +326,31 @@ export const useFormStore = defineStore('form', { }); } }, + async fetchEventStreamConfig(formId) { + const appStore = useAppStore(); + // see if this is an active feature... + if (appStore.config?.features?.eventStreamService) { + // populate the event service config object... + let resp = await eventStreamConfigService.getEventStreamConfig(formId); + const evntSrvCfg = resp.data; + let encKey = genInitialEncryptionKey(); + if (evntSrvCfg.encryptionKeyId) { + resp = await encryptionKeyService.getEncryptionKey( + formId, + evntSrvCfg.encryptionKeyId + ); + encKey = resp.data; + } + return { + ...evntSrvCfg, + encryptionKey: { + ...encKey, + }, + }; + } else { + return genInitialEventStreamConfig(); + } + }, async fetchForm(formId) { try { this.apiKey = null; @@ -326,7 +367,8 @@ export const useFormStore = defineStore('form', { ...genInitialSubscribe(), ...data.subscribe, }; - + const evntSrvCfg = await this.fetchEventStreamConfig(formId); + data.eventStreamConfig = evntSrvCfg; this.form = data; } catch (error) { const notificationStore = useNotificationStore(); @@ -415,7 +457,7 @@ export const useFormStore = defineStore('form', { const subscribe = this.form.subscribe.enabled ? this.form.subscribe : {}; - + const eventStreamConfig = this.form.eventStreamConfig; await formService.updateForm(this.form.id, { name: this.form.name, description: this.form.description, @@ -443,6 +485,7 @@ export const useFormStore = defineStore('form', { enableCopyExistingSubmission: this.form.enableCopyExistingSubmission ? this.form.enableCopyExistingSubmission : false, + eventStreamConfig: eventStreamConfig, }); // update user labels with any new added labels diff --git a/app/src/components/eventStreamService.js b/app/src/components/eventStreamService.js index f44406c6d..77fe9f1f3 100644 --- a/app/src/components/eventStreamService.js +++ b/app/src/components/eventStreamService.js @@ -2,9 +2,10 @@ const config = require('config'); const nats = require('nats'); const log = require('./log')(module.filename); -const { FormVersion, Form } = require('../forms/common/models'); +const { FormVersion, Form, FormEventStreamConfig } = require('../forms/common/models'); const { featureFlags } = require('./featureFlags'); +const { encryptionService } = require('./encryptionService'); const SERVICE = 'EventStreamService'; @@ -46,13 +47,14 @@ class DummyEventStreamService { } class EventStreamService { - constructor({ servers, streamName, domain, username, password }) { - if (!servers || !streamName || !domain || !username || !password) { + constructor({ servers, streamName, source, domain, username, password }) { + if (!servers || !streamName || !source || !domain || !username || !password) { throw new Error('EventStreamService is not configured. Check configuration.'); } this.servers = servers; this.streamName = streamName; + this.source = source; this.domain = domain; this.username = username; this.password = password; @@ -169,34 +171,42 @@ class EventStreamService { form['versions'] = [formVersion]; // need to fetch the encryption key... + const evntStrmCfg = await FormEventStreamConfig.query().modify('filterFormId', formId).allowGraph('[encryptionKey]').withGraphFetched('encryptionKey').first(); - const sub = `schema.${eventType}.${formId}`; - const publicSubj = `${this.publicSubject}.${sub}`; - const privateSubj = `${this.privateSubject}.${sub}`; - const meta = { - source: 'chefs', - domain: 'forms', - class: 'schema', - type: eventType, - formId: formId, - formVersionId: formVersionId, - }; - const privMsg = { - meta: meta, - payload: { - data: form, // we will encrypt for private - }, - }; - const pubMsg = { - meta: meta, - payload: {}, - }; - - // this will need to change when/if we allow configuration for sending public and/or private (or none!) - await Promise.all([this.js.publish(privateSubj, JSON.stringify(privMsg)), this.js.publish(publicSubj, JSON.stringify(pubMsg))]).then((values) => { - log.info(`form ${eventType} event (private) - formId: ${formId}, version: ${formVersion.version}, seq: ${values[0].seq}`, { function: 'onPublish' }); - log.info(`form ${eventType} event (public) - formId: ${formId}, version: ${formVersion.version}, seq: ${values[1].seq}`, { function: 'onPublish' }); - }); + if (evntStrmCfg) { + const sub = `schema.${eventType}.${formId}`; + const publicSubj = `${this.publicSubject}.${sub}`; + const privateSubj = `${this.privateSubject}.${sub}`; + const meta = { + source: this.source, + domain: this.domain, + class: 'schema', + type: eventType, + formId: formId, + formVersionId: formVersionId, + }; + if (evntStrmCfg.enablePrivateStream) { + const encPayload = encryptionService.encryptExternal(evntStrmCfg.encryptionKey.algorithm, evntStrmCfg.encryptionKey.key, form); + const privMsg = { + meta: meta, + payload: { + data: encPayload, + }, + }; + const ack = await this.js.publish(privateSubj, JSON.stringify(privMsg)); + log.info(`form ${eventType} event (private) - formId: ${formId}, version: ${formVersion.version}, seq: ${ack.seq}`, { function: 'onPublish' }); + } + if (evntStrmCfg.enablePublicStream) { + const pubMsg = { + meta: meta, + payload: {}, + }; + const ack = await this.js.publish(publicSubj, JSON.stringify(pubMsg)); + log.info(`form ${eventType} event (public) - formId: ${formId}, version: ${formVersion.version}, seq: ${ack.seq}`, { function: 'onPublish' }); + } + } else { + log.info(`formId '${formId}' has no event stream configuration; will not publish events.`); + } } else { // warn, error??? log.warn(`${SERVICE} is not connected. Cannot publish (form) event. [event: form.'${eventType}', formId: ${formId}, versionId: ${formVersionId}]`, { @@ -216,46 +226,49 @@ class EventStreamService { const formVersion = await FormVersion.query().findById(submission.formVersionId).throwIfNotFound(); // need to fetch the encryption key... + const evntStrmCfg = await FormEventStreamConfig.query().modify('filterFormId', formVersion.formId).allowGraph('[encryptionKey]').withGraphFetched('encryptionKey').first(); - const sub = `submission.${eventType}.${formVersion.formId}`; - const publicSubj = `${this.publicSubject}.${sub}`; - const privateSubj = `${this.privateSubject}.${sub}`; - const meta = { - source: 'chefs', - domain: 'forms', - class: 'submission', - type: eventType, - formId: formVersion.formId, - formVersionId: submission.formVersionId, - submissionId: submission.id, - draft: draft, - }; - const privMsg = { - meta: meta, - payload: { - data: submission, // we will encrypt for private - }, - }; - const pubMsg = { - meta: meta, - payload: {}, - }; + if (evntStrmCfg) { + const sub = `submission.${eventType}.${formVersion.formId}`; + const publicSubj = `${this.publicSubject}.${sub}`; + const privateSubj = `${this.privateSubject}.${sub}`; + const meta = { + source: this.source, + domain: this.domain, + class: 'submission', + type: eventType, + formId: formVersion.formId, + formVersionId: submission.formVersionId, + submissionId: submission.id, + draft: draft, + }; - // this will need to change when/if we allow configuration for sending public and/or private (or none!) - await Promise.all([this.js.publish(privateSubj, JSON.stringify(privMsg)), this.js.publish(publicSubj, JSON.stringify(pubMsg))]).then((values) => { - log.info( - `submission ${eventType} event (private) - formId: ${formVersion.formId}, version: ${formVersion.version}, submissionId: ${submission.id}, seq: ${values[0].seq}`, - { + if (evntStrmCfg.enablePrivateStream) { + const encPayload = encryptionService.encryptExternal(evntStrmCfg.encryptionKey.algorithm, evntStrmCfg.encryptionKey.key, submission); + const privMsg = { + meta: meta, + payload: { + data: encPayload, + }, + }; + const ack = await this.js.publish(privateSubj, JSON.stringify(privMsg)); + log.info(`submission ${eventType} event (private) - formId: ${formVersion.formId}, version: ${formVersion.version}, submissionId: ${submission.id}, seq: ${ack.seq}`, { function: 'onSubmit', - } - ); - log.info( - `submission ${eventType} event (public) - formId: ${formVersion.formId}, version: ${formVersion.version}, submissionId: ${submission.id}, seq: ${values[1].seq}`, - { + }); + } + if (evntStrmCfg.enablePublicStream) { + const pubMsg = { + meta: meta, + payload: {}, + }; + const ack = await this.js.publish(publicSubj, JSON.stringify(pubMsg)); + log.info(`submission ${eventType} event (public) - formId: ${formVersion.formId}, version: ${formVersion.version}, submissionId: ${submission.id}, seq: ${ack.seq}`, { function: 'onSubmit', - } - ); - }); + }); + } + } else { + log.info(`formId '${formVersion.formId}' has no event stream configuration; will not publish events.`); + } } else { // warn, error??? log.warn(`${SERVICE} is not connected. Cannot publish (submission) event. [submission.event: '${eventType}', submissionId: ${submissionId}]`, { diff --git a/app/src/components/featureFlags.js b/app/src/components/featureFlags.js index c8ebdab1e..12902b091 100644 --- a/app/src/components/featureFlags.js +++ b/app/src/components/featureFlags.js @@ -7,9 +7,7 @@ const FEATURES = { }; class FeatureFlags { - constructor() { - this._eventStreamService = this.enabled(FEATURES.EVENT_STREAM_SERVICE); - } + constructor() {} // generic flag check enabled(feature) { @@ -24,36 +22,43 @@ class FeatureFlags { // just add direct access helper functions get eventStreamService() { - return this._eventStreamService; + return this.enabled(FEATURES.EVENT_STREAM_SERVICE); } - // middleware, so we can short-circuit any api calls - async featureEnabled(_req, _res, next, feature) { - try { - const flag = this.enabled(feature); - if (flag) { - next(); // all good, feature enabled... - } else { - throw new Problem(400, { - detail: `Feature '${feature}' is not enabled.`, - }); + featureEnabled(feature) { + // actual middleware + return async (req, res, next) => { + try { + const flag = this.enabled(feature); + if (flag) { + next(); // all good, feature enabled... + } else { + throw new Problem(400, { + detail: `Feature '${feature}' is not enabled.`, + }); + } + } catch (error) { + next(error); } - } catch (error) { - next(error); - } + }; } - async eventStreamServiceEnabled(_req, _res, next) { - try { - if (this._eventStreamService) { - next(); // all good, feature enabled... - } else { - throw new Problem(400, { - detail: `Feature '${FEATURES.EVENT_STREAM_SERVICE}' is not enabled.`, - }); + + eventStreamServiceEnabled() { + // actual middleware + return async (req, res, next) => { + try { + const flag = this.enabled(FEATURES.EVENT_STREAM_SERVICE); + if (flag) { + next(); // all good, feature enabled... + } else { + throw new Problem(400, { + detail: `Feature '${FEATURES.EVENT_STREAM_SERVICE}' is not enabled.`, + }); + } + } catch (error) { + next(error); } - } catch (error) { - next(error); - } + }; } } diff --git a/app/src/forms/common/middleware/validateParameter.js b/app/src/forms/common/middleware/validateParameter.js index 39a2b36f8..dbbcc45b0 100644 --- a/app/src/forms/common/middleware/validateParameter.js +++ b/app/src/forms/common/middleware/validateParameter.js @@ -188,8 +188,8 @@ const validateFormEncryptionKeyId = async (req, _res, next, formEncryptionKeyId) try { _validateUuid(formEncryptionKeyId, 'formEncryptionKeyId'); - const rec = await encryptionKeyService.readEncryptionKey(formEncryptionKeyId); - if (!rec || rec.formId !== req.params.formId) { + const rec = await encryptionKeyService.readEncryptionKey(req.params.formId, formEncryptionKeyId); + if (!rec) { throw new Problem(404, { detail: 'formEncryptionKeyId does not exist on this form', }); diff --git a/app/src/forms/common/models/tables/formEventStreamConfig.js b/app/src/forms/common/models/tables/formEventStreamConfig.js index ad7b51e39..ee5c84cd9 100644 --- a/app/src/forms/common/models/tables/formEventStreamConfig.js +++ b/app/src/forms/common/models/tables/formEventStreamConfig.js @@ -47,7 +47,7 @@ class FormEventStreamConfig extends Timestamps(Model) { formId: { type: 'string', pattern: Regex.UUID }, enablePublicStream: { type: 'boolean', default: false }, enablePrivateStream: { type: 'boolean', default: false }, - encryptionKeyId: { type: 'string', pattern: Regex.UUID }, + encryptionKeyId: { type: ['string', 'null'], pattern: Regex.UUID }, ...stamps, }, additionalProperties: false, diff --git a/app/src/forms/form/encryptionKey/controller.js b/app/src/forms/form/encryptionKey/controller.js index 73788ac67..1ac7e42d3 100644 --- a/app/src/forms/form/encryptionKey/controller.js +++ b/app/src/forms/form/encryptionKey/controller.js @@ -27,7 +27,7 @@ module.exports = { }, readEncryptionKey: async (req, res, next) => { try { - const response = await service.readEncryptionKey(req.params.formId, req.params.formEncryptionKeyId, req.body, req.currentUser); + const response = await service.readEncryptionKey(req.params.formId, req.params.formEncryptionKeyId); res.status(200).json(response); } catch (error) { next(error); diff --git a/app/src/forms/form/encryptionKey/routes.js b/app/src/forms/form/encryptionKey/routes.js index db4f1f4be..b29adf267 100644 --- a/app/src/forms/form/encryptionKey/routes.js +++ b/app/src/forms/form/encryptionKey/routes.js @@ -1,18 +1,18 @@ const routes = require('express').Router(); const { currentUser, hasFormPermissions } = require('../../auth/middleware/userAccess'); const validateParameter = require('../../common/middleware/validateParameter'); -const featureFlags = require('../../../components/featureFlags'); +const { featureFlags } = require('../../../components/featureFlags'); const P = require('../../common/constants').Permissions; const controller = require('./controller'); -routes.use(featureFlags.eventStreamServiceEnabled); +routes.use(featureFlags.eventStreamServiceEnabled()); routes.use(currentUser); routes.param('formId', validateParameter.validateFormId); routes.param('formEncryptionKeyId', validateParameter.validateFormEncryptionKeyId); -routes.get('/:formId/encryptionKey/algorithms', hasFormPermissions([P.FORM_READ, P.FORM_UPDATE]), async (req, res, next) => { +routes.get('/encryptionKey/algorithms', async (req, res, next) => { await controller.listEncryptionAlgorithms(req, res, next); }); diff --git a/app/src/forms/form/encryptionKey/service.js b/app/src/forms/form/encryptionKey/service.js index 3692c9dc6..f72719aa9 100644 --- a/app/src/forms/form/encryptionKey/service.js +++ b/app/src/forms/form/encryptionKey/service.js @@ -6,8 +6,10 @@ const { FormEncryptionKey } = require('../../common/models'); const { ENCRYPTION_ALGORITHMS } = require('../../../components/encryptionService'); +const PRIVATE_EVENT_STREAM_NAME = 'private-event-stream'; + const service = { - listEncryptionAlgorithms: () => { + listEncryptionAlgorithms: async () => { return Object.values(ENCRYPTION_ALGORITHMS).map((x) => ({ code: x, display: x, @@ -24,7 +26,71 @@ const service = { return FormEncryptionKey.query().modify('filterFormId', formId); }, - createExternalAPI: async (formId, data, currentUser) => { + upsertForEventStreamConfig: async (formId, data, currentUser, transaction) => { + // special case for forms. they have only one event stream configuration + // that requires an encryption key if it has private streams. + // NOTE: event stream config will remove this key if it is unneeded! + const externalTrx = transaction != undefined; + let trx; + let id; + try { + trx = externalTrx ? transaction : await FormEncryptionKey.startTransaction(); + const existing = await FormEncryptionKey.query(trx).modify('findByFormIdAndName', formId, PRIVATE_EVENT_STREAM_NAME).first(); + + if (existing) { + id = existing.id; + // do we need to update? + if (existing.algorithm != data.algorithm || existing.key != data.key) { + // yes... update. + await FormEncryptionKey.query(trx) + .findById(existing.id) + .update({ + ...data, + updatedBy: currentUser.usernameIdp, + }); + } + } else { + // add a new configuration. + id = uuidv4(); + data.id = id; + data.formId = formId; + data.name = PRIVATE_EVENT_STREAM_NAME; + await FormEncryptionKey.query(trx).insert({ + ...data, + createdBy: currentUser.usernameIdp, + updatedBy: currentUser.usernameIdp, + }); + } + + if (!externalTrx) trx.commit(); + if (id) { + return FormEncryptionKey.query(trx).findById(id); + } + } catch (err) { + if (!externalTrx && trx) await trx.rollback(); + throw err; + } + }, + + remove: async (id, transaction) => { + const externalTrx = transaction != undefined; + let trx; + try { + trx = externalTrx ? transaction : await FormEncryptionKey.startTransaction(); + const existing = await FormEncryptionKey.query(trx).findById(id); + + if (existing) { + await FormEncryptionKey.query(trx).deleteById(id); + } + + if (!externalTrx) trx.commit(); + } catch (err) { + if (!externalTrx && trx) await trx.rollback(); + throw err; + } + }, + + createEncryptionKey: async (formId, data, currentUser) => { service.validateEncryptionKey(data); data.id = uuidv4(); @@ -36,7 +102,7 @@ const service = { return FormEncryptionKey.query().findById(data.id); }, - updateExternalAPI: async (formId, formEncryptionKeyId, data, currentUser) => { + updateEncryptionKey: async (formId, formEncryptionKeyId, data, currentUser) => { service.validateEncryptionKey(data); const existing = await FormEncryptionKey.query().modify('findByIdAndFormId', formEncryptionKeyId, formId).first().throwIfNotFound(); @@ -52,13 +118,17 @@ const service = { return FormEncryptionKey.query().findById(formEncryptionKeyId); }, - deleteExternalAPI: async (formId, formEncryptionKeyId) => { + deleteEncryptionKey: async (formId, formEncryptionKeyId) => { await FormEncryptionKey.query().modify('findByIdAndFormId', formEncryptionKeyId, formId).first().throwIfNotFound(); await FormEncryptionKey.query().deleteById(formEncryptionKeyId); }, - readEncryptionKey: (formEncryptionKeyId) => { - return FormEncryptionKey.findById(formEncryptionKeyId); + readEncryptionKey: async (formId, formEncryptionKeyId) => { + return FormEncryptionKey.query().modify('findByIdAndFormId', formEncryptionKeyId, formId).first(); + }, + + fetchEncryptionKey: async (formId, name) => { + return await FormEncryptionKey.query().modify('findByFormIdAndName', formId, name).first(); }, }; diff --git a/app/src/forms/form/eventStreamConfig/routes.js b/app/src/forms/form/eventStreamConfig/routes.js index f46fd6e73..836790583 100644 --- a/app/src/forms/form/eventStreamConfig/routes.js +++ b/app/src/forms/form/eventStreamConfig/routes.js @@ -1,12 +1,12 @@ const routes = require('express').Router(); const { currentUser, hasFormPermissions } = require('../../auth/middleware/userAccess'); const validateParameter = require('../../common/middleware/validateParameter'); -const featureFlags = require('../../../components/featureFlags'); +const { featureFlags } = require('../../../components/featureFlags'); const P = require('../../common/constants').Permissions; const controller = require('./controller'); -routes.use(featureFlags.eventStreamServiceEnabled); +routes.use(featureFlags.eventStreamServiceEnabled()); routes.use(currentUser); routes.param('formId', validateParameter.validateFormId); diff --git a/app/src/forms/form/eventStreamConfig/service.js b/app/src/forms/form/eventStreamConfig/service.js index 798e946b5..6e0d4d1df 100644 --- a/app/src/forms/form/eventStreamConfig/service.js +++ b/app/src/forms/form/eventStreamConfig/service.js @@ -4,6 +4,8 @@ const { v4: uuidv4 } = require('uuid'); const { FormEventStreamConfig } = require('../../common/models'); +const encryptionKeyService = require('../encryptionKey/service'); + const service = { validateEventStreamConfig: (data) => { if (!data) { @@ -11,6 +13,53 @@ const service = { } }, + upsert: async (formId, data, currentUser, transaction) => { + service.validateEventStreamConfig(data); + const externalTrx = transaction != undefined; + let trx; + try { + trx = externalTrx ? transaction : await FormEventStreamConfig.startTransaction(); + const existing = await FormEventStreamConfig.query(trx).modify('filterFormId', formId).first(); //only 1... + + // let's deal with encryption key + const encKey = await encryptionKeyService.upsertForEventStreamConfig(formId, data.encryptionKey, currentUser, transaction); + data.encryptionKeyId = encKey && data.enablePrivateStream ? encKey.id : null; + + if (existing) { + // do we need to update? + if ( + existing.enablePrivateStream != data.enablePrivateStream || + existing.enablePublicStream != data.enablePublicStream || + existing.encryptionKeyId != data.encryptionKeyId + ) { + // yes... update. + await FormEventStreamConfig.query(trx) + .findById(existing.id) + .update({ + ...data, + updatedBy: currentUser.usernameIdp, + }); + } + } else { + // add a new configuration. + data.id = uuidv4(); + data.formId = formId; + await FormEventStreamConfig.query(trx).insert({ + ...data, + createdBy: currentUser.usernameIdp, + }); + } + // finally, if we do not have private stream AND we have an encryption key, delete it... + if (!data.enablePrivateStream && encKey) { + await encryptionKeyService.remove(encKey.id, trx); + } + if (!externalTrx) trx.commit(); + } catch (err) { + if (!externalTrx && trx) await trx.rollback(); + throw err; + } + }, + createEventStreamConfig: async (formId, data, currentUser) => { service.validateEventStreamConfig(data); @@ -48,8 +97,15 @@ const service = { await FormEventStreamConfig.query().deleteById(existing.id); }, - readEventStreamConfig: (formId) => { - return FormEventStreamConfig.query().modify('findByFormId', formId).first().throwIfNotFound(); + readEventStreamConfig: async (formId) => { + let result = await FormEventStreamConfig.query().modify('filterFormId', formId).first(); // there should be only one + if (!result) { + // let's create a default + const rec = new FormEventStreamConfig(); + rec.formId = formId; + return await service.createEventStreamConfig(formId, rec, { usernameIdp: 'systemdefault' }); + } + return result; }, }; diff --git a/app/src/forms/form/index.js b/app/src/forms/form/index.js index 06c842dbe..e18f1c620 100644 --- a/app/src/forms/form/index.js +++ b/app/src/forms/form/index.js @@ -1,9 +1,11 @@ const routes = require('./routes'); const setupMount = require('../common/utils').setupMount; +const encryptionKeyRoutes = require('./encryptionKey/routes'); +const eventStreamConfigRoutes = require('./eventStreamConfig/routes'); const externalApiRoutes = require('./externalApi/routes'); module.exports.mount = (app) => { - const p = setupMount('forms', app, [routes, externalApiRoutes]); + const p = setupMount('forms', app, [routes, encryptionKeyRoutes, eventStreamConfigRoutes, externalApiRoutes]); return p; }; diff --git a/app/src/forms/form/service.js b/app/src/forms/form/service.js index 0ac1da561..c3f23f831 100644 --- a/app/src/forms/form/service.js +++ b/app/src/forms/form/service.js @@ -26,6 +26,7 @@ const { const { falsey, queryUtils, checkIsFormExpired, validateScheduleObject, typeUtils } = require('../common/utils'); const { Permissions, Roles, Statuses } = require('../common/constants'); const { eventStreamService, SUBMISSION_EVENT_TYPES } = require('../../components/eventStreamService'); +const eventStreamConfigService = require('./eventStreamConfig/service'); const Rolenames = [Roles.OWNER, Roles.TEAM_MANAGER, Roles.FORM_DESIGNER, Roles.SUBMISSION_REVIEWER, Roles.FORM_SUBMITTER, Roles.SUBMISSION_APPROVER]; const service = { @@ -134,6 +135,8 @@ const service = { })); await FormStatusCode.query(trx).insert(defaultStatuses); + await eventStreamConfigService.upsert(obj.id, data.eventStreamConfig, currentUser, trx); + await trx.commit(); const result = await service.readForm(obj.id); result.draft = draft; @@ -190,6 +193,8 @@ const service = { })); if (fIdps && fIdps.length) await FormIdentityProvider.query(trx).insert(fIdps); + await eventStreamConfigService.upsert(obj.id, data.eventStreamConfig, currentUser, trx); + await trx.commit(); return await service.readForm(obj.id); } catch (err) { From dda2634cb5aa63470381893d387200ca19949bba Mon Sep 17 00:00:00 2001 From: Jason Sherman Date: Thu, 15 Aug 2024 11:48:01 -0700 Subject: [PATCH 04/25] Add appStore to all tests that use formStore (there is now a dependency to check for features). Signed-off-by: Jason Sherman --- app/frontend/tests/unit/components/base/BaseDialog.spec.js | 3 +++ app/frontend/tests/unit/components/base/BaseFilter.spec.js | 3 +++ .../unit/components/base/BaseInternationalization.spec.js | 3 +++ .../tests/unit/components/designer/FormViewer.spec.js | 3 +++ .../components/designer/profile/FormAPIProfile.spec.js | 3 +++ .../designer/profile/FormDeploymentProfile.spec.js | 3 +++ .../components/designer/profile/FormLabelProfile.spec.js | 3 +++ .../components/designer/profile/FormUseCaseProfile.spec.js | 4 ++++ .../designer/settings/FormAccessSettings.spec.js | 3 +++ .../designer/settings/FormFunctionalitySettings.spec.js | 3 +++ .../designer/settings/FormScheduleSettings.spec.js | 3 +++ .../designer/settings/FormSubmissionSettings.spec.js | 3 +++ .../tests/unit/components/forms/ExportSubmissions.spec.js | 3 +++ .../tests/unit/components/forms/SubmissionsTable.spec.js | 3 +++ .../tests/unit/components/forms/manage/ApiKey.spec.js | 3 +++ .../unit/components/forms/manage/DocumentTemplate.spec.js | 5 ++++- .../unit/components/forms/manage/EmailTemplate.spec.js | 4 +++- .../tests/unit/components/forms/manage/ManageForm.spec.js | 3 +++ .../unit/components/forms/manage/ManageFormActions.spec.js | 3 +++ .../unit/components/forms/manage/ManageLayout.spec.js | 3 +++ .../unit/components/forms/manage/ManageVersions.spec.js | 3 +++ .../tests/unit/components/forms/manage/ShareForm.spec.js | 3 +++ .../unit/components/forms/manage/Subscription.spec.js | 3 +++ .../unit/components/forms/manage/TeamManagement.spec.js | 4 ++++ .../forms/submission/ManageSubmissionUsers.spec.js | 3 +++ .../forms/submission/MySubmissionsActions.spec.js | 4 ++++ .../components/forms/submission/MySubmissionsTable.spec.js | 3 +++ .../unit/components/forms/submission/NotesPanel.spec.js | 3 +++ .../unit/components/forms/submission/StatusPanel.spec.js | 3 +++ .../unit/components/forms/submission/StatusTable.spec.js | 3 +++ .../forms/submission/UserDuplicateSubmission.spec.js | 3 +++ .../components/forms/submission/UserSubmission.spec.js | 3 +++ .../infolinks/ProactiveHelpPreviewDialog.spec.js | 3 +++ app/frontend/tests/unit/store/modules/auth.actions.spec.js | 1 + app/frontend/tests/unit/store/modules/form.actions.spec.js | 3 +++ app/frontend/tests/unit/utils/constants.spec.js | 2 ++ app/frontend/tests/unit/views/file/Download.spec.js | 3 +++ app/frontend/tests/unit/views/form/Create.spec.js | 3 +++ app/frontend/tests/unit/views/form/Design.spec.js | 7 +++---- 39 files changed, 118 insertions(+), 6 deletions(-) diff --git a/app/frontend/tests/unit/components/base/BaseDialog.spec.js b/app/frontend/tests/unit/components/base/BaseDialog.spec.js index fe51b6699..d4f282486 100644 --- a/app/frontend/tests/unit/components/base/BaseDialog.spec.js +++ b/app/frontend/tests/unit/components/base/BaseDialog.spec.js @@ -6,14 +6,17 @@ import { createTestingPinia } from '@pinia/testing'; import BaseDialog from '~/components/base/BaseDialog.vue'; import { useFormStore } from '~/store/form'; +import { useAppStore } from '~/store/app'; describe('BaseDialog.vue', () => { const pinia = createTestingPinia(); setActivePinia(pinia); const formStore = useFormStore(); + const appStore = useAppStore(pinia); beforeEach(() => { formStore.$reset(); + appStore.$reset(); }); it('renders with ok button', async () => { diff --git a/app/frontend/tests/unit/components/base/BaseFilter.spec.js b/app/frontend/tests/unit/components/base/BaseFilter.spec.js index 00dc15d34..dc683cf05 100644 --- a/app/frontend/tests/unit/components/base/BaseFilter.spec.js +++ b/app/frontend/tests/unit/components/base/BaseFilter.spec.js @@ -3,13 +3,16 @@ import { describe, it } from 'vitest'; import { createTestingPinia } from '@pinia/testing'; import BaseFilter from '~/components/base/BaseFilter.vue'; import { useFormStore } from '~/store/form'; +import { useAppStore } from '~/store/app'; describe('BaseFilter.vue', () => { const pinia = createTestingPinia(); const formStore = useFormStore(); + const appStore = useAppStore(pinia); beforeEach(() => { formStore.$reset(); + appStore.$reset(); }); it('renders', async () => { diff --git a/app/frontend/tests/unit/components/base/BaseInternationalization.spec.js b/app/frontend/tests/unit/components/base/BaseInternationalization.spec.js index 378fe02fd..2ace70ff4 100644 --- a/app/frontend/tests/unit/components/base/BaseInternationalization.spec.js +++ b/app/frontend/tests/unit/components/base/BaseInternationalization.spec.js @@ -4,14 +4,17 @@ import { beforeEach, describe, it } from 'vitest'; import BaseInternationalization from '~/components/base/BaseInternationalization.vue'; import { useFormStore } from '~/store/form'; +import { useAppStore } from '~/store/app'; describe('BaseInternationalization.vue', () => { const pinia = createPinia(); setActivePinia(pinia); const formStore = useFormStore(pinia); + const appStore = useAppStore(pinia); beforeEach(() => { formStore.$reset(); + appStore.$reset(); formStore.isRTL = false; }); diff --git a/app/frontend/tests/unit/components/designer/FormViewer.spec.js b/app/frontend/tests/unit/components/designer/FormViewer.spec.js index 77c8f0090..277f8e82c 100644 --- a/app/frontend/tests/unit/components/designer/FormViewer.spec.js +++ b/app/frontend/tests/unit/components/designer/FormViewer.spec.js @@ -8,6 +8,7 @@ import getRouter from '~/router'; import FormViewer from '~/components/designer/FormViewer.vue'; import { useAuthStore } from '~/store/auth'; import { useFormStore } from '~/store/form'; +import { useAppStore } from '~/store/app'; describe('FormViewer.vue', () => { const formId = '123-456'; @@ -22,10 +23,12 @@ describe('FormViewer.vue', () => { setActivePinia(pinia); const authStore = useAuthStore(pinia); const formStore = useFormStore(pinia); + const appStore = useAppStore(pinia); beforeEach(() => { authStore.$reset(); formStore.$reset(); + appStore.$reset(); authStore.keycloak = { tokenParsed: { diff --git a/app/frontend/tests/unit/components/designer/profile/FormAPIProfile.spec.js b/app/frontend/tests/unit/components/designer/profile/FormAPIProfile.spec.js index bcbb6c94b..31d0ac25d 100644 --- a/app/frontend/tests/unit/components/designer/profile/FormAPIProfile.spec.js +++ b/app/frontend/tests/unit/components/designer/profile/FormAPIProfile.spec.js @@ -4,14 +4,17 @@ import { createTestingPinia } from '@pinia/testing'; import { setActivePinia } from 'pinia'; import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; +import { useAppStore } from '~/store/app'; describe('FormAPIProfile.vue', () => { const pinia = createTestingPinia(); setActivePinia(pinia); const formStore = useFormStore(pinia); + const appStore = useAppStore(pinia); beforeEach(() => { formStore.$reset(); + appStore.$reset(); }); it('renders properly', () => { diff --git a/app/frontend/tests/unit/components/designer/profile/FormDeploymentProfile.spec.js b/app/frontend/tests/unit/components/designer/profile/FormDeploymentProfile.spec.js index 4aed65007..7aa8a1992 100644 --- a/app/frontend/tests/unit/components/designer/profile/FormDeploymentProfile.spec.js +++ b/app/frontend/tests/unit/components/designer/profile/FormDeploymentProfile.spec.js @@ -5,14 +5,17 @@ import { setActivePinia } from 'pinia'; import { mount } from '@vue/test-utils'; import { FormProfileValues } from '~/utils/constants'; import { nextTick } from 'vue'; +import { useAppStore } from '~/store/app'; describe('FormDeploymentProfile.vue', () => { const pinia = createTestingPinia(); setActivePinia(pinia); const formStore = useFormStore(pinia); + const appStore = useAppStore(pinia); beforeEach(() => { formStore.$reset(); + appStore.$reset(); }); it('renders properly', () => { diff --git a/app/frontend/tests/unit/components/designer/profile/FormLabelProfile.spec.js b/app/frontend/tests/unit/components/designer/profile/FormLabelProfile.spec.js index d145f1888..1a014a118 100644 --- a/app/frontend/tests/unit/components/designer/profile/FormLabelProfile.spec.js +++ b/app/frontend/tests/unit/components/designer/profile/FormLabelProfile.spec.js @@ -7,18 +7,21 @@ import FormLabelProfile from '~/components/designer/profile/FormLabelProfile.vue import { userService } from '~/services'; import { useFormStore } from '~/store/form'; import { useNotificationStore } from '~/store/notification'; +import { useAppStore } from '~/store/app'; describe('FormLabelProfile.vue', () => { const pinia = createTestingPinia(); setActivePinia(pinia); const formStore = useFormStore(pinia); const notificationStore = useNotificationStore(pinia); + const appStore = useAppStore(pinia); const getUserLabelsSpy = vi.spyOn(userService, 'getUserLabels'); const addNotificationSpy = vi.spyOn(notificationStore, 'addNotification'); beforeEach(() => { formStore.$reset(); + appStore.$reset(); notificationStore.$reset(); getUserLabelsSpy.mockReset(); }); diff --git a/app/frontend/tests/unit/components/designer/profile/FormUseCaseProfile.spec.js b/app/frontend/tests/unit/components/designer/profile/FormUseCaseProfile.spec.js index 7faa37120..d2f44d546 100644 --- a/app/frontend/tests/unit/components/designer/profile/FormUseCaseProfile.spec.js +++ b/app/frontend/tests/unit/components/designer/profile/FormUseCaseProfile.spec.js @@ -5,14 +5,17 @@ import { FormProfileValues } from '~/utils/constants'; import { createTestingPinia } from '@pinia/testing'; import { setActivePinia } from 'pinia'; import { mount } from '@vue/test-utils'; +import { useAppStore } from '~/store/app'; describe('FormUseCaseProfile.vue', () => { const pinia = createTestingPinia(); setActivePinia(pinia); const formStore = useFormStore(pinia); + const appStore = useAppStore(pinia); beforeEach(() => { formStore.$reset(); + appStore.$reset(); }); it('renders properly', () => { @@ -36,6 +39,7 @@ describe('FormUseCaseProfile.vue', () => { const select = wrapper.findComponent('[data-test="case-select"]'); const items = select.componentVM.items; + // eslint-disable-next-line no-console console.log(items); expect(items).toEqual(FormProfileValues.USE_CASE); }); diff --git a/app/frontend/tests/unit/components/designer/settings/FormAccessSettings.spec.js b/app/frontend/tests/unit/components/designer/settings/FormAccessSettings.spec.js index 98590f307..82f8b5bf3 100644 --- a/app/frontend/tests/unit/components/designer/settings/FormAccessSettings.spec.js +++ b/app/frontend/tests/unit/components/designer/settings/FormAccessSettings.spec.js @@ -7,15 +7,18 @@ import { nextTick, ref } from 'vue'; import { useFormStore } from '~/store/form'; import FormAccessSettings from '~/components/designer/settings/FormAccessSettings.vue'; import { IdentityMode } from '~/utils/constants'; +import { useAppStore } from '~/store/app'; describe('FormAccessSettings.vue', () => { const pinia = createTestingPinia(); setActivePinia(pinia); const formStore = useFormStore(pinia); + const appStore = useAppStore(pinia); beforeEach(() => { formStore.$reset(); + appStore.$reset(); }); it('renders and displays 3 radio buttons', () => { diff --git a/app/frontend/tests/unit/components/designer/settings/FormFunctionalitySettings.spec.js b/app/frontend/tests/unit/components/designer/settings/FormFunctionalitySettings.spec.js index 4981b3489..8ab0c6a25 100644 --- a/app/frontend/tests/unit/components/designer/settings/FormFunctionalitySettings.spec.js +++ b/app/frontend/tests/unit/components/designer/settings/FormFunctionalitySettings.spec.js @@ -9,6 +9,7 @@ import { useFormStore } from '~/store/form'; import { useIdpStore } from '~/store/identityProviders'; import FormFunctionalitySettings from '~/components/designer/settings/FormFunctionalitySettings.vue'; import { FormRoleCodes, AppPermissions } from '~/utils/constants'; +import { useAppStore } from '~/store/app'; const IDIR = { active: true, @@ -109,11 +110,13 @@ describe('FormFunctionalitySettings.vue', () => { const authStore = useAuthStore(pinia); const formStore = useFormStore(pinia); const idpStore = useIdpStore(pinia); + const appStore = useAppStore(pinia); beforeEach(() => { authStore.$reset(); formStore.$reset(); idpStore.$reset(); + appStore.$reset(); authStore.identityProvider = ref({ code: 'idir', diff --git a/app/frontend/tests/unit/components/designer/settings/FormScheduleSettings.spec.js b/app/frontend/tests/unit/components/designer/settings/FormScheduleSettings.spec.js index 2fd74f621..5c3ec9f1e 100644 --- a/app/frontend/tests/unit/components/designer/settings/FormScheduleSettings.spec.js +++ b/app/frontend/tests/unit/components/designer/settings/FormScheduleSettings.spec.js @@ -9,15 +9,18 @@ import { useFormStore } from '~/store/form'; import FormScheduleSettings from '~/components/designer/settings/FormScheduleSettings.vue'; import { ScheduleType } from '~/utils/constants'; import { getSubmissionPeriodDates } from '~/utils/transformUtils'; +import { useAppStore } from '~/store/app'; describe('FormScheduleSettings.vue', () => { const pinia = createTestingPinia(); setActivePinia(pinia); const formStore = useFormStore(pinia); + const appStore = useAppStore(pinia); beforeEach(() => { formStore.$reset(); + appStore.$reset(); formStore.form = ref({ schedule: { enabled: null, diff --git a/app/frontend/tests/unit/components/designer/settings/FormSubmissionSettings.spec.js b/app/frontend/tests/unit/components/designer/settings/FormSubmissionSettings.spec.js index 9d323d0eb..acd2ea015 100644 --- a/app/frontend/tests/unit/components/designer/settings/FormSubmissionSettings.spec.js +++ b/app/frontend/tests/unit/components/designer/settings/FormSubmissionSettings.spec.js @@ -6,15 +6,18 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { useFormStore } from '~/store/form'; import FormSubmissionSettings from '~/components/designer/settings/FormSubmissionSettings.vue'; import { nextTick } from 'vue'; +import { useAppStore } from '~/store/app'; describe('FormSubmissionSettings.vue', () => { const pinia = createTestingPinia(); setActivePinia(pinia); const formStore = useFormStore(pinia); + const appStore = useAppStore(pinia); beforeEach(() => { formStore.$reset(); + appStore.$reset(); }); it('renders and tests default values', () => { diff --git a/app/frontend/tests/unit/components/forms/ExportSubmissions.spec.js b/app/frontend/tests/unit/components/forms/ExportSubmissions.spec.js index 84b7a29e0..dd46e3a0a 100644 --- a/app/frontend/tests/unit/components/forms/ExportSubmissions.spec.js +++ b/app/frontend/tests/unit/components/forms/ExportSubmissions.spec.js @@ -8,6 +8,7 @@ import getRouter from '~/router'; import ExportSubmissions from '~/components/forms/ExportSubmissions.vue'; import { useAuthStore } from '~/store/auth'; import { useFormStore } from '~/store/form'; +import { useAppStore } from '~/store/app'; describe('ExportSubmissions.vue', () => { const formId = '123-456'; @@ -22,10 +23,12 @@ describe('ExportSubmissions.vue', () => { setActivePinia(pinia); const authStore = useAuthStore(pinia); const formStore = useFormStore(pinia); + const appStore = useAppStore(pinia); beforeEach(() => { authStore.$reset(); formStore.$reset(); + appStore.$reset(); }); it('renders', () => { diff --git a/app/frontend/tests/unit/components/forms/SubmissionsTable.spec.js b/app/frontend/tests/unit/components/forms/SubmissionsTable.spec.js index 490cfd583..b0e5c1e9d 100644 --- a/app/frontend/tests/unit/components/forms/SubmissionsTable.spec.js +++ b/app/frontend/tests/unit/components/forms/SubmissionsTable.spec.js @@ -8,6 +8,7 @@ import getRouter from '~/router'; import SubmissionsTable from '~/components/forms/SubmissionsTable.vue'; import { useAuthStore } from '~/store/auth'; import { useFormStore } from '~/store/form'; +import { useAppStore } from '~/store/app'; describe('SubmissionsTable.vue', () => { const formId = '123-456'; @@ -22,10 +23,12 @@ describe('SubmissionsTable.vue', () => { setActivePinia(pinia); const authStore = useAuthStore(pinia); const formStore = useFormStore(pinia); + const appStore = useAppStore(pinia); beforeEach(() => { authStore.$reset(); formStore.$reset(); + appStore.$reset(); }); it('renders', () => { diff --git a/app/frontend/tests/unit/components/forms/manage/ApiKey.spec.js b/app/frontend/tests/unit/components/forms/manage/ApiKey.spec.js index e58d6a1ca..7e4b35809 100644 --- a/app/frontend/tests/unit/components/forms/manage/ApiKey.spec.js +++ b/app/frontend/tests/unit/components/forms/manage/ApiKey.spec.js @@ -7,6 +7,7 @@ import { describe, beforeEach, vi } from 'vitest'; import ApiKey from '~/components/forms/manage/ApiKey.vue'; import { useFormStore } from '~/store/form'; import { FormPermissions } from '~/utils/constants'; +import { useAppStore } from '~/store/app'; const STUBS = { BaseCopyToClipboard: { @@ -32,9 +33,11 @@ describe('ApiKey.vue', () => { setActivePinia(pinia); const formStore = useFormStore(pinia); + const appStore = useAppStore(pinia); beforeEach(() => { formStore.$reset(); + appStore.$reset(); }); it('renders', () => { diff --git a/app/frontend/tests/unit/components/forms/manage/DocumentTemplate.spec.js b/app/frontend/tests/unit/components/forms/manage/DocumentTemplate.spec.js index e9514c10b..d4b8f8b8b 100644 --- a/app/frontend/tests/unit/components/forms/manage/DocumentTemplate.spec.js +++ b/app/frontend/tests/unit/components/forms/manage/DocumentTemplate.spec.js @@ -15,6 +15,7 @@ import { useFormStore } from '~/store/form'; import { useNotificationStore } from '~/store/notification'; import getRouter from '~/router'; import { ref } from 'vue'; +import { useAppStore } from '~/store/app'; const STUBS = { VDataTableServer: { @@ -33,7 +34,7 @@ const STUBS = { }; describe('DocumentTemplate.vue', () => { - let router, pinia, formStore, notificationStore; + let router, pinia, formStore, notificationStore, appStore; let createObjectURLSpy = vi.spyOn(window.URL, 'createObjectURL'); beforeEach(() => { @@ -47,9 +48,11 @@ describe('DocumentTemplate.vue', () => { formStore = useFormStore(pinia); notificationStore = useNotificationStore(pinia); + appStore = useAppStore(pinia); formStore.$reset(); notificationStore.$reset(); + appStore.$reset(); // Explicitly mock/spy on global functions createObjectURLSpy.mockImplementation(() => '#'); diff --git a/app/frontend/tests/unit/components/forms/manage/EmailTemplate.spec.js b/app/frontend/tests/unit/components/forms/manage/EmailTemplate.spec.js index a2df1ab67..7d225484b 100644 --- a/app/frontend/tests/unit/components/forms/manage/EmailTemplate.spec.js +++ b/app/frontend/tests/unit/components/forms/manage/EmailTemplate.spec.js @@ -7,6 +7,7 @@ import { ref } from 'vue'; import EmailTemplate from '~/components/forms/manage/EmailTemplate.vue'; import { useFormStore } from '~/store/form'; import { useNotificationStore } from '~/store/notification'; +import { useAppStore } from '~/store/app'; const STUBS = {}; @@ -16,10 +17,11 @@ describe('EmailTemplate.vue', () => { const formStore = useFormStore(pinia); const notificationStore = useNotificationStore(pinia); + const appStore = useAppStore(pinia); beforeEach(() => { formStore.$reset(); - + appStore.$reset(); formStore.emailTemplates = ref([ { body: 'Thank you for your {{ form.name }} submission. You can view your submission details by visiting the following links:', diff --git a/app/frontend/tests/unit/components/forms/manage/ManageForm.spec.js b/app/frontend/tests/unit/components/forms/manage/ManageForm.spec.js index ec93f2a50..d2701c888 100644 --- a/app/frontend/tests/unit/components/forms/manage/ManageForm.spec.js +++ b/app/frontend/tests/unit/components/forms/manage/ManageForm.spec.js @@ -10,6 +10,7 @@ import getRouter from '~/router'; import { useFormStore } from '~/store/form'; import { useNotificationStore } from '~/store/notification'; import { FormPermissions } from '~/utils/constants'; +import { useAppStore } from '~/store/app'; const STUBS = { VDataTable: { @@ -42,12 +43,14 @@ describe('ManageForm.vue', () => { setActivePinia(pinia); const formStore = useFormStore(pinia); const notificationStore = useNotificationStore(pinia); + const appStore = useAppStore(pinia); const readFormSpy = vi.spyOn(formStore, 'readFormSubscriptionData'); const addNotificationSpy = vi.spyOn(notificationStore, 'addNotification'); beforeEach(() => { formStore.$reset(); notificationStore.$reset(); + appStore.$reset(); readFormSpy.mockReset(); addNotificationSpy.mockReset(); readFormSpy.mockImplementationOnce(async () => {}); diff --git a/app/frontend/tests/unit/components/forms/manage/ManageFormActions.spec.js b/app/frontend/tests/unit/components/forms/manage/ManageFormActions.spec.js index 7b7257561..3fa13bbfb 100644 --- a/app/frontend/tests/unit/components/forms/manage/ManageFormActions.spec.js +++ b/app/frontend/tests/unit/components/forms/manage/ManageFormActions.spec.js @@ -8,6 +8,7 @@ import ManageFormActions from '~/components/forms/manage/ManageFormActions.vue'; import { useFormStore } from '~/store/form'; import { FormPermissions } from '~/utils/constants'; import { ref } from 'vue'; +import { useAppStore } from '~/store/app'; vi.mock('vue-router', () => ({ useRouter: vi.fn(() => ({ @@ -30,9 +31,11 @@ describe('ManageForm.vue', () => { setActivePinia(pinia); const formStore = useFormStore(pinia); + const appStore = useAppStore(pinia); beforeEach(() => { formStore.$reset(); + appStore.$reset(); }); it('renders', async () => { diff --git a/app/frontend/tests/unit/components/forms/manage/ManageLayout.spec.js b/app/frontend/tests/unit/components/forms/manage/ManageLayout.spec.js index ffa573558..9e274eaf4 100644 --- a/app/frontend/tests/unit/components/forms/manage/ManageLayout.spec.js +++ b/app/frontend/tests/unit/components/forms/manage/ManageLayout.spec.js @@ -9,6 +9,7 @@ import ManageLayout from '~/components/forms/manage/ManageLayout.vue'; import { useFormStore } from '~/store/form'; import { FormPermissions } from '~/utils/constants'; import { ref } from 'vue'; +import { useAppStore } from '~/store/app'; describe('ManageLayout.vue', () => { const pinia = createTestingPinia(); @@ -19,9 +20,11 @@ describe('ManageLayout.vue', () => { setActivePinia(pinia); const formStore = useFormStore(pinia); + const appStore = useAppStore(pinia); beforeEach(() => { formStore.$reset(); + appStore.$reset(); }); it('renders', () => { diff --git a/app/frontend/tests/unit/components/forms/manage/ManageVersions.spec.js b/app/frontend/tests/unit/components/forms/manage/ManageVersions.spec.js index eef6a06cd..7796f8eb0 100644 --- a/app/frontend/tests/unit/components/forms/manage/ManageVersions.spec.js +++ b/app/frontend/tests/unit/components/forms/manage/ManageVersions.spec.js @@ -13,6 +13,7 @@ import ManageVersions from '~/components/forms/manage/ManageVersions.vue'; import { formService } from '~/services'; import { useFormStore } from '~/store/form'; import { useNotificationStore } from '~/store/notification'; +import { useAppStore } from '~/store/app'; vi.mock('vue-router', () => ({ useRouter: vi.fn(() => ({ @@ -32,10 +33,12 @@ describe('ManageVersions.vue', () => { setActivePinia(pinia); const formStore = useFormStore(pinia); const notificationStore = useNotificationStore(pinia); + const appStore = useAppStore(pinia); beforeEach(() => { formStore.$reset(); notificationStore.$reset(); + appStore.$reset(); }); it('renders', async () => { diff --git a/app/frontend/tests/unit/components/forms/manage/ShareForm.spec.js b/app/frontend/tests/unit/components/forms/manage/ShareForm.spec.js index 4be51d4c7..adeb85427 100644 --- a/app/frontend/tests/unit/components/forms/manage/ShareForm.spec.js +++ b/app/frontend/tests/unit/components/forms/manage/ShareForm.spec.js @@ -7,6 +7,7 @@ import { useRouter } from 'vue-router'; import ShareForm from '~/components/forms/manage/ShareForm.vue'; import { useFormStore } from '~/store/form'; +import { useAppStore } from '~/store/app'; const STUBS = { BaseCopyToClipboard: { @@ -41,9 +42,11 @@ describe('ShareForm.vue', () => { const pinia = createTestingPinia(); setActivePinia(pinia); const formStore = useFormStore(pinia); + const appStore = useAppStore(pinia); beforeEach(() => { formStore.$reset(); + appStore.$reset(); }); it('formLink resolves a URL and returns the href', async () => { diff --git a/app/frontend/tests/unit/components/forms/manage/Subscription.spec.js b/app/frontend/tests/unit/components/forms/manage/Subscription.spec.js index 03f882412..a4d645c38 100644 --- a/app/frontend/tests/unit/components/forms/manage/Subscription.spec.js +++ b/app/frontend/tests/unit/components/forms/manage/Subscription.spec.js @@ -5,14 +5,17 @@ import { beforeEach, vi } from 'vitest'; import Subscription from '~/components/forms/manage/Subscription.vue'; import { useFormStore } from '~/store/form'; +import { useAppStore } from '~/store/app'; describe('Subscription.vue', () => { const pinia = createTestingPinia(); setActivePinia(pinia); const formStore = useFormStore(pinia); + const appStore = useAppStore(pinia); beforeEach(() => { formStore.$reset(); + appStore.$reset(); }); it('showHideKey should toggle the value', async () => { diff --git a/app/frontend/tests/unit/components/forms/manage/TeamManagement.spec.js b/app/frontend/tests/unit/components/forms/manage/TeamManagement.spec.js index c5f210b1b..c166430e6 100644 --- a/app/frontend/tests/unit/components/forms/manage/TeamManagement.spec.js +++ b/app/frontend/tests/unit/components/forms/manage/TeamManagement.spec.js @@ -11,6 +11,8 @@ import { useAuthStore } from '~/store/auth'; import { useFormStore } from '~/store/form'; import { useIdpStore } from '~/store/identityProviders'; import { useNotificationStore } from '~/store/notification'; +import { useAppStore } from '~/store/app'; + import { AppPermissions, FormPermissions, @@ -275,6 +277,7 @@ describe('TeamManagement.vue', () => { const formStore = useFormStore(pinia); const idpStore = useIdpStore(pinia); const notificationStore = useNotificationStore(pinia); + const appStore = useAppStore(pinia); const fetchFormSpy = vi.spyOn(formStore, 'fetchForm'); const listRolesSpy = vi.spyOn(roleService, 'list'); @@ -282,6 +285,7 @@ describe('TeamManagement.vue', () => { authStore.$reset(); formStore.$reset(); idpStore.$reset(); + appStore.$reset(); notificationStore.$reset(); fetchFormSpy.mockReset(); listRolesSpy.mockReset(); diff --git a/app/frontend/tests/unit/components/forms/submission/ManageSubmissionUsers.spec.js b/app/frontend/tests/unit/components/forms/submission/ManageSubmissionUsers.spec.js index 6d08b408c..bfb3ff7c0 100644 --- a/app/frontend/tests/unit/components/forms/submission/ManageSubmissionUsers.spec.js +++ b/app/frontend/tests/unit/components/forms/submission/ManageSubmissionUsers.spec.js @@ -11,6 +11,7 @@ import { useFormStore } from '~/store/form'; import { useNotificationStore } from '~/store/notification'; import { useIdpStore } from '~/store/identityProviders'; import { FormPermissions } from '~/utils/constants'; +import { useAppStore } from '~/store/app'; const providers = require('../../../fixtures/identityProviders.json'); @@ -34,6 +35,8 @@ describe('ManageSubmissionUsers.vue', () => { const pinia = createTestingPinia({ stubActions: false }); setActivePinia(pinia); const formStore = useFormStore(pinia); + useAppStore(pinia); + formStore.form.name = 'myForm'; getSubmissionUsersSpy.mockImplementation(() => ({ data: [] })); const wrapper = mount(ManageSubmissionUsers, { diff --git a/app/frontend/tests/unit/components/forms/submission/MySubmissionsActions.spec.js b/app/frontend/tests/unit/components/forms/submission/MySubmissionsActions.spec.js index 81d35de05..b31fd7fc8 100644 --- a/app/frontend/tests/unit/components/forms/submission/MySubmissionsActions.spec.js +++ b/app/frontend/tests/unit/components/forms/submission/MySubmissionsActions.spec.js @@ -8,6 +8,7 @@ import MySubmissionsActions from '~/components/forms/submission/MySubmissionsAct import getRouter from '~/router'; import { useFormStore } from '~/store/form'; import { FormPermissions } from '~/utils/constants'; +import { useAppStore } from '~/store/app'; const FORM_ID = '123'; const STUBS = { @@ -24,6 +25,8 @@ const STUBS = { describe('MySubmissionsActions', () => { const pinia = createTestingPinia(); const formStore = useFormStore(pinia); + const appStore = useAppStore(pinia); + setActivePinia(pinia); const router = createRouter({ history: createWebHistory(), @@ -32,6 +35,7 @@ describe('MySubmissionsActions', () => { beforeEach(() => { formStore.$reset(); + appStore.$reset(); }); it('renders', () => { diff --git a/app/frontend/tests/unit/components/forms/submission/MySubmissionsTable.spec.js b/app/frontend/tests/unit/components/forms/submission/MySubmissionsTable.spec.js index efb9afaa9..5ef54adb6 100644 --- a/app/frontend/tests/unit/components/forms/submission/MySubmissionsTable.spec.js +++ b/app/frontend/tests/unit/components/forms/submission/MySubmissionsTable.spec.js @@ -7,6 +7,7 @@ import { createRouter, createWebHistory } from 'vue-router'; import getRouter from '~/router'; import MySubmissionsTable from '~/components/forms/submission/MySubmissionsTable.vue'; import { useFormStore } from '~/store/form'; +import { useAppStore } from '~/store/app'; describe('MySubmissionsTable.vue', () => { const formId = '123-456'; @@ -20,6 +21,7 @@ describe('MySubmissionsTable.vue', () => { setActivePinia(pinia); const formStore = useFormStore(pinia); + const appStore = useAppStore(pinia); const fetchFormSpy = vi.spyOn(formStore, 'fetchForm').mockResolvedValue({}); const fetchFormFieldsSpy = vi @@ -30,6 +32,7 @@ describe('MySubmissionsTable.vue', () => { fetchFormSpy.mockReset(); fetchFormFieldsSpy.mockReset(); formStore.$reset(); + appStore.$reset(); formStore.form = { versions: [ diff --git a/app/frontend/tests/unit/components/forms/submission/NotesPanel.spec.js b/app/frontend/tests/unit/components/forms/submission/NotesPanel.spec.js index cf18090bf..e7c1449fa 100644 --- a/app/frontend/tests/unit/components/forms/submission/NotesPanel.spec.js +++ b/app/frontend/tests/unit/components/forms/submission/NotesPanel.spec.js @@ -8,6 +8,7 @@ import { useFormStore } from '~/store/form'; import { useNotificationStore } from '~/store/notification'; import { formService } from '~/services'; import { rbacService } from '~/services'; +import { useAppStore } from '~/store/app'; const SUBMISSION_ID = 'submissionId'; const USER_ID = 'userId'; @@ -29,6 +30,7 @@ describe('NotesPanel', () => { setActivePinia(pinia); const formStore = useFormStore(pinia); const notificationStore = useNotificationStore(pinia); + const appStore = useAppStore(pinia); const addNotificationSpy = vi .spyOn(notificationStore, 'addNotification') @@ -37,6 +39,7 @@ describe('NotesPanel', () => { beforeEach(() => { formStore.$reset(); notificationStore.$reset(); + appStore.$reset(); addNotificationSpy.mockReset(); }); diff --git a/app/frontend/tests/unit/components/forms/submission/StatusPanel.spec.js b/app/frontend/tests/unit/components/forms/submission/StatusPanel.spec.js index 3272fbb74..f41cd9af8 100644 --- a/app/frontend/tests/unit/components/forms/submission/StatusPanel.spec.js +++ b/app/frontend/tests/unit/components/forms/submission/StatusPanel.spec.js @@ -10,6 +10,7 @@ import { useFormStore } from '~/store/form'; import { useNotificationStore } from '~/store/notification'; import { formService } from '~/services'; import { rbacService } from '~/services'; +import { useAppStore } from '~/store/app'; const FORM_ID = 'formId'; const SUBMISSION_ID = 'submissionId'; @@ -53,6 +54,7 @@ describe('StatusPanel', () => { const authStore = useAuthStore(pinia); const formStore = useFormStore(pinia); const notificationStore = useNotificationStore(pinia); + const appStore = useAppStore(pinia); const addNotificationSpy = vi .spyOn(notificationStore, 'addNotification') @@ -64,6 +66,7 @@ describe('StatusPanel', () => { authStore.$reset(); formStore.$reset(); notificationStore.$reset(); + appStore.$reset(); addNotificationSpy.mockReset(); getFormUsersSpy.mockReset(); diff --git a/app/frontend/tests/unit/components/forms/submission/StatusTable.spec.js b/app/frontend/tests/unit/components/forms/submission/StatusTable.spec.js index 7ba8def34..2bda04208 100644 --- a/app/frontend/tests/unit/components/forms/submission/StatusTable.spec.js +++ b/app/frontend/tests/unit/components/forms/submission/StatusTable.spec.js @@ -7,6 +7,7 @@ import { formService } from '~/services'; import StatusTable from '~/components/forms/submission/StatusTable.vue'; import { useFormStore } from '~/store/form'; import { useNotificationStore } from '~/store/notification'; +import { useAppStore } from '~/store/app'; describe('StatusTable.vue', () => { const submissionId = '123-456'; @@ -16,10 +17,12 @@ describe('StatusTable.vue', () => { setActivePinia(pinia); const formStore = useFormStore(pinia); const notificationStore = useNotificationStore(pinia); + const appStore = useAppStore(pinia); beforeEach(() => { formStore.$reset(); notificationStore.$reset(); + appStore.$reset(); }); it('renders', async () => { diff --git a/app/frontend/tests/unit/components/forms/submission/UserDuplicateSubmission.spec.js b/app/frontend/tests/unit/components/forms/submission/UserDuplicateSubmission.spec.js index 85531594e..620ca2dd3 100644 --- a/app/frontend/tests/unit/components/forms/submission/UserDuplicateSubmission.spec.js +++ b/app/frontend/tests/unit/components/forms/submission/UserDuplicateSubmission.spec.js @@ -6,6 +6,7 @@ import { beforeEach, vi } from 'vitest'; import UserDuplicateSubmission from '~/components/forms/submission/UserDuplicateSubmission.vue'; import { useFormStore } from '~/store/form'; import { useNotificationStore } from '~/store/notification'; +import { useAppStore } from '~/store/app'; const STUBS = { VSkeletonLoader: { @@ -22,10 +23,12 @@ describe('UserDuplicateSubmission.vue', () => { setActivePinia(pinia); const formStore = useFormStore(pinia); const notificationStore = useNotificationStore(pinia); + const appStore = useAppStore(pinia); beforeEach(() => { formStore.$reset(); notificationStore.$reset(); + appStore.$reset(); }); it('renders', async () => { diff --git a/app/frontend/tests/unit/components/forms/submission/UserSubmission.spec.js b/app/frontend/tests/unit/components/forms/submission/UserSubmission.spec.js index 83f0038d3..2c6712567 100644 --- a/app/frontend/tests/unit/components/forms/submission/UserSubmission.spec.js +++ b/app/frontend/tests/unit/components/forms/submission/UserSubmission.spec.js @@ -7,6 +7,7 @@ import { useRouter } from 'vue-router'; import UserSubmission from '~/components/forms/submission/UserSubmission.vue'; import { useFormStore } from '~/store/form'; import { useNotificationStore } from '~/store/notification'; +import { useAppStore } from '~/store/app'; vi.mock('vue-router', () => ({ useRouter: vi.fn(() => ({ @@ -31,10 +32,12 @@ describe('UserSubmission.vue', () => { setActivePinia(pinia); const formStore = useFormStore(pinia); const notificationStore = useNotificationStore(pinia); + const appStore = useAppStore(pinia); beforeEach(() => { formStore.$reset(); notificationStore.$reset(); + appStore.$reset(); }); it('renders', async () => { diff --git a/app/frontend/tests/unit/components/infolinks/ProactiveHelpPreviewDialog.spec.js b/app/frontend/tests/unit/components/infolinks/ProactiveHelpPreviewDialog.spec.js index 1eb4add91..c5a767b12 100644 --- a/app/frontend/tests/unit/components/infolinks/ProactiveHelpPreviewDialog.spec.js +++ b/app/frontend/tests/unit/components/infolinks/ProactiveHelpPreviewDialog.spec.js @@ -8,6 +8,7 @@ import { rbacService } from '~/services'; import getRouter from '~/router'; import ProactiveHelpPreviewDialog from '~/components/infolinks/ProactiveHelpPreviewDialog.vue'; import { useFormStore } from '~/store/form'; +import { useAppStore } from '~/store/app'; describe('ProactiveHelpPreviewDialog.vue', () => { const getSubmissionUsersSpy = vi.spyOn(rbacService, 'getSubmissionUsers'); @@ -19,10 +20,12 @@ describe('ProactiveHelpPreviewDialog.vue', () => { setActivePinia(pinia); const formStore = useFormStore(pinia); + const appStore = useAppStore(pinia); beforeEach(() => { getSubmissionUsersSpy.mockReset(); formStore.$reset(); + appStore.$reset(); }); afterAll(() => { diff --git a/app/frontend/tests/unit/store/modules/auth.actions.spec.js b/app/frontend/tests/unit/store/modules/auth.actions.spec.js index f3c87da42..03dbe633f 100644 --- a/app/frontend/tests/unit/store/modules/auth.actions.spec.js +++ b/app/frontend/tests/unit/store/modules/auth.actions.spec.js @@ -26,6 +26,7 @@ describe('auth actions', () => { beforeEach(() => { mockStore.$reset(); formStore.$reset(); + appStore.$reset(); mockStore.keycloak = { createLoginUrl: vi.fn(() => 'about:blank'), createLogoutUrl: vi.fn(() => 'about:blank'), diff --git a/app/frontend/tests/unit/store/modules/form.actions.spec.js b/app/frontend/tests/unit/store/modules/form.actions.spec.js index 6d559c41a..79c9f5835 100644 --- a/app/frontend/tests/unit/store/modules/form.actions.spec.js +++ b/app/frontend/tests/unit/store/modules/form.actions.spec.js @@ -9,12 +9,14 @@ import { } from '~/services'; import { useFormStore } from '~/store/form'; import { useNotificationStore } from '~/store/notification'; +import { useAppStore } from '../../../../src/store/app'; vi.mock('~/services'); describe('form actions', () => { setActivePinia(createPinia()); const mockStore = useFormStore(); + const appStore = useAppStore(); const notificationStore = useNotificationStore(); const addNotificationSpy = vi.spyOn(notificationStore, 'addNotification'); const listSubmissionsSpy = vi.spyOn(formService, 'listSubmissions'); @@ -24,6 +26,7 @@ describe('form actions', () => { beforeEach(() => { mockStore.$reset(); mockConsoleError.mockReset(); + appStore.$reset(); notificationStore.$reset(); addNotificationSpy.mockReset(); listSubmissionsSpy.mockReset(); diff --git a/app/frontend/tests/unit/utils/constants.spec.js b/app/frontend/tests/unit/utils/constants.spec.js index 17546e6f5..3ab18eb66 100644 --- a/app/frontend/tests/unit/utils/constants.spec.js +++ b/app/frontend/tests/unit/utils/constants.spec.js @@ -16,6 +16,8 @@ describe('Constants', () => { UTILS: '/utils', FILES_API_ACCESS: '/filesApiAccess', PROXY: '/proxy', + ENCRYPTION_KEY: '/encryptionKey', + EVENT_STREAM_CONFIG: '/eventStreamConfig', }); }); diff --git a/app/frontend/tests/unit/views/file/Download.spec.js b/app/frontend/tests/unit/views/file/Download.spec.js index 33facc393..76b5165e8 100644 --- a/app/frontend/tests/unit/views/file/Download.spec.js +++ b/app/frontend/tests/unit/views/file/Download.spec.js @@ -10,6 +10,7 @@ import { useFormStore } from '~/store/form'; import { useNotificationStore } from '~/store/notification'; import Download from '~/views/file/Download.vue'; import * as transformUtils from '~/utils/transformUtils'; +import { useAppStore } from '~/store/app'; describe('Download.vue', () => { let pinia; @@ -37,6 +38,8 @@ describe('Download.vue', () => { formStore.$reset(); const notificationStore = useNotificationStore(); notificationStore.$reset(); + const appStore = useAppStore(pinia); + appStore.$reset(); }); it('renders and downloads json', async () => { diff --git a/app/frontend/tests/unit/views/form/Create.spec.js b/app/frontend/tests/unit/views/form/Create.spec.js index baf063a73..3d3f69bff 100644 --- a/app/frontend/tests/unit/views/form/Create.spec.js +++ b/app/frontend/tests/unit/views/form/Create.spec.js @@ -11,6 +11,7 @@ import { useFormStore } from '~/store/form'; import Create from '~/views/form/Create.vue'; import { IdentityMode } from '~/utils/constants'; import { nextTick } from 'vue'; +import { useAppStore } from '~/store/app'; vi.mock('vue-router', () => ({ ...vi.importActual('vue-router'), @@ -25,9 +26,11 @@ describe('Create.vue', () => { setActivePinia(pinia); const formStore = useFormStore(pinia); + const appStore = useAppStore(pinia); beforeEach(async () => { formStore.$reset(); + appStore.$reset(); mockWindowConfirm.mockReset(); }); diff --git a/app/frontend/tests/unit/views/form/Design.spec.js b/app/frontend/tests/unit/views/form/Design.spec.js index 74365ff19..404158c48 100644 --- a/app/frontend/tests/unit/views/form/Design.spec.js +++ b/app/frontend/tests/unit/views/form/Design.spec.js @@ -8,6 +8,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useFormStore } from '~/store/form'; import Design from '~/views/form/Design.vue'; +import { useAppStore } from '~/store/app'; vi.mock('vue-router', () => ({ ...vi.importActual('vue-router'), @@ -21,9 +22,11 @@ describe('Design.vue', () => { setActivePinia(pinia); const formStore = useFormStore(pinia); + const appStore = useAppStore(pinia); beforeEach(() => { formStore.$reset(); + appStore.$reset(); mockWindowConfirm.mockReset(); }); @@ -47,10 +50,6 @@ describe('Design.vue', () => { }, template: '
', }, - BaseSecure: { - name: 'BaseSecure', - template: '
', - }, }, }, }); From 669193823e59161ed866d79fcd7d1238ffd9c4c6 Mon Sep 17 00:00:00 2001 From: Jason Sherman Date: Thu, 15 Aug 2024 11:55:26 -0700 Subject: [PATCH 05/25] lint fix. Signed-off-by: Jason Sherman --- app/src/forms/common/middleware/validateParameter.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/forms/common/middleware/validateParameter.js b/app/src/forms/common/middleware/validateParameter.js index 0447e665e..baeda9e20 100644 --- a/app/src/forms/common/middleware/validateParameter.js +++ b/app/src/forms/common/middleware/validateParameter.js @@ -209,8 +209,8 @@ const validateUserId = async (_req, _res, next, userId) => { next(error); } }; - -/** + +/** * Validates that the :formEncryptionKeyId route parameter exists and is a UUID. This * validator requires that the :formId route parameter also exists. * From df6ba31769ae9b29f707676e7004cfbecedd8219 Mon Sep 17 00:00:00 2001 From: Jason Sherman Date: Thu, 15 Aug 2024 12:02:31 -0700 Subject: [PATCH 06/25] add rate limits to routes Signed-off-by: Jason Sherman --- app/src/forms/form/encryptionKey/routes.js | 14 ++++++++------ app/src/forms/form/eventStreamConfig/routes.js | 10 ++++++---- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/app/src/forms/form/encryptionKey/routes.js b/app/src/forms/form/encryptionKey/routes.js index b29adf267..add834a69 100644 --- a/app/src/forms/form/encryptionKey/routes.js +++ b/app/src/forms/form/encryptionKey/routes.js @@ -2,6 +2,8 @@ const routes = require('express').Router(); const { currentUser, hasFormPermissions } = require('../../auth/middleware/userAccess'); const validateParameter = require('../../common/middleware/validateParameter'); const { featureFlags } = require('../../../components/featureFlags'); +const apiAccess = require('../auth/middleware/apiAccess'); +const rateLimiter = require('../common/middleware').apiKeyRateLimiter; const P = require('../../common/constants').Permissions; const controller = require('./controller'); @@ -12,27 +14,27 @@ routes.use(currentUser); routes.param('formId', validateParameter.validateFormId); routes.param('formEncryptionKeyId', validateParameter.validateFormEncryptionKeyId); -routes.get('/encryptionKey/algorithms', async (req, res, next) => { +routes.get('/encryptionKey/algorithms', rateLimiter, apiAccess, async (req, res, next) => { await controller.listEncryptionAlgorithms(req, res, next); }); -routes.get('/:formId/encryptionKey', hasFormPermissions([P.FORM_READ, P.FORM_UPDATE]), async (req, res, next) => { +routes.get('/:formId/encryptionKey', rateLimiter, apiAccess, hasFormPermissions([P.FORM_READ, P.FORM_UPDATE]), async (req, res, next) => { await controller.listEncryptionKeys(req, res, next); }); -routes.post('/:formId/encryptionKey', hasFormPermissions([P.FORM_READ, P.FORM_UPDATE]), async (req, res, next) => { +routes.post('/:formId/encryptionKey', rateLimiter, apiAccess, hasFormPermissions([P.FORM_READ, P.FORM_UPDATE]), async (req, res, next) => { await controller.createEncryptionKey(req, res, next); }); -routes.get('/:formId/encryptionKey/:formEncryptionKeyId', hasFormPermissions([P.FORM_READ, P.FORM_UPDATE]), async (req, res, next) => { +routes.get('/:formId/encryptionKey/:formEncryptionKeyId', rateLimiter, apiAccess, hasFormPermissions([P.FORM_READ, P.FORM_UPDATE]), async (req, res, next) => { await controller.readEncryptionKey(req, res, next); }); -routes.put('/:formId/encryptionKey/:formEncryptionKeyId', hasFormPermissions([P.FORM_READ, P.FORM_UPDATE]), async (req, res, next) => { +routes.put('/:formId/encryptionKey/:formEncryptionKeyId', rateLimiter, apiAccess, hasFormPermissions([P.FORM_READ, P.FORM_UPDATE]), async (req, res, next) => { await controller.updateEncryptionKey(req, res, next); }); -routes.delete('/:formId/encryptionKey/:formEncryptionKeyId', hasFormPermissions([P.FORM_READ, P.FORM_UPDATE]), async (req, res, next) => { +routes.delete('/:formId/encryptionKey/:formEncryptionKeyId', rateLimiter, apiAccess, hasFormPermissions([P.FORM_READ, P.FORM_UPDATE]), async (req, res, next) => { await controller.deleteEncryptionKey(req, res, next); }); diff --git a/app/src/forms/form/eventStreamConfig/routes.js b/app/src/forms/form/eventStreamConfig/routes.js index 836790583..1256ef83d 100644 --- a/app/src/forms/form/eventStreamConfig/routes.js +++ b/app/src/forms/form/eventStreamConfig/routes.js @@ -2,6 +2,8 @@ const routes = require('express').Router(); const { currentUser, hasFormPermissions } = require('../../auth/middleware/userAccess'); const validateParameter = require('../../common/middleware/validateParameter'); const { featureFlags } = require('../../../components/featureFlags'); +const apiAccess = require('../auth/middleware/apiAccess'); +const rateLimiter = require('../common/middleware').apiKeyRateLimiter; const P = require('../../common/constants').Permissions; const controller = require('./controller'); @@ -11,19 +13,19 @@ routes.use(currentUser); routes.param('formId', validateParameter.validateFormId); -routes.get('/:formId/eventStreamConfig', hasFormPermissions([P.FORM_READ, P.FORM_UPDATE]), async (req, res, next) => { +routes.get('/:formId/eventStreamConfig', rateLimiter, apiAccess, hasFormPermissions([P.FORM_READ, P.FORM_UPDATE]), async (req, res, next) => { await controller.readEventStreamConfig(req, res, next); }); -routes.post('/:formId/eventStreamConfig', hasFormPermissions([P.FORM_READ, P.FORM_UPDATE]), async (req, res, next) => { +routes.post('/:formId/eventStreamConfig', rateLimiter, apiAccess, hasFormPermissions([P.FORM_READ, P.FORM_UPDATE]), async (req, res, next) => { await controller.createEventStreamConfig(req, res, next); }); -routes.put('/:formId/eventStreamConfig', hasFormPermissions([P.FORM_READ, P.FORM_UPDATE]), async (req, res, next) => { +routes.put('/:formId/eventStreamConfig', rateLimiter, apiAccess, hasFormPermissions([P.FORM_READ, P.FORM_UPDATE]), async (req, res, next) => { await controller.updateEventStreamConfig(req, res, next); }); -routes.delete('/:formId/eventStreamConfig', hasFormPermissions([P.FORM_READ, P.FORM_UPDATE]), async (req, res, next) => { +routes.delete('/:formId/eventStreamConfig', rateLimiter, apiAccess, hasFormPermissions([P.FORM_READ, P.FORM_UPDATE]), async (req, res, next) => { await controller.deleteEventStreamConfig(req, res, next); }); From 56ef27f52dc018f8d4e41fa82ea55abe27e662a2 Mon Sep 17 00:00:00 2001 From: Jason Sherman Date: Thu, 15 Aug 2024 12:07:02 -0700 Subject: [PATCH 07/25] sigh - cut/paste error... fix Signed-off-by: Jason Sherman --- app/src/forms/form/encryptionKey/routes.js | 4 ++-- app/src/forms/form/eventStreamConfig/routes.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/forms/form/encryptionKey/routes.js b/app/src/forms/form/encryptionKey/routes.js index add834a69..c2a12cfd4 100644 --- a/app/src/forms/form/encryptionKey/routes.js +++ b/app/src/forms/form/encryptionKey/routes.js @@ -2,8 +2,8 @@ const routes = require('express').Router(); const { currentUser, hasFormPermissions } = require('../../auth/middleware/userAccess'); const validateParameter = require('../../common/middleware/validateParameter'); const { featureFlags } = require('../../../components/featureFlags'); -const apiAccess = require('../auth/middleware/apiAccess'); -const rateLimiter = require('../common/middleware').apiKeyRateLimiter; +const apiAccess = require('../../auth/middleware/apiAccess'); +const rateLimiter = require('../../common/middleware').apiKeyRateLimiter; const P = require('../../common/constants').Permissions; const controller = require('./controller'); diff --git a/app/src/forms/form/eventStreamConfig/routes.js b/app/src/forms/form/eventStreamConfig/routes.js index 1256ef83d..18668f0c8 100644 --- a/app/src/forms/form/eventStreamConfig/routes.js +++ b/app/src/forms/form/eventStreamConfig/routes.js @@ -2,8 +2,8 @@ const routes = require('express').Router(); const { currentUser, hasFormPermissions } = require('../../auth/middleware/userAccess'); const validateParameter = require('../../common/middleware/validateParameter'); const { featureFlags } = require('../../../components/featureFlags'); -const apiAccess = require('../auth/middleware/apiAccess'); -const rateLimiter = require('../common/middleware').apiKeyRateLimiter; +const apiAccess = require('../../auth/middleware/apiAccess'); +const rateLimiter = require('../../common/middleware').apiKeyRateLimiter; const P = require('../../common/constants').Permissions; const controller = require('./controller'); From dce72511c1cd70b303e138ff426e27eddf58b275 Mon Sep 17 00:00:00 2001 From: Jason Sherman Date: Thu, 22 Aug 2024 13:24:09 -0700 Subject: [PATCH 08/25] tweak ux when config is new Signed-off-by: Jason Sherman --- .../designer/settings/FormEventStreamSettings.vue | 2 +- app/src/forms/form/encryptionKey/service.js | 2 ++ app/src/forms/form/eventStreamConfig/service.js | 1 + event-stream-service/package-lock.json | 9 +++++++-- event-stream-service/package.json | 1 + event-stream-service/pullConsumer.js | 14 +++++++++++++- 6 files changed, 25 insertions(+), 4 deletions(-) diff --git a/app/frontend/src/components/designer/settings/FormEventStreamSettings.vue b/app/frontend/src/components/designer/settings/FormEventStreamSettings.vue index a3e2bf2ef..0ec6b9a54 100644 --- a/app/frontend/src/components/designer/settings/FormEventStreamSettings.vue +++ b/app/frontend/src/components/designer/settings/FormEventStreamSettings.vue @@ -238,7 +238,7 @@ defineExpose({ - + {{ $t('trans.formSettings.eventStreamUpdatedBy') }}: diff --git a/app/src/forms/form/encryptionKey/service.js b/app/src/forms/form/encryptionKey/service.js index f72719aa9..0470ca83d 100644 --- a/app/src/forms/form/encryptionKey/service.js +++ b/app/src/forms/form/encryptionKey/service.js @@ -30,6 +30,7 @@ const service = { // special case for forms. they have only one event stream configuration // that requires an encryption key if it has private streams. // NOTE: event stream config will remove this key if it is unneeded! + const externalTrx = transaction != undefined; let trx; let id; @@ -51,6 +52,7 @@ const service = { } } else { // add a new configuration. + if (data && !data.algorithm && !data.key) return; // no encryption key to insert id = uuidv4(); data.id = id; data.formId = formId; diff --git a/app/src/forms/form/eventStreamConfig/service.js b/app/src/forms/form/eventStreamConfig/service.js index 6e0d4d1df..27479720d 100644 --- a/app/src/forms/form/eventStreamConfig/service.js +++ b/app/src/forms/form/eventStreamConfig/service.js @@ -24,6 +24,7 @@ const service = { // let's deal with encryption key const encKey = await encryptionKeyService.upsertForEventStreamConfig(formId, data.encryptionKey, currentUser, transaction); data.encryptionKeyId = encKey && data.enablePrivateStream ? encKey.id : null; + data.encryptionKey = null; // only want the id for config upsert if (existing) { // do we need to update? diff --git a/event-stream-service/package-lock.json b/event-stream-service/package-lock.json index 52b4c5de1..db4417a7c 100644 --- a/event-stream-service/package-lock.json +++ b/event-stream-service/package-lock.json @@ -9,9 +9,14 @@ "version": "0.0.0", "license": "Apache-2.0", "dependencies": { + "cryptr": "^6.3.0", "nats": "^2.28.0" - }, - "devDependencies": {} + } + }, + "node_modules/cryptr": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/cryptr/-/cryptr-6.3.0.tgz", + "integrity": "sha512-TA4byAuorT8qooU9H8YJhBwnqD151i1rcauHfJ3Divg6HmukHB2AYMp0hmjv2873J2alr4t15QqC7zAnWFrtfQ==" }, "node_modules/nats": { "version": "2.28.0", diff --git a/event-stream-service/package.json b/event-stream-service/package.json index 4b64f1b4f..836c7a8e4 100644 --- a/event-stream-service/package.json +++ b/event-stream-service/package.json @@ -5,6 +5,7 @@ "license": "Apache-2.0", "scripts": {}, "dependencies": { + "cryptr": "^6.3.0", "nats": "^2.28.0" } } diff --git a/event-stream-service/pullConsumer.js b/event-stream-service/pullConsumer.js index b7678b8ff..44cde7146 100644 --- a/event-stream-service/pullConsumer.js +++ b/event-stream-service/pullConsumer.js @@ -1,4 +1,5 @@ const { AckPolicy, connect } = require("nats"); +const Cryptr = require("cryptr"); // connection info const servers = ["localhost:4222", "localhost:4223", "localhost:4224"]; @@ -13,6 +14,7 @@ const STREAM_NAME = "CHEFS"; const FILTER_SUBJECTS = ["PUBLIC.forms.>", "PRIVATE.forms.>"]; const MAX_MESSAGES = 2; const DURABLE_NAME = "pullConsumer"; +const ENCRYPTION_KEY = ""; const printMsg = (m) => { // illustrate grabbing the sequence and timestamp from the nats message... @@ -22,7 +24,17 @@ const printMsg = (m) => { `msg seq: ${m.seq}, subject: ${m.subject}, timestamp: ${ts}, streamSequence: ${m.info.streamSequence}, deliverySequence: ${m.info.deliverySequence}` ); // illustrate (one way of) grabbing message content as json - console.log(JSON.stringify(m.json(), null, 2)); + const data = m.json(); + console.log(JSON.stringify(data, null, 2)); + try { + if (data && data["payload"] && data["payload"]["data"]) { + const cryptr = new Cryptr(ENCRYPTION_KEY); + const d = cryptr.decrypt(data["payload"]["data"]); + console.log(JSON.stringify(d, null, 2)); + } + } catch (err) { + console.error("Error decrypting payload.data"); + } } catch (e) { console.error(`Error printing message: ${e.message}`); } From 0790db2c3eb20829a1135f007beed194767b62bd Mon Sep 17 00:00:00 2001 From: usingtechnology <39388115+usingtechnology@users.noreply.github.com> Date: Thu, 22 Aug 2024 15:09:23 -0700 Subject: [PATCH 09/25] Adjust external api id middleware for admin routes (#1486) Signed-off-by: Jason Sherman --- .../common/middleware/validateParameter.js | 13 ++++++++++--- .../common/middleware/validateParameter.spec.js | 17 ++++++++++++++++- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/app/src/forms/common/middleware/validateParameter.js b/app/src/forms/common/middleware/validateParameter.js index e917cb61e..af884f5a0 100644 --- a/app/src/forms/common/middleware/validateParameter.js +++ b/app/src/forms/common/middleware/validateParameter.js @@ -93,12 +93,19 @@ const validateExternalAPIId = async (req, _res, next, externalAPIId) => { _validateUuid(externalAPIId, 'externalAPIId'); const externalApi = await externalApiService.readExternalAPI(externalAPIId); - if (!externalApi || externalApi.formId !== req.params.formId) { + if (!externalApi) { throw new Problem(404, { - detail: 'externalAPIId does not exist on this form', + detail: 'externalAPIId does not exist', }); } - + // perform this check only if there is a formId (admin routes don't have form id) + if (req.params.formId) { + if (!externalApi || externalApi.formId !== req.params.formId) { + throw new Problem(404, { + detail: 'externalAPIId does not exist on this form', + }); + } + } next(); } catch (error) { next(error); diff --git a/app/tests/unit/forms/common/middleware/validateParameter.spec.js b/app/tests/unit/forms/common/middleware/validateParameter.spec.js index c5bef0af5..42fcace49 100644 --- a/app/tests/unit/forms/common/middleware/validateParameter.spec.js +++ b/app/tests/unit/forms/common/middleware/validateParameter.spec.js @@ -290,7 +290,8 @@ describe('validateExternalApiId', () => { describe('404 response when', () => { const expectedStatus = { status: 404 }; - test('formId is missing', async () => { + test('externalApiId not found', async () => { + externalApiService.readExternalAPI.mockReturnValueOnce(null); const req = getMockReq({ params: { externalAPIId: externalApiId, @@ -358,6 +359,20 @@ describe('validateExternalApiId', () => { expect(externalApiService.readExternalAPI).toBeCalledTimes(1); expect(next).toBeCalledWith(); }); + + test('external api id only', async () => { + const req = getMockReq({ + params: { + externalAPIId: externalApiId, + }, + }); + const { res, next } = getMockRes(); + + await validateParameter.validateExternalAPIId(req, res, next, externalApiId); + + expect(externalApiService.readExternalAPI).toBeCalledTimes(1); + expect(next).toBeCalledWith(); + }); }); }); From 5b8bd970daa35d5327043053ceccf5d7ae4673ee Mon Sep 17 00:00:00 2001 From: jasonchung1871 <101672465+jasonchung1871@users.noreply.github.com> Date: Fri, 23 Aug 2024 09:45:17 -0700 Subject: [PATCH 10/25] refactor: Forms 1123 composition forms (#1460) * Update ExportSubmissions ExportSubmissions updated to the composition API and almost all coverage is added.. missing the watch variables * Update FormSubmission FormSubmission updated to the composition API with almost full coverage. missing a child component check * PrintOptions Updated PrintOptions is updated to the composition api and has most of the script covered. it is missing watch variables and the print browser functionality as i didn't look deeply into how we can spy on the browsers print functionality. some functions in PrintOptions is moved to a composable or the transformUtils as it can be reused elsewhere and allowed for easier testing. * RequestReceipt Updated RequestReceipt updated to the composition api with full coverage * SubmissionsTable updated SubmissionsTable updated to the composition API but test coverage needs to be added * SubmissionsTable test coverage SubmissionsTable has almost full coverage.. just missing some checking for an error because we actually skip it. and another for checking if the lodash debounce works. --- .../components/forms/ExportSubmissions.vue | 488 +++---- .../src/components/forms/FormSubmission.vue | 150 ++- .../src/components/forms/PrintOptions.vue | 637 +++++---- .../src/components/forms/RequestReceipt.vue | 135 +- .../src/components/forms/SubmissionsTable.vue | 1050 +++++++-------- app/frontend/src/composables/printOptions.js | 10 + app/frontend/src/utils/transformUtils.js | 22 + .../forms/ExportSubmissions.spec.js | 417 +++++- .../components/forms/FormSubmission.spec.js | 256 ++++ .../components/forms/PrintOptions.spec.js | 580 +++++++++ .../components/forms/RequestReceipt.spec.js | 154 +++ .../components/forms/SubmissionsTable.spec.js | 1133 ++++++++++++++++- .../unit/composables/printOptions.spec.js | 16 + app/frontend/tests/unit/fixtures/form.json | 87 ++ .../tests/unit/fixtures/permissions.json | 25 + app/frontend/tests/unit/stubs.js | 40 + 16 files changed, 3930 insertions(+), 1270 deletions(-) create mode 100644 app/frontend/src/composables/printOptions.js create mode 100644 app/frontend/tests/unit/components/forms/FormSubmission.spec.js create mode 100644 app/frontend/tests/unit/components/forms/PrintOptions.spec.js create mode 100644 app/frontend/tests/unit/components/forms/RequestReceipt.spec.js create mode 100644 app/frontend/tests/unit/composables/printOptions.spec.js create mode 100644 app/frontend/tests/unit/fixtures/form.json create mode 100644 app/frontend/tests/unit/fixtures/permissions.json create mode 100644 app/frontend/tests/unit/stubs.js diff --git a/app/frontend/src/components/forms/ExportSubmissions.vue b/app/frontend/src/components/forms/ExportSubmissions.vue index 870a25cb8..fd0f8b1f3 100644 --- a/app/frontend/src/components/forms/ExportSubmissions.vue +++ b/app/frontend/src/components/forms/ExportSubmissions.vue @@ -1,7 +1,8 @@ -