From b0a3edb8f72873d950812eeb2bc71a7bd52ff37e Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Thu, 20 May 2021 10:24:45 +0100 Subject: [PATCH 01/58] EUI-2976 Introduced a basic mechanism for accepting "socket" connections via socket.io and responding to various messages from the client. The code is messy and all inline at the moment but it needs to get into the repo sooner rather than later. --- .gitignore | 2 + app.js | 7 +-- package.json | 9 +-- server.js | 168 +++++++++++++++++++++++++++++++++++++++++++++++++++ yarn.lock | 131 +++++++++++++++++++++++++++++++++++++-- 5 files changed, 302 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index 4d2b608f..969e69a5 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,5 @@ out/ bin/ build/ +config/local-development.yaml +.env diff --git a/app.js b/app.js index 3d2182bf..26a0d565 100644 --- a/app.js +++ b/app.js @@ -1,7 +1,6 @@ const healthcheck = require('@hmcts/nodejs-healthcheck'); const express = require('express'); const logger = require('morgan'); -const bodyParser = require('body-parser'); const config = require('config'); const debug = require('debug')('ccd-case-activity-api:app'); const enableAppInsights = require('./app/app-insights/app-insights'); @@ -43,9 +42,9 @@ if (config.util.getEnv('NODE_ENV') === 'test') { debug(`starting application with environment: ${config.util.getEnv('NODE_ENV')}`); app.use(corsHandler); -app.use(bodyParser.json()); -app.use(bodyParser.urlencoded({ extended: false })); -app.use(bodyParser.text()); +app.use(express.json()); +app.use(express.urlencoded({ extended: false })); +app.use(express.text()); app.use(authCheckerUserOnlyFilter); app.use('/', activity); diff --git a/package.json b/package.json index c493a725..6a1d9954 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,6 @@ "name": "ccd-case-activity-api", "version": "0.0.2", "private": true, - "engines": { - "node": "^12.14.1", - "yarn": "^1.12.3" - }, "scripts": { "setup": "cross-env NODE_PATH=. node --version", "start": "cross-env NODE_PATH=. node server.js", @@ -40,7 +36,6 @@ "@hmcts/nodejs-logging": "^3.0.1", "@hmcts/properties-volume": "^0.0.9", "applicationinsights": "^1.0.5", - "body-parser": "^1.18.2", "config": "^1.26.1", "connect-timeout": "^1.9.0", "cross-env": "^5.2.0", @@ -54,7 +49,9 @@ "nocache": "^2.1.0", "node-cache": "^5.1.0", "node-cron": "^1.2.1", - "node-fetch": "^2.6.1" + "node-fetch": "^2.6.1", + "socket.io": "^4.1.2", + "socket.io-router-middleware": "^1.1.2" }, "devDependencies": { "chai": "^4.1.2", diff --git a/server.js b/server.js index 22c822ff..d80bb9a1 100755 --- a/server.js +++ b/server.js @@ -24,6 +24,174 @@ app.set('port', port); var server = http.createServer(app); +/** + * Set up a socket.io router. + */ +const io = require('socket.io')(server, { + allowEIO3: true +}); +const IORouter = new require('socket.io-router-middleware'); +const iorouter = new IORouter(); + +// const socketsWatchingCases = {}; +const caseStatuses = {}; + +// Add router paths +iorouter.on('/socket', (socket, ctx, next) => { + const payload = ctx.request.payload; + if (payload) { + if (payload.edit) { + handleEdit(socket, payload); + } else if (payload.view) { + handleView(socket, payload); + } else if (payload.watch) { + handleWatch(socket, payload.watch); + } + } + // ctx.response = { hello: 'from server' }; + // socket.emit('response', ctx); + // Don't forget to call next() at the end to enable passing to other middlewares + next(); +}); +// On client connection attach the router +io.on('connection', function (socket) { + socket.use((packet, next) => { + // Call router.attach() with the client socket as the first parameter + iorouter.attach(socket, packet, next); + }); +}); + +function handleEdit(socket, payload) { + const caseId = payload.edit; + const stoppedViewing = stopViewingCases(socket.id); + if (stoppedViewing.length > 0) { + stoppedViewing.filter(c => c !== caseId).forEach(c => stopWatchingCase(socket, c)); + } + const stoppedEditing = stopEditingCases(socket.id, caseId); + if (stoppedEditing.length > 0) { + stoppedEditing.filter(c => c !== caseId).forEach(c => stopWatchingCase(socket, c)); + } + watchCase(socket, caseId); + const caseStatus = caseStatuses[caseId] || { viewers: [], editors: [] }; + caseStatuses[caseId] = caseStatus; + const matchingEditor = caseStatus.editors.find(e => e.id === payload.user.id); + const notify = stoppedViewing.concat(stoppedEditing); + if (!matchingEditor) { + caseStatus.editors.push({ ...payload.user, socketId: socket.id }); + notify.push(caseId); + } + if (notify.length > 0) { + notifyWatchers(socket, [ ...new Set(notify) ]); + } + socket.emit('response', getCaseStatuses([caseId])); +} + +function handleView(socket, payload) { + const caseId = payload.view; + const stoppedViewing = stopViewingCases(socket.id, caseId); + if (stoppedViewing.length > 0) { + stoppedViewing.filter(c => c !== caseId).forEach(c => stopWatchingCase(socket, c)); + } + const stoppedEditing = stopEditingCases(socket.id); + if (stoppedEditing.length > 0) { + stoppedEditing.filter(c => c !== caseId).forEach(c => stopWatchingCase(socket, c)); + } + watchCase(socket, caseId); + const caseStatus = caseStatuses[caseId] || { viewers: [], editors: [] }; + caseStatuses[caseId] = caseStatus; + const matchingViewer = caseStatus.viewers.find(v => v.id === payload.user.id); + const notify = stoppedViewing.concat(stoppedEditing); + if (!matchingViewer) { + caseStatus.viewers.push({ ...payload.user, socketId: socket.id }); + notify.push(caseId); + } + if (notify.length > 0) { + notifyWatchers(socket, [ ...new Set(notify) ]); + } + socket.emit('response', getCaseStatuses([caseId])); +} + +function handleWatch(socket, caseIds) { + watchCases(socket, caseIds); + socket.emit('cases', getCaseStatuses(caseIds)); +} + +function watchCases(socket, caseIds) { + caseIds.forEach(caseId => { + watchCase(socket, caseId); + }); +} +function watchCase(socket, caseId) { + socket.join(`case:${caseId}`); +} +function stopWatchingCase(socket, caseId) { + socket.leave(`case:${caseId}`); +} +function notifyWatchers(socket, caseIds) { + caseIds.sort().forEach(caseId => { + socket.to(`case:${caseId}`).emit('cases', getCaseStatuses([caseId])); + }); +} + +function getCaseStatuses(caseIds) { + return caseIds.reduce((obj, caseId) => { + const cs = caseStatuses[caseId]; + if (cs) { + obj[caseId] = { + viewers: [ ...cs.viewers.map(w => w.id) ], + editors: [ ...cs.editors.map(e => e.id) ] + }; + } + return obj; + }, {}); +} + +function stopViewingOrEditing(socketId, exceptCaseId) { + const stoppedViewing = stopViewingCases(socketId, exceptCaseId); + const stoppedEditing = stopEditingCases(socketId, exceptCaseId); + return { stoppedViewing, stoppedEditing }; +} +function stopViewingCases(socketId, exceptCaseId) { + const affectedCases = []; + Object.keys(caseStatuses).filter(key => key !== exceptCaseId).forEach(key => { + const c = caseStatuses[key]; + const viewer = c.viewers.find(v => v.socketId === socketId); + if (viewer) { + c.viewers.splice(c.viewers.indexOf(viewer), 1); + affectedCases.push(key); + } + }); + return affectedCases; +} +function stopEditingCases(socketId, exceptCaseId) { + const affectedCases = []; + Object.keys(caseStatuses).filter(key => key !== exceptCaseId).forEach(key => { + const c = caseStatuses[key]; + const editor = c.editors.find(e => e.socketId === socketId); + if (editor) { + c.editors.splice(c.editors.indexOf(editor), 1); + affectedCases.push(key); + } + }); + return affectedCases; +} + +const connections = []; +io.sockets.on("connection", (socket) => { + connections.push(socket); + console.log(" %s sockets is connected", connections.length); + // console.log('connections[0]', connections[0]); + socket.on("disconnect", () => { + console.log(socket.id, 'has disconnected'); + stopViewingOrEditing(socket.id); + connections.splice(connections.indexOf(socket), 1); + }); + socket.on("sending message", (message) => { + console.log("Message is received :", message); + io.sockets.emit("new message", { message: message }); + }); +}); + /** * Listen on provided port, on all network interfaces. */ diff --git a/yarn.lock b/yarn.lock index 8865da7d..a0bbe630 100644 --- a/yarn.lock +++ b/yarn.lock @@ -236,16 +236,36 @@ resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0" integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ== +"@types/component-emitter@^1.2.10": + version "1.2.10" + resolved "https://registry.yarnpkg.com/@types/component-emitter/-/component-emitter-1.2.10.tgz#ef5b1589b9f16544642e473db5ea5639107ef3ea" + integrity sha512-bsjleuRKWmGqajMerkzox19aGbscQX5rmmvvXl3wlIp5gMG1HgkiwPxsN5p070fBDKTNSPgojVbuY1+HWMbFhg== + +"@types/cookie@^0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.0.tgz#14f854c0f93d326e39da6e3b6f34f7d37513d108" + integrity sha512-y7mImlc/rNkvCRmg8gC3/lj87S7pTUIJ6QGjwHR9WQJcFs+ZMTOaoPrkdFA/YdbuqVEmEbb5RdhVxMkAcgOnpg== + "@types/cookiejar@*": version "2.1.1" resolved "https://registry.yarnpkg.com/@types/cookiejar/-/cookiejar-2.1.1.tgz#90b68446364baf9efd8e8349bb36bd3852b75b80" integrity sha512-aRnpPa7ysx3aNW60hTiCtLHlQaIFsXFCgQlpakNgDNVFzbtusSY8PwjAQgRWfSk0ekNoBjO51eQRB6upA9uuyw== +"@types/cors@^2.8.8": + version "2.8.10" + resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.10.tgz#61cc8469849e5bcdd0c7044122265c39cec10cf4" + integrity sha512-C7srjHiVG3Ey1nR6d511dtDkCEjxuN9W1HWAEjGq8kpcwmNM6JJkpC0xvabM7BXTG2wDq8Eu33iH9aQKa7IvLQ== + "@types/node@*": version "13.7.7" resolved "https://registry.yarnpkg.com/@types/node/-/node-13.7.7.tgz#1628e6461ba8cc9b53196dfeaeec7b07fa6eea99" integrity sha512-Uo4chgKbnPNlxQwoFmYIwctkQVkMMmsAoGGU4JKwLuvBefF0pCq4FybNSnfkfRCpC7ZW7kttcC/TrRtAJsvGtg== +"@types/node@>=10.0.0": + version "15.3.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-15.3.0.tgz#d6fed7d6bc6854306da3dea1af9f874b00783e26" + integrity sha512-8/bnjSZD86ZfpBsDlCIkNXIvm+h6wi9g7IqL+kmFkQ+Wvu3JrasgLElfiPgoo8V8vVfnEi0QVS12gbl94h9YsQ== + "@types/superagent@^3.8.3": version "3.8.7" resolved "https://registry.yarnpkg.com/@types/superagent/-/superagent-3.8.7.tgz#1f1ed44634d5459b3a672eb7235a8e7cfd97704c" @@ -254,7 +274,7 @@ "@types/cookiejar" "*" "@types/node" "*" -accepts@^1.3.7, accepts@~1.3.7: +accepts@^1.3.7, accepts@~1.3.4, accepts@~1.3.7: version "1.3.7" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd" integrity sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA== @@ -431,6 +451,16 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= +base64-arraybuffer@0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.1.4.tgz#9818c79e059b1355f97e0428a017c838e90ba812" + integrity sha1-mBjHngWbE1X5fgQooBfIOOkLqBI= + +base64id@2.0.0, base64id@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/base64id/-/base64id-2.0.0.tgz#2770ac6bc47d312af97a8bf9a634342e0cd25cb6" + integrity sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog== + basic-auth@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/basic-auth/-/basic-auth-2.0.0.tgz#015db3f353e02e56377755f962742e8981e7bbba" @@ -448,7 +478,7 @@ bluebird@^3.3.4: resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.1.tgz#d9551f9de98f1fcda1e683d17ee91a0602ee2eb9" integrity sha512-MKiLiV+I1AA596t9w1sQJ8jkiSr5+ZKi0WKrYGUn6d1Fx+Ij4tIj+m2WMQSGczs5jZVxV339chE8iwk6F64wjA== -body-parser@1.19.0, body-parser@^1.18.2: +body-parser@1.19.0: version "1.19.0" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a" integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw== @@ -690,7 +720,7 @@ commondir@^1.0.1: resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" integrity sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs= -component-emitter@^1.2.0, component-emitter@^1.3.0: +component-emitter@^1.2.0, component-emitter@^1.3.0, component-emitter@~1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== @@ -764,6 +794,11 @@ cookie@0.4.0: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba" integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg== +cookie@~0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1" + integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA== + cookiejar@^2.1.0, cookiejar@^2.1.1, cookiejar@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.2.tgz#dd8a235530752f988f9a0844f3fc589e3111125c" @@ -774,6 +809,14 @@ core-util-is@~1.0.0: resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= +cors@~2.8.5: + version "2.8.5" + resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" + integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== + dependencies: + object-assign "^4" + vary "^1" + cross-env@^5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-5.2.1.tgz#b2c76c1ca7add66dc874d11798466094f551b34d" @@ -827,6 +870,13 @@ debug@^4.0.1, debug@^4.1.0, debug@^4.1.1: dependencies: ms "^2.1.1" +debug@~4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee" + integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ== + dependencies: + ms "2.1.2" + decamelize@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" @@ -947,6 +997,26 @@ encodeurl@~1.0.2: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= +engine.io-parser@~4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-4.0.2.tgz#e41d0b3fb66f7bf4a3671d2038a154024edb501e" + integrity sha512-sHfEQv6nmtJrq6TKuIz5kyEKH/qSdK56H/A+7DnAuUPWosnIZAS2NHNcPLmyjtY3cGS/MqJdZbUjW97JU72iYg== + dependencies: + base64-arraybuffer "0.1.4" + +engine.io@~5.1.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-5.1.1.tgz#a1f97e51ddf10cbd4db8b5ff4b165aad3760cdd3" + integrity sha512-aMWot7H5aC8L4/T8qMYbLdvKlZOdJTH54FxfdFunTGvhMx1BHkJOntWArsVfgAZVwAO9LC2sryPWRcEeUzCe5w== + dependencies: + accepts "~1.3.4" + base64id "2.0.0" + cookie "~0.4.1" + cors "~2.8.5" + debug "~4.3.1" + engine.io-parser "~4.0.0" + ws "~7.4.2" + error-ex@^1.2.0: version "1.3.2" resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" @@ -2235,7 +2305,7 @@ ms@2.1.1: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg== -ms@^2.1.1: +ms@2.1.2, ms@^2.1.1: version "2.1.2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== @@ -2389,6 +2459,11 @@ nyc@^15.0.0: uuid "^3.3.3" yargs "^15.0.2" +object-assign@^4: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= + object-inspect@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.7.0.tgz#f4f6bd181ad77f006b5ece60bd0b6f398ff74a67" @@ -2860,6 +2935,11 @@ rimraf@^3.0.0: dependencies: glob "^7.1.3" +route-parser@^0.0.5: + version "0.0.5" + resolved "https://registry.yarnpkg.com/route-parser/-/route-parser-0.0.5.tgz#7d1d09d335e49094031ea16991a4a79b01bbe1f4" + integrity sha1-fR0J0zXkkJQDHqFpkaSnmwG74fQ= + run-async@^2.4.0: version "2.4.0" resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.0.tgz#e59054a5b86876cfae07f431d18cbaddc594f1e8" @@ -3014,6 +3094,42 @@ slice-ansi@^2.1.0: astral-regex "^1.0.0" is-fullwidth-code-point "^2.0.0" +socket.io-adapter@~2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-2.3.0.tgz#63090df6dd6d289b0806acff4a0b2f1952ffe37e" + integrity sha512-jdIbSFRWOkaZpo5mXy8T7rXEN6qo3bOFuq4nVeX1ZS7AtFlkbk39y153xTXEIW7W94vZfhVOux1wTU88YxcM1w== + +socket.io-parser@~4.0.3: + version "4.0.4" + resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.0.4.tgz#9ea21b0d61508d18196ef04a2c6b9ab630f4c2b0" + integrity sha512-t+b0SS+IxG7Rxzda2EVvyBZbvFPBCjJoyHuE0P//7OAsN23GItzDRdWa6ALxZI/8R5ygK7jAR6t028/z+7295g== + dependencies: + "@types/component-emitter" "^1.2.10" + component-emitter "~1.3.0" + debug "~4.3.1" + +socket.io-router-middleware@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/socket.io-router-middleware/-/socket.io-router-middleware-1.1.2.tgz#87fca4e826436275274ab867eec24bbd7c45f446" + integrity sha512-UmkZA0x0Ozf3Svq/q8a6We6kBaZOn5bTx/o2zgDE0+4vplFA1fdQRRYy7qzXSGPk+NhgePjQjUuqxP3c97P5IQ== + dependencies: + route-parser "^0.0.5" + +socket.io@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-4.1.2.tgz#f90f9002a8d550efe2aa1d320deebb9a45b83233" + integrity sha512-xK0SD1C7hFrh9+bYoYCdVt+ncixkSLKtNLCax5aEy1o3r5PaO5yQhVb97exIe67cE7lAK+EpyMytXWTWmyZY8w== + dependencies: + "@types/cookie" "^0.4.0" + "@types/cors" "^2.8.8" + "@types/node" ">=10.0.0" + accepts "~1.3.4" + base64id "~2.0.0" + debug "~4.3.1" + engine.io "~5.1.0" + socket.io-adapter "~2.3.0" + socket.io-parser "~4.0.3" + sonar-scanner@^3.0.3: version "3.1.0" resolved "https://registry.yarnpkg.com/sonar-scanner/-/sonar-scanner-3.1.0.tgz#51c1c1101f54b98abc5d8565209b1d9232979343" @@ -3393,7 +3509,7 @@ validate-npm-package-license@^3.0.1: spdx-correct "^3.0.0" spdx-expression-parse "^3.0.0" -vary@~1.1.2: +vary@^1, vary@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= @@ -3486,6 +3602,11 @@ write@1.0.3: dependencies: mkdirp "^0.5.1" +ws@~7.4.2: + version "7.4.5" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.5.tgz#a484dd851e9beb6fdb420027e3885e8ce48986c1" + integrity sha512-xzyu3hFvomRfXKH8vOFMU3OguG6oOvhXMo3xsGy3xWExqaM2dxBbVxuD99O7m3ZUFMvvscsZDqxfgMaRr/Nr1g== + y18n@^4.0.0, y18n@^4.0.1: version "4.0.3" resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf" From e294b18c7323ffe48f3ac8e1f0511acbfb4c6ed8 Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Thu, 20 May 2021 14:43:50 +0100 Subject: [PATCH 02/58] Update server.js Very small tweak to return the user name rather than their ID in the response and case listing. --- server.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server.js b/server.js index d80bb9a1..3b37aad1 100755 --- a/server.js +++ b/server.js @@ -138,8 +138,8 @@ function getCaseStatuses(caseIds) { const cs = caseStatuses[caseId]; if (cs) { obj[caseId] = { - viewers: [ ...cs.viewers.map(w => w.id) ], - editors: [ ...cs.editors.map(e => e.id) ] + viewers: [ ...cs.viewers.map(w => w.name) ], + editors: [ ...cs.editors.map(e => e.name) ] }; } return obj; From d3a0636594d044573a4d9f7d0c86b1a5da25361f Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Thu, 20 May 2021 17:17:13 +0100 Subject: [PATCH 03/58] Update server.js Separate routes for each type of action rather than a catch-all that then has to figure out what to do. Also, the user details are captured on a new 'register' route, rather than being supplied on every single message. Finally, some nicer logging to see what's going on. --- server.js | 94 ++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 68 insertions(+), 26 deletions(-) diff --git a/server.js b/server.js index 3b37aad1..7a673257 100755 --- a/server.js +++ b/server.js @@ -33,36 +33,69 @@ const io = require('socket.io')(server, { const IORouter = new require('socket.io-router-middleware'); const iorouter = new IORouter(); -// const socketsWatchingCases = {}; +// TODO: Track this stuff in redis. const caseStatuses = {}; - -// Add router paths -iorouter.on('/socket', (socket, ctx, next) => { - const payload = ctx.request.payload; - if (payload) { - if (payload.edit) { - handleEdit(socket, payload); - } else if (payload.view) { - handleView(socket, payload); - } else if (payload.watch) { - handleWatch(socket, payload.watch); +const socketUsers = {}; + +// Pretty way of logging. +function doLog(socket, payload, group) { + let text = `${new Date().toISOString()} | ${socket.id} | ${group}`; + if (typeof payload === 'string') { + if (payload) { + text = `${text} => ${payload}`; } + console.log(text); + } else { + console.group(text); + console.log(payload); + console.groupEnd(); } - // ctx.response = { hello: 'from server' }; - // socket.emit('response', ctx); - // Don't forget to call next() at the end to enable passing to other middlewares +} + +// Set up routes for each type of message. +iorouter.on('init', (socket, ctx, next) => { + // Do nothing in here. + doLog(socket, '', 'init'); + next(); +}); + +iorouter.on('register', (socket, ctx, next) => { + doLog(socket, ctx.request.user, 'register'); + socketUsers[socket.id] = ctx.request.user; + next(); +}); + +iorouter.on('view', (socket, ctx, next) => { + const user = socketUsers[socket.id]; + doLog(socket, `${ctx.request.caseId} (${user.name})`, 'view'); + handleView(socket, ctx.request.caseId, user); next(); }); + +iorouter.on('edit', (socket, ctx, next) => { + const user = socketUsers[socket.id]; + doLog(socket, `${ctx.request.caseId} (${user.name})`, 'edit'); + handleEdit(socket, ctx.request.caseId, user); + next(); +}); + +iorouter.on('watch', (socket, ctx, next) => { + const user = socketUsers[socket.id]; + doLog(socket, `${ctx.request.caseIds} (${user.name})`, 'watch'); + handleWatch(socket, ctx.request.caseIds); + next(); +}); + // On client connection attach the router io.on('connection', function (socket) { + // console.log('io.on.connection', socket.handshake); socket.use((packet, next) => { // Call router.attach() with the client socket as the first parameter iorouter.attach(socket, packet, next); }); }); -function handleEdit(socket, payload) { - const caseId = payload.edit; +function handleEdit(socket, caseId, user) { const stoppedViewing = stopViewingCases(socket.id); if (stoppedViewing.length > 0) { stoppedViewing.filter(c => c !== caseId).forEach(c => stopWatchingCase(socket, c)); @@ -74,10 +107,10 @@ function handleEdit(socket, payload) { watchCase(socket, caseId); const caseStatus = caseStatuses[caseId] || { viewers: [], editors: [] }; caseStatuses[caseId] = caseStatus; - const matchingEditor = caseStatus.editors.find(e => e.id === payload.user.id); + const matchingEditor = caseStatus.editors.find(e => e.id === user.id); const notify = stoppedViewing.concat(stoppedEditing); if (!matchingEditor) { - caseStatus.editors.push({ ...payload.user, socketId: socket.id }); + caseStatus.editors.push({ ...user, socketId: socket.id }); notify.push(caseId); } if (notify.length > 0) { @@ -86,8 +119,7 @@ function handleEdit(socket, payload) { socket.emit('response', getCaseStatuses([caseId])); } -function handleView(socket, payload) { - const caseId = payload.view; +function handleView(socket, caseId, user) { const stoppedViewing = stopViewingCases(socket.id, caseId); if (stoppedViewing.length > 0) { stoppedViewing.filter(c => c !== caseId).forEach(c => stopWatchingCase(socket, c)); @@ -99,10 +131,10 @@ function handleView(socket, payload) { watchCase(socket, caseId); const caseStatus = caseStatuses[caseId] || { viewers: [], editors: [] }; caseStatuses[caseId] = caseStatus; - const matchingViewer = caseStatus.viewers.find(v => v.id === payload.user.id); + const matchingViewer = caseStatus.viewers.find(v => v.id === user.id); const notify = stoppedViewing.concat(stoppedEditing); if (!matchingViewer) { - caseStatus.viewers.push({ ...payload.user, socketId: socket.id }); + caseStatus.viewers.push({ ...user, socketId: socket.id }); notify.push(caseId); } if (notify.length > 0) { @@ -129,6 +161,8 @@ function stopWatchingCase(socket, caseId) { } function notifyWatchers(socket, caseIds) { caseIds.sort().forEach(caseId => { + const cs = getCaseStatuses([caseId]); + doLog(socket, cs, `notify room 'case:${caseId}'`); socket.to(`case:${caseId}`).emit('cases', getCaseStatuses([caseId])); }); } @@ -138,13 +172,16 @@ function getCaseStatuses(caseIds) { const cs = caseStatuses[caseId]; if (cs) { obj[caseId] = { - viewers: [ ...cs.viewers.map(w => w.name) ], - editors: [ ...cs.editors.map(e => e.name) ] + viewers: [ ...cs.viewers.map(w => toUser(w)) ], + editors: [ ...cs.editors.map(e => toUser(e)) ] }; } return obj; }, {}); } +function toUser(obj) { + return { id: obj.id, name: obj.name }; +} function stopViewingOrEditing(socketId, exceptCaseId) { const stoppedViewing = stopViewingCases(socketId, exceptCaseId); @@ -179,11 +216,16 @@ function stopEditingCases(socketId, exceptCaseId) { const connections = []; io.sockets.on("connection", (socket) => { connections.push(socket); - console.log(" %s sockets is connected", connections.length); + if (connections.length === 1) { + console.log("1 socket connected"); + } else { + console.log("%s sockets connected", connections.length); + } // console.log('connections[0]', connections[0]); socket.on("disconnect", () => { console.log(socket.id, 'has disconnected'); stopViewingOrEditing(socket.id); + delete socketUsers[socket.id]; connections.splice(connections.indexOf(socket), 1); }); socket.on("sending message", (message) => { From 36714d3376ecf9b3fe8b744df8721e329444ac30 Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Fri, 21 May 2021 11:48:20 +0100 Subject: [PATCH 04/58] Update cors.js First check that there IS a whitelist before trying to split it. --- app/security/cors.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/security/cors.js b/app/security/cors.js index 9eadc7a2..d21ef04b 100644 --- a/app/security/cors.js +++ b/app/security/cors.js @@ -1,7 +1,8 @@ const config = require('config'); const createWhitelistValidator = (val) => { - const whitelist = config.get('security.cors_origin_whitelist').split(','); + const configValue = config.get('security.cors_origin_whitelist') || ''; + const whitelist = configValue.split(','); for (let i = 0; i < whitelist.length; i += 1) { if (val === whitelist[i]) { return true; From f190a45a2b7852d945d9a9a114d88a0ed072c99e Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Fri, 21 May 2021 12:02:26 +0100 Subject: [PATCH 05/58] Refactor Shifted all the socket logic into a app/socket/index.js. --- app/socket/index.js | 222 ++++++++++++++++++++++++++++++++++++++++++++ server.js | 211 +---------------------------------------- 2 files changed, 223 insertions(+), 210 deletions(-) create mode 100644 app/socket/index.js diff --git a/app/socket/index.js b/app/socket/index.js new file mode 100644 index 00000000..1dfc60c1 --- /dev/null +++ b/app/socket/index.js @@ -0,0 +1,222 @@ +/** + * Sets up a series of routes for a "socket" endpoint, that + * leverages socket.io and will more than likely use long polling + * instead of websockets as the latter isn't supported by Azure + * Front Door. + * + * The behaviour is the same, though. + * + * TODO: + * 1. Use redis rather than holding the details in memory. + * 2. Some sort of auth / get the credentials when the user connects. + */ +module.exports = (server) => { + const io = require('socket.io')(server, { + allowEIO3: true + }); + const IORouter = new require('socket.io-router-middleware'); + const iorouter = new IORouter(); + + // TODO: Track this stuff in redis. + const caseStatuses = {}; + const socketUsers = {}; + + // Pretty way of logging. + function doLog(socket, payload, group) { + let text = `${new Date().toISOString()} | ${socket.id} | ${group}`; + if (typeof payload === 'string') { + if (payload) { + text = `${text} => ${payload}`; + } + console.log(text); + } else { + console.group(text); + console.log(payload); + console.groupEnd(); + } + } + + // Set up routes for each type of message. + iorouter.on('init', (socket, ctx, next) => { + // Do nothing in here. + doLog(socket, '', 'init'); + next(); + }); + + iorouter.on('register', (socket, ctx, next) => { + doLog(socket, ctx.request.user, 'register'); + socketUsers[socket.id] = ctx.request.user; + next(); + }); + + iorouter.on('view', (socket, ctx, next) => { + const user = socketUsers[socket.id]; + doLog(socket, `${ctx.request.caseId} (${user.name})`, 'view'); + handleView(socket, ctx.request.caseId, user); + next(); + }); + + iorouter.on('edit', (socket, ctx, next) => { + const user = socketUsers[socket.id]; + doLog(socket, `${ctx.request.caseId} (${user.name})`, 'edit'); + handleEdit(socket, ctx.request.caseId, user); + next(); + }); + + iorouter.on('watch', (socket, ctx, next) => { + const user = socketUsers[socket.id]; + doLog(socket, `${ctx.request.caseIds} (${user.name})`, 'watch'); + handleWatch(socket, ctx.request.caseIds); + next(); + }); + + // On client connection attach the router + io.on('connection', function (socket) { + // console.log('io.on.connection', socket.handshake); + socket.use((packet, next) => { + // Call router.attach() with the client socket as the first parameter + iorouter.attach(socket, packet, next); + }); + }); + + function handleEdit(socket, caseId, user) { + const stoppedViewing = stopViewingCases(socket.id); + if (stoppedViewing.length > 0) { + stoppedViewing.filter(c => c !== caseId).forEach(c => stopWatchingCase(socket, c)); + } + const stoppedEditing = stopEditingCases(socket.id, caseId); + if (stoppedEditing.length > 0) { + stoppedEditing.filter(c => c !== caseId).forEach(c => stopWatchingCase(socket, c)); + } + watchCase(socket, caseId); + const caseStatus = caseStatuses[caseId] || { viewers: [], editors: [] }; + caseStatuses[caseId] = caseStatus; + const matchingEditor = caseStatus.editors.find(e => e.id === user.id); + const notify = stoppedViewing.concat(stoppedEditing); + if (!matchingEditor) { + caseStatus.editors.push({ ...user, socketId: socket.id }); + notify.push(caseId); + } + if (notify.length > 0) { + notifyWatchers(socket, [ ...new Set(notify) ]); + } + socket.emit('response', getCaseStatuses([caseId])); + } + + function handleView(socket, caseId, user) { + const stoppedViewing = stopViewingCases(socket.id, caseId); + if (stoppedViewing.length > 0) { + stoppedViewing.filter(c => c !== caseId).forEach(c => stopWatchingCase(socket, c)); + } + const stoppedEditing = stopEditingCases(socket.id); + if (stoppedEditing.length > 0) { + stoppedEditing.filter(c => c !== caseId).forEach(c => stopWatchingCase(socket, c)); + } + watchCase(socket, caseId); + const caseStatus = caseStatuses[caseId] || { viewers: [], editors: [] }; + caseStatuses[caseId] = caseStatus; + const matchingViewer = caseStatus.viewers.find(v => v.id === user.id); + const notify = stoppedViewing.concat(stoppedEditing); + if (!matchingViewer) { + caseStatus.viewers.push({ ...user, socketId: socket.id }); + notify.push(caseId); + } + if (notify.length > 0) { + notifyWatchers(socket, [ ...new Set(notify) ]); + } + socket.emit('response', getCaseStatuses([caseId])); + } + + function handleWatch(socket, caseIds) { + watchCases(socket, caseIds); + socket.emit('cases', getCaseStatuses(caseIds)); + } + + function watchCases(socket, caseIds) { + caseIds.forEach(caseId => { + watchCase(socket, caseId); + }); + } + function watchCase(socket, caseId) { + socket.join(`case:${caseId}`); + } + function stopWatchingCase(socket, caseId) { + socket.leave(`case:${caseId}`); + } + function notifyWatchers(socket, caseIds) { + caseIds.sort().forEach(caseId => { + const cs = getCaseStatuses([caseId]); + doLog(socket, cs, `notify room 'case:${caseId}'`); + socket.to(`case:${caseId}`).emit('cases', getCaseStatuses([caseId])); + }); + } + + function getCaseStatuses(caseIds) { + return caseIds.reduce((obj, caseId) => { + const cs = caseStatuses[caseId]; + if (cs) { + obj[caseId] = { + viewers: [ ...cs.viewers.map(w => toUser(w)) ], + editors: [ ...cs.editors.map(e => toUser(e)) ] + }; + } + return obj; + }, {}); + } + function toUser(obj) { + return { id: obj.id, name: obj.name }; + } + + function stopViewingOrEditing(socketId, exceptCaseId) { + const stoppedViewing = stopViewingCases(socketId, exceptCaseId); + const stoppedEditing = stopEditingCases(socketId, exceptCaseId); + return { stoppedViewing, stoppedEditing }; + } + function stopViewingCases(socketId, exceptCaseId) { + const affectedCases = []; + Object.keys(caseStatuses).filter(key => key !== exceptCaseId).forEach(key => { + const c = caseStatuses[key]; + const viewer = c.viewers.find(v => v.socketId === socketId); + if (viewer) { + c.viewers.splice(c.viewers.indexOf(viewer), 1); + affectedCases.push(key); + } + }); + return affectedCases; + } + function stopEditingCases(socketId, exceptCaseId) { + const affectedCases = []; + Object.keys(caseStatuses).filter(key => key !== exceptCaseId).forEach(key => { + const c = caseStatuses[key]; + const editor = c.editors.find(e => e.socketId === socketId); + if (editor) { + c.editors.splice(c.editors.indexOf(editor), 1); + affectedCases.push(key); + } + }); + return affectedCases; + } + + const connections = []; + io.sockets.on("connection", (socket) => { + connections.push(socket); + if (connections.length === 1) { + console.log("1 socket connected"); + } else { + console.log("%s sockets connected", connections.length); + } + // console.log('connections[0]', connections[0]); + socket.on("disconnect", () => { + console.log(socket.id, 'has disconnected'); + stopViewingOrEditing(socket.id); + delete socketUsers[socket.id]; + connections.splice(connections.indexOf(socket), 1); + }); + socket.on("sending message", (message) => { + console.log("Message is received :", message); + io.sockets.emit("new message", { message: message }); + }); + }); + + return io; +}; \ No newline at end of file diff --git a/server.js b/server.js index 7a673257..13b4cb37 100755 --- a/server.js +++ b/server.js @@ -23,216 +23,7 @@ app.set('port', port); */ var server = http.createServer(app); - -/** - * Set up a socket.io router. - */ -const io = require('socket.io')(server, { - allowEIO3: true -}); -const IORouter = new require('socket.io-router-middleware'); -const iorouter = new IORouter(); - -// TODO: Track this stuff in redis. -const caseStatuses = {}; -const socketUsers = {}; - -// Pretty way of logging. -function doLog(socket, payload, group) { - let text = `${new Date().toISOString()} | ${socket.id} | ${group}`; - if (typeof payload === 'string') { - if (payload) { - text = `${text} => ${payload}`; - } - console.log(text); - } else { - console.group(text); - console.log(payload); - console.groupEnd(); - } -} - -// Set up routes for each type of message. -iorouter.on('init', (socket, ctx, next) => { - // Do nothing in here. - doLog(socket, '', 'init'); - next(); -}); - -iorouter.on('register', (socket, ctx, next) => { - doLog(socket, ctx.request.user, 'register'); - socketUsers[socket.id] = ctx.request.user; - next(); -}); - -iorouter.on('view', (socket, ctx, next) => { - const user = socketUsers[socket.id]; - doLog(socket, `${ctx.request.caseId} (${user.name})`, 'view'); - handleView(socket, ctx.request.caseId, user); - next(); -}); - -iorouter.on('edit', (socket, ctx, next) => { - const user = socketUsers[socket.id]; - doLog(socket, `${ctx.request.caseId} (${user.name})`, 'edit'); - handleEdit(socket, ctx.request.caseId, user); - next(); -}); - -iorouter.on('watch', (socket, ctx, next) => { - const user = socketUsers[socket.id]; - doLog(socket, `${ctx.request.caseIds} (${user.name})`, 'watch'); - handleWatch(socket, ctx.request.caseIds); - next(); -}); - -// On client connection attach the router -io.on('connection', function (socket) { - // console.log('io.on.connection', socket.handshake); - socket.use((packet, next) => { - // Call router.attach() with the client socket as the first parameter - iorouter.attach(socket, packet, next); - }); -}); - -function handleEdit(socket, caseId, user) { - const stoppedViewing = stopViewingCases(socket.id); - if (stoppedViewing.length > 0) { - stoppedViewing.filter(c => c !== caseId).forEach(c => stopWatchingCase(socket, c)); - } - const stoppedEditing = stopEditingCases(socket.id, caseId); - if (stoppedEditing.length > 0) { - stoppedEditing.filter(c => c !== caseId).forEach(c => stopWatchingCase(socket, c)); - } - watchCase(socket, caseId); - const caseStatus = caseStatuses[caseId] || { viewers: [], editors: [] }; - caseStatuses[caseId] = caseStatus; - const matchingEditor = caseStatus.editors.find(e => e.id === user.id); - const notify = stoppedViewing.concat(stoppedEditing); - if (!matchingEditor) { - caseStatus.editors.push({ ...user, socketId: socket.id }); - notify.push(caseId); - } - if (notify.length > 0) { - notifyWatchers(socket, [ ...new Set(notify) ]); - } - socket.emit('response', getCaseStatuses([caseId])); -} - -function handleView(socket, caseId, user) { - const stoppedViewing = stopViewingCases(socket.id, caseId); - if (stoppedViewing.length > 0) { - stoppedViewing.filter(c => c !== caseId).forEach(c => stopWatchingCase(socket, c)); - } - const stoppedEditing = stopEditingCases(socket.id); - if (stoppedEditing.length > 0) { - stoppedEditing.filter(c => c !== caseId).forEach(c => stopWatchingCase(socket, c)); - } - watchCase(socket, caseId); - const caseStatus = caseStatuses[caseId] || { viewers: [], editors: [] }; - caseStatuses[caseId] = caseStatus; - const matchingViewer = caseStatus.viewers.find(v => v.id === user.id); - const notify = stoppedViewing.concat(stoppedEditing); - if (!matchingViewer) { - caseStatus.viewers.push({ ...user, socketId: socket.id }); - notify.push(caseId); - } - if (notify.length > 0) { - notifyWatchers(socket, [ ...new Set(notify) ]); - } - socket.emit('response', getCaseStatuses([caseId])); -} - -function handleWatch(socket, caseIds) { - watchCases(socket, caseIds); - socket.emit('cases', getCaseStatuses(caseIds)); -} - -function watchCases(socket, caseIds) { - caseIds.forEach(caseId => { - watchCase(socket, caseId); - }); -} -function watchCase(socket, caseId) { - socket.join(`case:${caseId}`); -} -function stopWatchingCase(socket, caseId) { - socket.leave(`case:${caseId}`); -} -function notifyWatchers(socket, caseIds) { - caseIds.sort().forEach(caseId => { - const cs = getCaseStatuses([caseId]); - doLog(socket, cs, `notify room 'case:${caseId}'`); - socket.to(`case:${caseId}`).emit('cases', getCaseStatuses([caseId])); - }); -} - -function getCaseStatuses(caseIds) { - return caseIds.reduce((obj, caseId) => { - const cs = caseStatuses[caseId]; - if (cs) { - obj[caseId] = { - viewers: [ ...cs.viewers.map(w => toUser(w)) ], - editors: [ ...cs.editors.map(e => toUser(e)) ] - }; - } - return obj; - }, {}); -} -function toUser(obj) { - return { id: obj.id, name: obj.name }; -} - -function stopViewingOrEditing(socketId, exceptCaseId) { - const stoppedViewing = stopViewingCases(socketId, exceptCaseId); - const stoppedEditing = stopEditingCases(socketId, exceptCaseId); - return { stoppedViewing, stoppedEditing }; -} -function stopViewingCases(socketId, exceptCaseId) { - const affectedCases = []; - Object.keys(caseStatuses).filter(key => key !== exceptCaseId).forEach(key => { - const c = caseStatuses[key]; - const viewer = c.viewers.find(v => v.socketId === socketId); - if (viewer) { - c.viewers.splice(c.viewers.indexOf(viewer), 1); - affectedCases.push(key); - } - }); - return affectedCases; -} -function stopEditingCases(socketId, exceptCaseId) { - const affectedCases = []; - Object.keys(caseStatuses).filter(key => key !== exceptCaseId).forEach(key => { - const c = caseStatuses[key]; - const editor = c.editors.find(e => e.socketId === socketId); - if (editor) { - c.editors.splice(c.editors.indexOf(editor), 1); - affectedCases.push(key); - } - }); - return affectedCases; -} - -const connections = []; -io.sockets.on("connection", (socket) => { - connections.push(socket); - if (connections.length === 1) { - console.log("1 socket connected"); - } else { - console.log("%s sockets connected", connections.length); - } - // console.log('connections[0]', connections[0]); - socket.on("disconnect", () => { - console.log(socket.id, 'has disconnected'); - stopViewingOrEditing(socket.id); - delete socketUsers[socket.id]; - connections.splice(connections.indexOf(socket), 1); - }); - socket.on("sending message", (message) => { - console.log("Message is received :", message); - io.sockets.emit("new message", { message: message }); - }); -}); +var io = require('./app/socket')(server); /** * Listen on provided port, on all network interfaces. From c72f4bbdd339224dcbd73ecb2e47a6b40a07fec9 Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Mon, 24 May 2021 12:11:12 +0100 Subject: [PATCH 06/58] Redis instantiator We need two redis clients to support pub/sub so made an instantiator that is called from the existing `redis/redis-client.js` and is also now being called from `socket/redis-watcher.js`. --- app/redis/instantiator.js | 48 +++++++++++++++++++++++++++++++++++++ app/redis/redis-client.js | 46 +---------------------------------- app/socket/redis-watcher.js | 3 +++ 3 files changed, 52 insertions(+), 45 deletions(-) create mode 100644 app/redis/instantiator.js create mode 100644 app/socket/redis-watcher.js diff --git a/app/redis/instantiator.js b/app/redis/instantiator.js new file mode 100644 index 00000000..3a383b1b --- /dev/null +++ b/app/redis/instantiator.js @@ -0,0 +1,48 @@ +const config = require('config'); +const Redis = require('ioredis'); + +const ERROR = 0; +const RESULT = 1; +const ENV = config.util.getEnv('NODE_ENV'); + +module.exports = (debug) => { + const redis = new Redis({ + port: config.get('redis.port'), + host: config.get('redis.host'), + password: config.get('secrets.ccd.activity-redis-password'), + tls: config.get('redis.ssl'), + keyPrefix: config.get('redis.keyPrefix'), + // log unhandled redis errors + showFriendlyErrorStack: ENV === 'test' || ENV === 'dev', + }); + + /* redis pipeline returns a reply of the form [[op1error, op1result], [op2error, op2result], ...]. + error is null in case of success */ + redis.logPipelineFailures = (plOutcome, message) => { + if (Array.isArray(plOutcome)) { + const operationsFailureOutcome = plOutcome.map((operationOutcome) => operationOutcome[ERROR]); + const failures = operationsFailureOutcome.filter((element) => element !== null); + failures.forEach((f) => debug(`${message}: ${f}`)); + } else { + debug(`${plOutcome} is not an Array...`); + } + return plOutcome; + }; + + redis.extractPipelineResults = (pipelineOutcome) => { + const results = pipelineOutcome.map((operationOutcome) => operationOutcome[RESULT]); + debug(`pipeline results: ${results}`); + return results; + }; + + redis + .on('error', (err) => { + // eslint-disable-next-line no-console + console.log(`Redis error: ${err.message}`); + }).on('connect', () => { + // eslint-disable-next-line no-console + console.log('connected to Redis'); + }); + + return redis; +}; diff --git a/app/redis/redis-client.js b/app/redis/redis-client.js index d88faeeb..a14d64b0 100644 --- a/app/redis/redis-client.js +++ b/app/redis/redis-client.js @@ -1,47 +1,3 @@ -const config = require('config'); const debug = require('debug')('ccd-case-activity-api:redis-client'); -const Redis = require('ioredis'); -const ERROR = 0; -const RESULT = 1; -const ENV = config.util.getEnv('NODE_ENV'); - -const redis = new Redis({ - port: config.get('redis.port'), - host: config.get('redis.host'), - password: config.get('secrets.ccd.activity-redis-password'), - tls: config.get('redis.ssl'), - keyPrefix: config.get('redis.keyPrefix'), - // log unhandled redis errors - showFriendlyErrorStack: ENV === 'test' || ENV === 'dev', -}); - -/* redis pipeline returns a reply of the form [[op1error, op1result], [op2error, op2result], ...]. - error is null in case of success */ -redis.logPipelineFailures = (plOutcome, message) => { - if (Array.isArray(plOutcome)) { - const operationsFailureOutcome = plOutcome.map((operationOutcome) => operationOutcome[ERROR]); - const failures = operationsFailureOutcome.filter((element) => element !== null); - failures.forEach((f) => debug(`${message}: ${f}`)); - } else { - debug(`${plOutcome} is not an Array...`); - } - return plOutcome; -}; - -redis.extractPipelineResults = (pipelineOutcome) => { - const results = pipelineOutcome.map((operationOutcome) => operationOutcome[RESULT]); - debug(`pipeline results: ${results}`); - return results; -}; - -redis - .on('error', (err) => { - // eslint-disable-next-line no-console - console.log(`Redis error: ${err.message}`); - }).on('connect', () => { - // eslint-disable-next-line no-console - console.log('connected to Redis'); - }); - -module.exports = redis; +module.exports = require('./instantiator')(debug); diff --git a/app/socket/redis-watcher.js b/app/socket/redis-watcher.js new file mode 100644 index 00000000..50c1a4e3 --- /dev/null +++ b/app/socket/redis-watcher.js @@ -0,0 +1,3 @@ +const debug = require('debug')('ccd-case-activity-api:redis-watcher'); + +module.exports = require('../redis/instantiator')(debug); From 10b3f97476cf34e9fd54e186e60d218cb7586bb6 Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Mon, 24 May 2021 12:13:56 +0100 Subject: [PATCH 07/58] Redis persistence Socket connections now persist to and retrieve from the redis database. There is further refactoring required, not least a mechanism for removing an entry when a user disconnects. There is also a second redis client to support pub/sub so any changes to the redis keys is accompanied by a redis publish that the socket's version of the activity service is subscribed to and then accordingly handles and notifies users about. --- app.js | 2 +- app/socket/activity-service.js | 120 +++++++++++++++++++++++++++++++++ app/socket/index.js | 113 ++++++++++++++++++++++--------- server.js | 10 ++- 4 files changed, 210 insertions(+), 35 deletions(-) create mode 100644 app/socket/activity-service.js diff --git a/app.js b/app.js index 26a0d565..12be98ba 100644 --- a/app.js +++ b/app.js @@ -72,4 +72,4 @@ app.use((err, req, res, next) => { }); }); -module.exports = app; +module.exports = { app, redis }; diff --git a/app/socket/activity-service.js b/app/socket/activity-service.js new file mode 100644 index 00000000..00ae6c31 --- /dev/null +++ b/app/socket/activity-service.js @@ -0,0 +1,120 @@ +const debug = require('debug')('ccd-case-activity-api:activity-service'); + +module.exports = (config, redis, ttlScoreGenerator) => { + const redisActivityKeys = { + view: (caseId) => `case:${caseId}:viewers`, + edit: (caseId) => `case:${caseId}:editors`, + base: (caseId) => `case:${caseId}` + }; + const userDetailsTtlSec = config.get('redis.userDetailsTtlSec'); + const toUserString = (user) => { + return JSON.stringify({ + id: user.uid, + forename: user.given_name, + surname: user.family_name + }); + }; + + const addActivity = (caseId, user, activity) => { + const storeUserActivity = () => { + const key = redisActivityKeys[activity](caseId); + debug(`about to store user activity with key: ${key}`); + return ['zadd', key, ttlScoreGenerator.getScore(), user.uid]; + }; + + const storeUserDetails = () => { + const userDetails = toUserString(user); + const key = `user:${user.uid}`; + debug(`about to store user details with key ${key}: ${userDetails}`); + return ['set', key, userDetails, 'EX', userDetailsTtlSec]; + }; + return redis.pipeline([ + storeUserActivity(), + storeUserDetails() + ]).exec().then(() => { + redis.publish(redisActivityKeys.base(caseId), Date.now().toString()); + }); + }; + + /** + * TODO: Implement a mechanism to remove activity. There are a few options here: + * I think this will require us to track which activities relates to which sockets + * so we can clear them whenever the socket disconnects. + * @param {*} caseId + * @param {*} user + * @param {*} activity + * @returns + */ + const removeActivity = (caseId, user, activity) => { + const removeUserActivity = () => { + const key = redisActivityKeys[activity](caseId); + debug(`about to remove user activity with key: ${key}`); + return ['zrem', key, user.uid]; + }; + + return redis.pipeline([ + removeUserActivity() + ]).exec().then(() => { + redis.publish(redisActivityKeys.base(caseId), Date.now().toString()); + }); + }; + + const getActivityForCases = async (caseIds) => { + const uniqueUserIds = []; + let caseViewers = []; + let caseEditors = []; + const now = Date.now(); + const getUserDetails = () => redis.pipeline(uniqueUserIds.map((userId) => ['get', `user:${userId}`])).exec(); + const extractUniqueUserIds = (result) => { + if (result) { + result.forEach(item => { + if (item && item[1]) { + item[1].forEach(userId => { + if (!uniqueUserIds.includes(userId)) { + uniqueUserIds.push(userId); + } + }); + } + }); + } + }; + const caseViewersPromise = redis + .pipeline(caseIds.map(caseId => ['zrangebyscore', `case:${caseId}:viewers`, now, '+inf'])) + .exec() + .then(result => { + redis.logPipelineFailures(result, 'caseViewersPromise'); + caseViewers = result; + extractUniqueUserIds(result); + }); + const caseEditorsPromise = redis + .pipeline(caseIds.map(caseId => ['zrangebyscore', `case:${caseId}:editors`, now, '+inf'])) + .exec() + .then(result => { + redis.logPipelineFailures(result, 'caseEditorsPromise'); + caseEditors = result; + extractUniqueUserIds(result); + }); + await Promise.all([caseViewersPromise, caseEditorsPromise]); + + const userDetails = await getUserDetails().reduce((obj, item) => { + const user = JSON.parse(item[1]); + obj[user.id] = { forename: user.forename, surname: user.surname }; + return obj; + }, {}); + + return caseIds.map((caseId, index) => { + redis.logPipelineFailures(userDetails, 'userDetails'); + const cv = caseViewers[index][1], ce = caseEditors[index][1]; + const viewers = cv ? cv.map(v => userDetails[v]) : []; + const editors = ce ? ce.map(e => userDetails[e]) : []; + return { + caseId, + viewers: viewers.filter(v => !!v), + unknownViewers: viewers.filter(v => !v).length, + editors: editors.filter(e => !!e), + unknownEditors: editors.filter(e => !e).length + }; + }); + }; + return { addActivity, removeActivity, getActivityForCases }; +}; diff --git a/app/socket/index.js b/app/socket/index.js index 1dfc60c1..3357b3e4 100644 --- a/app/socket/index.js +++ b/app/socket/index.js @@ -1,3 +1,10 @@ +const config = require('config'); +const ttlScoreGenerator = require('../service/ttl-score-generator'); +const redisWatcher = require('./redis-watcher'); + +const IORouter = new require('socket.io-router-middleware'); +const iorouter = new IORouter(); + /** * Sets up a series of routes for a "socket" endpoint, that * leverages socket.io and will more than likely use long polling @@ -9,13 +16,40 @@ * TODO: * 1. Use redis rather than holding the details in memory. * 2. Some sort of auth / get the credentials when the user connects. + * + * Add view activity looks like this: + * addActivity 1588201414700270 { + sub: 'leeds_et@mailinator.com', + uid: '85269805-3a70-419d-acab-193faeb89ad3', + roles: [ + 'caseworker-employment', + 'caseworker-employment-leeds', + 'caseworker' + ], + name: 'Ethos Leeds', + given_name: 'Ethos', + family_name: 'Leeds' + } view + * */ -module.exports = (server) => { +module.exports = (server, redis) => { + const activityService = require('./activity-service')(config, redis, ttlScoreGenerator); const io = require('socket.io')(server, { allowEIO3: true }); - const IORouter = new require('socket.io-router-middleware'); - const iorouter = new IORouter(); + + async function whenActivityForCases(caseIds) { + const caseActivty = await activityService.getActivityForCases(caseIds); + console.log('activity for cases', caseIds, caseActivty); + return caseActivty; + } + async function notifyWatchers(caseIds) { + caseIds = Array.isArray(caseIds) ? caseIds : [caseIds]; + caseIds.sort().forEach(async (caseId) => { + const cs = await whenActivityForCases([caseId]); + io.to(`case:${caseId}`).emit('cases', cs); + }); + } // TODO: Track this stuff in redis. const caseStatuses = {}; @@ -36,6 +70,28 @@ module.exports = (server) => { } } + redisWatcher.on('message', room => { + const caseId = room.replace('case:', ''); + console.log('redisWatcher.on.message', room, caseId); + notifyWatchers([caseId]); + // io.to(room).emit(message); + }); + + + // When a new room is created, we want to watch for changes to that case. + io.of('/').adapter.on('create-room', (room) => { + console.log(`room ${room} was created`); + if (room.indexOf('case:') === 0) { + redisWatcher.subscribe(`${room}`); + } + }); + + io.of('/').adapter.on('delete-room', (room) => { + if (room.indexOf('case:') === 0) { + redisWatcher.unsubscribe(`${room}`); + } + }); + // Set up routes for each type of message. iorouter.on('init', (socket, ctx, next) => { // Do nothing in here. @@ -53,6 +109,9 @@ module.exports = (server) => { const user = socketUsers[socket.id]; doLog(socket, `${ctx.request.caseId} (${user.name})`, 'view'); handleView(socket, ctx.request.caseId, user); + + // Try sticking it in redis. + activityService.addActivity(ctx.request.caseId, toUser(user), 'view'); next(); }); @@ -60,6 +119,9 @@ module.exports = (server) => { const user = socketUsers[socket.id]; doLog(socket, `${ctx.request.caseId} (${user.name})`, 'edit'); handleEdit(socket, ctx.request.caseId, user); + + // Try sticking it in redis. + activityService.addActivity(ctx.request.caseId, toUser(user), 'edit'); next(); }); @@ -72,7 +134,6 @@ module.exports = (server) => { // On client connection attach the router io.on('connection', function (socket) { - // console.log('io.on.connection', socket.handshake); socket.use((packet, next) => { // Call router.attach() with the client socket as the first parameter iorouter.attach(socket, packet, next); @@ -98,9 +159,8 @@ module.exports = (server) => { notify.push(caseId); } if (notify.length > 0) { - notifyWatchers(socket, [ ...new Set(notify) ]); + notifyWatchers([ ...new Set(notify) ]); } - socket.emit('response', getCaseStatuses([caseId])); } function handleView(socket, caseId, user) { @@ -122,14 +182,14 @@ module.exports = (server) => { notify.push(caseId); } if (notify.length > 0) { - notifyWatchers(socket, [ ...new Set(notify) ]); + notifyWatchers([ ...new Set(notify) ]); } - socket.emit('response', getCaseStatuses([caseId])); } - function handleWatch(socket, caseIds) { + async function handleWatch(socket, caseIds) { watchCases(socket, caseIds); - socket.emit('cases', getCaseStatuses(caseIds)); + const cs = await whenActivityForCases(caseIds); + socket.emit('cases', cs); } function watchCases(socket, caseIds) { @@ -143,28 +203,19 @@ module.exports = (server) => { function stopWatchingCase(socket, caseId) { socket.leave(`case:${caseId}`); } - function notifyWatchers(socket, caseIds) { - caseIds.sort().forEach(caseId => { - const cs = getCaseStatuses([caseId]); - doLog(socket, cs, `notify room 'case:${caseId}'`); - socket.to(`case:${caseId}`).emit('cases', getCaseStatuses([caseId])); - }); - } - - function getCaseStatuses(caseIds) { - return caseIds.reduce((obj, caseId) => { - const cs = caseStatuses[caseId]; - if (cs) { - obj[caseId] = { - viewers: [ ...cs.viewers.map(w => toUser(w)) ], - editors: [ ...cs.editors.map(e => toUser(e)) ] - }; - } - return obj; - }, {}); - } function toUser(obj) { - return { id: obj.id, name: obj.name }; + return { + sub: `${obj.name.replace(' ', '.')}@mailinator.com`, + uid: obj.id, + roles: [ + 'caseworker-employment', + 'caseworker-employment-leeds', + 'caseworker' + ], + name: obj.name, + given_name: obj.name.split(' ')[0], + family_name: obj.name.split(' ')[1] + }; } function stopViewingOrEditing(socketId, exceptCaseId) { diff --git a/server.js b/server.js index 13b4cb37..6a4447ce 100755 --- a/server.js +++ b/server.js @@ -16,14 +16,18 @@ var http = require('http'); var port = normalizePort(process.env.PORT || '3460'); console.log('Starting on port ' + port); -app.set('port', port); +app.app.set('port', port); /** * Create HTTP server. */ -var server = http.createServer(app); -var io = require('./app/socket')(server); +var server = http.createServer(app.app); + +/** + * Create the socket server. + */ +require('./app/socket')(server, app.redis); /** * Listen on provided port, on all network interfaces. From 329a8b72b7affe0e9b8fcb4eb06eae3ec2f92b51 Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Mon, 24 May 2021 17:24:46 +0100 Subject: [PATCH 08/58] Track what each socket is doing The activity service now also tracks what each socket is doing - i.e., viewing or editing a case - and only allows one activity for each socket at any one time. When a socket disconnects, the activity is cleared out as it's considered that the user is no longer view/editing a case. Also sorted out a bunch of lint errors and warnings. --- .eslintrc.yml | 4 + app/job/store-cleanup-job.js | 4 +- app/redis/instantiator.js | 5 +- app/routes/validate-request.js | 4 +- app/service/activity-service.js | 3 +- app/service/ttl-score-generator.js | 8 +- app/socket/activity-service.js | 108 +++++--- app/socket/index.js | 248 ++++++------------ server.js | 1 - test/e2e/utils/activity-store-commands.js | 5 +- test/spec/app/health/health-check.spec.js | 6 +- .../spec/app/service/activity-service.spec.js | 10 +- 12 files changed, 176 insertions(+), 230 deletions(-) diff --git a/.eslintrc.yml b/.eslintrc.yml index c0b019b5..9f5ce475 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -2,3 +2,7 @@ extends: airbnb-base env: mocha: true jasmine: true +rules: + comma-dangle: 0 + arrow-body-style: 0 + no-param-reassign: [ 2, { props: false } ] diff --git a/app/job/store-cleanup-job.js b/app/job/store-cleanup-job.js index fca51559..0da3179e 100644 --- a/app/job/store-cleanup-job.js +++ b/app/job/store-cleanup-job.js @@ -1,11 +1,9 @@ const cron = require('node-cron'); const debug = require('debug')('ccd-case-activity-api:store-cleanup-job'); -const moment = require('moment'); const config = require('config'); const redis = require('../redis/redis-client'); const { logPipelineFailures } = redis; -const now = () => moment().valueOf(); const REDIS_ACTIVITY_KEY_PREFIX = config.get('redis.keyPrefix'); const scanExistingCasesKeys = (f) => { @@ -30,7 +28,7 @@ const scanExistingCasesKeys = (f) => { const getCasesWithActivities = (f) => scanExistingCasesKeys(f); -const cleanupActivitiesCommand = (key) => ['zremrangebyscore', key, '-inf', now()]; +const cleanupActivitiesCommand = (key) => ['zremrangebyscore', key, '-inf', Date.now()]; const pipeline = (cases) => { const commands = cases.map((caseKey) => cleanupActivitiesCommand(caseKey)); diff --git a/app/redis/instantiator.js b/app/redis/instantiator.js index 3a383b1b..cad8a545 100644 --- a/app/redis/instantiator.js +++ b/app/redis/instantiator.js @@ -25,6 +25,7 @@ module.exports = (debug) => { failures.forEach((f) => debug(`${message}: ${f}`)); } else { debug(`${plOutcome} is not an Array...`); + debug(`${JSON.stringify(plOutcome)}`); } return plOutcome; }; @@ -38,10 +39,10 @@ module.exports = (debug) => { redis .on('error', (err) => { // eslint-disable-next-line no-console - console.log(`Redis error: ${err.message}`); + debug(`Redis error: ${err.message}`); }).on('connect', () => { // eslint-disable-next-line no-console - console.log('connected to Redis'); + debug('connected to Redis'); }); return redis; diff --git a/app/routes/validate-request.js b/app/routes/validate-request.js index c8be4759..7f05758a 100644 --- a/app/routes/validate-request.js +++ b/app/routes/validate-request.js @@ -1,3 +1,5 @@ +const debug = require('debug')('ccd-case-activity-api:validate-request'); + const validateRequest = (schema, value) => (req, res, next) => { const { error } = schema.validate(value); const valid = error == null; @@ -6,7 +8,7 @@ const validateRequest = (schema, value) => (req, res, next) => { } else { const { details } = error; const message = details.map((i) => i.message).join(','); - console.log('error', message); + debug(`error ${message}`); res.status(400).json({ error: message }); } }; diff --git a/app/service/activity-service.js b/app/service/activity-service.js index baa40b6d..db191468 100644 --- a/app/service/activity-service.js +++ b/app/service/activity-service.js @@ -1,4 +1,3 @@ -const moment = require('moment'); const debug = require('debug')('ccd-case-activity-api:activity-service'); module.exports = (config, redis, ttlScoreGenerator) => { @@ -31,7 +30,7 @@ module.exports = (config, redis, ttlScoreGenerator) => { const uniqueUserIds = []; let caseViewers = []; let caseEditors = []; - const now = moment.now(); + const now = Date.now(); const getUserDetails = () => redis.pipeline(uniqueUserIds.map((userId) => ['get', `user:${userId}`])).exec(); const extractUniqueUserIds = (result) => { result.forEach((item) => { diff --git a/app/service/ttl-score-generator.js b/app/service/ttl-score-generator.js index 5cb4a4ad..1ddc45d5 100644 --- a/app/service/ttl-score-generator.js +++ b/app/service/ttl-score-generator.js @@ -1,10 +1,10 @@ const config = require('config'); -const moment = require('moment'); const debug = require('debug')('ccd-case-activity-api:score-generator'); exports.getScore = () => { - const now = moment(); - const score = now.add(config.get('redis.activityTtlSec'), 'seconds').valueOf(); - debug(`generated score out of current timestamp '${now.valueOf()}' plus ${config.get('redis.activityTtlSec')} sec`); + const now = Date.now(); + const ttl = parseInt(config.get('redis.activityTtlSec'), 10) || 0; + const score = now + (ttl * 1000); + debug(`generated score out of current timestamp '${now}' plus ${ttl} sec`); return score; }; diff --git a/app/socket/activity-service.js b/app/socket/activity-service.js index 00ae6c31..a4ac61ac 100644 --- a/app/socket/activity-service.js +++ b/app/socket/activity-service.js @@ -4,7 +4,9 @@ module.exports = (config, redis, ttlScoreGenerator) => { const redisActivityKeys = { view: (caseId) => `case:${caseId}:viewers`, edit: (caseId) => `case:${caseId}:editors`, - base: (caseId) => `case:${caseId}` + baseCase: (caseId) => `case:${caseId}`, + user: (userId) => `user:${userId}`, + socket: (socketId) => `socket:${socketId}` }; const userDetailsTtlSec = config.get('redis.userDetailsTtlSec'); const toUserString = (user) => { @@ -15,7 +17,7 @@ module.exports = (config, redis, ttlScoreGenerator) => { }); }; - const addActivity = (caseId, user, activity) => { + const addActivity = (caseId, user, socketId, activity) => { const storeUserActivity = () => { const key = redisActivityKeys[activity](caseId); debug(`about to store user activity with key: ${key}`); @@ -24,39 +26,53 @@ module.exports = (config, redis, ttlScoreGenerator) => { const storeUserDetails = () => { const userDetails = toUserString(user); - const key = `user:${user.uid}`; + const key = redisActivityKeys.user(user.uid); debug(`about to store user details with key ${key}: ${userDetails}`); return ['set', key, userDetails, 'EX', userDetailsTtlSec]; }; + + const storeSocketActivity = () => { + const activityKey = redisActivityKeys[activity](caseId); + const key = redisActivityKeys.socket(socketId); + const store = JSON.stringify({ + activityKey, + caseId, + userId: user.uid + }); + return ['set', key, store, 'EX', userDetailsTtlSec]; + }; + return redis.pipeline([ storeUserActivity(), + storeSocketActivity(), storeUserDetails() - ]).exec().then(() => { - redis.publish(redisActivityKeys.base(caseId), Date.now().toString()); + ]).exec().then(async () => { + redis.publish(redisActivityKeys.baseCase(caseId), Date.now().toString()); }); }; - /** - * TODO: Implement a mechanism to remove activity. There are a few options here: - * I think this will require us to track which activities relates to which sockets - * so we can clear them whenever the socket disconnects. - * @param {*} caseId - * @param {*} user - * @param {*} activity - * @returns - */ - const removeActivity = (caseId, user, activity) => { - const removeUserActivity = () => { - const key = redisActivityKeys[activity](caseId); - debug(`about to remove user activity with key: ${key}`); - return ['zrem', key, user.uid]; - }; + const getSocketActivity = async (socketId) => { + const key = redisActivityKeys.socket(socketId); + return JSON.parse(await redis.get(key)); + }; - return redis.pipeline([ - removeUserActivity() - ]).exec().then(() => { - redis.publish(redisActivityKeys.base(caseId), Date.now().toString()); - }); + const removeSocketActivity = async (socketId) => { + const activity = await getSocketActivity(socketId); + if (activity) { + const removeUserActivity = () => { + return ['zrem', activity.activityKey, activity.userId]; + }; + const removeSocketEntry = () => { + return ['del', redisActivityKeys.socket(socketId)]; + }; + return redis.pipeline([ + removeUserActivity(), + removeSocketEntry() + ]).exec().then(() => { + redis.publish(redisActivityKeys.baseCase(activity.caseId), Date.now().toString()); + }); + } + return null; }; const getActivityForCases = async (caseIds) => { @@ -64,12 +80,19 @@ module.exports = (config, redis, ttlScoreGenerator) => { let caseViewers = []; let caseEditors = []; const now = Date.now(); - const getUserDetails = () => redis.pipeline(uniqueUserIds.map((userId) => ['get', `user:${userId}`])).exec(); + const getUserDetails = () => { + if (uniqueUserIds.length > 0) { + return redis.mget(uniqueUserIds.map((userId) => redisActivityKeys.user(userId)), (err, res) => { + return res; + }); + } + return []; + }; const extractUniqueUserIds = (result) => { if (result) { - result.forEach(item => { + result.forEach((item) => { if (item && item[1]) { - item[1].forEach(userId => { + item[1].forEach((userId) => { if (!uniqueUserIds.includes(userId)) { uniqueUserIds.push(userId); } @@ -79,17 +102,17 @@ module.exports = (config, redis, ttlScoreGenerator) => { } }; const caseViewersPromise = redis - .pipeline(caseIds.map(caseId => ['zrangebyscore', `case:${caseId}:viewers`, now, '+inf'])) + .pipeline(caseIds.map((caseId) => ['zrangebyscore', redisActivityKeys.view(caseId), now, '+inf'])) .exec() - .then(result => { + .then((result) => { redis.logPipelineFailures(result, 'caseViewersPromise'); caseViewers = result; extractUniqueUserIds(result); }); const caseEditorsPromise = redis - .pipeline(caseIds.map(caseId => ['zrangebyscore', `case:${caseId}:editors`, now, '+inf'])) + .pipeline(caseIds.map((caseId) => ['zrangebyscore', redisActivityKeys.edit(caseId), now, '+inf'])) .exec() - .then(result => { + .then((result) => { redis.logPipelineFailures(result, 'caseEditorsPromise'); caseEditors = result; extractUniqueUserIds(result); @@ -97,24 +120,27 @@ module.exports = (config, redis, ttlScoreGenerator) => { await Promise.all([caseViewersPromise, caseEditorsPromise]); const userDetails = await getUserDetails().reduce((obj, item) => { - const user = JSON.parse(item[1]); + const user = JSON.parse(item); obj[user.id] = { forename: user.forename, surname: user.surname }; return obj; }, {}); return caseIds.map((caseId, index) => { redis.logPipelineFailures(userDetails, 'userDetails'); - const cv = caseViewers[index][1], ce = caseEditors[index][1]; - const viewers = cv ? cv.map(v => userDetails[v]) : []; - const editors = ce ? ce.map(e => userDetails[e]) : []; + const cv = caseViewers[index][1]; + const ce = caseEditors[index][1]; + const viewers = cv ? cv.map((v) => userDetails[v]) : []; + const editors = ce ? ce.map((e) => userDetails[e]) : []; return { caseId, - viewers: viewers.filter(v => !!v), - unknownViewers: viewers.filter(v => !v).length, - editors: editors.filter(e => !!e), - unknownEditors: editors.filter(e => !e).length + viewers: viewers.filter((v) => !!v), + unknownViewers: viewers.filter((v) => !v).length, + editors: editors.filter((e) => !!e), + unknownEditors: editors.filter((e) => !e).length }; }); }; - return { addActivity, removeActivity, getActivityForCases }; + return { + addActivity, getActivityForCases, getSocketActivity, removeSocketActivity + }; }; diff --git a/app/socket/index.js b/app/socket/index.js index 3357b3e4..4044d74d 100644 --- a/app/socket/index.js +++ b/app/socket/index.js @@ -1,8 +1,11 @@ +const debug = require('debug')('ccd-case-activity-api:socket'); const config = require('config'); +const IORouter = require('socket.io-router-middleware'); +const SocketIO = require('socket.io'); const ttlScoreGenerator = require('../service/ttl-score-generator'); const redisWatcher = require('./redis-watcher'); +const ActivityService = require('./activity-service'); -const IORouter = new require('socket.io-router-middleware'); const iorouter = new IORouter(); /** @@ -16,9 +19,9 @@ const iorouter = new IORouter(); * TODO: * 1. Use redis rather than holding the details in memory. * 2. Some sort of auth / get the credentials when the user connects. - * + * * Add view activity looks like this: - * addActivity 1588201414700270 { + * addActivity 1588201414700270, { sub: 'leeds_et@mailinator.com', uid: '85269805-3a70-419d-acab-193faeb89ad3', roles: [ @@ -29,30 +32,85 @@ const iorouter = new IORouter(); name: 'Ethos Leeds', given_name: 'Ethos', family_name: 'Leeds' - } view + }, '18hs67171jak', 'view' * */ module.exports = (server, redis) => { - const activityService = require('./activity-service')(config, redis, ttlScoreGenerator); - const io = require('socket.io')(server, { + const activityService = ActivityService(config, redis, ttlScoreGenerator); + const io = SocketIO(server, { allowEIO3: true }); + function toUser(obj) { + return { + sub: `${obj.name.replace(' ', '.')}@mailinator.com`, + uid: obj.id, + roles: [ + 'caseworker-employment', + 'caseworker-employment-leeds', + 'caseworker' + ], + name: obj.name, + given_name: obj.name.split(' ')[0], + family_name: obj.name.split(' ')[1] + }; + } + function watchCase(socket, caseId) { + socket.join(`case:${caseId}`); + } + function watchCases(socket, caseIds) { + caseIds.forEach((caseId) => { + watchCase(socket, caseId); + }); + } + function stopWatchingCases(socket) { + [...socket.rooms].filter((r) => r.indexOf('case:') === 0).forEach((r) => socket.leave(r)); + } async function whenActivityForCases(caseIds) { - const caseActivty = await activityService.getActivityForCases(caseIds); - console.log('activity for cases', caseIds, caseActivty); - return caseActivty; + return activityService.getActivityForCases(caseIds); } async function notifyWatchers(caseIds) { - caseIds = Array.isArray(caseIds) ? caseIds : [caseIds]; - caseIds.sort().forEach(async (caseId) => { + const ids = Array.isArray(caseIds) ? caseIds : [caseIds]; + ids.sort().forEach(async (caseId) => { const cs = await whenActivityForCases([caseId]); - io.to(`case:${caseId}`).emit('cases', cs); + io.to(`case:${caseId}`).emit('activity', cs); }); } + async function handleViewOrEdit(socket, caseId, user, activity) { + // Leave all the case rooms. + stopWatchingCases(socket); + + // Remove the activity for this socket. + activityService.removeSocketActivity(socket.id); + + // Now watch this case again. + watchCase(socket, caseId); + + // Finally, add this new activity to redis. + activityService.addActivity(caseId, toUser(user), socket.id, activity); + } + function handleEdit(socket, caseId, user) { + handleViewOrEdit(socket, caseId, user, 'edit'); + } + function handleView(socket, caseId, user) { + handleViewOrEdit(socket, caseId, user, 'view'); + } + async function handleWatch(socket, caseIds) { + // Stop watching the current cases. + stopWatchingCases(socket); + + // Remove the activity for this socket. + activityService.removeSocketActivity(socket.id); + + // Now watch the specified cases. + watchCases(socket, caseIds); + + // And immediately dispatch a message about the activity on those cases. + const cs = await whenActivityForCases(caseIds); + socket.emit('activity', cs); + } // TODO: Track this stuff in redis. - const caseStatuses = {}; const socketUsers = {}; // Pretty way of logging. @@ -62,34 +120,17 @@ module.exports = (server, redis) => { if (payload) { text = `${text} => ${payload}`; } - console.log(text); + debug(text); } else { - console.group(text); - console.log(payload); - console.groupEnd(); + debug(text); + debug(payload); } } - redisWatcher.on('message', room => { + redisWatcher.psubscribe('case:*'); + redisWatcher.on('pmessage', (_, room) => { const caseId = room.replace('case:', ''); - console.log('redisWatcher.on.message', room, caseId); notifyWatchers([caseId]); - // io.to(room).emit(message); - }); - - - // When a new room is created, we want to watch for changes to that case. - io.of('/').adapter.on('create-room', (room) => { - console.log(`room ${room} was created`); - if (room.indexOf('case:') === 0) { - redisWatcher.subscribe(`${room}`); - } - }); - - io.of('/').adapter.on('delete-room', (room) => { - if (room.indexOf('case:') === 0) { - redisWatcher.unsubscribe(`${room}`); - } }); // Set up routes for each type of message. @@ -109,9 +150,6 @@ module.exports = (server, redis) => { const user = socketUsers[socket.id]; doLog(socket, `${ctx.request.caseId} (${user.name})`, 'view'); handleView(socket, ctx.request.caseId, user); - - // Try sticking it in redis. - activityService.addActivity(ctx.request.caseId, toUser(user), 'view'); next(); }); @@ -119,9 +157,6 @@ module.exports = (server, redis) => { const user = socketUsers[socket.id]; doLog(socket, `${ctx.request.caseId} (${user.name})`, 'edit'); handleEdit(socket, ctx.request.caseId, user); - - // Try sticking it in redis. - activityService.addActivity(ctx.request.caseId, toUser(user), 'edit'); next(); }); @@ -133,141 +168,24 @@ module.exports = (server, redis) => { }); // On client connection attach the router - io.on('connection', function (socket) { + io.on('connection', (socket) => { socket.use((packet, next) => { // Call router.attach() with the client socket as the first parameter iorouter.attach(socket, packet, next); }); }); - function handleEdit(socket, caseId, user) { - const stoppedViewing = stopViewingCases(socket.id); - if (stoppedViewing.length > 0) { - stoppedViewing.filter(c => c !== caseId).forEach(c => stopWatchingCase(socket, c)); - } - const stoppedEditing = stopEditingCases(socket.id, caseId); - if (stoppedEditing.length > 0) { - stoppedEditing.filter(c => c !== caseId).forEach(c => stopWatchingCase(socket, c)); - } - watchCase(socket, caseId); - const caseStatus = caseStatuses[caseId] || { viewers: [], editors: [] }; - caseStatuses[caseId] = caseStatus; - const matchingEditor = caseStatus.editors.find(e => e.id === user.id); - const notify = stoppedViewing.concat(stoppedEditing); - if (!matchingEditor) { - caseStatus.editors.push({ ...user, socketId: socket.id }); - notify.push(caseId); - } - if (notify.length > 0) { - notifyWatchers([ ...new Set(notify) ]); - } - } - - function handleView(socket, caseId, user) { - const stoppedViewing = stopViewingCases(socket.id, caseId); - if (stoppedViewing.length > 0) { - stoppedViewing.filter(c => c !== caseId).forEach(c => stopWatchingCase(socket, c)); - } - const stoppedEditing = stopEditingCases(socket.id); - if (stoppedEditing.length > 0) { - stoppedEditing.filter(c => c !== caseId).forEach(c => stopWatchingCase(socket, c)); - } - watchCase(socket, caseId); - const caseStatus = caseStatuses[caseId] || { viewers: [], editors: [] }; - caseStatuses[caseId] = caseStatus; - const matchingViewer = caseStatus.viewers.find(v => v.id === user.id); - const notify = stoppedViewing.concat(stoppedEditing); - if (!matchingViewer) { - caseStatus.viewers.push({ ...user, socketId: socket.id }); - notify.push(caseId); - } - if (notify.length > 0) { - notifyWatchers([ ...new Set(notify) ]); - } - } - - async function handleWatch(socket, caseIds) { - watchCases(socket, caseIds); - const cs = await whenActivityForCases(caseIds); - socket.emit('cases', cs); - } - - function watchCases(socket, caseIds) { - caseIds.forEach(caseId => { - watchCase(socket, caseId); - }); - } - function watchCase(socket, caseId) { - socket.join(`case:${caseId}`); - } - function stopWatchingCase(socket, caseId) { - socket.leave(`case:${caseId}`); - } - function toUser(obj) { - return { - sub: `${obj.name.replace(' ', '.')}@mailinator.com`, - uid: obj.id, - roles: [ - 'caseworker-employment', - 'caseworker-employment-leeds', - 'caseworker' - ], - name: obj.name, - given_name: obj.name.split(' ')[0], - family_name: obj.name.split(' ')[1] - }; - } - - function stopViewingOrEditing(socketId, exceptCaseId) { - const stoppedViewing = stopViewingCases(socketId, exceptCaseId); - const stoppedEditing = stopEditingCases(socketId, exceptCaseId); - return { stoppedViewing, stoppedEditing }; - } - function stopViewingCases(socketId, exceptCaseId) { - const affectedCases = []; - Object.keys(caseStatuses).filter(key => key !== exceptCaseId).forEach(key => { - const c = caseStatuses[key]; - const viewer = c.viewers.find(v => v.socketId === socketId); - if (viewer) { - c.viewers.splice(c.viewers.indexOf(viewer), 1); - affectedCases.push(key); - } - }); - return affectedCases; - } - function stopEditingCases(socketId, exceptCaseId) { - const affectedCases = []; - Object.keys(caseStatuses).filter(key => key !== exceptCaseId).forEach(key => { - const c = caseStatuses[key]; - const editor = c.editors.find(e => e.socketId === socketId); - if (editor) { - c.editors.splice(c.editors.indexOf(editor), 1); - affectedCases.push(key); - } - }); - return affectedCases; - } - const connections = []; - io.sockets.on("connection", (socket) => { + io.sockets.on('connection', (socket) => { connections.push(socket); - if (connections.length === 1) { - console.log("1 socket connected"); - } else { - console.log("%s sockets connected", connections.length); - } - // console.log('connections[0]', connections[0]); - socket.on("disconnect", () => { - console.log(socket.id, 'has disconnected'); - stopViewingOrEditing(socket.id); + doLog(socket, '', `connected (${connections.length} total)`); + socket.on('disconnect', () => { + doLog(socket, '', `disconnected (${connections.length - 1} total)`); + activityService.removeSocketActivity(socket.id); delete socketUsers[socket.id]; connections.splice(connections.indexOf(socket), 1); }); - socket.on("sending message", (message) => { - console.log("Message is received :", message); - io.sockets.emit("new message", { message: message }); - }); }); return io; -}; \ No newline at end of file +}; diff --git a/server.js b/server.js index 6a4447ce..88df8c5c 100755 --- a/server.js +++ b/server.js @@ -13,7 +13,6 @@ var http = require('http'); /** * Get port from environment and store in Express. */ - var port = normalizePort(process.env.PORT || '3460'); console.log('Starting on port ' + port); app.app.set('port', port); diff --git a/test/e2e/utils/activity-store-commands.js b/test/e2e/utils/activity-store-commands.js index dd14149e..a0e89a44 100644 --- a/test/e2e/utils/activity-store-commands.js +++ b/test/e2e/utils/activity-store-commands.js @@ -1,12 +1,11 @@ var redis = require('../../../app/redis/redis-client') -var moment = require('moment') exports.getAllCaseViewers = (caseId) => redis.zrangebyscore(`case:${caseId}:viewers`, '-inf', '+inf') -exports.getNotExpiredCaseViewers = (caseId) => redis.zrangebyscore(`case:${caseId}:viewers`, moment().valueOf(), '+inf') +exports.getNotExpiredCaseViewers = (caseId) => redis.zrangebyscore(`case:${caseId}:viewers`, Date.now(), '+inf') exports.getAllCaseEditors = (caseId) => redis.zrangebyscore(`case:${caseId}:editors`, '-inf', '+inf') -exports.getNotExpiredCaseEditors = (caseId) => redis.zrangebyscore(`case:${caseId}:editors`, moment().valueOf(), '+inf') +exports.getNotExpiredCaseEditors = (caseId) => redis.zrangebyscore(`case:${caseId}:editors`, Date.now(), '+inf') exports.getUser = (id) => redis.get(`user:${id}`) \ No newline at end of file diff --git a/test/spec/app/health/health-check.spec.js b/test/spec/app/health/health-check.spec.js index 9efb6c9c..e85f7054 100644 --- a/test/spec/app/health/health-check.spec.js +++ b/test/spec/app/health/health-check.spec.js @@ -5,7 +5,7 @@ const app = require('../../../../app'); describe('health check', () => { it('should return 200 OK for health check', async () => { - await request(app) + await request(app.app) .get('/health') .expect(res => { expect(res.status).equal(200); @@ -14,7 +14,7 @@ describe('health check', () => { }); it('should return 200 OK for liveness health check', async () => { - await request(app) + await request(app.app) .get('/health/liveness') .expect(res => { expect(res.status).equal(200); @@ -23,7 +23,7 @@ describe('health check', () => { }); it('should return 200 OK for readiness health check', async () => { - await request(app) + await request(app.app) .get('/health/readiness') .expect(res => { expect(res.status).equal(200); diff --git a/test/spec/app/service/activity-service.spec.js b/test/spec/app/service/activity-service.spec.js index 6ef91266..ef73e385 100644 --- a/test/spec/app/service/activity-service.spec.js +++ b/test/spec/app/service/activity-service.spec.js @@ -2,7 +2,6 @@ var redis = require('../../../../app/redis/redis-client'); var config = require('config'); var ttlScoreGenerator = require('../../../../app/service/ttl-score-generator'); var activityService = require('../../../../app/service/activity-service')(config, redis, ttlScoreGenerator); -var moment = require('moment'); var chai = require("chai"); var sinon = require("sinon"); var sinonChai = require("sinon-chai"); @@ -54,7 +53,8 @@ describe("activity service", () => { }); it("getActivities should create a redis pipeline with the correct redis commands for getViewers", (done) => { - sandbox.stub(moment, 'now').returns(TIMESTAMP); + sandbox.stub(Date, 'now').returns(TIMESTAMP); + // sandbox.stub(moment, 'now').returns(TIMESTAMP); sandbox.stub(config, 'get').returns(USER_DETAILS_TTL); sandbox.stub(redis, "pipeline").callsFake(function (arguments) { argStr = JSON.stringify(arguments); @@ -91,7 +91,7 @@ describe("activity service", () => { }) it("getActivities should return unknown users if users detail are missing", (done) => { - sandbox.stub(moment, 'now').returns(TIMESTAMP); + sandbox.stub(Date, 'now').returns(TIMESTAMP); sandbox.stub(config, 'get').returns(USER_DETAILS_TTL); sandbox.stub(redis, "pipeline").callsFake(function (arguments) { argStr = JSON.stringify(arguments); @@ -125,7 +125,7 @@ describe("activity service", () => { }) it("getActivities should not return in the list of viewers the requesting user id", (done) => { - sandbox.stub(moment, 'now').returns(TIMESTAMP); + sandbox.stub(Date, 'now').returns(TIMESTAMP); sandbox.stub(config, 'get').returns(USER_DETAILS_TTL); sandbox.stub(redis, "pipeline").callsFake(function (arguments) { argStr = JSON.stringify(arguments); @@ -159,7 +159,7 @@ describe("activity service", () => { }) it("getActivities should not return the requesting user id in the list of unknown viewers", (done) => { - sandbox.stub(moment, 'now').returns(TIMESTAMP); + sandbox.stub(Date, 'now').returns(TIMESTAMP); sandbox.stub(config, 'get').returns(USER_DETAILS_TTL); sandbox.stub(redis, "pipeline").callsFake(function (arguments) { argStr = JSON.stringify(arguments); From 93ae2ae250e612fa9fa66f86dd0b2987a4386d1a Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Mon, 24 May 2021 17:40:38 +0100 Subject: [PATCH 09/58] Update activity-service.js Removed a redundant callback. --- app/socket/activity-service.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/socket/activity-service.js b/app/socket/activity-service.js index a4ac61ac..82b59be7 100644 --- a/app/socket/activity-service.js +++ b/app/socket/activity-service.js @@ -82,9 +82,7 @@ module.exports = (config, redis, ttlScoreGenerator) => { const now = Date.now(); const getUserDetails = () => { if (uniqueUserIds.length > 0) { - return redis.mget(uniqueUserIds.map((userId) => redisActivityKeys.user(userId)), (err, res) => { - return res; - }); + return redis.mget(uniqueUserIds.map((userId) => redisActivityKeys.user(userId))); } return []; }; @@ -140,6 +138,7 @@ module.exports = (config, redis, ttlScoreGenerator) => { }; }); }; + return { addActivity, getActivityForCases, getSocketActivity, removeSocketActivity }; From f479ba11e589f5f88e6f8201fecceb488ff96c72 Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Mon, 24 May 2021 17:40:51 +0100 Subject: [PATCH 10/58] Update package.json Added an option for starting with debug output. --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 6a1d9954..c3522393 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "scripts": { "setup": "cross-env NODE_PATH=. node --version", "start": "cross-env NODE_PATH=. node server.js", + "start:debug": "DEBUG=ccd-case-activity-api:* yarn start", "test": "NODE_ENV=test mocha --exit --recursive test/spec app/user", "test:end2end": "NODE_ENV=test mocha --exit test/e2e --timeout 15000", "test:smoke": "./aat/gradlew -p aat smoke", From 9396497f1b6592f4a110d8944d5236623b8a4638 Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Tue, 25 May 2021 09:31:22 +0100 Subject: [PATCH 11/58] Update activity-service.js According to the docs, a pipeline is likely to be more performant so reverting back to that. For very low numbers, I think an mget will be quicker but we know we may well have thousands of concurrent users so we should account for that. --- app/socket/activity-service.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/socket/activity-service.js b/app/socket/activity-service.js index 82b59be7..b572f42c 100644 --- a/app/socket/activity-service.js +++ b/app/socket/activity-service.js @@ -82,7 +82,7 @@ module.exports = (config, redis, ttlScoreGenerator) => { const now = Date.now(); const getUserDetails = () => { if (uniqueUserIds.length > 0) { - return redis.mget(uniqueUserIds.map((userId) => redisActivityKeys.user(userId))); + return redis.pipeline(uniqueUserIds.map((userId) => ['get', redisActivityKeys.user(userId)])).exec(); } return []; }; @@ -118,7 +118,7 @@ module.exports = (config, redis, ttlScoreGenerator) => { await Promise.all([caseViewersPromise, caseEditorsPromise]); const userDetails = await getUserDetails().reduce((obj, item) => { - const user = JSON.parse(item); + const user = JSON.parse(item[1]); obj[user.id] = { forename: user.forename, surname: user.surname }; return obj; }, {}); From 38f84ff76d59fdb538bd4e47e344c3934b11b685 Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Tue, 25 May 2021 09:36:14 +0100 Subject: [PATCH 12/58] Update index.js Added cors properties to the socket.io creation. --- app/socket/index.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/socket/index.js b/app/socket/index.js index 4044d74d..126d7920 100644 --- a/app/socket/index.js +++ b/app/socket/index.js @@ -38,7 +38,11 @@ const iorouter = new IORouter(); module.exports = (server, redis) => { const activityService = ActivityService(config, redis, ttlScoreGenerator); const io = SocketIO(server, { - allowEIO3: true + allowEIO3: true, + cors: { + origin: '*', + methods: ['GET', 'POST'] + } }); function toUser(obj) { return { From 4bcbf057dcc7855869697a3fbb8a06cb55892a9e Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Tue, 25 May 2021 10:08:04 +0100 Subject: [PATCH 13/58] lint Trying to get lint working on Windows too. --- .eslintrc.js | 13 +++++++++++++ .eslintrc.yml | 8 -------- package.json | 2 +- 3 files changed, 14 insertions(+), 9 deletions(-) create mode 100644 .eslintrc.js delete mode 100644 .eslintrc.yml diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 00000000..dc511611 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,13 @@ +module.exports = { + "extends": "airbnb-base", + "env": { + "mocha": true, + "jasmine": true + }, + "rules": { + "comma-dangle": 0, + "arrow-body-style": 0, + "no-param-reassign": [ 2, { props: false } ], + "linebreak-style": [ "error", process.platform === 'win32' ? 'windows' : 'unix' ] + } +} diff --git a/.eslintrc.yml b/.eslintrc.yml deleted file mode 100644 index 9f5ce475..00000000 --- a/.eslintrc.yml +++ /dev/null @@ -1,8 +0,0 @@ -extends: airbnb-base -env: - mocha: true - jasmine: true -rules: - comma-dangle: 0 - arrow-body-style: 0 - no-param-reassign: [ 2, { props: false } ] diff --git a/package.json b/package.json index c3522393..97ba0c01 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "test:a11y": "echo 'TODO: Accessibility tests'", "sonar-scan": "NODE_PATH=. sonar-scanner -X", "highLevelDataSetup": "echo './aat/gradlew -p aat highLevelDataSetup --args=$1' > ./temp.sh && sh ./temp.sh", - "lint": "NODE_PATH=. eslint app.js app/**/*.js test/**/*.js", + "lint": "cross-env NODE_PATH=. eslint app.js app/**/*.js test/**/*.js", "lint-fix": "NODE_PATH=. eslint --fix app.js app/**/*.js test/**/*.js" }, "nyc": { From 8d853afce04c1f90a23769499a13bcf5f6ee09c2 Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Tue, 25 May 2021 14:10:13 +0100 Subject: [PATCH 14/58] Update instantiator.js Getting rid of a pointless debug. --- app/redis/instantiator.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/redis/instantiator.js b/app/redis/instantiator.js index cad8a545..c8b45947 100644 --- a/app/redis/instantiator.js +++ b/app/redis/instantiator.js @@ -23,9 +23,6 @@ module.exports = (debug) => { const operationsFailureOutcome = plOutcome.map((operationOutcome) => operationOutcome[ERROR]); const failures = operationsFailureOutcome.filter((element) => element !== null); failures.forEach((f) => debug(`${message}: ${f}`)); - } else { - debug(`${plOutcome} is not an Array...`); - debug(`${JSON.stringify(plOutcome)}`); } return plOutcome; }; From 569d85b6a391cf0f631f9aaeb082920815ed8d60 Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Tue, 25 May 2021 14:11:37 +0100 Subject: [PATCH 15/58] Refactoring Fixed the mechanism for clearing out activity on a socket before adding new activity and then refactored `socket/activity-service.js` to make it a bit more functional and easier to test. --- app/socket/activity-service.js | 217 +++++++++++++++++++-------------- app/socket/index.js | 13 +- 2 files changed, 130 insertions(+), 100 deletions(-) diff --git a/app/socket/activity-service.js b/app/socket/activity-service.js index b572f42c..2d49f51d 100644 --- a/app/socket/activity-service.js +++ b/app/socket/activity-service.js @@ -1,75 +1,127 @@ const debug = require('debug')('ccd-case-activity-api:activity-service'); -module.exports = (config, redis, ttlScoreGenerator) => { - const redisActivityKeys = { - view: (caseId) => `case:${caseId}:viewers`, - edit: (caseId) => `case:${caseId}:editors`, - baseCase: (caseId) => `case:${caseId}`, - user: (userId) => `user:${userId}`, - socket: (socketId) => `socket:${socketId}` - }; - const userDetailsTtlSec = config.get('redis.userDetailsTtlSec'); - const toUserString = (user) => { +const redisActivityKeys = { + view: (caseId) => `case:${caseId}:viewers`, + edit: (caseId) => `case:${caseId}:editors`, + baseCase: (caseId) => `case:${caseId}`, + user: (userId) => `user:${userId}`, + socket: (socketId) => `socket:${socketId}` +}; +const utils = { + toUserString: (user) => { return JSON.stringify({ id: user.uid, forename: user.given_name, surname: user.family_name }); - }; - - const addActivity = (caseId, user, socketId, activity) => { - const storeUserActivity = () => { - const key = redisActivityKeys[activity](caseId); - debug(`about to store user activity with key: ${key}`); - return ['zadd', key, ttlScoreGenerator.getScore(), user.uid]; - }; - - const storeUserDetails = () => { - const userDetails = toUserString(user); + }, + extractUniqueUserIds: (result, uniqueUserIds) => { + if (result) { + result.forEach((item) => { + if (item && item[1]) { + const users = item[1]; + users.forEach((userId) => { + if (!uniqueUserIds.includes(userId)) { + uniqueUserIds.push(userId); + } + }); + } + }); + } + }, + get: { + caseActivities: (caseIds, activity, now) => { + return caseIds.map((id) => ['zrangebyscore', redisActivityKeys[activity](id), now, '+inf']); + }, + users: (userIds) => { + return userIds.map((id) => ['get', redisActivityKeys.user(id)]); + } + }, + store: { + userActivity: (activityKey, userId, score) => { + debug(`about to store activity "${activityKey}" for user "${userId}"`); + return ['zadd', activityKey, score, userId]; + }, + userDetails: (user, ttl) => { const key = redisActivityKeys.user(user.uid); - debug(`about to store user details with key ${key}: ${userDetails}`); - return ['set', key, userDetails, 'EX', userDetailsTtlSec]; - }; - - const storeSocketActivity = () => { - const activityKey = redisActivityKeys[activity](caseId); + const store = utils.toUserString(user); + debug(`about to store details "${key}" for user "${user.uid}": ${store}`); + return ['set', key, store, 'EX', ttl]; + }, + socketActivity: (socketId, activityKey, caseId, userId, ttl) => { const key = redisActivityKeys.socket(socketId); - const store = JSON.stringify({ - activityKey, - caseId, - userId: user.uid - }); - return ['set', key, store, 'EX', userDetailsTtlSec]; - }; + const store = JSON.stringify({ activityKey, caseId, userId }); + debug(`about to store activity "${key}" for socket "${socketId}": ${store}`); + return ['set', key, store, 'EX', ttl]; + } + }, + remove: { + userActivity: (activity) => { + debug(`about to remove activity "${activity.activityKey}" for user "${activity.userId}"`); + return ['zrem', activity.activityKey, activity.userId]; + }, + socketEntry: (socketId) => { + debug(`about to remove activity for socket "${socketId}"`); + return ['del', redisActivityKeys.socket(socketId)]; + } + } +} - return redis.pipeline([ - storeUserActivity(), - storeSocketActivity(), - storeUserDetails() - ]).exec().then(async () => { - redis.publish(redisActivityKeys.baseCase(caseId), Date.now().toString()); - }); - }; +module.exports = (config, redis, ttlScoreGenerator) => { + const userDetailsTtlSec = config.get('redis.userDetailsTtlSec'); + + const notifyChange = (caseId) => { + redis.publish(redisActivityKeys.baseCase(caseId), Date.now().toString()); + } const getSocketActivity = async (socketId) => { const key = redisActivityKeys.socket(socketId); return JSON.parse(await redis.get(key)); }; - const removeSocketActivity = async (socketId) => { + const getUserDetails = async (userIds) => { + if (userIds.length > 0) { + // Get hold of the details. + const details = await redis.pipeline(utils.get.users(userIds)).exec(); + + // Now turn them into a map. + return details.reduce((obj, item) => { + const user = JSON.parse(item[1]); + if (user) { + obj[user.id] = { forename: user.forename, surname: user.surname }; + } + return obj; + }, {}); + } + return []; + }; + + const addActivity = async (caseId, user, socketId, activity) => { + // First, clear out any existing activity on this socket. + await removeSocketActivity(socketId, caseId); + + // Now store this activity. + const activityKey = redisActivityKeys[activity](caseId); + return redis.pipeline([ + utils.store.userActivity(activityKey, user.uid, ttlScoreGenerator.getScore()), + utils.store.socketActivity(socketId, activityKey, caseId, user.uid, userDetailsTtlSec), + utils.store.userDetails(user, userDetailsTtlSec) + ]).exec().then(() => { + notifyChange(caseId); + }); + }; + + const removeSocketActivity = async (socketId, skipNotifyForCaseId) => { + // First make sure we actually have some activity to remove. const activity = await getSocketActivity(socketId); if (activity) { - const removeUserActivity = () => { - return ['zrem', activity.activityKey, activity.userId]; - }; - const removeSocketEntry = () => { - return ['del', redisActivityKeys.socket(socketId)]; - }; return redis.pipeline([ - removeUserActivity(), - removeSocketEntry() + utils.remove.userActivity(activity), + utils.remove.socketEntry(socketId) ]).exec().then(() => { - redis.publish(redisActivityKeys.baseCase(activity.caseId), Date.now().toString()); + if (activity.caseId !== skipNotifyForCaseId) { + notifyChange(activity.caseId); + } }); } return null; @@ -80,51 +132,32 @@ module.exports = (config, redis, ttlScoreGenerator) => { let caseViewers = []; let caseEditors = []; const now = Date.now(); - const getUserDetails = () => { - if (uniqueUserIds.length > 0) { - return redis.pipeline(uniqueUserIds.map((userId) => ['get', redisActivityKeys.user(userId)])).exec(); - } - return []; + const getPromise = (activity, cb, failureMessage) => { + return redis.pipeline( + utils.get.caseActivities(caseIds, activity, now) + ).exec().then((result) => { + redis.logPipelineFailures(result, failureMessage); + cb(result); + utils.extractUniqueUserIds(result, uniqueUserIds); + }); }; - const extractUniqueUserIds = (result) => { - if (result) { - result.forEach((item) => { - if (item && item[1]) { - item[1].forEach((userId) => { - if (!uniqueUserIds.includes(userId)) { - uniqueUserIds.push(userId); - } - }); - } - }); - } - }; - const caseViewersPromise = redis - .pipeline(caseIds.map((caseId) => ['zrangebyscore', redisActivityKeys.view(caseId), now, '+inf'])) - .exec() - .then((result) => { - redis.logPipelineFailures(result, 'caseViewersPromise'); - caseViewers = result; - extractUniqueUserIds(result); - }); - const caseEditorsPromise = redis - .pipeline(caseIds.map((caseId) => ['zrangebyscore', redisActivityKeys.edit(caseId), now, '+inf'])) - .exec() - .then((result) => { - redis.logPipelineFailures(result, 'caseEditorsPromise'); - caseEditors = result; - extractUniqueUserIds(result); - }); + + // Set up the promises fore view and edit. + const caseViewersPromise = getPromise('view', (result) => { + caseViewers = result; + }, 'caseViewersPromise'); + const caseEditorsPromise = getPromise('edit', (result) => { + caseEditors = result; + }, 'caseEditorsPromise'); + + // Now wait until both promises have been completed. await Promise.all([caseViewersPromise, caseEditorsPromise]); - const userDetails = await getUserDetails().reduce((obj, item) => { - const user = JSON.parse(item[1]); - obj[user.id] = { forename: user.forename, surname: user.surname }; - return obj; - }, {}); + // Get all the user details for both viewers and editors. + const userDetails = await getUserDetails(uniqueUserIds); + // Now produce a response for every case requested. return caseIds.map((caseId, index) => { - redis.logPipelineFailures(userDetails, 'userDetails'); const cv = caseViewers[index][1]; const ce = caseEditors[index][1]; const viewers = cv ? cv.map((v) => userDetails[v]) : []; diff --git a/app/socket/index.js b/app/socket/index.js index 126d7920..a0489602 100644 --- a/app/socket/index.js +++ b/app/socket/index.js @@ -81,17 +81,14 @@ module.exports = (server, redis) => { }); } async function handleViewOrEdit(socket, caseId, user, activity) { - // Leave all the case rooms. + // Leave all the existing case rooms. stopWatchingCases(socket); - // Remove the activity for this socket. - activityService.removeSocketActivity(socket.id); - - // Now watch this case again. + // Now watch this case specifically. watchCase(socket, caseId); - // Finally, add this new activity to redis. - activityService.addActivity(caseId, toUser(user), socket.id, activity); + // Finally, add this new activity to redis, which will also clear out the old activity. + await activityService.addActivity(caseId, toUser(user), socket.id, activity); } function handleEdit(socket, caseId, user) { handleViewOrEdit(socket, caseId, user, 'edit'); @@ -104,7 +101,7 @@ module.exports = (server, redis) => { stopWatchingCases(socket); // Remove the activity for this socket. - activityService.removeSocketActivity(socket.id); + await activityService.removeSocketActivity(socket.id); // Now watch the specified cases. watchCases(socket, caseIds); From 7cd2c17590c9b35e1e7dbc86344ebd59c954a8bf Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Tue, 25 May 2021 14:23:14 +0100 Subject: [PATCH 16/58] Update activity-service.js Linting. Oops. --- app/socket/activity-service.js | 36 +++++++++++++++++----------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/app/socket/activity-service.js b/app/socket/activity-service.js index 2d49f51d..8c1638fe 100644 --- a/app/socket/activity-service.js +++ b/app/socket/activity-service.js @@ -65,14 +65,14 @@ const utils = { return ['del', redisActivityKeys.socket(socketId)]; } } -} +}; module.exports = (config, redis, ttlScoreGenerator) => { const userDetailsTtlSec = config.get('redis.userDetailsTtlSec'); const notifyChange = (caseId) => { redis.publish(redisActivityKeys.baseCase(caseId), Date.now().toString()); - } + }; const getSocketActivity = async (socketId) => { const key = redisActivityKeys.socket(socketId); @@ -96,21 +96,6 @@ module.exports = (config, redis, ttlScoreGenerator) => { return []; }; - const addActivity = async (caseId, user, socketId, activity) => { - // First, clear out any existing activity on this socket. - await removeSocketActivity(socketId, caseId); - - // Now store this activity. - const activityKey = redisActivityKeys[activity](caseId); - return redis.pipeline([ - utils.store.userActivity(activityKey, user.uid, ttlScoreGenerator.getScore()), - utils.store.socketActivity(socketId, activityKey, caseId, user.uid, userDetailsTtlSec), - utils.store.userDetails(user, userDetailsTtlSec) - ]).exec().then(() => { - notifyChange(caseId); - }); - }; - const removeSocketActivity = async (socketId, skipNotifyForCaseId) => { // First make sure we actually have some activity to remove. const activity = await getSocketActivity(socketId); @@ -127,6 +112,21 @@ module.exports = (config, redis, ttlScoreGenerator) => { return null; }; + const addActivity = async (caseId, user, socketId, activity) => { + // First, clear out any existing activity on this socket. + await removeSocketActivity(socketId, caseId); + + // Now store this activity. + const activityKey = redisActivityKeys[activity](caseId); + return redis.pipeline([ + utils.store.userActivity(activityKey, user.uid, ttlScoreGenerator.getScore()), + utils.store.socketActivity(socketId, activityKey, caseId, user.uid, userDetailsTtlSec), + utils.store.userDetails(user, userDetailsTtlSec) + ]).exec().then(() => { + notifyChange(caseId); + }); + }; + const getActivityForCases = async (caseIds) => { const uniqueUserIds = []; let caseViewers = []; @@ -139,7 +139,7 @@ module.exports = (config, redis, ttlScoreGenerator) => { redis.logPipelineFailures(result, failureMessage); cb(result); utils.extractUniqueUserIds(result, uniqueUserIds); - }); + }); }; // Set up the promises fore view and edit. From 2e4e5bc3b5cba39679a98f700509b804beaaa23d Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Tue, 25 May 2021 16:07:46 +0100 Subject: [PATCH 17/58] Tests Moved redis keys and utils into separate files. The former is fully tested, the latter still needs tests for store and remove. --- app/socket/activity-service.js | 79 +------ app/socket/redis-keys.js | 9 + app/socket/utils.js | 74 +++++++ test/spec/app/socket/redis-keys.spec.js | 31 +++ test/spec/app/socket/utils.spec.js | 263 ++++++++++++++++++++++++ 5 files changed, 385 insertions(+), 71 deletions(-) create mode 100644 app/socket/redis-keys.js create mode 100644 app/socket/utils.js create mode 100644 test/spec/app/socket/redis-keys.spec.js create mode 100644 test/spec/app/socket/utils.spec.js diff --git a/app/socket/activity-service.js b/app/socket/activity-service.js index 8c1638fe..bca905d4 100644 --- a/app/socket/activity-service.js +++ b/app/socket/activity-service.js @@ -1,71 +1,5 @@ -const debug = require('debug')('ccd-case-activity-api:activity-service'); - -const redisActivityKeys = { - view: (caseId) => `case:${caseId}:viewers`, - edit: (caseId) => `case:${caseId}:editors`, - baseCase: (caseId) => `case:${caseId}`, - user: (userId) => `user:${userId}`, - socket: (socketId) => `socket:${socketId}` -}; -const utils = { - toUserString: (user) => { - return JSON.stringify({ - id: user.uid, - forename: user.given_name, - surname: user.family_name - }); - }, - extractUniqueUserIds: (result, uniqueUserIds) => { - if (result) { - result.forEach((item) => { - if (item && item[1]) { - const users = item[1]; - users.forEach((userId) => { - if (!uniqueUserIds.includes(userId)) { - uniqueUserIds.push(userId); - } - }); - } - }); - } - }, - get: { - caseActivities: (caseIds, activity, now) => { - return caseIds.map((id) => ['zrangebyscore', redisActivityKeys[activity](id), now, '+inf']); - }, - users: (userIds) => { - return userIds.map((id) => ['get', redisActivityKeys.user(id)]); - } - }, - store: { - userActivity: (activityKey, userId, score) => { - debug(`about to store activity "${activityKey}" for user "${userId}"`); - return ['zadd', activityKey, score, userId]; - }, - userDetails: (user, ttl) => { - const key = redisActivityKeys.user(user.uid); - const store = utils.toUserString(user); - debug(`about to store details "${key}" for user "${user.uid}": ${store}`); - return ['set', key, store, 'EX', ttl]; - }, - socketActivity: (socketId, activityKey, caseId, userId, ttl) => { - const key = redisActivityKeys.socket(socketId); - const store = JSON.stringify({ activityKey, caseId, userId }); - debug(`about to store activity "${key}" for socket "${socketId}": ${store}`); - return ['set', key, store, 'EX', ttl]; - } - }, - remove: { - userActivity: (activity) => { - debug(`about to remove activity "${activity.activityKey}" for user "${activity.userId}"`); - return ['zrem', activity.activityKey, activity.userId]; - }, - socketEntry: (socketId) => { - debug(`about to remove activity for socket "${socketId}"`); - return ['del', redisActivityKeys.socket(socketId)]; - } - } -}; +const redisActivityKeys = require('./redis-keys'); +const utils = require('./utils'); module.exports = (config, redis, ttlScoreGenerator) => { const userDetailsTtlSec = config.get('redis.userDetailsTtlSec'); @@ -128,7 +62,7 @@ module.exports = (config, redis, ttlScoreGenerator) => { }; const getActivityForCases = async (caseIds) => { - const uniqueUserIds = []; + let uniqueUserIds = []; let caseViewers = []; let caseEditors = []; const now = Date.now(); @@ -138,7 +72,7 @@ module.exports = (config, redis, ttlScoreGenerator) => { ).exec().then((result) => { redis.logPipelineFailures(result, failureMessage); cb(result); - utils.extractUniqueUserIds(result, uniqueUserIds); + uniqueUserIds = utils.extractUniqueUserIds(result, uniqueUserIds); }); }; @@ -173,6 +107,9 @@ module.exports = (config, redis, ttlScoreGenerator) => { }; return { - addActivity, getActivityForCases, getSocketActivity, removeSocketActivity + addActivity, + getActivityForCases, + getSocketActivity, + removeSocketActivity }; }; diff --git a/app/socket/redis-keys.js b/app/socket/redis-keys.js new file mode 100644 index 00000000..7feafc1a --- /dev/null +++ b/app/socket/redis-keys.js @@ -0,0 +1,9 @@ +const redisActivityKeys = { + view: (caseId) => `case:${caseId}:viewers`, + edit: (caseId) => `case:${caseId}:editors`, + baseCase: (caseId) => `case:${caseId}`, + user: (userId) => `user:${userId}`, + socket: (socketId) => `socket:${socketId}` +}; + +module.exports = redisActivityKeys; diff --git a/app/socket/utils.js b/app/socket/utils.js new file mode 100644 index 00000000..9219f345 --- /dev/null +++ b/app/socket/utils.js @@ -0,0 +1,74 @@ +const debug = require('debug')('ccd-case-activity-api:socket-activity-service'); +const redisActivityKeys = require('./redis-keys'); + +const utils = { + toUserString: (user) => { + return user ? JSON.stringify({ + id: user.uid, + forename: user.given_name, + surname: user.family_name + }) : '{}'; + }, + extractUniqueUserIds: (result, uniqueUserIds) => { + const userIds = Array.isArray(uniqueUserIds) ? [...uniqueUserIds] : []; + if (Array.isArray(result)) { + result.forEach((item) => { + if (item && item[1]) { + const users = item[1]; + users.forEach((userId) => { + if (!userIds.includes(userId)) { + userIds.push(userId); + } + }); + } + }); + } + return userIds; + }, + get: { + caseActivities: (caseIds, activity, now) => { + if (Array.isArray(caseIds) && ['view', 'edit'].indexOf(activity) > -1) { + return caseIds.filter((id) => !!id).map((id) => { + return ['zrangebyscore', redisActivityKeys[activity](id), now, '+inf']; + }); + } + return []; + }, + users: (userIds) => { + if (Array.isArray(userIds)) { + return userIds.filter((id) => !!id).map((id) => ['get', redisActivityKeys.user(id)]); + } + return []; + } + }, + store: { + userActivity: (activityKey, userId, score) => { + debug(`about to store activity "${activityKey}" for user "${userId}"`); + return ['zadd', activityKey, score, userId]; + }, + userDetails: (user, ttl) => { + const key = redisActivityKeys.user(user.uid); + const store = utils.toUserString(user); + debug(`about to store details "${key}" for user "${user.uid}": ${store}`); + return ['set', key, store, 'EX', ttl]; + }, + socketActivity: (socketId, activityKey, caseId, userId, ttl) => { + const key = redisActivityKeys.socket(socketId); + const store = JSON.stringify({ activityKey, caseId, userId }); + debug(`about to store activity "${key}" for socket "${socketId}": ${store}`); + return ['set', key, store, 'EX', ttl]; + } + }, + remove: { + userActivity: (activity) => { + debug(`about to remove activity "${activity.activityKey}" for user "${activity.userId}"`); + return ['zrem', activity.activityKey, activity.userId]; + }, + socketEntry: (socketId) => { + debug(`about to remove activity for socket "${socketId}"`); + return ['del', redisActivityKeys.socket(socketId)]; + } + } +}; + +module.exports = utils; diff --git a/test/spec/app/socket/redis-keys.spec.js b/test/spec/app/socket/redis-keys.spec.js new file mode 100644 index 00000000..1441c506 --- /dev/null +++ b/test/spec/app/socket/redis-keys.spec.js @@ -0,0 +1,31 @@ +const expect = require('chai').expect; +const redisActivityKeys = require('../../../../app/socket/redis-keys'); + +describe('socket.redis-keys', () => { + + it('should get the correct key for viewing a case', () => { + const CASE_ID = '12345678'; + expect(redisActivityKeys.view(CASE_ID)).to.equal(`case:${CASE_ID}:viewers`); + }); + + it('should get the correct key for editing a case', () => { + const CASE_ID = '12345678'; + expect(redisActivityKeys.edit(CASE_ID)).to.equal(`case:${CASE_ID}:editors`); + }); + + it('should get the correct base key for a case', () => { + const CASE_ID = '12345678'; + expect(redisActivityKeys.baseCase(CASE_ID)).to.equal(`case:${CASE_ID}`); + }); + + it('should get the correct key for a user', () => { + const USER_ID = 'abcdef123456'; + expect(redisActivityKeys.user(USER_ID)).to.equal(`user:${USER_ID}`); + }); + + it('should get the correct key for a socket', () => { + const SOCKET_ID = 'zyxwvu987654'; + expect(redisActivityKeys.socket(SOCKET_ID)).to.equal(`socket:${SOCKET_ID}`); + }); + +}); diff --git a/test/spec/app/socket/utils.spec.js b/test/spec/app/socket/utils.spec.js new file mode 100644 index 00000000..94549274 --- /dev/null +++ b/test/spec/app/socket/utils.spec.js @@ -0,0 +1,263 @@ +const expect = require('chai').expect; +const utils = require('../../../../app/socket/utils'); +const redisActivityKeys = require('../../../../app/socket/redis-keys'); + +describe('socket.utils', () => { + + describe('toUserString', () => { + it('should handle a null user', () => { + expect(utils.toUserString(null)).to.equal('{}'); + }); + it('should handle an undefined user', () => { + expect(utils.toUserString(undefined)).to.equal('{}'); + }); + it('should handle an empty user', () => { + expect(utils.toUserString({})).to.equal('{}'); + }); + it('should handle a full user', () => { + const USER = { + uid: '1234567890', + given_name: 'Bob', + family_name: 'Smith' + }; + expect(utils.toUserString(USER)).to.equal('{"id":"1234567890","forename":"Bob","surname":"Smith"}'); + }); + it('should handle a user with a missing family name', () => { + const USER = { + uid: '1234567890', + given_name: 'Bob' + }; + expect(utils.toUserString(USER)).to.equal('{"id":"1234567890","forename":"Bob"}'); + }); + it('should handle a user with a missing given name', () => { + const USER = { + uid: '1234567890', + family_name: 'Smith' + }; + expect(utils.toUserString(USER)).to.equal('{"id":"1234567890","surname":"Smith"}'); + }); + it('should handle a user with a missing name', () => { + const USER = { + uid: '1234567890' + }; + expect(utils.toUserString(USER)).to.equal('{"id":"1234567890"}'); + }); + }); + + describe('extractUniqueUserIds', () => { + it('should handle a null result', () => { + const RESULT = null; + const UNIQUE = ['a']; + const IDS = utils.extractUniqueUserIds(RESULT, UNIQUE); + expect(IDS).to.be.an('array') + .that.has.lengthOf(1) + .and.that.includes('a'); + }); + it('should handle a result of the wrong type', () => { + const RESULT = 'bob'; + const UNIQUE = ['a']; + const IDS = utils.extractUniqueUserIds(RESULT, UNIQUE); + expect(IDS).to.be.an('array') + .that.has.lengthOf(1) + .and.that.includes('a'); + }); + it('should handle a result with the wrong structure', () => { + const RESULT = [ + ['bob'], + ['fred'] + ]; + const UNIQUE = ['a']; + const IDS = utils.extractUniqueUserIds(RESULT, UNIQUE); + expect(IDS).to.be.an('array') + .that.has.lengthOf(1) + .and.that.includes('a'); + }); + it('should handle a result containing nulls', () => { + const RESULT = [ + ['bob', ['b']], + ['fred', null] + ]; + const UNIQUE = ['a']; + const IDS = utils.extractUniqueUserIds(RESULT, UNIQUE); + expect(IDS).to.be.an('array') + .that.has.lengthOf(2) + .and.that.includes('a') + .and.that.includes('b'); + }); + it('should handle a result with the correct structure', () => { + const RESULT = [ + ['bob', ['b', 'g']], + ['fred', ['f']] + ]; + const UNIQUE = ['a']; + const IDS = utils.extractUniqueUserIds(RESULT, UNIQUE); + expect(IDS).to.be.an('array').that.has.lengthOf(4) + .and.that.includes('a') + .and.that.includes('b') + .and.that.includes('f') + .and.that.includes('g'); + }); + it('should handle a result with the correct structure but a null original array', () => { + const RESULT = [ + ['bob', ['b', 'g']], + ['fred', ['f']] + ]; + const UNIQUE = null; + const IDS = utils.extractUniqueUserIds(RESULT, UNIQUE); + expect(IDS).to.be.an('array').that.has.lengthOf(3) + .and.that.includes('b') + .and.that.includes('f') + .and.that.includes('g'); + }); + it('should handle a result with the correct structure but an original array of the wrong type', () => { + const RESULT = [ + ['bob', ['b', 'g']], + ['fred', ['f']] + ]; + const UNIQUE = 'a'; + const IDS = utils.extractUniqueUserIds(RESULT, UNIQUE); + expect(IDS).to.be.an('array').that.has.lengthOf(3) + .and.that.includes('b') + .and.that.includes('f') + .and.that.includes('g'); + }); + it('should strip out duplicates', () => { + const RESULT = [ + ['bob', ['a', 'b', 'g']], + ['fred', ['f', 'b']] + ]; + const UNIQUE = ['a']; + const IDS = utils.extractUniqueUserIds(RESULT, UNIQUE); + expect(IDS).to.be.an('array') + .and.that.includes('a') + .and.that.includes('b') + .and.that.includes('f') + .and.that.includes('g') + .but.that.has.lengthOf(4); // One of each, despite the RESULT containing an extra 'a', and 'b' twice. + }); + }); + + describe('get', () => { + + describe('caseActivities', () => { + it('should get the correct result for a single case being viewed', () => { + const CASE_IDS = ['1']; + const ACTIVITY = 'view'; + const NOW = 999; + const pipes = utils.get.caseActivities(CASE_IDS, ACTIVITY, NOW); + expect(pipes).to.be.an('array').and.have.lengthOf(1); + expect(pipes[0]).to.be.an('array').and.have.lengthOf(4); + expect(pipes[0][0]).to.equal('zrangebyscore'); + expect(pipes[0][1]).to.equal(redisActivityKeys.view(CASE_IDS[0])); + expect(pipes[0][2]).to.equal(NOW); + expect(pipes[0][3]).to.equal('+inf'); + }); + it('should get the correct result for a multiple cases being viewed', () => { + const CASE_IDS = ['1', '8', '2345678', 'x']; + const ACTIVITY = 'view'; + const NOW = 999; + const pipes = utils.get.caseActivities(CASE_IDS, ACTIVITY, NOW); + expect(pipes).to.be.an('array').and.have.lengthOf(CASE_IDS.length); + CASE_IDS.forEach((id, index) => { + expect(pipes[index]).to.be.an('array').and.have.lengthOf(4); + expect(pipes[index][0]).to.equal('zrangebyscore'); + expect(pipes[index][1]).to.equal(redisActivityKeys.view(id)); + expect(pipes[index][2]).to.equal(NOW); + expect(pipes[index][3]).to.equal('+inf'); + }); + }); + it('should handle a null case ID for cases being viewed', () => { + const CASE_IDS = ['1', '8', null, 'x']; + const ACTIVITY = 'view'; + const NOW = 999; + const pipes = utils.get.caseActivities(CASE_IDS, ACTIVITY, NOW); + expect(pipes).to.be.an('array').and.have.lengthOf(CASE_IDS.length - 1); + let pipeIndex = 0; + CASE_IDS.forEach((id) => { + if (id !== null) { + expect(pipes[pipeIndex]).to.be.an('array').and.have.lengthOf(4); + expect(pipes[pipeIndex][0]).to.equal('zrangebyscore'); + expect(pipes[pipeIndex][1]).to.equal(redisActivityKeys.view(id)); + expect(pipes[pipeIndex][2]).to.equal(NOW); + expect(pipes[pipeIndex][3]).to.equal('+inf'); + pipeIndex++; + } + }); + }); + it('should handle a null case ID for cases being edited', () => { + const CASE_IDS = ['1', '8', null, 'x']; + const ACTIVITY = 'edit'; + const NOW = 999; + const pipes = utils.get.caseActivities(CASE_IDS, ACTIVITY, NOW); + expect(pipes).to.be.an('array').and.have.lengthOf(CASE_IDS.length - 1); + let pipeIndex = 0; + CASE_IDS.forEach((id) => { + if (id !== null) { + expect(pipes[pipeIndex]).to.be.an('array').and.have.lengthOf(4); + expect(pipes[pipeIndex][0]).to.equal('zrangebyscore'); + expect(pipes[pipeIndex][1]).to.equal(redisActivityKeys.edit(id)); + expect(pipes[pipeIndex][2]).to.equal(NOW); + expect(pipes[pipeIndex][3]).to.equal('+inf'); + pipeIndex++; + } + }); + }); + it('should handle a null array of case IDs', () => { + const CASE_IDS = null; + const ACTIVITY = 'view'; + const NOW = 999; + const pipes = utils.get.caseActivities(CASE_IDS, ACTIVITY, NOW); + expect(pipes).to.be.an('array').and.have.lengthOf(0); + }); + it('should handle an invalid activity type', () => { + const CASE_IDS = ['1', '8', '2345678', 'x']; + const ACTIVITY = 'bob'; + const NOW = 999; + const pipes = utils.get.caseActivities(CASE_IDS, ACTIVITY, NOW); + expect(pipes).to.be.an('array').and.have.lengthOf(0); + }); + }); + + describe('users', () => { + it('should get the correct result for a single user ID', () => { + const USER_IDS = ['1']; + const pipes = utils.get.users(USER_IDS); + expect(pipes).to.be.an('array').and.have.lengthOf(1); + expect(pipes[0]).to.be.an('array').and.have.lengthOf(2); + expect(pipes[0][0]).to.equal('get'); + expect(pipes[0][1]).to.equal(redisActivityKeys.user(USER_IDS[0])); + }); + it('should get the correct result for multiple user IDs', () => { + const USER_IDS = ['1', '8', '2345678', 'x']; + const pipes = utils.get.users(USER_IDS); + expect(pipes).to.be.an('array').and.have.lengthOf(USER_IDS.length); + expect(pipes[0]).to.be.an('array').and.have.lengthOf(2); + USER_IDS.forEach((id, index) => { + expect(pipes[index][0]).to.equal('get'); + expect(pipes[index][1]).to.equal(redisActivityKeys.user(id)); + }); + }); + it('should handle a null user ID', () => { + const USER_IDS = ['1', '8', null, 'x']; + const pipes = utils.get.users(USER_IDS); + expect(pipes).to.be.an('array').and.have.lengthOf(USER_IDS.length - 1); + expect(pipes[0]).to.be.an('array').and.have.lengthOf(2); + let pipeIndex = 0; + USER_IDS.forEach((id) => { + if (id) { + expect(pipes[pipeIndex][0]).to.equal('get'); + expect(pipes[pipeIndex][1]).to.equal(redisActivityKeys.user(id)); + pipeIndex++; + } + }); + }); + it('should handle a null array of user IDs', () => { + const USER_IDS = null; + const pipes = utils.get.users(USER_IDS); + expect(pipes).to.be.an('array').and.have.lengthOf(0); + }); + }); + + }); + +}); From 8c30b43a4d3b15a74f92326b6842de1f55be9571 Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Wed, 26 May 2021 12:01:44 +0100 Subject: [PATCH 18/58] Unit tests Added a bunch of unit tests for `socket/utils` and, as a consequence, refactored some of that code to make it more robust and more testable. --- app/socket/activity-service.js | 13 +- app/socket/index.js | 118 +++------- app/socket/utils.js | 74 ------ app/socket/utils/get.js | 20 ++ app/socket/utils/index.js | 9 + app/socket/utils/other.js | 70 ++++++ app/socket/utils/remove.js | 15 ++ app/socket/utils/store.js | 24 ++ app/socket/utils/watch.js | 27 +++ test/spec/app/socket/utils.spec.js | 263 ---------------------- test/spec/app/socket/utils/get.spec.js | 130 +++++++++++ test/spec/app/socket/utils/index.spec.js | 244 ++++++++++++++++++++ test/spec/app/socket/utils/remove.spec.js | 36 +++ test/spec/app/socket/utils/store.spec.js | 57 +++++ test/spec/app/socket/utils/watch.spec.js | 140 ++++++++++++ 15 files changed, 806 insertions(+), 434 deletions(-) delete mode 100644 app/socket/utils.js create mode 100644 app/socket/utils/get.js create mode 100644 app/socket/utils/index.js create mode 100644 app/socket/utils/other.js create mode 100644 app/socket/utils/remove.js create mode 100644 app/socket/utils/store.js create mode 100644 app/socket/utils/watch.js delete mode 100644 test/spec/app/socket/utils.spec.js create mode 100644 test/spec/app/socket/utils/get.spec.js create mode 100644 test/spec/app/socket/utils/index.spec.js create mode 100644 test/spec/app/socket/utils/remove.spec.js create mode 100644 test/spec/app/socket/utils/store.spec.js create mode 100644 test/spec/app/socket/utils/watch.spec.js diff --git a/app/socket/activity-service.js b/app/socket/activity-service.js index bca905d4..e29c6742 100644 --- a/app/socket/activity-service.js +++ b/app/socket/activity-service.js @@ -1,8 +1,11 @@ const redisActivityKeys = require('./redis-keys'); const utils = require('./utils'); -module.exports = (config, redis, ttlScoreGenerator) => { - const userDetailsTtlSec = config.get('redis.userDetailsTtlSec'); +module.exports = (config, redis) => { + const ttl = { + user: config.get('redis.userDetailsTtlSec'), + activity: config.get('redis.activityTtlSec') + }; const notifyChange = (caseId) => { redis.publish(redisActivityKeys.baseCase(caseId), Date.now().toString()); @@ -53,9 +56,9 @@ module.exports = (config, redis, ttlScoreGenerator) => { // Now store this activity. const activityKey = redisActivityKeys[activity](caseId); return redis.pipeline([ - utils.store.userActivity(activityKey, user.uid, ttlScoreGenerator.getScore()), - utils.store.socketActivity(socketId, activityKey, caseId, user.uid, userDetailsTtlSec), - utils.store.userDetails(user, userDetailsTtlSec) + utils.store.userActivity(activityKey, user.uid, utils.score(ttl.activity)), + utils.store.socketActivity(socketId, activityKey, caseId, user.uid, ttl.user), + utils.store.userDetails(user, ttl.user) ]).exec().then(() => { notifyChange(caseId); }); diff --git a/app/socket/index.js b/app/socket/index.js index a0489602..1866f273 100644 --- a/app/socket/index.js +++ b/app/socket/index.js @@ -1,10 +1,10 @@ -const debug = require('debug')('ccd-case-activity-api:socket'); const config = require('config'); const IORouter = require('socket.io-router-middleware'); const SocketIO = require('socket.io'); -const ttlScoreGenerator = require('../service/ttl-score-generator'); -const redisWatcher = require('./redis-watcher'); + const ActivityService = require('./activity-service'); +const redisWatcher = require('./redis-watcher'); +const utils = require('./utils'); const iorouter = new IORouter(); @@ -36,7 +36,7 @@ const iorouter = new IORouter(); * */ module.exports = (server, redis) => { - const activityService = ActivityService(config, redis, ttlScoreGenerator); + const activityService = ActivityService(config, redis); const io = SocketIO(server, { allowEIO3: true, cors: { @@ -44,134 +44,68 @@ module.exports = (server, redis) => { methods: ['GET', 'POST'] } }); - function toUser(obj) { - return { - sub: `${obj.name.replace(' ', '.')}@mailinator.com`, - uid: obj.id, - roles: [ - 'caseworker-employment', - 'caseworker-employment-leeds', - 'caseworker' - ], - name: obj.name, - given_name: obj.name.split(' ')[0], - family_name: obj.name.split(' ')[1] - }; - } - function watchCase(socket, caseId) { - socket.join(`case:${caseId}`); - } - function watchCases(socket, caseIds) { - caseIds.forEach((caseId) => { - watchCase(socket, caseId); - }); - } - function stopWatchingCases(socket) { - [...socket.rooms].filter((r) => r.indexOf('case:') === 0).forEach((r) => socket.leave(r)); - } + const socketUsers = {}; - async function whenActivityForCases(caseIds) { - return activityService.getActivityForCases(caseIds); - } - async function notifyWatchers(caseIds) { - const ids = Array.isArray(caseIds) ? caseIds : [caseIds]; - ids.sort().forEach(async (caseId) => { - const cs = await whenActivityForCases([caseId]); - io.to(`case:${caseId}`).emit('activity', cs); - }); + async function notifyWatchers(caseId) { + const cs = await activityService.getActivityForCases([caseId]); + io.to(`case:${caseId}`).emit('activity', cs); } - async function handleViewOrEdit(socket, caseId, user, activity) { - // Leave all the existing case rooms. - stopWatchingCases(socket); - - // Now watch this case specifically. - watchCase(socket, caseId); + async function handleActivity(socket, caseId, user, activity) { + // Update what's being watched. + utils.watch.update(socket, [caseId]); - // Finally, add this new activity to redis, which will also clear out the old activity. - await activityService.addActivity(caseId, toUser(user), socket.id, activity); - } - function handleEdit(socket, caseId, user) { - handleViewOrEdit(socket, caseId, user, 'edit'); - } - function handleView(socket, caseId, user) { - handleViewOrEdit(socket, caseId, user, 'view'); + // Then add this new activity to redis, which will also clear out the old activity. + await activityService.addActivity(caseId, utils.toUser(user), socket.id, activity); } async function handleWatch(socket, caseIds) { // Stop watching the current cases. - stopWatchingCases(socket); + utils.watch.stop(socket); // Remove the activity for this socket. await activityService.removeSocketActivity(socket.id); // Now watch the specified cases. - watchCases(socket, caseIds); + utils.watch.cases(socket, caseIds); // And immediately dispatch a message about the activity on those cases. - const cs = await whenActivityForCases(caseIds); + const cs = await activityService.getActivityForCases(caseIds); socket.emit('activity', cs); } - // TODO: Track this stuff in redis. - const socketUsers = {}; - - // Pretty way of logging. - function doLog(socket, payload, group) { - let text = `${new Date().toISOString()} | ${socket.id} | ${group}`; - if (typeof payload === 'string') { - if (payload) { - text = `${text} => ${payload}`; - } - debug(text); - } else { - debug(text); - debug(payload); - } - } - redisWatcher.psubscribe('case:*'); redisWatcher.on('pmessage', (_, room) => { const caseId = room.replace('case:', ''); - notifyWatchers([caseId]); + notifyWatchers(caseId); }); // Set up routes for each type of message. - iorouter.on('init', (socket, ctx, next) => { - // Do nothing in here. - doLog(socket, '', 'init'); - next(); - }); - iorouter.on('register', (socket, ctx, next) => { - doLog(socket, ctx.request.user, 'register'); + utils.log(socket, ctx.request.user, 'register'); socketUsers[socket.id] = ctx.request.user; next(); }); - iorouter.on('view', (socket, ctx, next) => { const user = socketUsers[socket.id]; - doLog(socket, `${ctx.request.caseId} (${user.name})`, 'view'); - handleView(socket, ctx.request.caseId, user); + utils.log(socket, `${ctx.request.caseId} (${user.name})`, 'view'); + handleActivity(socket, ctx.request.caseId, user, 'view'); next(); }); - iorouter.on('edit', (socket, ctx, next) => { const user = socketUsers[socket.id]; - doLog(socket, `${ctx.request.caseId} (${user.name})`, 'edit'); - handleEdit(socket, ctx.request.caseId, user); + utils.log(socket, `${ctx.request.caseId} (${user.name})`, 'edit'); + handleActivity(socket, ctx.request.caseId, user, 'edit'); next(); }); - iorouter.on('watch', (socket, ctx, next) => { const user = socketUsers[socket.id]; - doLog(socket, `${ctx.request.caseIds} (${user.name})`, 'watch'); + utils.log(socket, `${ctx.request.caseIds} (${user.name})`, 'watch'); handleWatch(socket, ctx.request.caseIds); next(); }); - // On client connection attach the router + // On client connection, attach the router io.on('connection', (socket) => { socket.use((packet, next) => { - // Call router.attach() with the client socket as the first parameter iorouter.attach(socket, packet, next); }); }); @@ -179,9 +113,9 @@ module.exports = (server, redis) => { const connections = []; io.sockets.on('connection', (socket) => { connections.push(socket); - doLog(socket, '', `connected (${connections.length} total)`); + utils.log(socket, '', `connected (${connections.length} total)`); socket.on('disconnect', () => { - doLog(socket, '', `disconnected (${connections.length - 1} total)`); + utils.log(socket, '', `disconnected (${connections.length - 1} total)`); activityService.removeSocketActivity(socket.id); delete socketUsers[socket.id]; connections.splice(connections.indexOf(socket), 1); diff --git a/app/socket/utils.js b/app/socket/utils.js deleted file mode 100644 index 9219f345..00000000 --- a/app/socket/utils.js +++ /dev/null @@ -1,74 +0,0 @@ -const debug = require('debug')('ccd-case-activity-api:socket-activity-service'); -const redisActivityKeys = require('./redis-keys'); - -const utils = { - toUserString: (user) => { - return user ? JSON.stringify({ - id: user.uid, - forename: user.given_name, - surname: user.family_name - }) : '{}'; - }, - extractUniqueUserIds: (result, uniqueUserIds) => { - const userIds = Array.isArray(uniqueUserIds) ? [...uniqueUserIds] : []; - if (Array.isArray(result)) { - result.forEach((item) => { - if (item && item[1]) { - const users = item[1]; - users.forEach((userId) => { - if (!userIds.includes(userId)) { - userIds.push(userId); - } - }); - } - }); - } - return userIds; - }, - get: { - caseActivities: (caseIds, activity, now) => { - if (Array.isArray(caseIds) && ['view', 'edit'].indexOf(activity) > -1) { - return caseIds.filter((id) => !!id).map((id) => { - return ['zrangebyscore', redisActivityKeys[activity](id), now, '+inf']; - }); - } - return []; - }, - users: (userIds) => { - if (Array.isArray(userIds)) { - return userIds.filter((id) => !!id).map((id) => ['get', redisActivityKeys.user(id)]); - } - return []; - } - }, - store: { - userActivity: (activityKey, userId, score) => { - debug(`about to store activity "${activityKey}" for user "${userId}"`); - return ['zadd', activityKey, score, userId]; - }, - userDetails: (user, ttl) => { - const key = redisActivityKeys.user(user.uid); - const store = utils.toUserString(user); - debug(`about to store details "${key}" for user "${user.uid}": ${store}`); - return ['set', key, store, 'EX', ttl]; - }, - socketActivity: (socketId, activityKey, caseId, userId, ttl) => { - const key = redisActivityKeys.socket(socketId); - const store = JSON.stringify({ activityKey, caseId, userId }); - debug(`about to store activity "${key}" for socket "${socketId}": ${store}`); - return ['set', key, store, 'EX', ttl]; - } - }, - remove: { - userActivity: (activity) => { - debug(`about to remove activity "${activity.activityKey}" for user "${activity.userId}"`); - return ['zrem', activity.activityKey, activity.userId]; - }, - socketEntry: (socketId) => { - debug(`about to remove activity for socket "${socketId}"`); - return ['del', redisActivityKeys.socket(socketId)]; - } - } -}; - -module.exports = utils; diff --git a/app/socket/utils/get.js b/app/socket/utils/get.js new file mode 100644 index 00000000..0b10618e --- /dev/null +++ b/app/socket/utils/get.js @@ -0,0 +1,20 @@ +const redisActivityKeys = require('../redis-keys'); + +const get = { + caseActivities: (caseIds, activity, now) => { + if (Array.isArray(caseIds) && ['view', 'edit'].indexOf(activity) > -1) { + return caseIds.filter((id) => !!id).map((id) => { + return ['zrangebyscore', redisActivityKeys[activity](id), now, '+inf']; + }); + } + return []; + }, + users: (userIds) => { + if (Array.isArray(userIds)) { + return userIds.filter((id) => !!id).map((id) => ['get', redisActivityKeys.user(id)]); + } + return []; + } +}; + +module.exports = get; diff --git a/app/socket/utils/index.js b/app/socket/utils/index.js new file mode 100644 index 00000000..c54179ce --- /dev/null +++ b/app/socket/utils/index.js @@ -0,0 +1,9 @@ +const other = require('./other'); + +module.exports = { + ...other, + get: require('./get'), + remove: require('./remove'), + store: require('./store'), + watch: require('./watch') +}; diff --git a/app/socket/utils/other.js b/app/socket/utils/other.js new file mode 100644 index 00000000..9524b777 --- /dev/null +++ b/app/socket/utils/other.js @@ -0,0 +1,70 @@ +const debug = require('debug')('ccd-case-activity-api:socket-utils'); + +const other = { + extractUniqueUserIds: (result, uniqueUserIds) => { + const userIds = Array.isArray(uniqueUserIds) ? [...uniqueUserIds] : []; + if (Array.isArray(result)) { + result.forEach((item) => { + if (item && item[1]) { + const users = item[1]; + users.forEach((userId) => { + if (!userIds.includes(userId)) { + userIds.push(userId); + } + }); + } + }); + } + return userIds; + }, + log: (socket, payload, group, logTo) => { + const outputTo = logTo || debug; + let text = `${new Date().toISOString()} | ${socket.id} | ${group}`; + if (typeof payload === 'string') { + if (payload) { + text = `${text} => ${payload}`; + } + outputTo(text); + } else { + outputTo(text); + outputTo(payload); + } + }, + score: (ttlStr) => { + const now = Date.now(); + const ttl = parseInt(ttlStr, 10) || 0; + const score = now + (ttl * 1000); + debug(`generated score out of current timestamp '${now}' plus ${ttl} sec`); + return score; + }, + toUser: (obj) => { + // TODO: REMOVE THIS + // This is here purely until we have proper auth coming from a client. + if (!obj) { + return {}; + } + const nameParts = obj.name.split(' '); + const givenName = nameParts.shift(); + return { + sub: `${givenName}.${nameParts.join('-')}@mailinator.com`, + uid: obj.id, + roles: [ + 'caseworker-employment', + 'caseworker-employment-leeds', + 'caseworker' + ], + name: obj.name, + given_name: givenName, + family_name: nameParts.join(' ') + }; + }, + toUserString: (user) => { + return user ? JSON.stringify({ + id: user.uid, + forename: user.given_name, + surname: user.family_name + }) : '{}'; + } +}; + +module.exports = other; diff --git a/app/socket/utils/remove.js b/app/socket/utils/remove.js new file mode 100644 index 00000000..ee491903 --- /dev/null +++ b/app/socket/utils/remove.js @@ -0,0 +1,15 @@ +const debug = require('debug')('ccd-case-activity-api:socket-utils-remove'); +const redisActivityKeys = require('../redis-keys'); + +const remove = { + userActivity: (activity) => { + debug(`about to remove activity "${activity.activityKey}" for user "${activity.userId}"`); + return ['zrem', activity.activityKey, activity.userId]; + }, + socketEntry: (socketId) => { + debug(`about to remove activity for socket "${socketId}"`); + return ['del', redisActivityKeys.socket(socketId)]; + } +}; + +module.exports = remove; diff --git a/app/socket/utils/store.js b/app/socket/utils/store.js new file mode 100644 index 00000000..165c90af --- /dev/null +++ b/app/socket/utils/store.js @@ -0,0 +1,24 @@ +const debug = require('debug')('ccd-case-activity-api:socket-utils-store'); +const redisActivityKeys = require('../redis-keys'); +const toUserString = require('./other').toUserString; + +const store = { + userActivity: (activityKey, userId, score) => { + debug(`about to store activity "${activityKey}" for user "${userId}"`); + return ['zadd', activityKey, score, userId]; + }, + userDetails: (user, ttl) => { + const key = redisActivityKeys.user(user.uid); + const store = toUserString(user); + debug(`about to store details "${key}" for user "${user.uid}": ${store}`); + return ['set', key, store, 'EX', ttl]; + }, + socketActivity: (socketId, activityKey, caseId, userId, ttl) => { + const key = redisActivityKeys.socket(socketId); + const store = JSON.stringify({ activityKey, caseId, userId }); + debug(`about to store activity "${key}" for socket "${socketId}": ${store}`); + return ['set', key, store, 'EX', ttl]; + } +}; + +module.exports = store; diff --git a/app/socket/utils/watch.js b/app/socket/utils/watch.js new file mode 100644 index 00000000..99339203 --- /dev/null +++ b/app/socket/utils/watch.js @@ -0,0 +1,27 @@ +const watch = { + case: (socket, caseId) => { + if (socket && caseId) { + socket.join(`case:${caseId}`); + } + }, + cases: (socket, caseIds) => { + if (socket && Array.isArray(caseIds)) { + caseIds.forEach((caseId) => { + watch.case(socket, caseId); + }); + } + }, + stop: (socket) => { + if (socket) { + [...socket.rooms] + .filter((r) => r.indexOf('case:') === 0) // Only case rooms. + .forEach((r) => socket.leave(r)); + } + }, + update: (socket, caseIds) => { + watch.stop(socket); + watch.cases(socket, caseIds); + } +}; + +module.exports = watch; diff --git a/test/spec/app/socket/utils.spec.js b/test/spec/app/socket/utils.spec.js deleted file mode 100644 index 94549274..00000000 --- a/test/spec/app/socket/utils.spec.js +++ /dev/null @@ -1,263 +0,0 @@ -const expect = require('chai').expect; -const utils = require('../../../../app/socket/utils'); -const redisActivityKeys = require('../../../../app/socket/redis-keys'); - -describe('socket.utils', () => { - - describe('toUserString', () => { - it('should handle a null user', () => { - expect(utils.toUserString(null)).to.equal('{}'); - }); - it('should handle an undefined user', () => { - expect(utils.toUserString(undefined)).to.equal('{}'); - }); - it('should handle an empty user', () => { - expect(utils.toUserString({})).to.equal('{}'); - }); - it('should handle a full user', () => { - const USER = { - uid: '1234567890', - given_name: 'Bob', - family_name: 'Smith' - }; - expect(utils.toUserString(USER)).to.equal('{"id":"1234567890","forename":"Bob","surname":"Smith"}'); - }); - it('should handle a user with a missing family name', () => { - const USER = { - uid: '1234567890', - given_name: 'Bob' - }; - expect(utils.toUserString(USER)).to.equal('{"id":"1234567890","forename":"Bob"}'); - }); - it('should handle a user with a missing given name', () => { - const USER = { - uid: '1234567890', - family_name: 'Smith' - }; - expect(utils.toUserString(USER)).to.equal('{"id":"1234567890","surname":"Smith"}'); - }); - it('should handle a user with a missing name', () => { - const USER = { - uid: '1234567890' - }; - expect(utils.toUserString(USER)).to.equal('{"id":"1234567890"}'); - }); - }); - - describe('extractUniqueUserIds', () => { - it('should handle a null result', () => { - const RESULT = null; - const UNIQUE = ['a']; - const IDS = utils.extractUniqueUserIds(RESULT, UNIQUE); - expect(IDS).to.be.an('array') - .that.has.lengthOf(1) - .and.that.includes('a'); - }); - it('should handle a result of the wrong type', () => { - const RESULT = 'bob'; - const UNIQUE = ['a']; - const IDS = utils.extractUniqueUserIds(RESULT, UNIQUE); - expect(IDS).to.be.an('array') - .that.has.lengthOf(1) - .and.that.includes('a'); - }); - it('should handle a result with the wrong structure', () => { - const RESULT = [ - ['bob'], - ['fred'] - ]; - const UNIQUE = ['a']; - const IDS = utils.extractUniqueUserIds(RESULT, UNIQUE); - expect(IDS).to.be.an('array') - .that.has.lengthOf(1) - .and.that.includes('a'); - }); - it('should handle a result containing nulls', () => { - const RESULT = [ - ['bob', ['b']], - ['fred', null] - ]; - const UNIQUE = ['a']; - const IDS = utils.extractUniqueUserIds(RESULT, UNIQUE); - expect(IDS).to.be.an('array') - .that.has.lengthOf(2) - .and.that.includes('a') - .and.that.includes('b'); - }); - it('should handle a result with the correct structure', () => { - const RESULT = [ - ['bob', ['b', 'g']], - ['fred', ['f']] - ]; - const UNIQUE = ['a']; - const IDS = utils.extractUniqueUserIds(RESULT, UNIQUE); - expect(IDS).to.be.an('array').that.has.lengthOf(4) - .and.that.includes('a') - .and.that.includes('b') - .and.that.includes('f') - .and.that.includes('g'); - }); - it('should handle a result with the correct structure but a null original array', () => { - const RESULT = [ - ['bob', ['b', 'g']], - ['fred', ['f']] - ]; - const UNIQUE = null; - const IDS = utils.extractUniqueUserIds(RESULT, UNIQUE); - expect(IDS).to.be.an('array').that.has.lengthOf(3) - .and.that.includes('b') - .and.that.includes('f') - .and.that.includes('g'); - }); - it('should handle a result with the correct structure but an original array of the wrong type', () => { - const RESULT = [ - ['bob', ['b', 'g']], - ['fred', ['f']] - ]; - const UNIQUE = 'a'; - const IDS = utils.extractUniqueUserIds(RESULT, UNIQUE); - expect(IDS).to.be.an('array').that.has.lengthOf(3) - .and.that.includes('b') - .and.that.includes('f') - .and.that.includes('g'); - }); - it('should strip out duplicates', () => { - const RESULT = [ - ['bob', ['a', 'b', 'g']], - ['fred', ['f', 'b']] - ]; - const UNIQUE = ['a']; - const IDS = utils.extractUniqueUserIds(RESULT, UNIQUE); - expect(IDS).to.be.an('array') - .and.that.includes('a') - .and.that.includes('b') - .and.that.includes('f') - .and.that.includes('g') - .but.that.has.lengthOf(4); // One of each, despite the RESULT containing an extra 'a', and 'b' twice. - }); - }); - - describe('get', () => { - - describe('caseActivities', () => { - it('should get the correct result for a single case being viewed', () => { - const CASE_IDS = ['1']; - const ACTIVITY = 'view'; - const NOW = 999; - const pipes = utils.get.caseActivities(CASE_IDS, ACTIVITY, NOW); - expect(pipes).to.be.an('array').and.have.lengthOf(1); - expect(pipes[0]).to.be.an('array').and.have.lengthOf(4); - expect(pipes[0][0]).to.equal('zrangebyscore'); - expect(pipes[0][1]).to.equal(redisActivityKeys.view(CASE_IDS[0])); - expect(pipes[0][2]).to.equal(NOW); - expect(pipes[0][3]).to.equal('+inf'); - }); - it('should get the correct result for a multiple cases being viewed', () => { - const CASE_IDS = ['1', '8', '2345678', 'x']; - const ACTIVITY = 'view'; - const NOW = 999; - const pipes = utils.get.caseActivities(CASE_IDS, ACTIVITY, NOW); - expect(pipes).to.be.an('array').and.have.lengthOf(CASE_IDS.length); - CASE_IDS.forEach((id, index) => { - expect(pipes[index]).to.be.an('array').and.have.lengthOf(4); - expect(pipes[index][0]).to.equal('zrangebyscore'); - expect(pipes[index][1]).to.equal(redisActivityKeys.view(id)); - expect(pipes[index][2]).to.equal(NOW); - expect(pipes[index][3]).to.equal('+inf'); - }); - }); - it('should handle a null case ID for cases being viewed', () => { - const CASE_IDS = ['1', '8', null, 'x']; - const ACTIVITY = 'view'; - const NOW = 999; - const pipes = utils.get.caseActivities(CASE_IDS, ACTIVITY, NOW); - expect(pipes).to.be.an('array').and.have.lengthOf(CASE_IDS.length - 1); - let pipeIndex = 0; - CASE_IDS.forEach((id) => { - if (id !== null) { - expect(pipes[pipeIndex]).to.be.an('array').and.have.lengthOf(4); - expect(pipes[pipeIndex][0]).to.equal('zrangebyscore'); - expect(pipes[pipeIndex][1]).to.equal(redisActivityKeys.view(id)); - expect(pipes[pipeIndex][2]).to.equal(NOW); - expect(pipes[pipeIndex][3]).to.equal('+inf'); - pipeIndex++; - } - }); - }); - it('should handle a null case ID for cases being edited', () => { - const CASE_IDS = ['1', '8', null, 'x']; - const ACTIVITY = 'edit'; - const NOW = 999; - const pipes = utils.get.caseActivities(CASE_IDS, ACTIVITY, NOW); - expect(pipes).to.be.an('array').and.have.lengthOf(CASE_IDS.length - 1); - let pipeIndex = 0; - CASE_IDS.forEach((id) => { - if (id !== null) { - expect(pipes[pipeIndex]).to.be.an('array').and.have.lengthOf(4); - expect(pipes[pipeIndex][0]).to.equal('zrangebyscore'); - expect(pipes[pipeIndex][1]).to.equal(redisActivityKeys.edit(id)); - expect(pipes[pipeIndex][2]).to.equal(NOW); - expect(pipes[pipeIndex][3]).to.equal('+inf'); - pipeIndex++; - } - }); - }); - it('should handle a null array of case IDs', () => { - const CASE_IDS = null; - const ACTIVITY = 'view'; - const NOW = 999; - const pipes = utils.get.caseActivities(CASE_IDS, ACTIVITY, NOW); - expect(pipes).to.be.an('array').and.have.lengthOf(0); - }); - it('should handle an invalid activity type', () => { - const CASE_IDS = ['1', '8', '2345678', 'x']; - const ACTIVITY = 'bob'; - const NOW = 999; - const pipes = utils.get.caseActivities(CASE_IDS, ACTIVITY, NOW); - expect(pipes).to.be.an('array').and.have.lengthOf(0); - }); - }); - - describe('users', () => { - it('should get the correct result for a single user ID', () => { - const USER_IDS = ['1']; - const pipes = utils.get.users(USER_IDS); - expect(pipes).to.be.an('array').and.have.lengthOf(1); - expect(pipes[0]).to.be.an('array').and.have.lengthOf(2); - expect(pipes[0][0]).to.equal('get'); - expect(pipes[0][1]).to.equal(redisActivityKeys.user(USER_IDS[0])); - }); - it('should get the correct result for multiple user IDs', () => { - const USER_IDS = ['1', '8', '2345678', 'x']; - const pipes = utils.get.users(USER_IDS); - expect(pipes).to.be.an('array').and.have.lengthOf(USER_IDS.length); - expect(pipes[0]).to.be.an('array').and.have.lengthOf(2); - USER_IDS.forEach((id, index) => { - expect(pipes[index][0]).to.equal('get'); - expect(pipes[index][1]).to.equal(redisActivityKeys.user(id)); - }); - }); - it('should handle a null user ID', () => { - const USER_IDS = ['1', '8', null, 'x']; - const pipes = utils.get.users(USER_IDS); - expect(pipes).to.be.an('array').and.have.lengthOf(USER_IDS.length - 1); - expect(pipes[0]).to.be.an('array').and.have.lengthOf(2); - let pipeIndex = 0; - USER_IDS.forEach((id) => { - if (id) { - expect(pipes[pipeIndex][0]).to.equal('get'); - expect(pipes[pipeIndex][1]).to.equal(redisActivityKeys.user(id)); - pipeIndex++; - } - }); - }); - it('should handle a null array of user IDs', () => { - const USER_IDS = null; - const pipes = utils.get.users(USER_IDS); - expect(pipes).to.be.an('array').and.have.lengthOf(0); - }); - }); - - }); - -}); diff --git a/test/spec/app/socket/utils/get.spec.js b/test/spec/app/socket/utils/get.spec.js new file mode 100644 index 00000000..2f9cdc4d --- /dev/null +++ b/test/spec/app/socket/utils/get.spec.js @@ -0,0 +1,130 @@ +const expect = require('chai').expect; +const get = require('../../../../../app/socket/utils/get'); +const redisActivityKeys = require('../../../../../app/socket/redis-keys'); + +describe('socket.utils', () => { + + describe('get', () => { + + describe('caseActivities', () => { + it('should get the correct result for a single case being viewed', () => { + const CASE_IDS = ['1']; + const ACTIVITY = 'view'; + const NOW = 999; + const pipes = get.caseActivities(CASE_IDS, ACTIVITY, NOW); + expect(pipes).to.be.an('array').and.have.lengthOf(1); + expect(pipes[0]).to.be.an('array').and.have.lengthOf(4); + expect(pipes[0][0]).to.equal('zrangebyscore'); + expect(pipes[0][1]).to.equal(redisActivityKeys.view(CASE_IDS[0])); + expect(pipes[0][2]).to.equal(NOW); + expect(pipes[0][3]).to.equal('+inf'); + }); + it('should get the correct result for a multiple cases being viewed', () => { + const CASE_IDS = ['1', '8', '2345678', 'x']; + const ACTIVITY = 'view'; + const NOW = 999; + const pipes = get.caseActivities(CASE_IDS, ACTIVITY, NOW); + expect(pipes).to.be.an('array').and.have.lengthOf(CASE_IDS.length); + CASE_IDS.forEach((id, index) => { + expect(pipes[index]).to.be.an('array').and.have.lengthOf(4); + expect(pipes[index][0]).to.equal('zrangebyscore'); + expect(pipes[index][1]).to.equal(redisActivityKeys.view(id)); + expect(pipes[index][2]).to.equal(NOW); + expect(pipes[index][3]).to.equal('+inf'); + }); + }); + it('should handle a null case ID for cases being viewed', () => { + const CASE_IDS = ['1', '8', null, 'x']; + const ACTIVITY = 'view'; + const NOW = 999; + const pipes = get.caseActivities(CASE_IDS, ACTIVITY, NOW); + expect(pipes).to.be.an('array').and.have.lengthOf(CASE_IDS.length - 1); + let pipeIndex = 0; + CASE_IDS.forEach((id) => { + if (id !== null) { + expect(pipes[pipeIndex]).to.be.an('array').and.have.lengthOf(4); + expect(pipes[pipeIndex][0]).to.equal('zrangebyscore'); + expect(pipes[pipeIndex][1]).to.equal(redisActivityKeys.view(id)); + expect(pipes[pipeIndex][2]).to.equal(NOW); + expect(pipes[pipeIndex][3]).to.equal('+inf'); + pipeIndex++; + } + }); + }); + it('should handle a null case ID for cases being edited', () => { + const CASE_IDS = ['1', '8', null, 'x']; + const ACTIVITY = 'edit'; + const NOW = 999; + const pipes = get.caseActivities(CASE_IDS, ACTIVITY, NOW); + expect(pipes).to.be.an('array').and.have.lengthOf(CASE_IDS.length - 1); + let pipeIndex = 0; + CASE_IDS.forEach((id) => { + if (id !== null) { + expect(pipes[pipeIndex]).to.be.an('array').and.have.lengthOf(4); + expect(pipes[pipeIndex][0]).to.equal('zrangebyscore'); + expect(pipes[pipeIndex][1]).to.equal(redisActivityKeys.edit(id)); + expect(pipes[pipeIndex][2]).to.equal(NOW); + expect(pipes[pipeIndex][3]).to.equal('+inf'); + pipeIndex++; + } + }); + }); + it('should handle a null array of case IDs', () => { + const CASE_IDS = null; + const ACTIVITY = 'view'; + const NOW = 999; + const pipes = get.caseActivities(CASE_IDS, ACTIVITY, NOW); + expect(pipes).to.be.an('array').and.have.lengthOf(0); + }); + it('should handle an invalid activity type', () => { + const CASE_IDS = ['1', '8', '2345678', 'x']; + const ACTIVITY = 'bob'; + const NOW = 999; + const pipes = get.caseActivities(CASE_IDS, ACTIVITY, NOW); + expect(pipes).to.be.an('array').and.have.lengthOf(0); + }); + }); + + describe('users', () => { + it('should get the correct result for a single user ID', () => { + const USER_IDS = ['1']; + const pipes = get.users(USER_IDS); + expect(pipes).to.be.an('array').and.have.lengthOf(1); + expect(pipes[0]).to.be.an('array').and.have.lengthOf(2); + expect(pipes[0][0]).to.equal('get'); + expect(pipes[0][1]).to.equal(redisActivityKeys.user(USER_IDS[0])); + }); + it('should get the correct result for multiple user IDs', () => { + const USER_IDS = ['1', '8', '2345678', 'x']; + const pipes = get.users(USER_IDS); + expect(pipes).to.be.an('array').and.have.lengthOf(USER_IDS.length); + expect(pipes[0]).to.be.an('array').and.have.lengthOf(2); + USER_IDS.forEach((id, index) => { + expect(pipes[index][0]).to.equal('get'); + expect(pipes[index][1]).to.equal(redisActivityKeys.user(id)); + }); + }); + it('should handle a null user ID', () => { + const USER_IDS = ['1', '8', null, 'x']; + const pipes = get.users(USER_IDS); + expect(pipes).to.be.an('array').and.have.lengthOf(USER_IDS.length - 1); + expect(pipes[0]).to.be.an('array').and.have.lengthOf(2); + let pipeIndex = 0; + USER_IDS.forEach((id) => { + if (id) { + expect(pipes[pipeIndex][0]).to.equal('get'); + expect(pipes[pipeIndex][1]).to.equal(redisActivityKeys.user(id)); + pipeIndex++; + } + }); + }); + it('should handle a null array of user IDs', () => { + const USER_IDS = null; + const pipes = get.users(USER_IDS); + expect(pipes).to.be.an('array').and.have.lengthOf(0); + }); + }); + + }); + +}); diff --git a/test/spec/app/socket/utils/index.spec.js b/test/spec/app/socket/utils/index.spec.js new file mode 100644 index 00000000..40be897a --- /dev/null +++ b/test/spec/app/socket/utils/index.spec.js @@ -0,0 +1,244 @@ +const expect = require('chai').expect; +const sandbox = require("sinon").createSandbox(); +const utils = require('../../../../../app/socket/utils'); + +describe('socket.utils', () => { + + describe('extractUniqueUserIds', () => { + it('should handle a null result', () => { + const RESULT = null; + const UNIQUE = ['a']; + const IDS = utils.extractUniqueUserIds(RESULT, UNIQUE); + expect(IDS).to.be.an('array') + .that.has.lengthOf(1) + .and.that.includes('a'); + }); + it('should handle a result of the wrong type', () => { + const RESULT = 'bob'; + const UNIQUE = ['a']; + const IDS = utils.extractUniqueUserIds(RESULT, UNIQUE); + expect(IDS).to.be.an('array') + .that.has.lengthOf(1) + .and.that.includes('a'); + }); + it('should handle a result with the wrong structure', () => { + const RESULT = [ + ['bob'], + ['fred'] + ]; + const UNIQUE = ['a']; + const IDS = utils.extractUniqueUserIds(RESULT, UNIQUE); + expect(IDS).to.be.an('array') + .that.has.lengthOf(1) + .and.that.includes('a'); + }); + it('should handle a result containing nulls', () => { + const RESULT = [ + ['bob', ['b']], + ['fred', null] + ]; + const UNIQUE = ['a']; + const IDS = utils.extractUniqueUserIds(RESULT, UNIQUE); + expect(IDS).to.be.an('array') + .that.has.lengthOf(2) + .and.that.includes('a') + .and.that.includes('b'); + }); + it('should handle a result with the correct structure', () => { + const RESULT = [ + ['bob', ['b', 'g']], + ['fred', ['f']] + ]; + const UNIQUE = ['a']; + const IDS = utils.extractUniqueUserIds(RESULT, UNIQUE); + expect(IDS).to.be.an('array').that.has.lengthOf(4) + .and.that.includes('a') + .and.that.includes('b') + .and.that.includes('f') + .and.that.includes('g'); + }); + it('should handle a result with the correct structure but a null original array', () => { + const RESULT = [ + ['bob', ['b', 'g']], + ['fred', ['f']] + ]; + const UNIQUE = null; + const IDS = utils.extractUniqueUserIds(RESULT, UNIQUE); + expect(IDS).to.be.an('array').that.has.lengthOf(3) + .and.that.includes('b') + .and.that.includes('f') + .and.that.includes('g'); + }); + it('should handle a result with the correct structure but an original array of the wrong type', () => { + const RESULT = [ + ['bob', ['b', 'g']], + ['fred', ['f']] + ]; + const UNIQUE = 'a'; + const IDS = utils.extractUniqueUserIds(RESULT, UNIQUE); + expect(IDS).to.be.an('array').that.has.lengthOf(3) + .and.that.includes('b') + .and.that.includes('f') + .and.that.includes('g'); + }); + it('should strip out duplicates', () => { + const RESULT = [ + ['bob', ['a', 'b', 'g']], + ['fred', ['f', 'b']] + ]; + const UNIQUE = ['a']; + const IDS = utils.extractUniqueUserIds(RESULT, UNIQUE); + expect(IDS).to.be.an('array') + .and.that.includes('a') + .and.that.includes('b') + .and.that.includes('f') + .and.that.includes('g') + .but.that.has.lengthOf(4); // One of each, despite the RESULT containing an extra 'a', and 'b' twice. + }); + }); + + describe('log', () => { + it('should output string payload', () => { + const logs = []; + const logTo = (str) => { + logs.push(str); + }; + const SOCKET = { id: 'Are' }; + const PAYLOAD = 'entertained?'; + const GROUP = 'you not'; + utils.log(SOCKET, PAYLOAD, GROUP, logTo); + expect(logs).to.have.lengthOf(1); + expect(logs[0]).to.include(`| Are | you not => entertained?`); + }); + it('should output object payload', () => { + const logs = []; + const logTo = (str) => { + logs.push(str); + }; + const SOCKET = { id: 'Are' }; + const PAYLOAD = { sufficiently: 'entertained?' }; + const GROUP = 'you not'; + utils.log(SOCKET, PAYLOAD, GROUP, logTo); + expect(logs).to.have.lengthOf(2); + expect(logs[0]).to.include(`| Are | you not`); + expect(logs[1]).to.equal(PAYLOAD); + }); + }); + + describe('score', () => { + it('should handle a string TTL', () => { + const TTL = '12'; + const NOW = 55; + sandbox.stub(Date, 'now').returns(NOW); + const score = utils.score(TTL); + expect(score).to.equal(12055); // (TTL * 1000) + NOW + }); + it('should handle a numeric TTL', () => { + const TTL = 13; + const NOW = 55; + sandbox.stub(Date, 'now').returns(NOW); + const score = utils.score(TTL); + expect(score).to.equal(13055); // (TTL * 1000) + NOW + }); + it('should handle a null TTL', () => { + const TTL = null; + const NOW = 55; + sandbox.stub(Date, 'now').returns(NOW); + const score = utils.score(TTL); + expect(score).to.equal(55); // null TTL => 0 + }); + + afterEach(() => { + // completely restore all fakes created through the sandbox + sandbox.restore(); + }); + }); + + describe('toUser', () => { + it('should handle a null object', () => { + const OBJ = null; + const user = utils.toUser(OBJ); + expect(user).to.deep.equal({}); + }); + it('should handle a valid object', () => { + const OBJ = { id: 'bob', name: 'Bob Smith' }; + const user = utils.toUser(OBJ); + expect(user.uid).to.equal(OBJ.id); + expect(user.name).to.equal(OBJ.name); + expect(user.given_name).to.equal('Bob'); + expect(user.family_name).to.equal('Smith'); + expect(user.sub).to.equal('Bob.Smith@mailinator.com'); + }); + it('should handle a valid object with a long name', () => { + const OBJ = { id: 'ddl', name: 'Daniel Day Lewis' }; + const user = utils.toUser(OBJ); + expect(user.uid).to.equal(OBJ.id); + expect(user.name).to.equal(OBJ.name); + expect(user.given_name).to.equal('Daniel'); + expect(user.family_name).to.equal('Day Lewis'); + expect(user.sub).to.equal('Daniel.Day-Lewis@mailinator.com'); + }); + }); + + describe('toUserString', () => { + it('should handle a null user', () => { + expect(utils.toUserString(null)).to.equal('{}'); + }); + it('should handle an undefined user', () => { + expect(utils.toUserString(undefined)).to.equal('{}'); + }); + it('should handle an empty user', () => { + expect(utils.toUserString({})).to.equal('{}'); + }); + it('should handle a full user', () => { + const USER = { + uid: '1234567890', + given_name: 'Bob', + family_name: 'Smith' + }; + expect(utils.toUserString(USER)).to.equal('{"id":"1234567890","forename":"Bob","surname":"Smith"}'); + }); + it('should handle a user with a missing family name', () => { + const USER = { + uid: '1234567890', + given_name: 'Bob' + }; + expect(utils.toUserString(USER)).to.equal('{"id":"1234567890","forename":"Bob"}'); + }); + it('should handle a user with a missing given name', () => { + const USER = { + uid: '1234567890', + family_name: 'Smith' + }; + expect(utils.toUserString(USER)).to.equal('{"id":"1234567890","surname":"Smith"}'); + }); + it('should handle a user with a missing name', () => { + const USER = { + uid: '1234567890' + }; + expect(utils.toUserString(USER)).to.equal('{"id":"1234567890"}'); + }); + }); + + describe('get', () => { + it('should be appropriately set up', () => { + expect(utils.get).to.equal(require('../../../../../app/socket/utils/get')); + }); + }); + describe('remove', () => { + it('should be appropriately set up', () => { + expect(utils.remove).to.equal(require('../../../../../app/socket/utils/remove')); + }); + }); + describe('store', () => { + it('should be appropriately set up', () => { + expect(utils.store).to.equal(require('../../../../../app/socket/utils/store')); + }); + }); + describe('watch', () => { + it('should be appropriately set up', () => { + expect(utils.watch).to.equal(require('../../../../../app/socket/utils/watch')); + }); + }); + +}); diff --git a/test/spec/app/socket/utils/remove.spec.js b/test/spec/app/socket/utils/remove.spec.js new file mode 100644 index 00000000..756189fc --- /dev/null +++ b/test/spec/app/socket/utils/remove.spec.js @@ -0,0 +1,36 @@ +const expect = require('chai').expect; +const remove = require('../../../../../app/socket/utils/remove'); +const redisActivityKeys = require('../../../../../app/socket/redis-keys'); + +describe('socket.utils', () => { + + describe('remove', () => { + + describe('userActivity', () => { + it('should produce an appopriate pipe', () => { + const CASE_ID = '1234567890'; + const ACTIVITY = { + activityKey: redisActivityKeys.view(CASE_ID), + userId: 'a' + }; + const pipe = remove.userActivity(ACTIVITY); + expect(pipe).to.be.an('array').and.have.lengthOf(3); + expect(pipe[0]).to.equal('zrem'); + expect(pipe[1]).to.equal(ACTIVITY.activityKey); + expect(pipe[2]).to.equal(ACTIVITY.userId); + }); + }); + + describe('socketEntry', () => { + it('should produce an appopriate pipe', () => { + const SOCKET_ID = 'abcdef123456'; + const pipe = remove.socketEntry(SOCKET_ID); + expect(pipe).to.be.an('array').and.have.lengthOf(2); + expect(pipe[0]).to.equal('del'); + expect(pipe[1]).to.equal(redisActivityKeys.socket(SOCKET_ID)); + }); + }); + + }); + +}); diff --git a/test/spec/app/socket/utils/store.spec.js b/test/spec/app/socket/utils/store.spec.js new file mode 100644 index 00000000..2cc486e9 --- /dev/null +++ b/test/spec/app/socket/utils/store.spec.js @@ -0,0 +1,57 @@ +const expect = require('chai').expect; +const store = require('../../../../../app/socket/utils/store'); +const redisActivityKeys = require('../../../../../app/socket/redis-keys'); + +describe('socket.utils', () => { + + describe('store', () => { + + describe('userActivity', () => { + it('should produce an appopriate pipe', () => { + const CASE_ID = '1234567890'; + const ACTIVITY_KEY = redisActivityKeys.view(CASE_ID); + const USER_ID = 'a'; + const SCORE = 500; + const pipe = store.userActivity(ACTIVITY_KEY, USER_ID, SCORE); + expect(pipe).to.be.an('array').and.have.lengthOf(4); + expect(pipe[0]).to.equal('zadd'); + expect(pipe[1]).to.equal(ACTIVITY_KEY); + expect(pipe[2]).to.equal(SCORE); + expect(pipe[3]).to.equal(USER_ID); + }); + }); + + describe('userDetails', () => { + it('should produce an appopriate pipe', () => { + const USER = { uid: 'a', given_name: 'Bob', family_name: 'Smith' }; + const TTL = 487; + const pipe = store.userDetails(USER, TTL); + expect(pipe).to.be.an('array').and.have.lengthOf(5); + expect(pipe[0]).to.equal('set'); + expect(pipe[1]).to.equal(redisActivityKeys.user(USER.uid)); + expect(pipe[2]).to.equal('{"id":"a","forename":"Bob","surname":"Smith"}'); + expect(pipe[3]).to.equal('EX'); // Expires in... + expect(pipe[4]).to.equal(TTL); // ...487 seconds. + }); + }); + + describe('socketActivity', () => { + it('should produce an appopriate pipe', () => { + const CASE_ID = '1234567890'; + const SOCKET_ID = 'abcdef123456'; + const ACTIVITY_KEY = redisActivityKeys.view(CASE_ID); + const USER_ID = 'a'; + const TTL = 487; + const pipe = store.socketActivity(SOCKET_ID, ACTIVITY_KEY, CASE_ID, USER_ID, TTL); + expect(pipe).to.be.an('array').and.have.lengthOf(5); + expect(pipe[0]).to.equal('set'); + expect(pipe[1]).to.equal(redisActivityKeys.socket(SOCKET_ID)); + expect(pipe[2]).to.equal(`{"activityKey":"${ACTIVITY_KEY}","caseId":"${CASE_ID}","userId":"${USER_ID}"}`); + expect(pipe[3]).to.equal('EX'); // Expires in... + expect(pipe[4]).to.equal(TTL); // ...487 seconds. + }); + }); + + }); + +}); diff --git a/test/spec/app/socket/utils/watch.spec.js b/test/spec/app/socket/utils/watch.spec.js new file mode 100644 index 00000000..77933bc0 --- /dev/null +++ b/test/spec/app/socket/utils/watch.spec.js @@ -0,0 +1,140 @@ +const expect = require('chai').expect; +const watch = require('../../../../../app/socket/utils/watch'); + +describe('socket.utils', () => { + + describe('watch', () => { + + const MOCK_SOCKET = { + id: 'socket-id', + rooms: ['socket-id'], + join: (room) => { + if (!MOCK_SOCKET.rooms.includes(room)) { + MOCK_SOCKET.rooms.push(room); + } + }, + leave: (room) => { + const roomIndex = MOCK_SOCKET.rooms.indexOf(room); + if (roomIndex > -1) { + MOCK_SOCKET.rooms.splice(roomIndex, 1); + } + } + }; + + afterEach(() => { + MOCK_SOCKET.rooms.length = 0; + MOCK_SOCKET.rooms.push(MOCK_SOCKET.id) + }); + + describe('case', () => { + it('should join the appropriate room on the socket', () => { + const CASE_ID = '1234567890'; + watch.case(MOCK_SOCKET, CASE_ID); + expect(MOCK_SOCKET.rooms).to.have.lengthOf(2) + .and.to.include(MOCK_SOCKET.id) + .and.to.include(`case:${CASE_ID}`); + }); + it('should handle a null room', () => { + const CASE_ID = null; + watch.case(MOCK_SOCKET, CASE_ID); + expect(MOCK_SOCKET.rooms).to.have.lengthOf(1) + .and.to.include(MOCK_SOCKET.id); + }); + it('should handle a null socket', () => { + const CASE_ID = null; + watch.case(null, CASE_ID); + expect(MOCK_SOCKET.rooms).to.have.lengthOf(1) + .and.to.include(MOCK_SOCKET.id); + }); + }); + + describe('cases', () => { + it('should join all appropriate rooms on the socket', () => { + const CASE_IDS = ['1234567890', '0987654321', 'bob']; + watch.cases(MOCK_SOCKET, CASE_IDS); + expect(MOCK_SOCKET.rooms).to.have.lengthOf(CASE_IDS.length + 1) + .and.to.include(MOCK_SOCKET.id); + CASE_IDS.forEach((id) => { + expect(MOCK_SOCKET.rooms).to.include(`case:${id}`); + }); + }); + it('should handle a null room', () => { + const CASE_IDS = ['1234567890', null, 'bob']; + watch.cases(MOCK_SOCKET, CASE_IDS); + expect(MOCK_SOCKET.rooms).to.have.lengthOf(CASE_IDS.length) + .and.to.include(MOCK_SOCKET.id); + CASE_IDS.forEach((id) => { + if (id) { + expect(MOCK_SOCKET.rooms).to.include(`case:${id}`); + } + }); + }); + it('should handle a null socket', () => { + const CASE_IDS = ['1234567890', '0987654321', 'bob']; + watch.cases(null, CASE_IDS); + expect(MOCK_SOCKET.rooms).to.have.lengthOf(1) + .and.to.include(MOCK_SOCKET.id); + }); + }); + + describe('stop', () => { + it('should leave all the case rooms', () => { + // First, join a bunch of rooms. + const CASE_IDS = ['1234567890', '0987654321', 'bob']; + watch.cases(MOCK_SOCKET, CASE_IDS); + expect(MOCK_SOCKET.rooms).to.have.lengthOf(CASE_IDS.length + 1) + .and.to.include(MOCK_SOCKET.id); + + // Now stop watching the rooms. + watch.stop(MOCK_SOCKET); + expect(MOCK_SOCKET.rooms).to.have.lengthOf(1) + .and.to.include(MOCK_SOCKET.id); + }); + it('should handle a null socket', () => { + // First, join a bunch of rooms. + const CASE_IDS = ['1234567890', '0987654321', 'bob']; + watch.cases(MOCK_SOCKET, CASE_IDS); + expect(MOCK_SOCKET.rooms).to.have.lengthOf(CASE_IDS.length + 1) + .and.to.include(MOCK_SOCKET.id); + + // Now pass a null socket to the stop method. + watch.stop(null); + + // The MOCK_SOCKET's rooms should be untouched. + expect(MOCK_SOCKET.rooms).to.have.lengthOf(CASE_IDS.length + 1) + .and.to.include(MOCK_SOCKET.id); + }); + it('should handle no case rooms to leave', () => { + expect(MOCK_SOCKET.rooms).to.have.lengthOf(1) + .and.to.include(MOCK_SOCKET.id); + + // Now stop watching the rooms, which should have no effect. + watch.stop(MOCK_SOCKET); + expect(MOCK_SOCKET.rooms).to.have.lengthOf(1) + .and.to.include(MOCK_SOCKET.id); + }); + }); + + describe('update', () => { + it('should appropriately replace one set of cases with another', () => { + // First, let's watch a bunch of cases. + const CASE_IDS = ['1234567890', '0987654321', 'bob']; + watch.cases(MOCK_SOCKET, CASE_IDS); + + // Now, let's use a whole different bunch. + const REPLACEMENT_CASE_IDS = ['a', 'b', 'c', 'd']; + watch.update(MOCK_SOCKET, REPLACEMENT_CASE_IDS); + expect(MOCK_SOCKET.rooms).to.have.lengthOf(REPLACEMENT_CASE_IDS.length + 1) + .and.to.include(MOCK_SOCKET.id); + REPLACEMENT_CASE_IDS.forEach((id) => { + expect(MOCK_SOCKET.rooms).to.include(`case:${id}`); + }); + CASE_IDS.forEach((id) => { + expect(MOCK_SOCKET.rooms).not.to.include(`case:${id}`); + }); + }); + }); + + }); + +}); From 46dde6a43513c3e9bf3ef00c51ef2f40147bbd66 Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Wed, 26 May 2021 15:40:33 +0100 Subject: [PATCH 19/58] Unit tests Additional unit tests and further refactoring. --- app/socket/index.js | 112 ++---------------- app/socket/redis-watcher.js | 3 - app/socket/{redis-keys.js => redis/keys.js} | 0 app/socket/redis/pub-sub.js | 16 +++ app/socket/redis/watcher.js | 4 + app/socket/router/index.js | 59 +++++++++ app/socket/{ => service}/activity-service.js | 13 +- app/socket/service/handlers.js | 66 +++++++++++ app/socket/utils/get.js | 2 +- app/socket/utils/remove.js | 2 +- app/socket/utils/store.js | 2 +- test/spec/app/socket/index.spec.js | 18 +++ .../keys.spec.js} | 4 +- test/spec/app/socket/redis/pub-sub.spec.js | 64 ++++++++++ test/spec/app/socket/redis/watcher.spec.js | 12 ++ test/spec/app/socket/utils/get.spec.js | 2 +- test/spec/app/socket/utils/remove.spec.js | 2 +- test/spec/app/socket/utils/store.spec.js | 2 +- 18 files changed, 266 insertions(+), 117 deletions(-) delete mode 100644 app/socket/redis-watcher.js rename app/socket/{redis-keys.js => redis/keys.js} (100%) create mode 100644 app/socket/redis/pub-sub.js create mode 100644 app/socket/redis/watcher.js create mode 100644 app/socket/router/index.js rename app/socket/{ => service}/activity-service.js (92%) create mode 100644 app/socket/service/handlers.js create mode 100644 test/spec/app/socket/index.spec.js rename test/spec/app/socket/{redis-keys.spec.js => redis/keys.spec.js} (89%) create mode 100644 test/spec/app/socket/redis/pub-sub.spec.js create mode 100644 test/spec/app/socket/redis/watcher.spec.js diff --git a/app/socket/index.js b/app/socket/index.js index 1866f273..a0f9332d 100644 --- a/app/socket/index.js +++ b/app/socket/index.js @@ -2,11 +2,11 @@ const config = require('config'); const IORouter = require('socket.io-router-middleware'); const SocketIO = require('socket.io'); -const ActivityService = require('./activity-service'); -const redisWatcher = require('./redis-watcher'); -const utils = require('./utils'); - -const iorouter = new IORouter(); +const ActivityService = require('./service/activity-service'); +const Handlers = require('./service/handlers'); +const watcher = require('./redis/watcher'); +const pubSub = require('./redis/pub-sub')(); +const router = require('./router'); /** * Sets up a series of routes for a "socket" endpoint, that @@ -17,110 +17,20 @@ const iorouter = new IORouter(); * The behaviour is the same, though. * * TODO: - * 1. Use redis rather than holding the details in memory. - * 2. Some sort of auth / get the credentials when the user connects. - * - * Add view activity looks like this: - * addActivity 1588201414700270, { - sub: 'leeds_et@mailinator.com', - uid: '85269805-3a70-419d-acab-193faeb89ad3', - roles: [ - 'caseworker-employment', - 'caseworker-employment-leeds', - 'caseworker' - ], - name: 'Ethos Leeds', - given_name: 'Ethos', - family_name: 'Leeds' - }, '18hs67171jak', 'view' - * + * * Some sort of auth / get the credentials when the user connects. */ module.exports = (server, redis) => { const activityService = ActivityService(config, redis); - const io = SocketIO(server, { + const socketServer = SocketIO(server, { allowEIO3: true, cors: { origin: '*', methods: ['GET', 'POST'] } }); - const socketUsers = {}; - - async function notifyWatchers(caseId) { - const cs = await activityService.getActivityForCases([caseId]); - io.to(`case:${caseId}`).emit('activity', cs); - } - async function handleActivity(socket, caseId, user, activity) { - // Update what's being watched. - utils.watch.update(socket, [caseId]); - - // Then add this new activity to redis, which will also clear out the old activity. - await activityService.addActivity(caseId, utils.toUser(user), socket.id, activity); - } - async function handleWatch(socket, caseIds) { - // Stop watching the current cases. - utils.watch.stop(socket); - - // Remove the activity for this socket. - await activityService.removeSocketActivity(socket.id); - - // Now watch the specified cases. - utils.watch.cases(socket, caseIds); - - // And immediately dispatch a message about the activity on those cases. - const cs = await activityService.getActivityForCases(caseIds); - socket.emit('activity', cs); - } - - redisWatcher.psubscribe('case:*'); - redisWatcher.on('pmessage', (_, room) => { - const caseId = room.replace('case:', ''); - notifyWatchers(caseId); - }); - - // Set up routes for each type of message. - iorouter.on('register', (socket, ctx, next) => { - utils.log(socket, ctx.request.user, 'register'); - socketUsers[socket.id] = ctx.request.user; - next(); - }); - iorouter.on('view', (socket, ctx, next) => { - const user = socketUsers[socket.id]; - utils.log(socket, `${ctx.request.caseId} (${user.name})`, 'view'); - handleActivity(socket, ctx.request.caseId, user, 'view'); - next(); - }); - iorouter.on('edit', (socket, ctx, next) => { - const user = socketUsers[socket.id]; - utils.log(socket, `${ctx.request.caseId} (${user.name})`, 'edit'); - handleActivity(socket, ctx.request.caseId, user, 'edit'); - next(); - }); - iorouter.on('watch', (socket, ctx, next) => { - const user = socketUsers[socket.id]; - utils.log(socket, `${ctx.request.caseIds} (${user.name})`, 'watch'); - handleWatch(socket, ctx.request.caseIds); - next(); - }); - - // On client connection, attach the router - io.on('connection', (socket) => { - socket.use((packet, next) => { - iorouter.attach(socket, packet, next); - }); - }); - - const connections = []; - io.sockets.on('connection', (socket) => { - connections.push(socket); - utils.log(socket, '', `connected (${connections.length} total)`); - socket.on('disconnect', () => { - utils.log(socket, '', `disconnected (${connections.length - 1} total)`); - activityService.removeSocketActivity(socket.id); - delete socketUsers[socket.id]; - connections.splice(connections.indexOf(socket), 1); - }); - }); + const handlers = Handlers(activityService, socketServer); + pubSub.init(watcher, handlers.notify); + router.init(socketServer, new IORouter(), handlers); - return io; + return { socketServer, activityService, handlers }; }; diff --git a/app/socket/redis-watcher.js b/app/socket/redis-watcher.js deleted file mode 100644 index 50c1a4e3..00000000 --- a/app/socket/redis-watcher.js +++ /dev/null @@ -1,3 +0,0 @@ -const debug = require('debug')('ccd-case-activity-api:redis-watcher'); - -module.exports = require('../redis/instantiator')(debug); diff --git a/app/socket/redis-keys.js b/app/socket/redis/keys.js similarity index 100% rename from app/socket/redis-keys.js rename to app/socket/redis/keys.js diff --git a/app/socket/redis/pub-sub.js b/app/socket/redis/pub-sub.js new file mode 100644 index 00000000..5d4fe7d6 --- /dev/null +++ b/app/socket/redis/pub-sub.js @@ -0,0 +1,16 @@ +const ROOM_PREFIX = 'case:'; + +module.exports = () => { + return { + init: (sub, caseNotifier) => { + if (sub && typeof caseNotifier === 'function') { + sub.psubscribe(`${ROOM_PREFIX}*`); + sub.on('pmessage', (_, room) => { + const caseId = room.replace(ROOM_PREFIX, ''); + caseNotifier(caseId); + }); + } + }, + ROOM_PREFIX + }; +}; diff --git a/app/socket/redis/watcher.js b/app/socket/redis/watcher.js new file mode 100644 index 00000000..196bb24c --- /dev/null +++ b/app/socket/redis/watcher.js @@ -0,0 +1,4 @@ +const debug = require('debug')('ccd-case-activity-api:redis-watcher'); +const watcher = require('../../redis/instantiator')(debug); + +module.exports = watcher; diff --git a/app/socket/router/index.js b/app/socket/router/index.js new file mode 100644 index 00000000..714c710c --- /dev/null +++ b/app/socket/router/index.js @@ -0,0 +1,59 @@ +const utils = require('../utils'); + +const users = {}; +const connections = []; +const router = { + addUser: (socketId, user) => { + users[socketId] = user; + }, + removeUser: (socketId) => { + delete users[socketId]; + }, + getUser: (socketId) => { + return users[socketId]; + }, + init: (io, iorouter, handlers) => { + // Set up routes for each type of message. + iorouter.on('register', (socket, ctx, next) => { + utils.log(socket, ctx.request.user, 'register'); + router.addUser(socket.id, ctx.request.user); + next(); + }); + iorouter.on('view', (socket, ctx, next) => { + const user = router.getUser(socket.id); + utils.log(socket, `${ctx.request.caseId} (${user.name})`, 'view'); + handlers.addActivity(socket, ctx.request.caseId, user, 'view'); + next(); + }); + iorouter.on('edit', (socket, ctx, next) => { + const user = router.getUser(socket.id); + utils.log(socket, `${ctx.request.caseId} (${user.name})`, 'edit'); + handlers.addActivity(socket, ctx.request.caseId, user, 'edit'); + next(); + }); + iorouter.on('watch', (socket, ctx, next) => { + const user = router.getUser(socket.id); + utils.log(socket, `${ctx.request.caseIds} (${user.name})`, 'watch'); + handlers.watch(socket, ctx.request.caseIds); + next(); + }); + + // On client connection, attach the router and track the socket. + io.on('connection', (socket) => { + connections.push(socket); + utils.log(socket, '', `connected (${connections.length} total)`); + socket.use((packet, next) => { + iorouter.attach(socket, packet, next); + }); + // When the socket disconnects, do an appropriate teardown. + socket.on('disconnect', () => { + utils.log(socket, '', `disconnected (${connections.length - 1} total)`); + handlers.removeSocketActivity(socket.id); + router.removeUser(socket.id); + connections.splice(connections.indexOf(socket), 1); + }); + }); + } +}; + +module.exports = router; diff --git a/app/socket/activity-service.js b/app/socket/service/activity-service.js similarity index 92% rename from app/socket/activity-service.js rename to app/socket/service/activity-service.js index e29c6742..7c77b9b0 100644 --- a/app/socket/activity-service.js +++ b/app/socket/service/activity-service.js @@ -1,5 +1,5 @@ -const redisActivityKeys = require('./redis-keys'); -const utils = require('./utils'); +const keys = require('../redis/keys'); +const utils = require('../utils'); module.exports = (config, redis) => { const ttl = { @@ -8,11 +8,11 @@ module.exports = (config, redis) => { }; const notifyChange = (caseId) => { - redis.publish(redisActivityKeys.baseCase(caseId), Date.now().toString()); + redis.publish(keys.baseCase(caseId), Date.now().toString()); }; const getSocketActivity = async (socketId) => { - const key = redisActivityKeys.socket(socketId); + const key = keys.socket(socketId); return JSON.parse(await redis.get(key)); }; @@ -54,7 +54,7 @@ module.exports = (config, redis) => { await removeSocketActivity(socketId, caseId); // Now store this activity. - const activityKey = redisActivityKeys[activity](caseId); + const activityKey = keys[activity](caseId); return redis.pipeline([ utils.store.userActivity(activityKey, user.uid, utils.score(ttl.activity)), utils.store.socketActivity(socketId, activityKey, caseId, user.uid, ttl.user), @@ -113,6 +113,9 @@ module.exports = (config, redis) => { addActivity, getActivityForCases, getSocketActivity, + getUserDetails, + notifyChange, + redis, removeSocketActivity }; }; diff --git a/app/socket/service/handlers.js b/app/socket/service/handlers.js new file mode 100644 index 00000000..68dba882 --- /dev/null +++ b/app/socket/service/handlers.js @@ -0,0 +1,66 @@ +const utils = require('../utils'); + +module.exports = (activityService, socketServer) => { + /** + * Handle a user viewing or editing a case on a specific socket. + * @param {*} socket The socket they're connected on. + * @param {*} caseId The id of the case they're viewing or editing. + * @param {*} user The user object. + * @param {*} activity Whether they're viewing or editing. + */ + async function addActivity(socket, caseId, user, activity) { + // Update what's being watched. + utils.watch.update(socket, [caseId]); + + // Then add this new activity to redis, which will also clear out the old activity. + await activityService.addActivity(caseId, utils.toUser(user), socket.id, activity); + } + + /** + * Notify all users in a case room about any change to activity on a case. + * @param {*} caseId + */ + async function notify(caseId) { + const cs = await activityService.getActivityForCases([caseId]); + socketServer.to(`case:${caseId}`).emit('activity', cs); + } + + /** + * Remove any activity associated with a socket. This can be called when the + * socket disconnects. + * @param {*} socketId + * @returns + */ + async function removeSocketActivity(socketId) { + return activityService.removeSocketActivity(socketId); + } + + /** + * Handle a user watching a bunch of cases on a specific socket. + * @param {*} socket The socket they're connected on. + * @param {*} caseIds The ids of the cases they're interested in. + */ + async function watch(socket, caseIds) { + // Stop watching the current cases. + utils.watch.stop(socket); + + // Remove the activity for this socket. + await activityService.removeSocketActivity(socket.id); + + // Now watch the specified cases. + utils.watch.cases(socket, caseIds); + + // And immediately dispatch a message about the activity on those cases. + const cs = await activityService.getActivityForCases(caseIds); + socket.emit('activity', cs); + } + + return { + activityService, + addActivity, + notify, + removeSocketActivity, + socketServer, + watch + }; +}; diff --git a/app/socket/utils/get.js b/app/socket/utils/get.js index 0b10618e..954b2310 100644 --- a/app/socket/utils/get.js +++ b/app/socket/utils/get.js @@ -1,4 +1,4 @@ -const redisActivityKeys = require('../redis-keys'); +const redisActivityKeys = require('../redis/keys'); const get = { caseActivities: (caseIds, activity, now) => { diff --git a/app/socket/utils/remove.js b/app/socket/utils/remove.js index ee491903..dad26c6f 100644 --- a/app/socket/utils/remove.js +++ b/app/socket/utils/remove.js @@ -1,5 +1,5 @@ const debug = require('debug')('ccd-case-activity-api:socket-utils-remove'); -const redisActivityKeys = require('../redis-keys'); +const redisActivityKeys = require('../redis/keys'); const remove = { userActivity: (activity) => { diff --git a/app/socket/utils/store.js b/app/socket/utils/store.js index 165c90af..f139bdfe 100644 --- a/app/socket/utils/store.js +++ b/app/socket/utils/store.js @@ -1,5 +1,5 @@ const debug = require('debug')('ccd-case-activity-api:socket-utils-store'); -const redisActivityKeys = require('../redis-keys'); +const redisActivityKeys = require('../redis/keys'); const toUserString = require('./other').toUserString; const store = { diff --git a/test/spec/app/socket/index.spec.js b/test/spec/app/socket/index.spec.js new file mode 100644 index 00000000..54e514eb --- /dev/null +++ b/test/spec/app/socket/index.spec.js @@ -0,0 +1,18 @@ +const SocketIO = require('socket.io'); +const expect = require('chai').expect; +const Socket = require('../../../../app/socket'); + +describe('socket', () => { + const MOCK_SERVER = {}; + const MOCK_REDIS = {}; + it('should be appropriately initialised', () => { + const socket = Socket(MOCK_SERVER, MOCK_REDIS); + expect(socket).not.to.be.undefined; + expect(socket.socketServer).to.be.instanceOf(SocketIO.Server); + expect(socket.activityService).to.be.an('object'); + expect(socket.activityService.redis).to.equal(MOCK_REDIS); + expect(socket.handlers).to.be.an('object'); + expect(socket.handlers.activityService).to.equal(socket.activityService); + expect(socket.handlers.socketServer).to.equal(socket.socketServer); + }) +}); \ No newline at end of file diff --git a/test/spec/app/socket/redis-keys.spec.js b/test/spec/app/socket/redis/keys.spec.js similarity index 89% rename from test/spec/app/socket/redis-keys.spec.js rename to test/spec/app/socket/redis/keys.spec.js index 1441c506..910df7e4 100644 --- a/test/spec/app/socket/redis-keys.spec.js +++ b/test/spec/app/socket/redis/keys.spec.js @@ -1,7 +1,7 @@ const expect = require('chai').expect; -const redisActivityKeys = require('../../../../app/socket/redis-keys'); +const redisActivityKeys = require('../../../../../app/socket/redis/keys'); -describe('socket.redis-keys', () => { +describe('socket.redis.keys', () => { it('should get the correct key for viewing a case', () => { const CASE_ID = '12345678'; diff --git a/test/spec/app/socket/redis/pub-sub.spec.js b/test/spec/app/socket/redis/pub-sub.spec.js new file mode 100644 index 00000000..75804b07 --- /dev/null +++ b/test/spec/app/socket/redis/pub-sub.spec.js @@ -0,0 +1,64 @@ +const expect = require('chai').expect; +const pubSub = require('../../../../../app/socket/redis/pub-sub')(); + +describe('socket.redis.pub-sub', () => { + const MOCK_SUBSCRIBER = { + patterns: [], + events: {}, + psubscribe: (pattern) => { + if (!MOCK_SUBSCRIBER.patterns.includes(pattern)) { + MOCK_SUBSCRIBER.patterns.push(pattern); + } + }, + on: (event, eventHandler) => { + MOCK_SUBSCRIBER.events[event] = eventHandler; + }, + dispatch: (event, channel, message) => { + const handler = MOCK_SUBSCRIBER.events[event]; + if (handler) { + handler(MOCK_SUBSCRIBER.patterns[0], channel, message); + } + } + }; + const MOCK_NOTIFIER = { + messages: [], + notify: (message) => { + MOCK_NOTIFIER.messages.push(message); + } + }; + + afterEach(() => { + MOCK_SUBSCRIBER.patterns.length = 0; + MOCK_SUBSCRIBER.events = {}; + MOCK_NOTIFIER.messages.length = 0; + }); + + describe('init', () => { + it('should handle a null subscription client', () => { + pubSub.init(null, MOCK_NOTIFIER.notify); + expect(MOCK_SUBSCRIBER.patterns).to.have.lengthOf(0); + expect(MOCK_SUBSCRIBER.events).to.deep.equal({}) + }); + it('should handle a null caseNotifier', () => { + pubSub.init(MOCK_SUBSCRIBER, null); + expect(MOCK_SUBSCRIBER.patterns).to.have.lengthOf(0); + expect(MOCK_SUBSCRIBER.events).to.deep.equal({}) + }); + it('should handle appropriate parameters', () => { + pubSub.init(MOCK_SUBSCRIBER, MOCK_NOTIFIER.notify); + expect(MOCK_SUBSCRIBER.patterns).to.have.lengthOf(1) + .and.to.include(`${pubSub.ROOM_PREFIX}*`); + expect(MOCK_SUBSCRIBER.events.pmessage).to.be.a('function'); + expect(MOCK_NOTIFIER.messages).to.have.lengthOf(0); + }); + it('should call the caseNotifier when the correct event is received', () => { + pubSub.init(MOCK_SUBSCRIBER, MOCK_NOTIFIER.notify); + const CASE_ID = '1234567890'; + expect(MOCK_NOTIFIER.messages).to.have.lengthOf(0); + MOCK_SUBSCRIBER.dispatch('pmessage', `${pubSub.ROOM_PREFIX}${CASE_ID}`, new Date().toISOString()); + expect(MOCK_NOTIFIER.messages).to.have.lengthOf(1); + expect(MOCK_NOTIFIER.messages[0]).to.equal(CASE_ID); + }); + }); + +}); diff --git a/test/spec/app/socket/redis/watcher.spec.js b/test/spec/app/socket/redis/watcher.spec.js new file mode 100644 index 00000000..f5151d44 --- /dev/null +++ b/test/spec/app/socket/redis/watcher.spec.js @@ -0,0 +1,12 @@ +const config = require('config'); +const expect = require('chai').expect; +const Redis = require('ioredis'); + +describe('socket.redis.watcher', () => { + it('should instantiate a Redis client', () => { + const watcher = require('../../../../../app/socket/redis/watcher'); + expect(watcher).to.be.instanceOf(Redis); + expect(watcher.options.port).to.equal(config.get('redis.port')); + expect(watcher.options.host).to.equal(config.get('redis.host')); + }); +}); \ No newline at end of file diff --git a/test/spec/app/socket/utils/get.spec.js b/test/spec/app/socket/utils/get.spec.js index 2f9cdc4d..1de07a42 100644 --- a/test/spec/app/socket/utils/get.spec.js +++ b/test/spec/app/socket/utils/get.spec.js @@ -1,6 +1,6 @@ const expect = require('chai').expect; const get = require('../../../../../app/socket/utils/get'); -const redisActivityKeys = require('../../../../../app/socket/redis-keys'); +const redisActivityKeys = require('../../../../../app/socket/redis/keys'); describe('socket.utils', () => { diff --git a/test/spec/app/socket/utils/remove.spec.js b/test/spec/app/socket/utils/remove.spec.js index 756189fc..3912f42a 100644 --- a/test/spec/app/socket/utils/remove.spec.js +++ b/test/spec/app/socket/utils/remove.spec.js @@ -1,6 +1,6 @@ const expect = require('chai').expect; const remove = require('../../../../../app/socket/utils/remove'); -const redisActivityKeys = require('../../../../../app/socket/redis-keys'); +const redisActivityKeys = require('../../../../../app/socket/redis/keys'); describe('socket.utils', () => { diff --git a/test/spec/app/socket/utils/store.spec.js b/test/spec/app/socket/utils/store.spec.js index 2cc486e9..2fce2e45 100644 --- a/test/spec/app/socket/utils/store.spec.js +++ b/test/spec/app/socket/utils/store.spec.js @@ -1,6 +1,6 @@ const expect = require('chai').expect; const store = require('../../../../../app/socket/utils/store'); -const redisActivityKeys = require('../../../../../app/socket/redis-keys'); +const redisActivityKeys = require('../../../../../app/socket/redis/keys'); describe('socket.utils', () => { From cbc6c22c9ca221f8b4a77b513687a7d88d89c813 Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Wed, 26 May 2021 18:00:34 +0100 Subject: [PATCH 20/58] Key space and tests Changed the key space for cases (to 'c'), users (to 'u'), and sockets (to 's') to keep them distinct from the existing mechanism. Also started adding unit tests for `socket/service/handlers.js`. --- app/socket/redis/keys.js | 26 +++++-- app/socket/service/handlers.js | 10 +-- app/socket/utils/watch.js | 6 +- test/spec/app/socket/redis/keys.spec.js | 12 ++-- test/spec/app/socket/service/handlers.spec.js | 71 +++++++++++++++++++ test/spec/app/socket/utils/watch.spec.js | 13 ++-- 6 files changed, 112 insertions(+), 26 deletions(-) create mode 100644 test/spec/app/socket/service/handlers.spec.js diff --git a/app/socket/redis/keys.js b/app/socket/redis/keys.js index 7feafc1a..91799b6b 100644 --- a/app/socket/redis/keys.js +++ b/app/socket/redis/keys.js @@ -1,9 +1,21 @@ -const redisActivityKeys = { - view: (caseId) => `case:${caseId}:viewers`, - edit: (caseId) => `case:${caseId}:editors`, - baseCase: (caseId) => `case:${caseId}`, - user: (userId) => `user:${userId}`, - socket: (socketId) => `socket:${socketId}` +const keys = { + prefixes: { + case: 'c', + user: 'u', + socket: 's' + }, + view: (caseId) => keys.compile('case', caseId, 'viewers'), + edit: (caseId) => keys.compile('case', caseId, 'editors'), + baseCase: (caseId) => keys.compile('case', caseId), + user: (userId) => keys.compile('user', userId), + socket: (socketId) => keys.compile('socket', socketId), + compile: (prefix, value, suffix) => { + const key = `${keys.prefixes[prefix]}:${value}`; + if (suffix) { + return `${key}:${suffix}`; + } + return key; + } }; -module.exports = redisActivityKeys; +module.exports = keys; diff --git a/app/socket/service/handlers.js b/app/socket/service/handlers.js index 68dba882..6388c53c 100644 --- a/app/socket/service/handlers.js +++ b/app/socket/service/handlers.js @@ -1,3 +1,4 @@ +const keys = require('../redis/keys'); const utils = require('../utils'); module.exports = (activityService, socketServer) => { @@ -18,21 +19,20 @@ module.exports = (activityService, socketServer) => { /** * Notify all users in a case room about any change to activity on a case. - * @param {*} caseId + * @param {*} caseId The id of the case that has activity and that people should be notified about. */ async function notify(caseId) { const cs = await activityService.getActivityForCases([caseId]); - socketServer.to(`case:${caseId}`).emit('activity', cs); + socketServer.to(keys.baseCase(caseId)).emit('activity', cs); } /** * Remove any activity associated with a socket. This can be called when the * socket disconnects. - * @param {*} socketId - * @returns + * @param {*} socketId The id of the socket to remove activity for. */ async function removeSocketActivity(socketId) { - return activityService.removeSocketActivity(socketId); + await activityService.removeSocketActivity(socketId); } /** diff --git a/app/socket/utils/watch.js b/app/socket/utils/watch.js index 99339203..df5525cd 100644 --- a/app/socket/utils/watch.js +++ b/app/socket/utils/watch.js @@ -1,7 +1,9 @@ +const keys = require('../redis/keys'); + const watch = { case: (socket, caseId) => { if (socket && caseId) { - socket.join(`case:${caseId}`); + socket.join(keys.baseCase(caseId)); } }, cases: (socket, caseIds) => { @@ -14,7 +16,7 @@ const watch = { stop: (socket) => { if (socket) { [...socket.rooms] - .filter((r) => r.indexOf('case:') === 0) // Only case rooms. + .filter((r) => r.indexOf(`${keys.prefixes.case}:`) === 0) // Only case rooms. .forEach((r) => socket.leave(r)); } }, diff --git a/test/spec/app/socket/redis/keys.spec.js b/test/spec/app/socket/redis/keys.spec.js index 910df7e4..c1f94459 100644 --- a/test/spec/app/socket/redis/keys.spec.js +++ b/test/spec/app/socket/redis/keys.spec.js @@ -1,31 +1,31 @@ +const keys = require('../../../../../app/socket/redis/keys'); const expect = require('chai').expect; -const redisActivityKeys = require('../../../../../app/socket/redis/keys'); describe('socket.redis.keys', () => { it('should get the correct key for viewing a case', () => { const CASE_ID = '12345678'; - expect(redisActivityKeys.view(CASE_ID)).to.equal(`case:${CASE_ID}:viewers`); + expect(keys.view(CASE_ID)).to.equal(`${keys.prefixes.case}:${CASE_ID}:viewers`); }); it('should get the correct key for editing a case', () => { const CASE_ID = '12345678'; - expect(redisActivityKeys.edit(CASE_ID)).to.equal(`case:${CASE_ID}:editors`); + expect(keys.edit(CASE_ID)).to.equal(`${keys.prefixes.case}:${CASE_ID}:editors`); }); it('should get the correct base key for a case', () => { const CASE_ID = '12345678'; - expect(redisActivityKeys.baseCase(CASE_ID)).to.equal(`case:${CASE_ID}`); + expect(keys.baseCase(CASE_ID)).to.equal(`${keys.prefixes.case}:${CASE_ID}`); }); it('should get the correct key for a user', () => { const USER_ID = 'abcdef123456'; - expect(redisActivityKeys.user(USER_ID)).to.equal(`user:${USER_ID}`); + expect(keys.user(USER_ID)).to.equal(`${keys.prefixes.user}:${USER_ID}`); }); it('should get the correct key for a socket', () => { const SOCKET_ID = 'zyxwvu987654'; - expect(redisActivityKeys.socket(SOCKET_ID)).to.equal(`socket:${SOCKET_ID}`); + expect(keys.socket(SOCKET_ID)).to.equal(`${keys.prefixes.socket}:${SOCKET_ID}`); }); }); diff --git a/test/spec/app/socket/service/handlers.spec.js b/test/spec/app/socket/service/handlers.spec.js new file mode 100644 index 00000000..f6faff5d --- /dev/null +++ b/test/spec/app/socket/service/handlers.spec.js @@ -0,0 +1,71 @@ +const keys = require('../../../../../app/socket/redis/keys'); +const Handlers = require('../../../../../app/socket/service/handlers'); +const expect = require('chai').expect; + + +describe('socket.service.handlers', () => { + const MOCK_ACTIVITY_SERVICE = { + calls: [], + getActivityForCases: async(caseIds) => { + MOCK_ACTIVITY_SERVICE.calls.push({ method: 'getActivityForCases', params: { caseIds } }); + return caseIds.map((caseId) => { + return { + caseId, + viewers: [], + unknownViewers: 0, + editors: [], + unknownEditors: 0 + }; + }); + }, + removeSocketActivity: (socketId) => { + + } + }; + const MOCK_SOCKET_SERVER = { + messagesTo: [], + to: (room) => { + const messageTo = { room } + MOCK_SOCKET_SERVER.messagesTo.push(messageTo); + return { + emit: (event, message) => { + messageTo.event = event; + messageTo.message = message; + } + }; + } + }; + + afterEach(async () => { + MOCK_ACTIVITY_SERVICE.calls.length = 0; + MOCK_SOCKET_SERVER.messagesTo.length = 0; + }); + + describe('addActivity', () => {}); + + describe('notify', () => { + let handlers; + beforeEach(async () => { + handlers = Handlers(MOCK_ACTIVITY_SERVICE, MOCK_SOCKET_SERVER); + }); + + it('should get activity for specified case and notify watchers', async () => { + const CASE_ID = '1234567890'; + await handlers.notify(CASE_ID); + + // The activity service should have been called. + expect(MOCK_ACTIVITY_SERVICE.calls).to.have.lengthOf(1); + expect(MOCK_ACTIVITY_SERVICE.calls[0].method).to.equal('getActivityForCases'); + expect(MOCK_ACTIVITY_SERVICE.calls[0].params.caseIds).to.deep.equal([CASE_ID]); + + // The socket server should also have been called. + expect(MOCK_SOCKET_SERVER.messagesTo).to.have.lengthOf(1); + expect(MOCK_SOCKET_SERVER.messagesTo[0].room).to.equal(keys.baseCase(CASE_ID)); + expect(MOCK_SOCKET_SERVER.messagesTo[0].event).to.equal('activity'); + expect(MOCK_SOCKET_SERVER.messagesTo[0].message).to.be.an('array').and.to.have.lengthOf(1); + expect(MOCK_SOCKET_SERVER.messagesTo[0].message[0].caseId).to.equal(CASE_ID); + }); + }); + + +}); diff --git a/test/spec/app/socket/utils/watch.spec.js b/test/spec/app/socket/utils/watch.spec.js index 77933bc0..f1b8de11 100644 --- a/test/spec/app/socket/utils/watch.spec.js +++ b/test/spec/app/socket/utils/watch.spec.js @@ -1,5 +1,6 @@ -const expect = require('chai').expect; +const keys = require('../../../../../app/socket/redis/keys'); const watch = require('../../../../../app/socket/utils/watch'); +const expect = require('chai').expect; describe('socket.utils', () => { @@ -32,7 +33,7 @@ describe('socket.utils', () => { watch.case(MOCK_SOCKET, CASE_ID); expect(MOCK_SOCKET.rooms).to.have.lengthOf(2) .and.to.include(MOCK_SOCKET.id) - .and.to.include(`case:${CASE_ID}`); + .and.to.include(keys.baseCase(CASE_ID)); }); it('should handle a null room', () => { const CASE_ID = null; @@ -55,7 +56,7 @@ describe('socket.utils', () => { expect(MOCK_SOCKET.rooms).to.have.lengthOf(CASE_IDS.length + 1) .and.to.include(MOCK_SOCKET.id); CASE_IDS.forEach((id) => { - expect(MOCK_SOCKET.rooms).to.include(`case:${id}`); + expect(MOCK_SOCKET.rooms).to.include(keys.baseCase(id)); }); }); it('should handle a null room', () => { @@ -65,7 +66,7 @@ describe('socket.utils', () => { .and.to.include(MOCK_SOCKET.id); CASE_IDS.forEach((id) => { if (id) { - expect(MOCK_SOCKET.rooms).to.include(`case:${id}`); + expect(MOCK_SOCKET.rooms).to.include(keys.baseCase(id)); } }); }); @@ -127,10 +128,10 @@ describe('socket.utils', () => { expect(MOCK_SOCKET.rooms).to.have.lengthOf(REPLACEMENT_CASE_IDS.length + 1) .and.to.include(MOCK_SOCKET.id); REPLACEMENT_CASE_IDS.forEach((id) => { - expect(MOCK_SOCKET.rooms).to.include(`case:${id}`); + expect(MOCK_SOCKET.rooms).to.include(keys.baseCase(id)); }); CASE_IDS.forEach((id) => { - expect(MOCK_SOCKET.rooms).not.to.include(`case:${id}`); + expect(MOCK_SOCKET.rooms).not.to.include(keys.baseCase(id)); }); }); }); From b2bf897258ec1fe041cc22ca25e033c7ae855271 Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Thu, 27 May 2021 09:15:30 +0100 Subject: [PATCH 21/58] Update keys.js --- app/socket/redis/keys.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/socket/redis/keys.js b/app/socket/redis/keys.js index 91799b6b..79b0076f 100644 --- a/app/socket/redis/keys.js +++ b/app/socket/redis/keys.js @@ -1,8 +1,8 @@ const keys = { prefixes: { case: 'c', - user: 'u', - socket: 's' + socket: 's', + user: 'u' }, view: (caseId) => keys.compile('case', caseId, 'viewers'), edit: (caseId) => keys.compile('case', caseId, 'editors'), From 9a0a6f2092e06888c086ac48a5790da831b768e9 Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Thu, 27 May 2021 11:01:00 +0100 Subject: [PATCH 22/58] Unit tests Unit tests for `socket/service/handlers` and a little bit of a refactor around the redis keys. --- app/socket/redis/keys.js | 8 +- app/socket/service/activity-service.js | 4 +- app/socket/service/handlers.js | 2 +- app/socket/utils/get.js | 6 +- app/socket/utils/watch.js | 2 +- test/spec/app/socket/redis/keys.spec.js | 6 +- test/spec/app/socket/service/handlers.spec.js | 135 ++++++++++++++++-- test/spec/app/socket/utils/get.spec.js | 16 +-- test/spec/app/socket/utils/remove.spec.js | 6 +- test/spec/app/socket/utils/store.spec.js | 10 +- test/spec/app/socket/utils/watch.spec.js | 10 +- 11 files changed, 161 insertions(+), 44 deletions(-) diff --git a/app/socket/redis/keys.js b/app/socket/redis/keys.js index 79b0076f..a73fb18e 100644 --- a/app/socket/redis/keys.js +++ b/app/socket/redis/keys.js @@ -4,9 +4,11 @@ const keys = { socket: 's', user: 'u' }, - view: (caseId) => keys.compile('case', caseId, 'viewers'), - edit: (caseId) => keys.compile('case', caseId, 'editors'), - baseCase: (caseId) => keys.compile('case', caseId), + case: { + view: (caseId) => keys.compile('case', caseId, 'viewers'), + edit: (caseId) => keys.compile('case', caseId, 'editors'), + base: (caseId) => keys.compile('case', caseId), + }, user: (userId) => keys.compile('user', userId), socket: (socketId) => keys.compile('socket', socketId), compile: (prefix, value, suffix) => { diff --git a/app/socket/service/activity-service.js b/app/socket/service/activity-service.js index 7c77b9b0..95f22e02 100644 --- a/app/socket/service/activity-service.js +++ b/app/socket/service/activity-service.js @@ -8,7 +8,7 @@ module.exports = (config, redis) => { }; const notifyChange = (caseId) => { - redis.publish(keys.baseCase(caseId), Date.now().toString()); + redis.publish(keys.case.base(caseId), Date.now().toString()); }; const getSocketActivity = async (socketId) => { @@ -54,7 +54,7 @@ module.exports = (config, redis) => { await removeSocketActivity(socketId, caseId); // Now store this activity. - const activityKey = keys[activity](caseId); + const activityKey = keys.case[activity](caseId); return redis.pipeline([ utils.store.userActivity(activityKey, user.uid, utils.score(ttl.activity)), utils.store.socketActivity(socketId, activityKey, caseId, user.uid, ttl.user), diff --git a/app/socket/service/handlers.js b/app/socket/service/handlers.js index 6388c53c..09c5348f 100644 --- a/app/socket/service/handlers.js +++ b/app/socket/service/handlers.js @@ -23,7 +23,7 @@ module.exports = (activityService, socketServer) => { */ async function notify(caseId) { const cs = await activityService.getActivityForCases([caseId]); - socketServer.to(keys.baseCase(caseId)).emit('activity', cs); + socketServer.to(keys.case.base(caseId)).emit('activity', cs); } /** diff --git a/app/socket/utils/get.js b/app/socket/utils/get.js index 954b2310..91f9d728 100644 --- a/app/socket/utils/get.js +++ b/app/socket/utils/get.js @@ -1,17 +1,17 @@ -const redisActivityKeys = require('../redis/keys'); +const keys = require('../redis/keys'); const get = { caseActivities: (caseIds, activity, now) => { if (Array.isArray(caseIds) && ['view', 'edit'].indexOf(activity) > -1) { return caseIds.filter((id) => !!id).map((id) => { - return ['zrangebyscore', redisActivityKeys[activity](id), now, '+inf']; + return ['zrangebyscore', keys.case[activity](id), now, '+inf']; }); } return []; }, users: (userIds) => { if (Array.isArray(userIds)) { - return userIds.filter((id) => !!id).map((id) => ['get', redisActivityKeys.user(id)]); + return userIds.filter((id) => !!id).map((id) => ['get', keys.user(id)]); } return []; } diff --git a/app/socket/utils/watch.js b/app/socket/utils/watch.js index df5525cd..8820298d 100644 --- a/app/socket/utils/watch.js +++ b/app/socket/utils/watch.js @@ -3,7 +3,7 @@ const keys = require('../redis/keys'); const watch = { case: (socket, caseId) => { if (socket && caseId) { - socket.join(keys.baseCase(caseId)); + socket.join(keys.case.base(caseId)); } }, cases: (socket, caseIds) => { diff --git a/test/spec/app/socket/redis/keys.spec.js b/test/spec/app/socket/redis/keys.spec.js index c1f94459..5711711e 100644 --- a/test/spec/app/socket/redis/keys.spec.js +++ b/test/spec/app/socket/redis/keys.spec.js @@ -5,17 +5,17 @@ describe('socket.redis.keys', () => { it('should get the correct key for viewing a case', () => { const CASE_ID = '12345678'; - expect(keys.view(CASE_ID)).to.equal(`${keys.prefixes.case}:${CASE_ID}:viewers`); + expect(keys.case.view(CASE_ID)).to.equal(`${keys.prefixes.case}:${CASE_ID}:viewers`); }); it('should get the correct key for editing a case', () => { const CASE_ID = '12345678'; - expect(keys.edit(CASE_ID)).to.equal(`${keys.prefixes.case}:${CASE_ID}:editors`); + expect(keys.case.edit(CASE_ID)).to.equal(`${keys.prefixes.case}:${CASE_ID}:editors`); }); it('should get the correct base key for a case', () => { const CASE_ID = '12345678'; - expect(keys.baseCase(CASE_ID)).to.equal(`${keys.prefixes.case}:${CASE_ID}`); + expect(keys.case.base(CASE_ID)).to.equal(`${keys.prefixes.case}:${CASE_ID}`); }); it('should get the correct key for a user', () => { diff --git a/test/spec/app/socket/service/handlers.spec.js b/test/spec/app/socket/service/handlers.spec.js index f6faff5d..ef9644e6 100644 --- a/test/spec/app/socket/service/handlers.spec.js +++ b/test/spec/app/socket/service/handlers.spec.js @@ -4,10 +4,19 @@ const expect = require('chai').expect; describe('socket.service.handlers', () => { + // An instance that can be tested. + let handlers; + const MOCK_ACTIVITY_SERVICE = { calls: [], - getActivityForCases: async(caseIds) => { - MOCK_ACTIVITY_SERVICE.calls.push({ method: 'getActivityForCases', params: { caseIds } }); + addActivity: async (caseId, user, socketId, activity) => { + const params = { caseId, user, socketId, activity }; + MOCK_ACTIVITY_SERVICE.calls.push({ method: 'addActivity', params }); + return null; + }, + getActivityForCases: async (caseIds) => { + const params = { caseIds }; + MOCK_ACTIVITY_SERVICE.calls.push({ method: 'getActivityForCases', params }); return caseIds.map((caseId) => { return { caseId, @@ -18,8 +27,10 @@ describe('socket.service.handlers', () => { }; }); }, - removeSocketActivity: (socketId) => { - + removeSocketActivity: async (socketId) => { + const params = { socketId }; + MOCK_ACTIVITY_SERVICE.calls.push({ method: 'removeSocketActivity', params }); + return; } }; const MOCK_SOCKET_SERVER = { @@ -35,20 +46,74 @@ describe('socket.service.handlers', () => { }; } }; + const MOCK_SOCKET = { + id: 'socket-id', + rooms: ['socket-id'], + messages: [], + join: (room) => { + if (!MOCK_SOCKET.rooms.includes(room)) { + MOCK_SOCKET.rooms.push(room); + } + }, + leave: (room) => { + const roomIndex = MOCK_SOCKET.rooms.indexOf(room); + if (roomIndex > -1) { + MOCK_SOCKET.rooms.splice(roomIndex, 1); + } + }, + emit: (event, message) => { + MOCK_SOCKET.messages.push({ event, message }); + } + }; + + beforeEach(async () => { + handlers = Handlers(MOCK_ACTIVITY_SERVICE, MOCK_SOCKET_SERVER); + }); afterEach(async () => { MOCK_ACTIVITY_SERVICE.calls.length = 0; MOCK_SOCKET_SERVER.messagesTo.length = 0; + MOCK_SOCKET.rooms.length = 0; + MOCK_SOCKET.rooms.push(MOCK_SOCKET.id); + MOCK_SOCKET.messages.length = 0; }); - describe('addActivity', () => {}); + describe('addActivity', () => { + it('should update what the socket is watching and add activity for the specified case', async () => { + const CASE_ID = '0987654321'; + const USER = { id: 'a', name: 'John Smith' }; + const ACTIVITY = 'view'; - describe('notify', () => { - let handlers; - beforeEach(async () => { - handlers = Handlers(MOCK_ACTIVITY_SERVICE, MOCK_SOCKET_SERVER); + // Pretend the socket is watching a bunch of additional rooms. + MOCK_SOCKET.join(keys.case.base('bob')); + MOCK_SOCKET.join(keys.case.base('fred')); + MOCK_SOCKET.join(keys.case.base('xyz')); + expect(MOCK_SOCKET.rooms).to.have.lengthOf(4); + + // Now make the call. + await handlers.addActivity(MOCK_SOCKET, CASE_ID, USER, ACTIVITY); + + // The socket should be watching that case and that case alone... + // ... plus its own room, which is not related to a case, hence lengthOf(2). + expect(MOCK_SOCKET.rooms).to.have.lengthOf(2) + .and.to.include(MOCK_SOCKET.id) + .and.to.include(keys.case.base(CASE_ID)); + + // The activity service should have been called with appropriate parameters + expect(MOCK_ACTIVITY_SERVICE.calls).to.have.lengthOf(1); + expect(MOCK_ACTIVITY_SERVICE.calls[0].method).to.equal('addActivity'); + expect(MOCK_ACTIVITY_SERVICE.calls[0].params.caseId).to.equal(CASE_ID); + expect(MOCK_ACTIVITY_SERVICE.calls[0].params.socketId).to.equal(MOCK_SOCKET.id); + expect(MOCK_ACTIVITY_SERVICE.calls[0].params.activity).to.equal(ACTIVITY); + // The user parameter should have been transformed appropriatel. + expect(MOCK_ACTIVITY_SERVICE.calls[0].params.user.uid).to.equal(USER.id); + expect(MOCK_ACTIVITY_SERVICE.calls[0].params.user.name).to.equal(USER.name); + expect(MOCK_ACTIVITY_SERVICE.calls[0].params.user.given_name).to.equal('John'); + expect(MOCK_ACTIVITY_SERVICE.calls[0].params.user.family_name).to.equal('Smith'); }); + }); + describe('notify', () => { it('should get activity for specified case and notify watchers', async () => { const CASE_ID = '1234567890'; await handlers.notify(CASE_ID); @@ -60,12 +125,62 @@ describe('socket.service.handlers', () => { // The socket server should also have been called. expect(MOCK_SOCKET_SERVER.messagesTo).to.have.lengthOf(1); - expect(MOCK_SOCKET_SERVER.messagesTo[0].room).to.equal(keys.baseCase(CASE_ID)); + expect(MOCK_SOCKET_SERVER.messagesTo[0].room).to.equal(keys.case.base(CASE_ID)); expect(MOCK_SOCKET_SERVER.messagesTo[0].event).to.equal('activity'); expect(MOCK_SOCKET_SERVER.messagesTo[0].message).to.be.an('array').and.to.have.lengthOf(1); expect(MOCK_SOCKET_SERVER.messagesTo[0].message[0].caseId).to.equal(CASE_ID); }); }); + describe('removeSocketActivity', () => { + it('should remove activity for specified socket', async () => { + const SOCKET_ID = 'abcdef123456'; + await handlers.removeSocketActivity(SOCKET_ID); + + // The activity service should have been called. + expect(MOCK_ACTIVITY_SERVICE.calls).to.have.lengthOf(1); + expect(MOCK_ACTIVITY_SERVICE.calls[0].method).to.equal('removeSocketActivity'); + expect(MOCK_ACTIVITY_SERVICE.calls[0].params.socketId).to.equal(SOCKET_ID); + }); + }); + + describe('watch', () => { + it('should update what the socket is watching, remove its activity, and let the user know what state the cases are in', async () => { + const CASE_IDS = ['0987654321', '9876543210', '8765432109']; + + // Pretend the socket is watching a bunch of additional rooms. + MOCK_SOCKET.join(keys.case.base('bob')); + MOCK_SOCKET.join(keys.case.base('fred')); + MOCK_SOCKET.join(keys.case.base('xyz')); + expect(MOCK_SOCKET.rooms).to.have.lengthOf(4); + + // Now make the call. + await handlers.watch(MOCK_SOCKET, CASE_IDS); + + // The socket should be watching just the cases specified... + // ... plus its own room, which is not related to a case, hence lengthOf(2). + expect(MOCK_SOCKET.rooms).to.have.lengthOf(CASE_IDS.length + 1) + .and.to.include(MOCK_SOCKET.id); + CASE_IDS.forEach((caseId) => { + expect(MOCK_SOCKET.rooms).to.include(keys.case.base(caseId)); + }); + + // The activity service should have been called twice. + expect(MOCK_ACTIVITY_SERVICE.calls).to.have.lengthOf(2); + expect(MOCK_ACTIVITY_SERVICE.calls[0].method).to.equal('removeSocketActivity'); + expect(MOCK_ACTIVITY_SERVICE.calls[0].params.socketId).to.equal(MOCK_SOCKET.id); + expect(MOCK_ACTIVITY_SERVICE.calls[1].method).to.equal('getActivityForCases'); + expect(MOCK_ACTIVITY_SERVICE.calls[1].params.caseIds).to.deep.equal(CASE_IDS); + + // And the socket should have been told about the case statuses. + expect(MOCK_SOCKET.messages).to.have.lengthOf(1); + expect(MOCK_SOCKET.messages[0].event).to.equal('activity'); + expect(MOCK_SOCKET.messages[0].message).to.be.an('array').and.have.lengthOf(CASE_IDS.length); + CASE_IDS.forEach((caseId, index) => { + expect(MOCK_SOCKET.messages[0].message[index].caseId).to.equal(caseId); + }) + }) + }); + }); diff --git a/test/spec/app/socket/utils/get.spec.js b/test/spec/app/socket/utils/get.spec.js index 1de07a42..36bd84a5 100644 --- a/test/spec/app/socket/utils/get.spec.js +++ b/test/spec/app/socket/utils/get.spec.js @@ -1,6 +1,6 @@ const expect = require('chai').expect; const get = require('../../../../../app/socket/utils/get'); -const redisActivityKeys = require('../../../../../app/socket/redis/keys'); +const keys = require('../../../../../app/socket/redis/keys'); describe('socket.utils', () => { @@ -15,7 +15,7 @@ describe('socket.utils', () => { expect(pipes).to.be.an('array').and.have.lengthOf(1); expect(pipes[0]).to.be.an('array').and.have.lengthOf(4); expect(pipes[0][0]).to.equal('zrangebyscore'); - expect(pipes[0][1]).to.equal(redisActivityKeys.view(CASE_IDS[0])); + expect(pipes[0][1]).to.equal(keys.case.view(CASE_IDS[0])); expect(pipes[0][2]).to.equal(NOW); expect(pipes[0][3]).to.equal('+inf'); }); @@ -28,7 +28,7 @@ describe('socket.utils', () => { CASE_IDS.forEach((id, index) => { expect(pipes[index]).to.be.an('array').and.have.lengthOf(4); expect(pipes[index][0]).to.equal('zrangebyscore'); - expect(pipes[index][1]).to.equal(redisActivityKeys.view(id)); + expect(pipes[index][1]).to.equal(keys.case.view(id)); expect(pipes[index][2]).to.equal(NOW); expect(pipes[index][3]).to.equal('+inf'); }); @@ -44,7 +44,7 @@ describe('socket.utils', () => { if (id !== null) { expect(pipes[pipeIndex]).to.be.an('array').and.have.lengthOf(4); expect(pipes[pipeIndex][0]).to.equal('zrangebyscore'); - expect(pipes[pipeIndex][1]).to.equal(redisActivityKeys.view(id)); + expect(pipes[pipeIndex][1]).to.equal(keys.case.view(id)); expect(pipes[pipeIndex][2]).to.equal(NOW); expect(pipes[pipeIndex][3]).to.equal('+inf'); pipeIndex++; @@ -62,7 +62,7 @@ describe('socket.utils', () => { if (id !== null) { expect(pipes[pipeIndex]).to.be.an('array').and.have.lengthOf(4); expect(pipes[pipeIndex][0]).to.equal('zrangebyscore'); - expect(pipes[pipeIndex][1]).to.equal(redisActivityKeys.edit(id)); + expect(pipes[pipeIndex][1]).to.equal(keys.case.edit(id)); expect(pipes[pipeIndex][2]).to.equal(NOW); expect(pipes[pipeIndex][3]).to.equal('+inf'); pipeIndex++; @@ -92,7 +92,7 @@ describe('socket.utils', () => { expect(pipes).to.be.an('array').and.have.lengthOf(1); expect(pipes[0]).to.be.an('array').and.have.lengthOf(2); expect(pipes[0][0]).to.equal('get'); - expect(pipes[0][1]).to.equal(redisActivityKeys.user(USER_IDS[0])); + expect(pipes[0][1]).to.equal(keys.user(USER_IDS[0])); }); it('should get the correct result for multiple user IDs', () => { const USER_IDS = ['1', '8', '2345678', 'x']; @@ -101,7 +101,7 @@ describe('socket.utils', () => { expect(pipes[0]).to.be.an('array').and.have.lengthOf(2); USER_IDS.forEach((id, index) => { expect(pipes[index][0]).to.equal('get'); - expect(pipes[index][1]).to.equal(redisActivityKeys.user(id)); + expect(pipes[index][1]).to.equal(keys.user(id)); }); }); it('should handle a null user ID', () => { @@ -113,7 +113,7 @@ describe('socket.utils', () => { USER_IDS.forEach((id) => { if (id) { expect(pipes[pipeIndex][0]).to.equal('get'); - expect(pipes[pipeIndex][1]).to.equal(redisActivityKeys.user(id)); + expect(pipes[pipeIndex][1]).to.equal(keys.user(id)); pipeIndex++; } }); diff --git a/test/spec/app/socket/utils/remove.spec.js b/test/spec/app/socket/utils/remove.spec.js index 3912f42a..773ca9aa 100644 --- a/test/spec/app/socket/utils/remove.spec.js +++ b/test/spec/app/socket/utils/remove.spec.js @@ -1,6 +1,6 @@ const expect = require('chai').expect; const remove = require('../../../../../app/socket/utils/remove'); -const redisActivityKeys = require('../../../../../app/socket/redis/keys'); +const keys = require('../../../../../app/socket/redis/keys'); describe('socket.utils', () => { @@ -10,7 +10,7 @@ describe('socket.utils', () => { it('should produce an appopriate pipe', () => { const CASE_ID = '1234567890'; const ACTIVITY = { - activityKey: redisActivityKeys.view(CASE_ID), + activityKey: keys.case.view(CASE_ID), userId: 'a' }; const pipe = remove.userActivity(ACTIVITY); @@ -27,7 +27,7 @@ describe('socket.utils', () => { const pipe = remove.socketEntry(SOCKET_ID); expect(pipe).to.be.an('array').and.have.lengthOf(2); expect(pipe[0]).to.equal('del'); - expect(pipe[1]).to.equal(redisActivityKeys.socket(SOCKET_ID)); + expect(pipe[1]).to.equal(keys.socket(SOCKET_ID)); }); }); diff --git a/test/spec/app/socket/utils/store.spec.js b/test/spec/app/socket/utils/store.spec.js index 2fce2e45..7134a0b5 100644 --- a/test/spec/app/socket/utils/store.spec.js +++ b/test/spec/app/socket/utils/store.spec.js @@ -1,6 +1,6 @@ const expect = require('chai').expect; const store = require('../../../../../app/socket/utils/store'); -const redisActivityKeys = require('../../../../../app/socket/redis/keys'); +const keys = require('../../../../../app/socket/redis/keys'); describe('socket.utils', () => { @@ -9,7 +9,7 @@ describe('socket.utils', () => { describe('userActivity', () => { it('should produce an appopriate pipe', () => { const CASE_ID = '1234567890'; - const ACTIVITY_KEY = redisActivityKeys.view(CASE_ID); + const ACTIVITY_KEY = keys.case.view(CASE_ID); const USER_ID = 'a'; const SCORE = 500; const pipe = store.userActivity(ACTIVITY_KEY, USER_ID, SCORE); @@ -28,7 +28,7 @@ describe('socket.utils', () => { const pipe = store.userDetails(USER, TTL); expect(pipe).to.be.an('array').and.have.lengthOf(5); expect(pipe[0]).to.equal('set'); - expect(pipe[1]).to.equal(redisActivityKeys.user(USER.uid)); + expect(pipe[1]).to.equal(keys.user(USER.uid)); expect(pipe[2]).to.equal('{"id":"a","forename":"Bob","surname":"Smith"}'); expect(pipe[3]).to.equal('EX'); // Expires in... expect(pipe[4]).to.equal(TTL); // ...487 seconds. @@ -39,13 +39,13 @@ describe('socket.utils', () => { it('should produce an appopriate pipe', () => { const CASE_ID = '1234567890'; const SOCKET_ID = 'abcdef123456'; - const ACTIVITY_KEY = redisActivityKeys.view(CASE_ID); + const ACTIVITY_KEY = keys.case.view(CASE_ID); const USER_ID = 'a'; const TTL = 487; const pipe = store.socketActivity(SOCKET_ID, ACTIVITY_KEY, CASE_ID, USER_ID, TTL); expect(pipe).to.be.an('array').and.have.lengthOf(5); expect(pipe[0]).to.equal('set'); - expect(pipe[1]).to.equal(redisActivityKeys.socket(SOCKET_ID)); + expect(pipe[1]).to.equal(keys.socket(SOCKET_ID)); expect(pipe[2]).to.equal(`{"activityKey":"${ACTIVITY_KEY}","caseId":"${CASE_ID}","userId":"${USER_ID}"}`); expect(pipe[3]).to.equal('EX'); // Expires in... expect(pipe[4]).to.equal(TTL); // ...487 seconds. diff --git a/test/spec/app/socket/utils/watch.spec.js b/test/spec/app/socket/utils/watch.spec.js index f1b8de11..a2d1650a 100644 --- a/test/spec/app/socket/utils/watch.spec.js +++ b/test/spec/app/socket/utils/watch.spec.js @@ -33,7 +33,7 @@ describe('socket.utils', () => { watch.case(MOCK_SOCKET, CASE_ID); expect(MOCK_SOCKET.rooms).to.have.lengthOf(2) .and.to.include(MOCK_SOCKET.id) - .and.to.include(keys.baseCase(CASE_ID)); + .and.to.include(keys.case.base(CASE_ID)); }); it('should handle a null room', () => { const CASE_ID = null; @@ -56,7 +56,7 @@ describe('socket.utils', () => { expect(MOCK_SOCKET.rooms).to.have.lengthOf(CASE_IDS.length + 1) .and.to.include(MOCK_SOCKET.id); CASE_IDS.forEach((id) => { - expect(MOCK_SOCKET.rooms).to.include(keys.baseCase(id)); + expect(MOCK_SOCKET.rooms).to.include(keys.case.base(id)); }); }); it('should handle a null room', () => { @@ -66,7 +66,7 @@ describe('socket.utils', () => { .and.to.include(MOCK_SOCKET.id); CASE_IDS.forEach((id) => { if (id) { - expect(MOCK_SOCKET.rooms).to.include(keys.baseCase(id)); + expect(MOCK_SOCKET.rooms).to.include(keys.case.base(id)); } }); }); @@ -128,10 +128,10 @@ describe('socket.utils', () => { expect(MOCK_SOCKET.rooms).to.have.lengthOf(REPLACEMENT_CASE_IDS.length + 1) .and.to.include(MOCK_SOCKET.id); REPLACEMENT_CASE_IDS.forEach((id) => { - expect(MOCK_SOCKET.rooms).to.include(keys.baseCase(id)); + expect(MOCK_SOCKET.rooms).to.include(keys.case.base(id)); }); CASE_IDS.forEach((id) => { - expect(MOCK_SOCKET.rooms).not.to.include(keys.baseCase(id)); + expect(MOCK_SOCKET.rooms).not.to.include(keys.case.base(id)); }); }); }); From b30efab8e92549a41aec13976025792d0be51a0e Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Thu, 27 May 2021 14:42:47 +0100 Subject: [PATCH 23/58] Fixed the subscriptions The subscriptions were still looking for a 'case:' prefix but it's now 'c:'. --- app/socket/redis/pub-sub.js | 9 ++++----- test/spec/app/socket/redis/pub-sub.spec.js | 5 +++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/socket/redis/pub-sub.js b/app/socket/redis/pub-sub.js index 5d4fe7d6..5a311f31 100644 --- a/app/socket/redis/pub-sub.js +++ b/app/socket/redis/pub-sub.js @@ -1,16 +1,15 @@ -const ROOM_PREFIX = 'case:'; +const keys = require('./keys'); module.exports = () => { return { init: (sub, caseNotifier) => { if (sub && typeof caseNotifier === 'function') { - sub.psubscribe(`${ROOM_PREFIX}*`); + sub.psubscribe(`${keys.prefixes.case}:*`); sub.on('pmessage', (_, room) => { - const caseId = room.replace(ROOM_PREFIX, ''); + const caseId = room.replace(`${keys.prefixes.case}:`, ''); caseNotifier(caseId); }); } - }, - ROOM_PREFIX + } }; }; diff --git a/test/spec/app/socket/redis/pub-sub.spec.js b/test/spec/app/socket/redis/pub-sub.spec.js index 75804b07..92cd064f 100644 --- a/test/spec/app/socket/redis/pub-sub.spec.js +++ b/test/spec/app/socket/redis/pub-sub.spec.js @@ -1,4 +1,5 @@ const expect = require('chai').expect; +const keys = require('../../../../../app/socket/redis/keys'); const pubSub = require('../../../../../app/socket/redis/pub-sub')(); describe('socket.redis.pub-sub', () => { @@ -47,7 +48,7 @@ describe('socket.redis.pub-sub', () => { it('should handle appropriate parameters', () => { pubSub.init(MOCK_SUBSCRIBER, MOCK_NOTIFIER.notify); expect(MOCK_SUBSCRIBER.patterns).to.have.lengthOf(1) - .and.to.include(`${pubSub.ROOM_PREFIX}*`); + .and.to.include(`${keys.prefixes.case}:*`); expect(MOCK_SUBSCRIBER.events.pmessage).to.be.a('function'); expect(MOCK_NOTIFIER.messages).to.have.lengthOf(0); }); @@ -55,7 +56,7 @@ describe('socket.redis.pub-sub', () => { pubSub.init(MOCK_SUBSCRIBER, MOCK_NOTIFIER.notify); const CASE_ID = '1234567890'; expect(MOCK_NOTIFIER.messages).to.have.lengthOf(0); - MOCK_SUBSCRIBER.dispatch('pmessage', `${pubSub.ROOM_PREFIX}${CASE_ID}`, new Date().toISOString()); + MOCK_SUBSCRIBER.dispatch('pmessage', `${keys.prefixes.case}:${CASE_ID}`, new Date().toISOString()); expect(MOCK_NOTIFIER.messages).to.have.lengthOf(1); expect(MOCK_NOTIFIER.messages[0]).to.equal(CASE_ID); }); From 985bdc33295b9debab64a68907df88e592c1828d Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Thu, 27 May 2021 14:43:09 +0100 Subject: [PATCH 24/58] Unit tests Unit tests for `socket/router`. --- app/socket/router/index.js | 20 +- test/spec/app/socket/router/index.spec.js | 219 ++++++++++++++++++++++ 2 files changed, 235 insertions(+), 4 deletions(-) create mode 100644 test/spec/app/socket/router/index.spec.js diff --git a/app/socket/router/index.js b/app/socket/router/index.js index 714c710c..3a97e346 100644 --- a/app/socket/router/index.js +++ b/app/socket/router/index.js @@ -12,6 +12,18 @@ const router = { getUser: (socketId) => { return users[socketId]; }, + addConnection: (socket) => { + connections.push(socket); + }, + removeConnection: (socket) => { + const socketIndex = connections.indexOf(socket); + if (socketIndex > -1) { + connections.splice(socketIndex, 1); + } + }, + getConnections: () => { + return [...connections]; + }, init: (io, iorouter, handlers) => { // Set up routes for each type of message. iorouter.on('register', (socket, ctx, next) => { @@ -40,17 +52,17 @@ const router = { // On client connection, attach the router and track the socket. io.on('connection', (socket) => { - connections.push(socket); - utils.log(socket, '', `connected (${connections.length} total)`); + router.addConnection(socket); + utils.log(socket, '', `connected (${router.getConnections().length} total)`); socket.use((packet, next) => { iorouter.attach(socket, packet, next); }); // When the socket disconnects, do an appropriate teardown. socket.on('disconnect', () => { - utils.log(socket, '', `disconnected (${connections.length - 1} total)`); + utils.log(socket, '', `disconnected (${router.getConnections().length - 1} total)`); handlers.removeSocketActivity(socket.id); router.removeUser(socket.id); - connections.splice(connections.indexOf(socket), 1); + router.removeConnection(socket); }); }); } diff --git a/test/spec/app/socket/router/index.spec.js b/test/spec/app/socket/router/index.spec.js new file mode 100644 index 00000000..6938edf4 --- /dev/null +++ b/test/spec/app/socket/router/index.spec.js @@ -0,0 +1,219 @@ +const expect = require('chai').expect; +const router = require('../../../../../app/socket/router'); + +describe('socket.router', () => { + const MOCK_SOCKET_SERVER = { + events: {}, + on: (event, eventHandler) => { + MOCK_SOCKET_SERVER.events[event] = eventHandler; + }, + dispatch: (event, socket) => { + const handler = MOCK_SOCKET_SERVER.events[event]; + if (handler) { + handler(socket); + } + } + }; + const MOCK_IO_ROUTER = { + events: {}, + attachments: [], + on: (event, eventHandler) => { + MOCK_IO_ROUTER.events[event] = eventHandler; + }, + attach: (socket, packet, next) => { + MOCK_IO_ROUTER.attachments.push({ socket, packet, next }); + }, + dispatch: (event, socket, ctx, next) => { + const handler = MOCK_IO_ROUTER.events[event]; + if (handler) { + handler(socket, ctx, next); + } + } + }; + const MOCK_HANDLERS = { + calls: [], + addActivity: (socket, caseId, user, activity) => { + const params = { socket, caseId, user, activity }; + MOCK_HANDLERS.calls.push({ method: 'addActivity', params }); + }, + watch: (socket, caseIds) => { + const params = { socket, caseIds }; + MOCK_HANDLERS.calls.push({ method: 'watch', params }); + }, + removeSocketActivity: async (socketId) => { + const params = { socketId }; + MOCK_HANDLERS.calls.push({ method: 'removeSocketActivity', params }); + } + }; + const MOCK_SOCKET = { + id: 'socket-id', + rooms: ['socket-id'], + events: {}, + messages: [], + using: [], + join: (room) => { + if (!MOCK_SOCKET.rooms.includes(room)) { + MOCK_SOCKET.rooms.push(room); + } + }, + leave: (room) => { + const roomIndex = MOCK_SOCKET.rooms.indexOf(room); + if (roomIndex > -1) { + MOCK_SOCKET.rooms.splice(roomIndex, 1); + } + }, + emit: (event, message) => { + MOCK_SOCKET.messages.push({ event, message }); + }, + use: (fn) => { + MOCK_SOCKET.using.push(fn); + }, + on: (event, eventHandler) => { + MOCK_SOCKET.events[event] = eventHandler; + }, + dispatch: (event) => { + const handler = MOCK_SOCKET.events[event]; + if (handler) { + handler(MOCK_SOCKET); + } + } + }; + + beforeEach(() => { + router.init(MOCK_SOCKET_SERVER, MOCK_IO_ROUTER, MOCK_HANDLERS); + }); + + afterEach(() => { + MOCK_SOCKET_SERVER.events = {}; + MOCK_IO_ROUTER.events = {}; + MOCK_IO_ROUTER.attachments.length = 0; + MOCK_HANDLERS.calls.length = 0; + MOCK_SOCKET.using.length = 0; + router.removeUser(MOCK_SOCKET.id); + router.removeConnection(MOCK_SOCKET); + }); + + describe('init', () => { + it('should have set up the appropriate events on the socket server', () => { + const EXPECTED_EVENTS = ['connection']; + EXPECTED_EVENTS.forEach((event) => { + expect(MOCK_SOCKET_SERVER.events[event]).to.be.a('function'); + }); + }); + it('should have set up the appropriate events on the io router', () => { + const EXPECTED_EVENTS = ['register', 'view', 'edit', 'watch']; + EXPECTED_EVENTS.forEach((event) => { + expect(MOCK_IO_ROUTER.events[event]).to.be.a('function'); + }); + }); + }); + + describe('iorouter', () => { + const MOCK_CONTEXT_REGISTER = { + request: { + user: { id: 'a', name: 'Bob Smith' } + } + }; + const MOCK_CONTEXT = { + request: { + caseId: '1234567890', + caseIds: ['2345678901', '3456789012', '4567890123'] + } + }; + beforeEach(() => { + // We need to register before each call as it sets up the user. + MOCK_IO_ROUTER.dispatch('register', MOCK_SOCKET, MOCK_CONTEXT_REGISTER, () => {}); + }); + it('should appropriately handle registering a user', () => { + expect(router.getUser(MOCK_SOCKET.id)).to.deep.equal(MOCK_CONTEXT_REGISTER.request.user); + }); + it('should appropriately handle viewing a case', () => { + const ACTIVITY = 'view'; + let nextCalled = false; + MOCK_IO_ROUTER.dispatch(ACTIVITY, MOCK_SOCKET, MOCK_CONTEXT, () => { + // next() should be called last so everything else should have been done already. + nextCalled = true; + expect(MOCK_HANDLERS.calls).to.have.lengthOf(1); + expect(MOCK_HANDLERS.calls[0].method).to.equal('addActivity'); + expect(MOCK_HANDLERS.calls[0].params.socket).to.equal(MOCK_SOCKET); + expect(MOCK_HANDLERS.calls[0].params.caseId).to.equal(MOCK_CONTEXT.request.caseId); + // Note that the MOCK_CONTEXT doesn't include the user, which means we had to get it from elsewhere. + expect(MOCK_HANDLERS.calls[0].params.user).to.deep.equal(MOCK_CONTEXT_REGISTER.request.user); + expect(MOCK_HANDLERS.calls[0].params.activity).to.equal(ACTIVITY); + }); + expect(nextCalled).to.be.true; + }); + it('should appropriately handle editing a case', () => { + const ACTIVITY = 'edit'; + let nextCalled = false; + MOCK_IO_ROUTER.dispatch(ACTIVITY, MOCK_SOCKET, MOCK_CONTEXT, () => { + // next() should be called last so everything else should have been done already. + nextCalled = true; + expect(MOCK_HANDLERS.calls).to.have.lengthOf(1); + expect(MOCK_HANDLERS.calls[0].method).to.equal('addActivity'); + expect(MOCK_HANDLERS.calls[0].params.socket).to.equal(MOCK_SOCKET); + expect(MOCK_HANDLERS.calls[0].params.caseId).to.equal(MOCK_CONTEXT.request.caseId); + // Note that the MOCK_CONTEXT doesn't include the user, which means we had to get it from elsewhere. + expect(MOCK_HANDLERS.calls[0].params.user).to.deep.equal(MOCK_CONTEXT_REGISTER.request.user); + expect(MOCK_HANDLERS.calls[0].params.activity).to.equal(ACTIVITY); + }); + expect(nextCalled).to.be.true; + }); + it('should appropriately handle watching cases', () => { + const ACTIVITY = 'watch'; + let nextCalled = false; + MOCK_IO_ROUTER.dispatch(ACTIVITY, MOCK_SOCKET, MOCK_CONTEXT, () => { + // next() should be called last so everything else should have been done already. + nextCalled = true; + expect(MOCK_HANDLERS.calls).to.have.lengthOf(1); + expect(MOCK_HANDLERS.calls[0].method).to.equal('watch'); + expect(MOCK_HANDLERS.calls[0].params.socket).to.equal(MOCK_SOCKET); + expect(MOCK_HANDLERS.calls[0].params.caseIds).to.deep.equal(MOCK_CONTEXT.request.caseIds); + }); + expect(nextCalled).to.be.true; + }); + }); + + describe('io', () => { + const MOCK_CONTEXT_REGISTER = { + request: { + user: { id: 'a', name: 'Bob Smith' } + } + }; + beforeEach(() => { + // We need to register before each call as it sets up the user. + MOCK_IO_ROUTER.dispatch('register', MOCK_SOCKET, MOCK_CONTEXT_REGISTER, () => {}); + + // Dispatch the connection each time. + MOCK_SOCKET_SERVER.dispatch('connection', MOCK_SOCKET); + }); + it('should appropriately handle a new connection', () => { + expect(router.getConnections()).to.have.lengthOf(1) + .and.to.contain(MOCK_SOCKET); + expect(MOCK_SOCKET.using).to.have.lengthOf(1); + expect(MOCK_SOCKET.using[0]).to.be.a('function'); + expect(MOCK_SOCKET.events.disconnect).to.be.a('function'); + }); + it('should handle a socket use', () => { + const useFn = MOCK_SOCKET.using[0]; + const PACKET = 'packet'; + const NEXT_FN = () => {}; + + expect(MOCK_IO_ROUTER.attachments).to.have.lengthOf(0); + useFn(PACKET, NEXT_FN); + expect(MOCK_IO_ROUTER.attachments).to.have.lengthOf(1); + expect(MOCK_IO_ROUTER.attachments[0].socket).to.equal(MOCK_SOCKET); + expect(MOCK_IO_ROUTER.attachments[0].packet).to.equal(PACKET); + expect(MOCK_IO_ROUTER.attachments[0].next).to.equal(NEXT_FN); + }); + it('should handle a socket disconnecting', () => { + MOCK_SOCKET.dispatch('disconnect'); + expect(MOCK_HANDLERS.calls).to.have.lengthOf(1); + expect(MOCK_HANDLERS.calls[0].method).to.equal('removeSocketActivity'); + expect(MOCK_HANDLERS.calls[0].params.socketId).to.equal(MOCK_SOCKET.id); + expect(router.getUser(MOCK_SOCKET.id)).to.be.undefined; + expect(router.getConnections()).to.have.lengthOf(0); + }); + }); + +}); From caee0ae02b2a0214b9b6b2e0c2548bef30724bd6 Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Fri, 28 May 2021 09:25:20 +0100 Subject: [PATCH 25/58] Redis subscriber Did away with the need for a separate redis client by simply calling `redis.duplicate()` on the existing one - much cleaner. --- app/socket/index.js | 2 +- app/socket/redis/pub-sub.js | 8 ++++---- app/socket/redis/watcher.js | 4 ---- test/spec/app/socket/index.spec.js | 16 +++++++++++++++- test/spec/app/socket/redis/watcher.spec.js | 12 ------------ 5 files changed, 20 insertions(+), 22 deletions(-) delete mode 100644 app/socket/redis/watcher.js delete mode 100644 test/spec/app/socket/redis/watcher.spec.js diff --git a/app/socket/index.js b/app/socket/index.js index a0f9332d..34300519 100644 --- a/app/socket/index.js +++ b/app/socket/index.js @@ -4,7 +4,6 @@ const SocketIO = require('socket.io'); const ActivityService = require('./service/activity-service'); const Handlers = require('./service/handlers'); -const watcher = require('./redis/watcher'); const pubSub = require('./redis/pub-sub')(); const router = require('./router'); @@ -29,6 +28,7 @@ module.exports = (server, redis) => { } }); const handlers = Handlers(activityService, socketServer); + const watcher = redis.duplicate(); pubSub.init(watcher, handlers.notify); router.init(socketServer, new IORouter(), handlers); diff --git a/app/socket/redis/pub-sub.js b/app/socket/redis/pub-sub.js index 5a311f31..7cb287ad 100644 --- a/app/socket/redis/pub-sub.js +++ b/app/socket/redis/pub-sub.js @@ -2,10 +2,10 @@ const keys = require('./keys'); module.exports = () => { return { - init: (sub, caseNotifier) => { - if (sub && typeof caseNotifier === 'function') { - sub.psubscribe(`${keys.prefixes.case}:*`); - sub.on('pmessage', (_, room) => { + init: (watcher, caseNotifier) => { + if (watcher && typeof caseNotifier === 'function') { + watcher.psubscribe(`${keys.prefixes.case}:*`); + watcher.on('pmessage', (_, room) => { const caseId = room.replace(`${keys.prefixes.case}:`, ''); caseNotifier(caseId); }); diff --git a/app/socket/redis/watcher.js b/app/socket/redis/watcher.js deleted file mode 100644 index 196bb24c..00000000 --- a/app/socket/redis/watcher.js +++ /dev/null @@ -1,4 +0,0 @@ -const debug = require('debug')('ccd-case-activity-api:redis-watcher'); -const watcher = require('../../redis/instantiator')(debug); - -module.exports = watcher; diff --git a/test/spec/app/socket/index.spec.js b/test/spec/app/socket/index.spec.js index 54e514eb..34f8b893 100644 --- a/test/spec/app/socket/index.spec.js +++ b/test/spec/app/socket/index.spec.js @@ -4,7 +4,20 @@ const Socket = require('../../../../app/socket'); describe('socket', () => { const MOCK_SERVER = {}; - const MOCK_REDIS = {}; + const MOCK_REDIS = { + duplicated: false, + duplicate: () => { + MOCK_REDIS.duplicated = true; + return MOCK_REDIS; + }, + psubscribe: () => {}, + on: () => {} + }; + + afterEach(() => { + MOCK_REDIS.duplicated = false; + }); + it('should be appropriately initialised', () => { const socket = Socket(MOCK_SERVER, MOCK_REDIS); expect(socket).not.to.be.undefined; @@ -14,5 +27,6 @@ describe('socket', () => { expect(socket.handlers).to.be.an('object'); expect(socket.handlers.activityService).to.equal(socket.activityService); expect(socket.handlers.socketServer).to.equal(socket.socketServer); + expect(MOCK_REDIS.duplicated).to.be.true; }) }); \ No newline at end of file diff --git a/test/spec/app/socket/redis/watcher.spec.js b/test/spec/app/socket/redis/watcher.spec.js deleted file mode 100644 index f5151d44..00000000 --- a/test/spec/app/socket/redis/watcher.spec.js +++ /dev/null @@ -1,12 +0,0 @@ -const config = require('config'); -const expect = require('chai').expect; -const Redis = require('ioredis'); - -describe('socket.redis.watcher', () => { - it('should instantiate a Redis client', () => { - const watcher = require('../../../../../app/socket/redis/watcher'); - expect(watcher).to.be.instanceOf(Redis); - expect(watcher.options.port).to.equal(config.get('redis.port')); - expect(watcher.options.host).to.equal(config.get('redis.host')); - }); -}); \ No newline at end of file From 7f8a6786ed7dcb12558b2e2204df6beda2ba967a Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Fri, 28 May 2021 09:26:07 +0100 Subject: [PATCH 26/58] Unit tests Unit tests for the activity service. --- app/socket/service/activity-service.js | 84 ++-- .../socket/service/activity-service.spec.js | 391 ++++++++++++++++++ test/spec/app/socket/utils/watch.spec.js | 1 - 3 files changed, 444 insertions(+), 32 deletions(-) create mode 100644 test/spec/app/socket/service/activity-service.spec.js diff --git a/app/socket/service/activity-service.js b/app/socket/service/activity-service.js index 95f22e02..fe64578f 100644 --- a/app/socket/service/activity-service.js +++ b/app/socket/service/activity-service.js @@ -8,84 +8,105 @@ module.exports = (config, redis) => { }; const notifyChange = (caseId) => { - redis.publish(keys.case.base(caseId), Date.now().toString()); + if (caseId) { + redis.publish(keys.case.base(caseId), Date.now().toString()); + } }; const getSocketActivity = async (socketId) => { - const key = keys.socket(socketId); - return JSON.parse(await redis.get(key)); + if (socketId) { + const key = keys.socket(socketId); + return JSON.parse(await redis.get(key)); + } + return null; }; const getUserDetails = async (userIds) => { - if (userIds.length > 0) { + if (Array.isArray(userIds) && userIds.length > 0) { // Get hold of the details. const details = await redis.pipeline(utils.get.users(userIds)).exec(); // Now turn them into a map. return details.reduce((obj, item) => { - const user = JSON.parse(item[1]); - if (user) { + if (item[1]) { + const user = JSON.parse(item[1]); obj[user.id] = { forename: user.forename, surname: user.surname }; } return obj; }, {}); } - return []; + return {}; }; - const removeSocketActivity = async (socketId, skipNotifyForCaseId) => { + const doRemoveSocketActivity = async (socketId) => { // First make sure we actually have some activity to remove. const activity = await getSocketActivity(socketId); if (activity) { - return redis.pipeline([ + await redis.pipeline([ utils.remove.userActivity(activity), utils.remove.socketEntry(socketId) - ]).exec().then(() => { - if (activity.caseId !== skipNotifyForCaseId) { - notifyChange(activity.caseId); - } - }); + ]).exec(); + return activity.caseId; } return null; }; - const addActivity = async (caseId, user, socketId, activity) => { - // First, clear out any existing activity on this socket. - await removeSocketActivity(socketId, caseId); + const removeSocketActivity = async (socketId) => { + const removedCaseId = await doRemoveSocketActivity(socketId); + if (removedCaseId) { + notifyChange(removedCaseId); + } + }; + const doAddActivity = async (caseId, user, socketId, activity) => { // Now store this activity. const activityKey = keys.case[activity](caseId); return redis.pipeline([ utils.store.userActivity(activityKey, user.uid, utils.score(ttl.activity)), utils.store.socketActivity(socketId, activityKey, caseId, user.uid, ttl.user), utils.store.userDetails(user, ttl.user) - ]).exec().then(() => { + ]).exec(); + }; + + const addActivity = async (caseId, user, socketId, activity) => { + if (caseId && user && socketId && activity) { + // First, clear out any existing activity on this socket. + const removedCaseId = await doRemoveSocketActivity(socketId); + + // Now store this activity. + await doAddActivity(caseId, user, socketId, activity); + if (removedCaseId !== caseId) { + notifyChange(removedCaseId); + } notifyChange(caseId); - }); + } + return null; }; const getActivityForCases = async (caseIds) => { + if (!Array.isArray(caseIds) || caseIds.length === 0) { + return []; + } let uniqueUserIds = []; let caseViewers = []; let caseEditors = []; const now = Date.now(); - const getPromise = (activity, cb, failureMessage) => { - return redis.pipeline( + const getPromise = async (activity, failureMessage, cb) => { + const result = await redis.pipeline( utils.get.caseActivities(caseIds, activity, now) - ).exec().then((result) => { - redis.logPipelineFailures(result, failureMessage); - cb(result); - uniqueUserIds = utils.extractUniqueUserIds(result, uniqueUserIds); - }); + ).exec(); + redis.logPipelineFailures(result, failureMessage); + cb(result); + uniqueUserIds = utils.extractUniqueUserIds(result, uniqueUserIds); }; // Set up the promises fore view and edit. - const caseViewersPromise = getPromise('view', (result) => { + const caseViewersPromise = getPromise('view', 'caseViewersPromise', (result) => { caseViewers = result; - }, 'caseViewersPromise'); - const caseEditorsPromise = getPromise('edit', (result) => { + }); + const caseEditorsPromise = getPromise('edit', 'caseEditorsPromise', (result) => { caseEditors = result; - }, 'caseEditorsPromise'); + }); // Now wait until both promises have been completed. await Promise.all([caseViewersPromise, caseEditorsPromise]); @@ -116,6 +137,7 @@ module.exports = (config, redis) => { getUserDetails, notifyChange, redis, - removeSocketActivity + removeSocketActivity, + ttl }; }; diff --git a/test/spec/app/socket/service/activity-service.spec.js b/test/spec/app/socket/service/activity-service.spec.js new file mode 100644 index 00000000..46939bbf --- /dev/null +++ b/test/spec/app/socket/service/activity-service.spec.js @@ -0,0 +1,391 @@ +const keys = require('../../../../../app/socket/redis/keys'); +const ActivityService = require('../../../../../app/socket/service/activity-service'); +const expect = require('chai').expect; +const sandbox = require("sinon").createSandbox(); + +describe('socket.service.activity-service', () => { + // An instance that can be tested. + let activityService; + + const USER_ID = 'a'; + const CASE_ID = '1234567890'; + const TTL_USER = 20; + const TTL_ACTIVITY = 99; + const MOCK_CONFIG = { + getCalls: [], + keys: { + 'redis.activityTtlSec': TTL_ACTIVITY, + 'redis.userDetailsTtlSec': TTL_USER + }, + get: (key) => { + MOCK_CONFIG.getCalls.push(key); + return MOCK_CONFIG.keys[key]; + } + }; + const MOCK_REDIS = { + messages: [], + gets: [], + pipelines: [], + pipelineFailureLogs: [], + pipelineMode: undefined, + publish: (channel, message) => { + MOCK_REDIS.messages.push({ channel, message }); + }, + get: (key) => { + MOCK_REDIS.gets.push(key); + return JSON.stringify({ + activityKey: keys.case.view(CASE_ID), + caseId: CASE_ID, + userId: USER_ID + }); + }, + pipeline: (pipes) => { + MOCK_REDIS.pipelines.push(pipes); + let result = null; + let execResult = null; + switch (MOCK_REDIS.pipelineMode) { + case 'get': + if (MOCK_REDIS.isUserGet(pipes)) { + execResult = MOCK_REDIS.userPipeline(pipes); + } else { + execResult = MOCK_REDIS.casePipeline(pipes); + } + break; + case 'socket': + execResult = CASE_ID; + break; + case 'user': + execResult = MOCK_REDIS.userPipeline(pipes); + break; + } + return { + exec: () => { + return execResult; + } + }; + }, + casePipeline: (pipes) => { + return pipes.map((pipe) => { + // ['zrangebyscore', keys.case[activity](id), now, '+inf']; + const id = pipe[1].replace(`${keys.prefixes.case}:`, ''); + return [null, [USER_ID, 'MISSING']]; + }); + }, + userPipeline: (pipes) => { + return pipes.map((pipe) => { + const id = pipe[1].replace(`${keys.prefixes.user}:`, ''); + if (id === 'MISSING') { + return [null, null]; + } + return [null, JSON.stringify({ id, forename: `Bob ${id.toUpperCase()}`, surname: 'Smith' })]; + }); + }, + logPipelineFailures: (result, message) => { + MOCK_REDIS.pipelineFailureLogs.push({ result, message }); + }, + isUserGet: (pipes) => { + if (pipes.length > 0) { + return pipes[0][0] === 'get'; + } + return false; + } + }; + + beforeEach(() => { + activityService = ActivityService(MOCK_CONFIG, MOCK_REDIS); + }); + + afterEach(async () => { + MOCK_CONFIG.getCalls.length = 0; + MOCK_REDIS.messages.length = 0; + MOCK_REDIS.gets.length = 0; + MOCK_REDIS.pipelines.length = 0; + MOCK_REDIS.pipelineMode = undefined; + MOCK_REDIS.pipelineFailureLogs.length = 0; + }); + + it('should have appropriately initialised from the config', () => { + expect(MOCK_CONFIG.getCalls).to.include('redis.activityTtlSec'); + expect(activityService.ttl.activity).to.equal(TTL_ACTIVITY); + expect(MOCK_CONFIG.getCalls).to.include('redis.userDetailsTtlSec'); + expect(activityService.ttl.user).to.equal(TTL_USER); + }); + + describe('notifyChange', () => { + it('should broadcast via redis that there is a change to a case', () => { + const NOW = Date.now(); + activityService.notifyChange(CASE_ID); + expect(MOCK_REDIS.messages).to.have.lengthOf(1); + expect(MOCK_REDIS.messages[0].channel).to.equal(keys.case.base(CASE_ID)); + const messageTS = parseInt(MOCK_REDIS.messages[0].message, 10); + expect(messageTS).to.be.approximately(NOW, 5); // Within 5ms. + }); + it('should handle a null caseId', () => { + activityService.notifyChange(null); + expect(MOCK_REDIS.messages).to.have.lengthOf(0); // Should have been no broadcast. + }); + }); + + describe('getSocketActivity', () => { + it('should appropriately get socket activity', async () => { + const SOCKET_ID = 'abcdef123456'; + const activity = await activityService.getSocketActivity(SOCKET_ID); + expect(MOCK_REDIS.gets).to.have.lengthOf(1); + expect(MOCK_REDIS.gets[0]).to.equal(keys.socket(SOCKET_ID)); + expect(activity).to.be.an('object'); + expect(activity.activityKey).to.equal(keys.case.view(CASE_ID)); // Just our mock response. + }); + it('should handle a null caseId', async () => { + const SOCKET_ID = null; + const activity = await activityService.getSocketActivity(SOCKET_ID); + expect(MOCK_REDIS.messages).to.have.lengthOf(0); // Should have been no broadcast. + expect(activity).to.be.null; + }); + }); + + describe('getUserDetails', () => { + beforeEach(() => { + MOCK_REDIS.pipelineMode = 'user'; + }); + + it('should appropriately get user details', async () => { + const USER_IDS = ['a', 'b']; + const userDetails = await activityService.getUserDetails(USER_IDS); + expect(MOCK_REDIS.pipelines).to.have.lengthOf(1); + const pipes = MOCK_REDIS.pipelines[0]; + expect(pipes).to.be.an('array').and.have.lengthOf(USER_IDS.length); + USER_IDS.forEach((id, index) => { + const user = userDetails[id]; + expect(user).to.be.an('object'); + expect(user.forename).to.be.a('string'); + expect(user.surname).to.be.a('string'); + + expect(pipes[index]).to.be.an('array') + .and.to.have.lengthOf(2) + .and.to.contain('get') + .and.to.contain(keys.user(id)); + }); + }); + it('should handle null userIds', async () => { + const USER_IDS = null; + const userDetails = await activityService.getUserDetails(USER_IDS); + expect(MOCK_REDIS.pipelines).to.have.lengthOf(0); // Should have been no calls to redis. + expect(userDetails).to.deep.equal({}); + }); + it('should handle empty userIds', async () => { + const USER_IDS = []; + const userDetails = await activityService.getUserDetails(USER_IDS); + expect(MOCK_REDIS.pipelines).to.have.lengthOf(0); // Should have been no calls to redis. + expect(userDetails).to.deep.equal({}); + }); + it('should handle a missing user', async () => { + const USER_IDS = ['a', 'b', 'MISSING']; + const userDetails = await activityService.getUserDetails(USER_IDS); + expect(MOCK_REDIS.pipelines).to.have.lengthOf(1); + const pipes = MOCK_REDIS.pipelines[0]; + expect(pipes).to.be.an('array').and.have.lengthOf(USER_IDS.length); + USER_IDS.forEach((id, index) => { + if (id === 'MISSING') { + expect(userDetails[id]).to.be.undefined; + } else { + const user = userDetails[id]; + expect(user).to.be.an('object'); + expect(user.forename).to.be.a('string'); + expect(user.surname).to.be.a('string'); + } + expect(pipes[index]).to.be.an('array') + .and.to.have.lengthOf(2) + .and.to.contain('get') + .and.to.contain(keys.user(id)); + }); + }); + it('should handle a null userId', async () => { + const USER_IDS = ['a', 'b', null]; + const userDetails = await activityService.getUserDetails(USER_IDS); + expect(MOCK_REDIS.pipelines).to.have.lengthOf(1); + const pipes = MOCK_REDIS.pipelines[0]; + // Should not have tried to retrieve the null user at all. + expect(pipes).to.be.an('array').and.have.lengthOf(USER_IDS.length - 1); + let userIndex = 0; + USER_IDS.forEach((id) => { + if (id) { + const user = userDetails[id]; + expect(user).to.be.an('object'); + expect(user.forename).to.be.a('string'); + expect(user.surname).to.be.a('string'); + + expect(pipes[userIndex]).to.be.an('array') + .and.to.have.lengthOf(2) + .and.to.contain('get') + .and.to.contain(keys.user(id)); + userIndex++; + } + }); + }); + }); + + describe('removeSocketActivity', () => { + beforeEach(() => { + MOCK_REDIS.pipelineMode = 'socket'; + }); + + it('should appropriately remove socket activity', async () => { + const NOW = Date.now(); + const SOCKET_ID = 'abcdef123456'; + await activityService.removeSocketActivity(SOCKET_ID); + expect(MOCK_REDIS.pipelines).to.have.lengthOf(1); + const pipes = MOCK_REDIS.pipelines[0]; + expect(pipes).to.be.an('array').with.a.lengthOf(2); + // First one should be to remove the user activity. + expect(pipes[0]).to.be.an('array').with.a.lengthOf(3) + .and.to.contain('zrem') + .and.to.contain(keys.case.view(CASE_ID)) + .and.to.contain(USER_ID); + // Second one should be to remove the socket entry. + expect(pipes[1]).to.be.an('array').with.a.lengthOf(2) + .and.to.contain('del') + .and.to.contain(keys.socket(SOCKET_ID)); + + // Should have also notified about the change. + expect(MOCK_REDIS.messages).to.have.lengthOf(1); + expect(MOCK_REDIS.messages[0].channel).to.equal(keys.case.base(CASE_ID)); + const messageTS = parseInt(MOCK_REDIS.messages[0].message, 10); + expect(messageTS).to.be.approximately(NOW, 5); // Within 5ms. + }); + it('should handle a null socketId', async () => { + await activityService.removeSocketActivity(null); + expect(MOCK_REDIS.pipelines).to.have.lengthOf(0); // Should have been no calls to redis. + }); + }); + + describe('addActivity', () => { + const DATE_NOW = 55; + + beforeEach(() => { + MOCK_REDIS.pipelineMode = 'add'; + sandbox.stub(Date, 'now').returns(DATE_NOW); + }); + + afterEach(() => { + // completely restore all fakes created through the sandbox + sandbox.restore(); + }); + + it('should appropriately add view activity', async () => { + const NOW = Date.now(); + const USER = { uid: USER_ID, given_name: 'Joe', family_name: 'Bloggs' }; + const SOCKET_ID = 'abcdef123456'; + await activityService.addActivity(CASE_ID, USER, SOCKET_ID, 'view'); + expect(MOCK_REDIS.pipelines).to.have.lengthOf(2); + const removePipes = MOCK_REDIS.pipelines[0]; + expect(removePipes).to.be.an('array').with.a.lengthOf(2); // Remove + + const pipes = MOCK_REDIS.pipelines[1]; + // First one should be to add the user activity. + expect(pipes[0]).to.be.an('array').with.a.lengthOf(4) + .and.to.contain('zadd') + .and.to.contain(keys.case.view(CASE_ID)) + .and.to.contain(DATE_NOW + TTL_ACTIVITY * 1000) // TTL + NOW + .and.to.contain(USER_ID); + // Second one should be to add the socket entry. + expect(pipes[1]).to.be.an('array').with.a.lengthOf(5) + .and.to.contain('set') + .and.to.contain(keys.socket(SOCKET_ID)) + .and.to.contain(`{"activityKey":"${keys.case.view(CASE_ID)}","caseId":"${CASE_ID}","userId":"${USER_ID}"}`) + .and.to.contain('EX') + .and.to.contain(TTL_USER); + // Third one should be to set the user details. + expect(pipes[2]).to.be.an('array').with.a.lengthOf(5) + .and.to.contain('set') + .and.to.contain(keys.user(USER_ID)) + .and.to.contain(`{"id":"${USER_ID}","forename":"Joe","surname":"Bloggs"}`) + .and.to.contain('EX') + .and.to.contain(TTL_USER); + + // Should have also notified about the change. + expect(MOCK_REDIS.messages).to.have.lengthOf(1); + expect(MOCK_REDIS.messages[0].channel).to.equal(keys.case.base(CASE_ID)); + const messageTS = parseInt(MOCK_REDIS.messages[0].message, 10); + expect(messageTS).to.be.approximately(NOW, 5); // Within 5ms. + }); + it('should notifications about both removed and added cases', async () => { + const NOW = Date.now(); + const USER = { uid: USER_ID, given_name: 'Joe', family_name: 'Bloggs' }; + const SOCKET_ID = 'abcdef123456'; + const NEW_CASE_ID = '0987654321'; + await activityService.addActivity(NEW_CASE_ID, USER, SOCKET_ID, 'view'); + + // Should have been two notifictions... + expect(MOCK_REDIS.messages).to.have.lengthOf(2); + // ... firstly about the original case. + expect(MOCK_REDIS.messages[0].channel).to.equal(keys.case.base(CASE_ID)); + // ... and then about the new case. + expect(MOCK_REDIS.messages[1].channel).to.equal(keys.case.base(NEW_CASE_ID)); + }); + it('should handle a null caseId', async () => { + const USER = { uid: USER_ID }; + const SOCKET_ID = 'abcdef123456'; + await activityService.addActivity(null, USER, SOCKET_ID, 'view'); + expect(MOCK_REDIS.pipelines).to.have.lengthOf(0); // Should have been no calls to redis. + }); + it('should handle a null user', async () => { + const SOCKET_ID = 'abcdef123456'; + await activityService.addActivity(CASE_ID, null, SOCKET_ID, 'view'); + expect(MOCK_REDIS.pipelines).to.have.lengthOf(0); // Should have been no calls to redis. + }); + it('should handle a null socketId', async () => { + const USER = { uid: USER_ID }; + await activityService.addActivity(CASE_ID, USER, null, 'view'); + expect(MOCK_REDIS.pipelines).to.have.lengthOf(0); // Should have been no calls to redis. + }); + it('should handle a null activity', async () => { + const USER = { uid: USER_ID }; + const SOCKET_ID = 'abcdef123456'; + await activityService.addActivity(CASE_ID, USER, SOCKET_ID, null); + expect(MOCK_REDIS.pipelines).to.have.lengthOf(0); // Should have been no calls to redis. + }); + }); + + describe('getActivityForCases', () => { + const DATE_NOW = 55; + + beforeEach(() => { + MOCK_REDIS.pipelineMode = 'get'; + sandbox.stub(Date, 'now').returns(DATE_NOW); + }); + + afterEach(() => { + // completely restore all fakes created through the sandbox + sandbox.restore(); + }); + + it('should appropriately get case activity', async () => { + const CASE_IDS = ['1234567890','0987654321']; + const result = await activityService.getActivityForCases(CASE_IDS); + expect(result).to.be.an('array').with.a.lengthOf(CASE_IDS.length); + CASE_IDS.forEach((id, index) => { + expect(result[index]).to.be.an('object'); + expect(result[index].caseId).to.equal(id); + expect(result[index].viewers).to.be.an('array').with.a.lengthOf(1); + expect(result[index].viewers[0]).to.be.an('object'); + expect(result[index].viewers[0].forename).to.equal(`Bob ${USER_ID.toUpperCase()}`); + expect(result[index].unknownViewers).to.equal(1); // 'MISSING' id. + expect(result[index].editors).to.be.an('array').with.a.lengthOf(1); + expect(result[index].editors[0]).to.be.an('object'); + expect(result[index].unknownEditors).to.equal(1); // 'MISSING' id. + expect(result[index].editors[0].forename).to.equal(`Bob ${USER_ID.toUpperCase()}`); + }); + }); + it('should handle null caseIds', async () => { + const result = await activityService.getActivityForCases(null); + expect(result).to.be.an('array').with.a.lengthOf(0); + expect(MOCK_REDIS.pipelines).to.have.lengthOf(0); // Should have been no calls to redis. + }); + it('should handle empty caseIds', async () => { + const result = await activityService.getActivityForCases([]); + expect(result).to.be.an('array').with.a.lengthOf(0); + expect(MOCK_REDIS.pipelines).to.have.lengthOf(0); // Should have been no calls to redis. + }); + }); + +}); diff --git a/test/spec/app/socket/utils/watch.spec.js b/test/spec/app/socket/utils/watch.spec.js index a2d1650a..94651344 100644 --- a/test/spec/app/socket/utils/watch.spec.js +++ b/test/spec/app/socket/utils/watch.spec.js @@ -5,7 +5,6 @@ const expect = require('chai').expect; describe('socket.utils', () => { describe('watch', () => { - const MOCK_SOCKET = { id: 'socket-id', rooms: ['socket-id'], From 2762b6732e2aabf6382a3df679f33453a336fc81 Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Fri, 28 May 2021 09:36:39 +0100 Subject: [PATCH 27/58] moment Moment isn't needed so ditching it. --- package.json | 1 - test/spec/app/service/activity-service.spec.js | 1 - 2 files changed, 2 deletions(-) diff --git a/package.json b/package.json index 97ba0c01..18a449cd 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,6 @@ "ioredis": "^3.1.4", "joi": "^17.2.1", "jwt-decode": "^2.2.0", - "moment": "^2.19.3", "morgan": "^1.9.1", "nocache": "^2.1.0", "node-cache": "^5.1.0", diff --git a/test/spec/app/service/activity-service.spec.js b/test/spec/app/service/activity-service.spec.js index ef73e385..d431fd57 100644 --- a/test/spec/app/service/activity-service.spec.js +++ b/test/spec/app/service/activity-service.spec.js @@ -54,7 +54,6 @@ describe("activity service", () => { it("getActivities should create a redis pipeline with the correct redis commands for getViewers", (done) => { sandbox.stub(Date, 'now').returns(TIMESTAMP); - // sandbox.stub(moment, 'now').returns(TIMESTAMP); sandbox.stub(config, 'get').returns(USER_DETAILS_TTL); sandbox.stub(redis, "pipeline").callsFake(function (arguments) { argStr = JSON.stringify(arguments); From 3288a680d298e4c75fb3ee55ec8a023a7c8eee66 Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Fri, 28 May 2021 09:41:12 +0100 Subject: [PATCH 28/58] Fixed underscore CVE issue Resolving underscore to `^1.12.1` to address a security vulnerability. --- package.json | 2 +- yarn.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 18a449cd..1877586b 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,6 @@ "minimist": "^1.2.3", "y18n": "^4.0.1", "hosted-git-info": "^3.0.8", - "underscore": "^1.13.1" + "underscore": "^1.12.1" } } diff --git a/yarn.lock b/yarn.lock index a0bbe630..2026dc8f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3457,7 +3457,7 @@ typedarray-to-buffer@^3.1.5: dependencies: is-typedarray "^1.0.0" -underscore@^1.13.1, underscore@~1.9.1: +underscore@^1.12.1, underscore@~1.9.1: version "1.13.1" resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.1.tgz#0c1c6bd2df54b6b69f2314066d65b6cde6fcf9d1" integrity sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g== From a4000c96f417b549a539134e4a513c723641349e Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Fri, 28 May 2021 12:18:48 +0100 Subject: [PATCH 29/58] Refactor for testing Moved a bunch of functions out of `server.js` and into `app/util/utils.js` and added unit tests for them all. --- app.js | 2 +- app/util/utils.js | 51 ++++++ server.js | 89 ++--------- test/spec/app/health/health-check.spec.js | 6 +- test/spec/app/util/utils.spec.js | 186 ++++++++++++++++++++++ 5 files changed, 253 insertions(+), 81 deletions(-) create mode 100644 test/spec/app/util/utils.spec.js diff --git a/app.js b/app.js index 12be98ba..26a0d565 100644 --- a/app.js +++ b/app.js @@ -72,4 +72,4 @@ app.use((err, req, res, next) => { }); }); -module.exports = { app, redis }; +module.exports = app; diff --git a/app/util/utils.js b/app/util/utils.js index 28ba42fe..3ef5572e 100644 --- a/app/util/utils.js +++ b/app/util/utils.js @@ -7,3 +7,54 @@ exports.ifNotTimedOut = (request, f) => { debug('request timed out'); } }; + +exports.normalizePort = (val) => { + const port = parseInt(val, 10); + if (Number.isNaN(port)) { + // named pipe + return val; + } + if (port >= 0) { + // port number + return port; + } + return false; +}; + +/** + * Event listener for HTTP server "error" event. + */ +exports.onServerError = (port, logTo, exitRoute) => { + return (error) => { + if (error.syscall !== 'listen') { + throw error; + } + + const bind = typeof port === 'string' ? `Pipe ${port}` : `Port ${port}`; + + // Handle specific listen errors with friendly messages. + switch (error.code) { + case 'EACCES': + logTo(`${bind} requires elevated privileges`); + exitRoute(1); + break; + case 'EADDRINUSE': + logTo(`${bind} is already in use`); + exitRoute(1); + break; + default: + throw error; + } + }; +}; + +/** + * Event listener for HTTP server "listening" event. + */ +exports.onListening = (server, logTo) => { + return () => { + const addr = server.address(); + const bind = typeof addr === 'string' ? `pipe ${addr}` : `port ${addr.port}`; + logTo(`Listening on ${bind}`); + }; +}; diff --git a/server.js b/server.js index 88df8c5c..b83cf80b 100755 --- a/server.js +++ b/server.js @@ -3,98 +3,33 @@ /** * Module dependencies. */ - require('@hmcts/properties-volume').addTo(require('config')); -var app = require('./app'); - -var debug = require('debug')('ccd-case-activity-api:server'); -var http = require('http'); +const { normalizePort, onListening, onServerError } = require('./app/util/utils'); +const debug = require('debug')('ccd-case-activity-api:server'); +const http = require('http'); +const app = require('./app'); /** * Get port from environment and store in Express. */ -var port = normalizePort(process.env.PORT || '3460'); -console.log('Starting on port ' + port); -app.app.set('port', port); +const port = normalizePort(process.env.PORT || '3460'); +console.log(`Starting on port ${port}`); +app.set('port', port); /** * Create HTTP server. */ - -var server = http.createServer(app.app); +const server = http.createServer(app); /** * Create the socket server. */ -require('./app/socket')(server, app.redis); +const redis = require('./app/redis/redis-client'); +require('./app/socket')(server, redis); /** * Listen on provided port, on all network interfaces. */ - server.listen(port); -server.on('error', onError); -server.on('listening', onListening); - -/** - * Normalize a port into a number, string, or false. - */ - -function normalizePort(val) { - var port = parseInt(val, 10); - - if (isNaN(port)) { - // named pipe - return val; - } - - if (port >= 0) { - // port number - return port; - } - - return false; -} - -/** - * Event listener for HTTP server "error" event. - */ - -function onError(error) { - if (error.syscall !== 'listen') { - throw error; - } - - var bind = typeof port === 'string' - ? 'Pipe ' + port - : 'Port ' + port; - - // handle specific listen errors with friendly messages - switch (error.code) { - case 'EACCES': - console.error(bind + ' requires elevated privileges'); - process.exit(1); - break; - case 'EADDRINUSE': - console.error(bind + ' is already in use'); - process.exit(1); - break; - default: - throw error; - } -} - -/** - * Event listener for HTTP server "listening" event. - */ - -function onListening() { - - var addr = server.address(); - - var bind = typeof addr === 'string' - ? 'pipe ' + addr - : 'port ' + addr.port; - - debug('Listening on ' + bind); -} +server.on('error', onServerError(port, console.error, process.exit)); +server.on('listening', onListening(server, debug)); diff --git a/test/spec/app/health/health-check.spec.js b/test/spec/app/health/health-check.spec.js index e85f7054..9efb6c9c 100644 --- a/test/spec/app/health/health-check.spec.js +++ b/test/spec/app/health/health-check.spec.js @@ -5,7 +5,7 @@ const app = require('../../../../app'); describe('health check', () => { it('should return 200 OK for health check', async () => { - await request(app.app) + await request(app) .get('/health') .expect(res => { expect(res.status).equal(200); @@ -14,7 +14,7 @@ describe('health check', () => { }); it('should return 200 OK for liveness health check', async () => { - await request(app.app) + await request(app) .get('/health/liveness') .expect(res => { expect(res.status).equal(200); @@ -23,7 +23,7 @@ describe('health check', () => { }); it('should return 200 OK for readiness health check', async () => { - await request(app.app) + await request(app) .get('/health/readiness') .expect(res => { expect(res.status).equal(200); diff --git a/test/spec/app/util/utils.spec.js b/test/spec/app/util/utils.spec.js new file mode 100644 index 00000000..29bd184f --- /dev/null +++ b/test/spec/app/util/utils.spec.js @@ -0,0 +1,186 @@ +const expect = require('chai').expect; +const utils = require('../../../../app/util/utils'); + +describe('util.utils', () => { + + describe('ifNotTimedOut', () => { + it('should call the function if it is not timed out', () => { + const REQUEST = { timedout: false }; + let functionCalled = false; + utils.ifNotTimedOut(REQUEST, () => { + functionCalled = true; + }); + expect(functionCalled).to.be.true; + }); + it('should not the function if it is timed out', () => { + const REQUEST = { timedout: true }; + let functionCalled = false; + utils.ifNotTimedOut(REQUEST, () => { + functionCalled = true; + }); + expect(functionCalled).to.be.false; + }); + }); + + describe('normalizePort', () => { + it('should parse and use a numeric string', () => { + const PORT = '1234'; + const response = utils.normalizePort(PORT); + expect(response).to.be.a('number').and.to.equal(1234); + }); + it('should parse and use a zero string', () => { + const PORT = '0'; + const response = utils.normalizePort(PORT); + expect(response).to.be.a('number').and.to.equal(0); + }); + it('should bounce a null', () => { + const PORT = null; + const response = utils.normalizePort(PORT); + expect(response).to.equal(PORT); + }); + it('should bounce an object', () => { + const PORT = { bob: 'Bob' }; + const response = utils.normalizePort(PORT); + expect(response).to.equal(PORT); + }); + it('should bounce a string that cannot be parsed as a number', () => { + const PORT = 'Bob'; + const response = utils.normalizePort(PORT); + expect(response).to.equal(PORT); + }); + it('should reject an invalid numeric string', () => { + const PORT = '-1234'; + const response = utils.normalizePort(PORT); + expect(response).to.be.false; + }); + }); + + describe('onServerError', () => { + const getSystemError = (code, syscall, message) => { + return { + address: 'http://test.address.net', + code: code, + errno: 1, + message: message || 'An error occurred', + syscall: syscall + }; + }; + let logTo; + let exitRoute; + beforeEach(() => { + logTo = { + logs: [], + output: (str) => { + logTo.logs.push(str); + } + }; + exitRoute = { + calls: [], + exit: (code) => { + exitRoute.calls.push(code); + } + } + }); + + it('should handle an access error on a numeric port', () => { + const PORT = 1234; + const ERROR = getSystemError('EACCES', 'listen'); + utils.onServerError(PORT, logTo.output, exitRoute.exit)(ERROR); + expect(logTo.logs).to.have.a.lengthOf(1) + .and.to.contain('Port 1234 requires elevated privileges'); + expect(exitRoute.calls).to.have.a.lengthOf(1) + .and.to.contain(1); + }); + it('should handle an access error on a string port', () => { + const PORT = 'BOBBINS'; + const ERROR = getSystemError('EACCES', 'listen'); + utils.onServerError(PORT, logTo.output, exitRoute.exit)(ERROR); + expect(logTo.logs).to.have.a.lengthOf(1) + .and.to.contain('Pipe BOBBINS requires elevated privileges'); + expect(exitRoute.calls).to.have.a.lengthOf(1) + .and.to.contain(1); + }); + it('should handle an address in use error on a numeric port', () => { + const PORT = 1234; + const ERROR = getSystemError('EADDRINUSE', 'listen'); + utils.onServerError(PORT, logTo.output, exitRoute.exit)(ERROR); + expect(logTo.logs).to.have.a.lengthOf(1) + .and.to.contain('Port 1234 is already in use'); + expect(exitRoute.calls).to.have.a.lengthOf(1) + .and.to.contain(1); + }); + it('should handle an address in use error on a string port', () => { + const PORT = 'BOBBINS'; + const ERROR = getSystemError('EADDRINUSE', 'listen'); + utils.onServerError(PORT, logTo.output, exitRoute.exit)(ERROR); + expect(logTo.logs).to.have.a.lengthOf(1) + .and.to.contain('Pipe BOBBINS is already in use'); + expect(exitRoute.calls).to.have.a.lengthOf(1) + .and.to.contain(1); + }); + it('should throw an error when not a listen syscall', () => { + const PORT = 1234; + const ERROR = getSystemError('EADDRINUSE', 'not listening', `Sorry, what was that? I wasn't listening.`); + const onServerError = utils.onServerError(PORT, logTo.output, exitRoute.exit); + let errorThrown = null; + try { + onServerError(ERROR); + } catch (err) { + errorThrown = err; + } + expect(errorThrown).to.equal(ERROR); + expect(logTo.logs).to.have.a.lengthOf(0); + expect(exitRoute.calls).to.have.a.lengthOf(0); + }); + it('should rethrow an unhandled error', () => { + const PORT = 1234; + const ERROR = getSystemError('PANIC_STATIONS', 'listen'); + const onServerError = utils.onServerError(PORT, logTo.output, exitRoute.exit); + let errorThrown = null; + try { + onServerError(ERROR); + } catch (err) { + errorThrown = err; + } + expect(errorThrown).to.equal(ERROR); + expect(logTo.logs).to.have.a.lengthOf(0); + expect(exitRoute.calls).to.have.a.lengthOf(0); + }); + + }); + + describe('onListening', () => { + let logTo; + beforeEach(() => { + logTo = { + logs: [], + output: (str) => { + logTo.logs.push(str); + } + }; + }); + it('should handle a string address', () => { + const ADDRESS = 'http://test.address'; + const SERVER = { + address: () => { + return ADDRESS; + } + }; + utils.onListening(SERVER, logTo.output)(); + expect(logTo.logs).to.have.a.lengthOf(1) + .and.to.contain(`Listening on pipe ${ADDRESS}`); + }); + it('should handle an address with a port', () => { + const PORT = 6251; + const SERVER = { + address: () => { + return { port: PORT }; + } + }; + utils.onListening(SERVER, logTo.output)(); + expect(logTo.logs).to.have.a.lengthOf(1) + .and.to.contain(`Listening on port ${PORT}`); + }); + }); + +}); \ No newline at end of file From 23abf9dde93cc51697e8ac1270e6f8db929031b5 Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Fri, 28 May 2021 12:32:28 +0100 Subject: [PATCH 30/58] Create ttl-score-generator.spec.js Added some unit tests for `service/ttl-score-generator`. --- .../app/service/ttl-score-generator.spec.js | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 test/spec/app/service/ttl-score-generator.spec.js diff --git a/test/spec/app/service/ttl-score-generator.spec.js b/test/spec/app/service/ttl-score-generator.spec.js new file mode 100644 index 00000000..5fbff506 --- /dev/null +++ b/test/spec/app/service/ttl-score-generator.spec.js @@ -0,0 +1,40 @@ + +const expect = require('chai').expect; +const config = require('config'); +const sandbox = require("sinon").createSandbox(); +const ttlScoreGenerator = require('../../../../app/service/ttl-score-generator'); + +describe('service.ttl-score-generator', () => { + + afterEach(() => { + sandbox.restore(); + }); + + describe('getScore', () => { + it('should handle an activity TTL', () => { + const TTL = '12'; + const NOW = 55; + sandbox.stub(Date, 'now').returns(NOW); + sandbox.stub(config, 'get').returns(TTL); + const score = ttlScoreGenerator.getScore(); + expect(score).to.equal(12055); // (TTL * 1000) + NOW + }); + it('should handle a numeric TTL', () => { + const TTL = 13; + const NOW = 55; + sandbox.stub(Date, 'now').returns(NOW); + sandbox.stub(config, 'get').returns(TTL); + const score = ttlScoreGenerator.getScore(); + expect(score).to.equal(13055); // (TTL * 1000) + NOW + }); + it('should handle a null TTL', () => { + const TTL = null; + const NOW = 55; + sandbox.stub(Date, 'now').returns(NOW); + sandbox.stub(config, 'get').returns(TTL); + const score = ttlScoreGenerator.getScore(); + expect(score).to.equal(55); // null TTL => 0 + }); + }); + +}); From 9f5a85f00d8d049b7c90b62b4fbdd71900568d0a Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Fri, 28 May 2021 12:56:11 +0100 Subject: [PATCH 31/58] Update store-cleanup-job.js Adjusted the store cleanup so it now also clears out cases that have arrived via the socket interface, which has a different prefix - `c:`. --- app/job/store-cleanup-job.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/app/job/store-cleanup-job.js b/app/job/store-cleanup-job.js index 0da3179e..88198325 100644 --- a/app/job/store-cleanup-job.js +++ b/app/job/store-cleanup-job.js @@ -6,10 +6,10 @@ const redis = require('../redis/redis-client'); const { logPipelineFailures } = redis; const REDIS_ACTIVITY_KEY_PREFIX = config.get('redis.keyPrefix'); -const scanExistingCasesKeys = (f) => { +const scanExistingCasesKeys = (f, prefix) => { const stream = redis.scanStream({ // only returns keys following the pattern - match: `${REDIS_ACTIVITY_KEY_PREFIX}case:*`, + match: `${REDIS_ACTIVITY_KEY_PREFIX}${prefix}:*`, // returns approximately 100 elements per call count: 100, }); @@ -26,7 +26,7 @@ const scanExistingCasesKeys = (f) => { }); }; -const getCasesWithActivities = (f) => scanExistingCasesKeys(f); +const getCasesWithActivities = (f, prefix) => scanExistingCasesKeys(f, prefix); const cleanupActivitiesCommand = (key) => ['zremrangebyscore', key, '-inf', Date.now()]; @@ -38,6 +38,11 @@ const pipeline = (cases) => { const storeCleanup = () => { debug('store cleanup starting...'); + cleanCasesWithPrefix('case'); // Cases via RESTful interface. + cleanCasesWithPrefix('c'); // Cases via socket interface. +}; + +const cleanCasesWithPrefix = (prefix) => { getCasesWithActivities((cases) => { // scan returns the prefixed keys. Remove them since the redis client will add it back const casesWithoutPrefix = cases.map((k) => k.replace(REDIS_ACTIVITY_KEY_PREFIX, '')); @@ -48,7 +53,7 @@ const storeCleanup = () => { .catch((err) => { debug('Error in getCasesWithActivities', err.message); }); - }); + }, prefix); }; exports.start = (crontab) => { From 753d42fd2b72e2585aa1613ea44aec6691682a7d Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Fri, 28 May 2021 13:11:48 +0100 Subject: [PATCH 32/58] Update store-cleanup-job.js Lint error. --- app/job/store-cleanup-job.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/job/store-cleanup-job.js b/app/job/store-cleanup-job.js index 88198325..279a2bb8 100644 --- a/app/job/store-cleanup-job.js +++ b/app/job/store-cleanup-job.js @@ -36,12 +36,6 @@ const pipeline = (cases) => { return redis.pipeline(commands); }; -const storeCleanup = () => { - debug('store cleanup starting...'); - cleanCasesWithPrefix('case'); // Cases via RESTful interface. - cleanCasesWithPrefix('c'); // Cases via socket interface. -}; - const cleanCasesWithPrefix = (prefix) => { getCasesWithActivities((cases) => { // scan returns the prefixed keys. Remove them since the redis client will add it back @@ -56,6 +50,12 @@ const cleanCasesWithPrefix = (prefix) => { }, prefix); }; +const storeCleanup = () => { + debug('store cleanup starting...'); + cleanCasesWithPrefix('case'); // Cases via RESTful interface. + cleanCasesWithPrefix('c'); // Cases via socket interface. +}; + exports.start = (crontab) => { const isValid = cron.validate(crontab); if (!isValid) throw new Error(`invalid crontab: ${crontab}`); From 52d039566bcca4848b05777349522dc67e66af56 Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Fri, 28 May 2021 16:00:28 +0100 Subject: [PATCH 33/58] Update server.js Added a description for how the socket server behaves in parallel. --- server.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/server.js b/server.js index b83cf80b..9e9e676e 100755 --- a/server.js +++ b/server.js @@ -23,6 +23,16 @@ const server = http.createServer(app); /** * Create the socket server. + * + * This runs on the same server, in parallel to the RESTful interface. At the present + * time, interoperability is turned off to keep them isolated but, with a couple of + * tweaks, it can easily be enabled: + * + * * Adjust the prefixes in socket/redis/keys.js to be the same as the RESTful ones. + * * This will immediately allow the RESTful interface to see what people on sockets + * are viewing/editing. + * * Add redis.publish(...) calls in service/activity-service.js. + * * To notify those on sockets when someone is viewing or editing a case. */ const redis = require('./app/redis/redis-client'); require('./app/socket')(server, redis); From af4a7d8f6dcd12936b7c7698f9350dc2c6e76a54 Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Wed, 9 Jun 2021 13:06:03 +0100 Subject: [PATCH 34/58] CVE issue CVE fix for `ws`, which is part of `engine.io`. --- package.json | 3 ++- yarn.lock | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 1877586b..ee064e61 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "minimist": "^1.2.3", "y18n": "^4.0.1", "hosted-git-info": "^3.0.8", - "underscore": "^1.12.1" + "underscore": "^1.12.1", + "ws": "^7.4.6" } } diff --git a/yarn.lock b/yarn.lock index 2026dc8f..d242cd8c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3602,10 +3602,10 @@ write@1.0.3: dependencies: mkdirp "^0.5.1" -ws@~7.4.2: - version "7.4.5" - resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.5.tgz#a484dd851e9beb6fdb420027e3885e8ce48986c1" - integrity sha512-xzyu3hFvomRfXKH8vOFMU3OguG6oOvhXMo3xsGy3xWExqaM2dxBbVxuD99O7m3ZUFMvvscsZDqxfgMaRr/Nr1g== +ws@^7.4.6, ws@~7.4.2: + version "7.4.6" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.6.tgz#5654ca8ecdeee47c33a9a4bf6d28e2be2980377c" + integrity sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A== y18n@^4.0.0, y18n@^4.0.1: version "4.0.3" From a8a177efa7ae8102eee4e81f4b7962244bb51f9c Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Thu, 10 Jun 2021 09:50:21 +0100 Subject: [PATCH 35/58] Socket TTLs Adjusted the TTLs on the socket as they should last a lot longer than the ones made via RESTful calls. --- app/socket/service/activity-service.js | 4 ++-- config/default.yaml | 3 +++ test/spec/app/socket/service/activity-service.spec.js | 8 ++++---- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/app/socket/service/activity-service.js b/app/socket/service/activity-service.js index fe64578f..d19a6dfc 100644 --- a/app/socket/service/activity-service.js +++ b/app/socket/service/activity-service.js @@ -3,8 +3,8 @@ const utils = require('../utils'); module.exports = (config, redis) => { const ttl = { - user: config.get('redis.userDetailsTtlSec'), - activity: config.get('redis.activityTtlSec') + user: config.get('redis.socket.userDetailsTtlSec'), + activity: config.get('redis.socket.activityTtlSec') }; const notifyChange = (caseId) => { diff --git a/config/default.yaml b/config/default.yaml index cf3b21f2..30f811b1 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -12,6 +12,9 @@ redis: keyPrefix: "activity:" activityTtlSec: 5 userDetailsTtlSec: 2 + socket: + activityTtlSec: 600 + userDetailsTtlSec: 3600 cache: user_info_enabled: true user_info_ttl: 600 diff --git a/test/spec/app/socket/service/activity-service.spec.js b/test/spec/app/socket/service/activity-service.spec.js index 46939bbf..8e8ef18e 100644 --- a/test/spec/app/socket/service/activity-service.spec.js +++ b/test/spec/app/socket/service/activity-service.spec.js @@ -14,8 +14,8 @@ describe('socket.service.activity-service', () => { const MOCK_CONFIG = { getCalls: [], keys: { - 'redis.activityTtlSec': TTL_ACTIVITY, - 'redis.userDetailsTtlSec': TTL_USER + 'redis.socket.activityTtlSec': TTL_ACTIVITY, + 'redis.socket.userDetailsTtlSec': TTL_USER }, get: (key) => { MOCK_CONFIG.getCalls.push(key); @@ -105,9 +105,9 @@ describe('socket.service.activity-service', () => { }); it('should have appropriately initialised from the config', () => { - expect(MOCK_CONFIG.getCalls).to.include('redis.activityTtlSec'); + expect(MOCK_CONFIG.getCalls).to.include('redis.socket.activityTtlSec'); expect(activityService.ttl.activity).to.equal(TTL_ACTIVITY); - expect(MOCK_CONFIG.getCalls).to.include('redis.userDetailsTtlSec'); + expect(MOCK_CONFIG.getCalls).to.include('redis.socket.userDetailsTtlSec'); expect(activityService.ttl.user).to.equal(TTL_USER); }); From 5558216272f8d9d6e8eea1060835b1a9135f8582 Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Thu, 10 Jun 2021 11:41:20 +0100 Subject: [PATCH 36/58] console.logs Temporarily log new and closed connections to the console as nothing is coming through in the logs when going via debug. --- app/socket/router/index.js | 7 +++++-- app/socket/utils/other.js | 15 +++++++++------ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/app/socket/router/index.js b/app/socket/router/index.js index 3a97e346..a452838c 100644 --- a/app/socket/router/index.js +++ b/app/socket/router/index.js @@ -25,6 +25,7 @@ const router = { return [...connections]; }, init: (io, iorouter, handlers) => { + const start = Date.now(); // Set up routes for each type of message. iorouter.on('register', (socket, ctx, next) => { utils.log(socket, ctx.request.user, 'register'); @@ -53,13 +54,15 @@ const router = { // On client connection, attach the router and track the socket. io.on('connection', (socket) => { router.addConnection(socket); - utils.log(socket, '', `connected (${router.getConnections().length} total)`); + const ts = (Date.now() - start).toString(10).padStart(10, '0'); + utils.log(socket, '', `connected (${router.getConnections().length} total)`, console.log, ts); socket.use((packet, next) => { iorouter.attach(socket, packet, next); }); // When the socket disconnects, do an appropriate teardown. socket.on('disconnect', () => { - utils.log(socket, '', `disconnected (${router.getConnections().length - 1} total)`); + const ts = (Date.now() - start).toString(10).padStart(10, '0'); + utils.log(socket, '', `disconnected (${router.getConnections().length - 1} total)`, console.log, ts); handlers.removeSocketActivity(socket.id); router.removeUser(socket.id); router.removeConnection(socket); diff --git a/app/socket/utils/other.js b/app/socket/utils/other.js index 9524b777..82ca055f 100644 --- a/app/socket/utils/other.js +++ b/app/socket/utils/other.js @@ -17,9 +17,10 @@ const other = { } return userIds; }, - log: (socket, payload, group, logTo) => { + log: (socket, payload, group, logTo, ts) => { const outputTo = logTo || debug; - let text = `${new Date().toISOString()} | ${socket.id} | ${group}`; + const now = ts || new Date().toISOString(); + let text = `${now} | ${socket.id} | ${group}`; if (typeof payload === 'string') { if (payload) { text = `${text} => ${payload}`; @@ -43,8 +44,10 @@ const other = { if (!obj) { return {}; } - const nameParts = obj.name.split(' '); - const givenName = nameParts.shift(); + const name = obj.name || `${obj.forename} ${obj.surname}`; + const nameParts = name.split(' '); + const givenName = obj.forename || nameParts.shift(); + const familyName = obj.surname || nameParts.join(' '); return { sub: `${givenName}.${nameParts.join('-')}@mailinator.com`, uid: obj.id, @@ -53,9 +56,9 @@ const other = { 'caseworker-employment-leeds', 'caseworker' ], - name: obj.name, + name: name, given_name: givenName, - family_name: nameParts.join(' ') + family_name: familyName }; }, toUserString: (user) => { From 8b5881f50df06eb91ff78da199053a5af9c43bdf Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Mon, 14 Jun 2021 11:22:20 +0100 Subject: [PATCH 37/58] Update custom-environment-variables.yaml Socket TTLs in custom environment variables. --- config/custom-environment-variables.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config/custom-environment-variables.yaml b/config/custom-environment-variables.yaml index 631efb5a..c5a44250 100644 --- a/config/custom-environment-variables.yaml +++ b/config/custom-environment-variables.yaml @@ -13,6 +13,9 @@ redis: keyPrefix: REDIS_KEY_PREFIX activityTtlSec: REDIS_ACTIVITY_TTL userDetailsTtlSec: REDIS_USER_DETAILS_TTL + socket: + activityTtlSec: REDIS_SOCKET_ACTIVITY_TTL + userDetailsTtlSec: REDIS_SOCKET_USER_DETAILS_TTL cache: user_info_enabled: CACHE_USER_INFO_ENABLED user_info_ttl: CACHE_USER_INFO_TTL From d37a5ee7ecf81933efea4b0368fc0b4b3ce21e7d Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Tue, 15 Jun 2021 10:49:33 +0100 Subject: [PATCH 38/58] No need for 'register' event Removed the 'register' event as the user details are now being passed as part of the initial socket setup. --- app/socket/index.js | 5 ++-- app/socket/router/index.js | 15 ++++-------- test/spec/app/socket/router/index.spec.js | 30 +++++++++-------------- 3 files changed, 19 insertions(+), 31 deletions(-) diff --git a/app/socket/index.js b/app/socket/index.js index 34300519..c62eb394 100644 --- a/app/socket/index.js +++ b/app/socket/index.js @@ -24,8 +24,9 @@ module.exports = (server, redis) => { allowEIO3: true, cors: { origin: '*', - methods: ['GET', 'POST'] - } + methods: ['GET', 'POST'], + credentials: true + }, }); const handlers = Handlers(activityService, socketServer); const watcher = redis.duplicate(); diff --git a/app/socket/router/index.js b/app/socket/router/index.js index a452838c..7b3f9e06 100644 --- a/app/socket/router/index.js +++ b/app/socket/router/index.js @@ -25,13 +25,7 @@ const router = { return [...connections]; }, init: (io, iorouter, handlers) => { - const start = Date.now(); // Set up routes for each type of message. - iorouter.on('register', (socket, ctx, next) => { - utils.log(socket, ctx.request.user, 'register'); - router.addUser(socket.id, ctx.request.user); - next(); - }); iorouter.on('view', (socket, ctx, next) => { const user = router.getUser(socket.id); utils.log(socket, `${ctx.request.caseId} (${user.name})`, 'view'); @@ -54,15 +48,16 @@ const router = { // On client connection, attach the router and track the socket. io.on('connection', (socket) => { router.addConnection(socket); - const ts = (Date.now() - start).toString(10).padStart(10, '0'); - utils.log(socket, '', `connected (${router.getConnections().length} total)`, console.log, ts); + router.addUser(socket.id, socket.handshake.query.user); + utils.log(socket, '', `connected (${router.getConnections().length} total)`); + utils.log(socket, '', `connected (${router.getConnections().length} total)`, console.log, Date.now()); socket.use((packet, next) => { iorouter.attach(socket, packet, next); }); // When the socket disconnects, do an appropriate teardown. socket.on('disconnect', () => { - const ts = (Date.now() - start).toString(10).padStart(10, '0'); - utils.log(socket, '', `disconnected (${router.getConnections().length - 1} total)`, console.log, ts); + utils.log(socket, '', `disconnected (${router.getConnections().length - 1} total)`); + utils.log(socket, '', `disconnected (${router.getConnections().length - 1} total)`, console.log, Date.now()); handlers.removeSocketActivity(socket.id); router.removeUser(socket.id); router.removeConnection(socket); diff --git a/test/spec/app/socket/router/index.spec.js b/test/spec/app/socket/router/index.spec.js index 6938edf4..65ff3269 100644 --- a/test/spec/app/socket/router/index.spec.js +++ b/test/spec/app/socket/router/index.spec.js @@ -47,6 +47,11 @@ describe('socket.router', () => { }; const MOCK_SOCKET = { id: 'socket-id', + handshake: { + query: { + user: { id: 'a', name: 'Bob Smith' } + } + }, rooms: ['socket-id'], events: {}, messages: [], @@ -101,7 +106,7 @@ describe('socket.router', () => { }); }); it('should have set up the appropriate events on the io router', () => { - const EXPECTED_EVENTS = ['register', 'view', 'edit', 'watch']; + const EXPECTED_EVENTS = ['view', 'edit', 'watch']; EXPECTED_EVENTS.forEach((event) => { expect(MOCK_IO_ROUTER.events[event]).to.be.a('function'); }); @@ -109,11 +114,6 @@ describe('socket.router', () => { }); describe('iorouter', () => { - const MOCK_CONTEXT_REGISTER = { - request: { - user: { id: 'a', name: 'Bob Smith' } - } - }; const MOCK_CONTEXT = { request: { caseId: '1234567890', @@ -121,11 +121,11 @@ describe('socket.router', () => { } }; beforeEach(() => { - // We need to register before each call as it sets up the user. - MOCK_IO_ROUTER.dispatch('register', MOCK_SOCKET, MOCK_CONTEXT_REGISTER, () => {}); + // Dispatch the connection each time. + MOCK_SOCKET_SERVER.dispatch('connection', MOCK_SOCKET); }); it('should appropriately handle registering a user', () => { - expect(router.getUser(MOCK_SOCKET.id)).to.deep.equal(MOCK_CONTEXT_REGISTER.request.user); + expect(router.getUser(MOCK_SOCKET.id)).to.deep.equal(MOCK_SOCKET.handshake.query.user); }); it('should appropriately handle viewing a case', () => { const ACTIVITY = 'view'; @@ -138,7 +138,7 @@ describe('socket.router', () => { expect(MOCK_HANDLERS.calls[0].params.socket).to.equal(MOCK_SOCKET); expect(MOCK_HANDLERS.calls[0].params.caseId).to.equal(MOCK_CONTEXT.request.caseId); // Note that the MOCK_CONTEXT doesn't include the user, which means we had to get it from elsewhere. - expect(MOCK_HANDLERS.calls[0].params.user).to.deep.equal(MOCK_CONTEXT_REGISTER.request.user); + expect(MOCK_HANDLERS.calls[0].params.user).to.deep.equal(MOCK_SOCKET.handshake.query.user); expect(MOCK_HANDLERS.calls[0].params.activity).to.equal(ACTIVITY); }); expect(nextCalled).to.be.true; @@ -154,7 +154,7 @@ describe('socket.router', () => { expect(MOCK_HANDLERS.calls[0].params.socket).to.equal(MOCK_SOCKET); expect(MOCK_HANDLERS.calls[0].params.caseId).to.equal(MOCK_CONTEXT.request.caseId); // Note that the MOCK_CONTEXT doesn't include the user, which means we had to get it from elsewhere. - expect(MOCK_HANDLERS.calls[0].params.user).to.deep.equal(MOCK_CONTEXT_REGISTER.request.user); + expect(MOCK_HANDLERS.calls[0].params.user).to.deep.equal(MOCK_SOCKET.handshake.query.user); expect(MOCK_HANDLERS.calls[0].params.activity).to.equal(ACTIVITY); }); expect(nextCalled).to.be.true; @@ -175,15 +175,7 @@ describe('socket.router', () => { }); describe('io', () => { - const MOCK_CONTEXT_REGISTER = { - request: { - user: { id: 'a', name: 'Bob Smith' } - } - }; beforeEach(() => { - // We need to register before each call as it sets up the user. - MOCK_IO_ROUTER.dispatch('register', MOCK_SOCKET, MOCK_CONTEXT_REGISTER, () => {}); - // Dispatch the connection each time. MOCK_SOCKET_SERVER.dispatch('connection', MOCK_SOCKET); }); From 67acec7db934f999564c4253ebb4b0ecd9733ac6 Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Thu, 15 Jul 2021 11:36:20 +0100 Subject: [PATCH 39/58] Update activity-service.js Return the user id when indicating viewers and editors so that the client can filter out _its_ user, rather than doing the filtering on the server and sending different messages to each client. --- app/socket/service/activity-service.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/socket/service/activity-service.js b/app/socket/service/activity-service.js index d19a6dfc..5ecf6c03 100644 --- a/app/socket/service/activity-service.js +++ b/app/socket/service/activity-service.js @@ -30,7 +30,7 @@ module.exports = (config, redis) => { return details.reduce((obj, item) => { if (item[1]) { const user = JSON.parse(item[1]); - obj[user.id] = { forename: user.forename, surname: user.surname }; + obj[user.id] = { id: user.id, forename: user.forename, surname: user.surname }; } return obj; }, {}); From 166cdfe84f44849b35cf083a9af0ccec598de842 Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Thu, 15 Jul 2021 11:37:17 +0100 Subject: [PATCH 40/58] Update index.js The user must arrive as a string because it's being sent in the query string (for some reason). Therefore, this needs to be parsed as JSON before storing it. --- app/socket/router/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/socket/router/index.js b/app/socket/router/index.js index 7b3f9e06..10598cb2 100644 --- a/app/socket/router/index.js +++ b/app/socket/router/index.js @@ -48,7 +48,7 @@ const router = { // On client connection, attach the router and track the socket. io.on('connection', (socket) => { router.addConnection(socket); - router.addUser(socket.id, socket.handshake.query.user); + router.addUser(socket.id, JSON.parse(socket.handshake.query.user)); utils.log(socket, '', `connected (${router.getConnections().length} total)`); utils.log(socket, '', `connected (${router.getConnections().length} total)`, console.log, Date.now()); socket.use((packet, next) => { From 3b20e7e3f2790d1adc99d0f5e0fd28d63183ba05 Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Thu, 15 Jul 2021 11:45:32 +0100 Subject: [PATCH 41/58] Update index.spec.js The user is now arriving as a JSON string and getting parsed. Updated the tests to reflect that. --- test/spec/app/socket/router/index.spec.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/spec/app/socket/router/index.spec.js b/test/spec/app/socket/router/index.spec.js index 65ff3269..e3cd9475 100644 --- a/test/spec/app/socket/router/index.spec.js +++ b/test/spec/app/socket/router/index.spec.js @@ -49,7 +49,7 @@ describe('socket.router', () => { id: 'socket-id', handshake: { query: { - user: { id: 'a', name: 'Bob Smith' } + user: JSON.stringify({ id: 'a', name: 'Bob Smith' }) } }, rooms: ['socket-id'], @@ -120,12 +120,13 @@ describe('socket.router', () => { caseIds: ['2345678901', '3456789012', '4567890123'] } }; + const MOCK_JSON_USER = JSON.parse(MOCK_SOCKET.handshake.query.user); beforeEach(() => { // Dispatch the connection each time. MOCK_SOCKET_SERVER.dispatch('connection', MOCK_SOCKET); }); it('should appropriately handle registering a user', () => { - expect(router.getUser(MOCK_SOCKET.id)).to.deep.equal(MOCK_SOCKET.handshake.query.user); + expect(router.getUser(MOCK_SOCKET.id)).to.deep.equal(MOCK_JSON_USER); }); it('should appropriately handle viewing a case', () => { const ACTIVITY = 'view'; @@ -138,7 +139,7 @@ describe('socket.router', () => { expect(MOCK_HANDLERS.calls[0].params.socket).to.equal(MOCK_SOCKET); expect(MOCK_HANDLERS.calls[0].params.caseId).to.equal(MOCK_CONTEXT.request.caseId); // Note that the MOCK_CONTEXT doesn't include the user, which means we had to get it from elsewhere. - expect(MOCK_HANDLERS.calls[0].params.user).to.deep.equal(MOCK_SOCKET.handshake.query.user); + expect(MOCK_HANDLERS.calls[0].params.user).to.deep.equal(MOCK_JSON_USER); expect(MOCK_HANDLERS.calls[0].params.activity).to.equal(ACTIVITY); }); expect(nextCalled).to.be.true; @@ -154,7 +155,7 @@ describe('socket.router', () => { expect(MOCK_HANDLERS.calls[0].params.socket).to.equal(MOCK_SOCKET); expect(MOCK_HANDLERS.calls[0].params.caseId).to.equal(MOCK_CONTEXT.request.caseId); // Note that the MOCK_CONTEXT doesn't include the user, which means we had to get it from elsewhere. - expect(MOCK_HANDLERS.calls[0].params.user).to.deep.equal(MOCK_SOCKET.handshake.query.user); + expect(MOCK_HANDLERS.calls[0].params.user).to.deep.equal(MOCK_JSON_USER); expect(MOCK_HANDLERS.calls[0].params.activity).to.equal(ACTIVITY); }); expect(nextCalled).to.be.true; From b2e40f11af99fef7543b3c67fafb00b7ff416d27 Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Mon, 19 Jul 2021 16:55:09 +0100 Subject: [PATCH 42/58] Version Added some release notes and upped the version to a pre-release one with a minor increment. --- RELEASE-NOTES.md | 4 ++++ package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 RELEASE-NOTES.md diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md new file mode 100644 index 00000000..5b590aca --- /dev/null +++ b/RELEASE-NOTES.md @@ -0,0 +1,4 @@ +## RELEASE NOTES + +### Version 0.1.0-socket-alpha +**EUI-2976** Socket-based Activity Tracking. diff --git a/package.json b/package.json index ee064e61..fc488f36 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ccd-case-activity-api", - "version": "0.0.2", + "version": "0.1.0-socket-alpha", "private": true, "scripts": { "setup": "cross-env NODE_PATH=. node --version", From 745a45ff9eac3b9e0f0cd8f05079f955e7ffda87 Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Mon, 19 Jul 2021 17:03:43 +0100 Subject: [PATCH 43/58] Update values.preview.template.yaml Adding the MC PR to the whitelist. Let's see if this helps. --- charts/ccd-case-activity-api/values.preview.template.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/charts/ccd-case-activity-api/values.preview.template.yaml b/charts/ccd-case-activity-api/values.preview.template.yaml index af6b90c6..f8ee11bf 100644 --- a/charts/ccd-case-activity-api/values.preview.template.yaml +++ b/charts/ccd-case-activity-api/values.preview.template.yaml @@ -6,6 +6,7 @@ nodejs: REDIS_PORT: 6379 REDIS_PASSWORD: fake-password REDIS_SSL_ENABLED: "" + CORS_ORIGIN_WHITELIST: https://www-ccd.{{ .Values.global.environment }}.platform.hmcts.net, https://xui-webapp-pr-1172.service.core-compute-preview.internal keyVaults: redis: From 19b59f0aec9e69e3cfbc36469273c1299dd151a1 Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Mon, 19 Jul 2021 17:24:27 +0100 Subject: [PATCH 44/58] Update values.preview.template.yaml No space between the whitelist items. --- charts/ccd-case-activity-api/values.preview.template.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/charts/ccd-case-activity-api/values.preview.template.yaml b/charts/ccd-case-activity-api/values.preview.template.yaml index f8ee11bf..7c2319a5 100644 --- a/charts/ccd-case-activity-api/values.preview.template.yaml +++ b/charts/ccd-case-activity-api/values.preview.template.yaml @@ -6,7 +6,7 @@ nodejs: REDIS_PORT: 6379 REDIS_PASSWORD: fake-password REDIS_SSL_ENABLED: "" - CORS_ORIGIN_WHITELIST: https://www-ccd.{{ .Values.global.environment }}.platform.hmcts.net, https://xui-webapp-pr-1172.service.core-compute-preview.internal + CORS_ORIGIN_WHITELIST: https://www-ccd.{{ .Values.global.environment }}.platform.hmcts.net,https://xui-webapp-pr-1172.service.core-compute-preview.internal keyVaults: redis: From 32c725d8a0f744eafc921c4494edfbacaa1e5c10 Mon Sep 17 00:00:00 2001 From: Paul Graham Date: Wed, 28 Jul 2021 09:37:37 +0100 Subject: [PATCH 45/58] User name for logging Setting the user name property when it doesn't have one. This is purely a logging convenience. --- app/socket/router/index.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/socket/router/index.js b/app/socket/router/index.js index 10598cb2..c6cbb2e0 100644 --- a/app/socket/router/index.js +++ b/app/socket/router/index.js @@ -4,6 +4,9 @@ const users = {}; const connections = []; const router = { addUser: (socketId, user) => { + if (user && !user.name) { + user.name = `${user.forename} ${user.surname}`; + } users[socketId] = user; }, removeUser: (socketId) => { From 15350c336507414fcb981a720d177bffdcc4543e Mon Sep 17 00:00:00 2001 From: Paul Howes Date: Fri, 14 Jan 2022 11:15:16 +0000 Subject: [PATCH 46/58] Update packages --- yarn.lock | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/yarn.lock b/yarn.lock index a7ace39c..4e9e28c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -484,6 +484,22 @@ body-parser@1.19.0: raw-body "2.4.0" type-is "~1.6.17" +body-parser@^1.18.2: + version "1.19.1" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.1.tgz#1499abbaa9274af3ecc9f6f10396c995943e31d4" + integrity sha512-8ljfQi5eBk8EJfECMrgqNGWPEY5jWP+1IzkzkGdFFEwFQZZyaZ21UqdaHktgiMlH0xLHqIFtE/u2OYE5dOtViA== + dependencies: + bytes "3.1.1" + content-type "~1.0.4" + debug "2.6.9" + depd "~1.1.2" + http-errors "1.8.1" + iconv-lite "0.4.24" + on-finished "~2.3.0" + qs "6.9.6" + raw-body "2.4.2" + type-is "~1.6.18" + brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -509,6 +525,11 @@ bytes@3.1.0: resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6" integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg== +bytes@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.1.tgz#3f018291cb4cbad9accb6e6970bca9c8889e879a" + integrity sha512-dWe4nWO/ruEOY7HkUJ5gFt1DCFV9zPRoJr8pV0/ASQermOZjtq8jMjOprC0Kd10GLN+l7xaUPvxzJFWtxGu8Fg== + caching-transform@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/caching-transform/-/caching-transform-4.0.0.tgz#00d297a4206d71e2163c39eaffa8157ac0651f0f" @@ -1591,6 +1612,17 @@ http-errors@1.7.2: statuses ">= 1.5.0 < 2" toidentifier "1.0.0" +http-errors@1.8.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.8.1.tgz#7c3f28577cbc8a207388455dbd62295ed07bd68c" + integrity sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g== + dependencies: + depd "~1.1.2" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses ">= 1.5.0 < 2" + toidentifier "1.0.1" + http-errors@~1.6.1: version "1.6.2" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.2.tgz#0a002cc85707192a7e7946ceedc11155f60ec736" @@ -2771,6 +2803,11 @@ qs@6.7.0: resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ== +qs@6.9.6: + version "6.9.6" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.9.6.tgz#26ed3c8243a431b2924aca84cc90471f35d5a0ee" + integrity sha512-TIRk4aqYLNoJUbd+g2lEdz5kLWIuTMRagAXxl78Q0RiVjAOugHmeKNGdd3cwo/ktpf9aL9epCfFqWDEKysUlLQ== + qs@^6.5.1, qs@^6.9.1: version "6.9.1" resolved "https://registry.yarnpkg.com/qs/-/qs-6.9.1.tgz#20082c65cb78223635ab1a9eaca8875a29bf8ec9" @@ -2791,6 +2828,16 @@ raw-body@2.4.0: iconv-lite "0.4.24" unpipe "1.0.0" +raw-body@2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.2.tgz#baf3e9c21eebced59dd6533ac872b71f7b61cb32" + integrity sha512-RPMAFUJP19WIet/99ngh6Iv8fzAbqum4Li7AD6DtGaW2RpMB/11xDoalPiJMTbu6I3hkbMVkATvZrqb9EEqeeQ== + dependencies: + bytes "3.1.1" + http-errors "1.8.1" + iconv-lite "0.4.24" + unpipe "1.0.0" + read-pkg-up@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-2.0.0.tgz#6b72a8048984e0c41e79510fd5e9fa99b3b549be" @@ -3018,6 +3065,11 @@ setprototypeof@1.1.1: resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683" integrity sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw== +setprototypeof@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + shebang-command@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" @@ -3405,6 +3457,11 @@ toidentifier@1.0.0: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553" integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw== +toidentifier@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" + integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== + tslib@^1.9.0: version "1.11.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.11.1.tgz#eb15d128827fbee2841549e171f45ed338ac7e35" @@ -3447,10 +3504,10 @@ typedarray-to-buffer@^3.1.5: dependencies: is-typedarray "^1.0.0" -underscore@^1.12.1, underscore@~1.9.1: - version "1.13.1" - resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.1.tgz#0c1c6bd2df54b6b69f2314066d65b6cde6fcf9d1" - integrity sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g== +underscore@^1.13.1, underscore@~1.9.1: + version "1.13.2" + resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.2.tgz#276cea1e8b9722a8dbed0100a407dda572125881" + integrity sha512-ekY1NhRzq0B08g4bGuX4wd2jZx5GnKz6mKSqFL4nqBlfyMGiG10gDFhDTMEfYmDL6Jy0FUIZp7wiRB+0BP7J2g== unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0" From 4bf8c813c91b7e183ddf959fb55eb8f2ef1323ed Mon Sep 17 00:00:00 2001 From: Paul Howes Date: Fri, 14 Jan 2022 11:39:29 +0000 Subject: [PATCH 47/58] Yarn audit --- yarn-audit-known-issues | 1 + 1 file changed, 1 insertion(+) diff --git a/yarn-audit-known-issues b/yarn-audit-known-issues index e69de29b..a9cf81bd 100644 --- a/yarn-audit-known-issues +++ b/yarn-audit-known-issues @@ -0,0 +1 @@ +{"type":"auditAdvisory","data":{"resolution":{"id":1006871,"path":"socket.io>engine.io","dev":false,"optional":false,"bundled":false},"advisory":{"findings":[{"version":"5.1.1","paths":["socket.io>engine.io"]}],"metadata":null,"vulnerable_versions":">=5.0.0 <5.2.1","module_name":"engine.io","severity":"high","github_advisory_id":"GHSA-273r-mgr4-v34f","cves":["CVE-2022-21676"],"access":"public","patched_versions":">=5.2.1","updated":"2022-01-13T16:10:29.000Z","recommendation":"Upgrade to version 5.2.1 or later","cwe":"CWE-754","found_by":null,"deleted":null,"id":1006871,"references":"- https://github.com/socketio/engine.io/security/advisories/GHSA-273r-mgr4-v34f\n- https://nvd.nist.gov/vuln/detail/CVE-2022-21676\n- https://github.com/socketio/engine.io/commit/66f889fc1d966bf5bfa0de1939069153643874ab\n- https://github.com/socketio/engine.io/commit/a70800d7e96da32f6e6622804ef659ebc58659db\n- https://github.com/socketio/engine.io/commit/c0e194d44933bd83bf9a4b126fca68ba7bf5098c\n- https://github.com/socketio/engine.io/releases/tag/4.1.2\n- https://github.com/socketio/engine.io/releases/tag/5.2.1\n- https://github.com/socketio/engine.io/releases/tag/6.1.1\n- https://github.com/advisories/GHSA-273r-mgr4-v34f","created":"2022-01-13T17:00:45.667Z","reported_by":null,"title":"Uncaught Exception in engine.io","npm_advisory_id":null,"overview":"### Impact\n\nA specially crafted HTTP request can trigger an uncaught exception on the Engine.IO server, thus killing the Node.js process.\n\n> RangeError: Invalid WebSocket frame: RSV2 and RSV3 must be clear\n> at Receiver.getInfo (/.../node_modules/ws/lib/receiver.js:176:14)\n> at Receiver.startLoop (/.../node_modules/ws/lib/receiver.js:136:22)\n> at Receiver._write (/.../node_modules/ws/lib/receiver.js:83:10)\n> at writeOrBuffer (internal/streams/writable.js:358:12)\n\nThis impacts all the users of the [`engine.io`](https://www.npmjs.com/package/engine.io) package starting from version `4.0.0`, including those who uses depending packages like [`socket.io`](https://www.npmjs.com/package/socket.io).\n\n### Patches\n\nA fix has been released for each major branch:\n\n| Version range | Fixed version |\n| --- | --- |\n| `engine.io@4.x.x` | `4.1.2` |\n| `engine.io@5.x.x` | `5.2.1` |\n| `engine.io@6.x.x` | `6.1.1` |\n\nPrevious versions (`< 4.0.0`) are not impacted.\n\nFor `socket.io` users:\n\n| Version range | `engine.io` version | Needs minor update? |\n| --- | --- | --- |\n| `socket.io@4.4.x` | `~6.1.0` | -\n| `socket.io@4.3.x` | `~6.0.0` | Please upgrade to `socket.io@4.4.x`\n| `socket.io@4.2.x` | `~5.2.0` | -\n| `socket.io@4.1.x` | `~5.1.1` | Please upgrade to `socket.io@4.4.x`\n| `socket.io@4.0.x` | `~5.0.0` | Please upgrade to `socket.io@4.4.x`\n| `socket.io@3.1.x` | `~4.1.0` | -\n| `socket.io@3.0.x` | `~4.0.0` | Please upgrade to `socket.io@3.1.x` or `socket.io@4.4.x` (see [here](https://socket.io/docs/v4/migrating-from-3-x-to-4-0/))\n\nIn most cases, running `npm audit fix` should be sufficient. You can also use `npm update engine.io --depth=9999`.\n\n### Workarounds\n\nThere is no known workaround except upgrading to a safe version.\n\n### For more information\n\nIf you have any questions or comments about this advisory:\n\n* Open an issue in [`engine.io`](https://github.com/socketio/engine.io)\n\nThanks to Marcus Wejderot from Mevisio for the responsible disclosure.\n","url":"https://github.com/advisories/GHSA-273r-mgr4-v34f"}}} From f48e9721a4464fe796c1229616d36f1d8688c07b Mon Sep 17 00:00:00 2001 From: Paul Howes Date: Tue, 25 Jan 2022 06:19:01 +0000 Subject: [PATCH 48/58] Known issues temporary workaround --- yarn-audit-known-issues | 1 + 1 file changed, 1 insertion(+) diff --git a/yarn-audit-known-issues b/yarn-audit-known-issues index a9cf81bd..8e1af06e 100644 --- a/yarn-audit-known-issues +++ b/yarn-audit-known-issues @@ -1 +1,2 @@ {"type":"auditAdvisory","data":{"resolution":{"id":1006871,"path":"socket.io>engine.io","dev":false,"optional":false,"bundled":false},"advisory":{"findings":[{"version":"5.1.1","paths":["socket.io>engine.io"]}],"metadata":null,"vulnerable_versions":">=5.0.0 <5.2.1","module_name":"engine.io","severity":"high","github_advisory_id":"GHSA-273r-mgr4-v34f","cves":["CVE-2022-21676"],"access":"public","patched_versions":">=5.2.1","updated":"2022-01-13T16:10:29.000Z","recommendation":"Upgrade to version 5.2.1 or later","cwe":"CWE-754","found_by":null,"deleted":null,"id":1006871,"references":"- https://github.com/socketio/engine.io/security/advisories/GHSA-273r-mgr4-v34f\n- https://nvd.nist.gov/vuln/detail/CVE-2022-21676\n- https://github.com/socketio/engine.io/commit/66f889fc1d966bf5bfa0de1939069153643874ab\n- https://github.com/socketio/engine.io/commit/a70800d7e96da32f6e6622804ef659ebc58659db\n- https://github.com/socketio/engine.io/commit/c0e194d44933bd83bf9a4b126fca68ba7bf5098c\n- https://github.com/socketio/engine.io/releases/tag/4.1.2\n- https://github.com/socketio/engine.io/releases/tag/5.2.1\n- https://github.com/socketio/engine.io/releases/tag/6.1.1\n- https://github.com/advisories/GHSA-273r-mgr4-v34f","created":"2022-01-13T17:00:45.667Z","reported_by":null,"title":"Uncaught Exception in engine.io","npm_advisory_id":null,"overview":"### Impact\n\nA specially crafted HTTP request can trigger an uncaught exception on the Engine.IO server, thus killing the Node.js process.\n\n> RangeError: Invalid WebSocket frame: RSV2 and RSV3 must be clear\n> at Receiver.getInfo (/.../node_modules/ws/lib/receiver.js:176:14)\n> at Receiver.startLoop (/.../node_modules/ws/lib/receiver.js:136:22)\n> at Receiver._write (/.../node_modules/ws/lib/receiver.js:83:10)\n> at writeOrBuffer (internal/streams/writable.js:358:12)\n\nThis impacts all the users of the [`engine.io`](https://www.npmjs.com/package/engine.io) package starting from version `4.0.0`, including those who uses depending packages like [`socket.io`](https://www.npmjs.com/package/socket.io).\n\n### Patches\n\nA fix has been released for each major branch:\n\n| Version range | Fixed version |\n| --- | --- |\n| `engine.io@4.x.x` | `4.1.2` |\n| `engine.io@5.x.x` | `5.2.1` |\n| `engine.io@6.x.x` | `6.1.1` |\n\nPrevious versions (`< 4.0.0`) are not impacted.\n\nFor `socket.io` users:\n\n| Version range | `engine.io` version | Needs minor update? |\n| --- | --- | --- |\n| `socket.io@4.4.x` | `~6.1.0` | -\n| `socket.io@4.3.x` | `~6.0.0` | Please upgrade to `socket.io@4.4.x`\n| `socket.io@4.2.x` | `~5.2.0` | -\n| `socket.io@4.1.x` | `~5.1.1` | Please upgrade to `socket.io@4.4.x`\n| `socket.io@4.0.x` | `~5.0.0` | Please upgrade to `socket.io@4.4.x`\n| `socket.io@3.1.x` | `~4.1.0` | -\n| `socket.io@3.0.x` | `~4.0.0` | Please upgrade to `socket.io@3.1.x` or `socket.io@4.4.x` (see [here](https://socket.io/docs/v4/migrating-from-3-x-to-4-0/))\n\nIn most cases, running `npm audit fix` should be sufficient. You can also use `npm update engine.io --depth=9999`.\n\n### Workarounds\n\nThere is no known workaround except upgrading to a safe version.\n\n### For more information\n\nIf you have any questions or comments about this advisory:\n\n* Open an issue in [`engine.io`](https://github.com/socketio/engine.io)\n\nThanks to Marcus Wejderot from Mevisio for the responsible disclosure.\n","url":"https://github.com/advisories/GHSA-273r-mgr4-v34f"}}} +{"type":"auditAdvisory","data":{"resolution":{"id":1006899,"path":"node-fetch","dev":false,"optional":false,"bundled":false},"advisory":{"findings":[{"version":"2.6.1","paths":["node-fetch"]}],"metadata":null,"vulnerable_versions":"<2.6.7","module_name":"node-fetch","severity":"high","github_advisory_id":"GHSA-r683-j2x4-v87g","cves":["CVE-2022-0235"],"access":"public","patched_versions":">=2.6.7","updated":"2022-01-23T01:52:43.000Z","recommendation":"Upgrade to version 2.6.7 or later","cwe":"CWE-173","found_by":null,"deleted":null,"id":1006899,"references":"- https://nvd.nist.gov/vuln/detail/CVE-2022-0235\n- https://github.com/node-fetch/node-fetch/commit/36e47e8a6406185921e4985dcbeff140d73eaa10\n- https://huntr.dev/bounties/d26ab655-38d6-48b3-be15-f9ad6b6ae6f7\n- https://github.com/node-fetch/node-fetch/pull/1453\n- https://github.com/advisories/GHSA-r683-j2x4-v87g","created":"2022-01-23T02:00:40.828Z","reported_by":null,"title":"node-fetch is vulnerable to Exposure of Sensitive Information to an Unauthorized Actor","npm_advisory_id":null,"overview":"node-fetch is vulnerable to Exposure of Sensitive Information to an Unauthorized Actor","url":"https://github.com/advisories/GHSA-r683-j2x4-v87g"}}} From 0da814d07bd4db537afcb053e8a1bea8464a2bb2 Mon Sep 17 00:00:00 2001 From: Phillip Whitaker Date: Fri, 7 Oct 2022 14:59:52 +0100 Subject: [PATCH 49/58] CVE test --- yarn-audit-known-issues | 1 - 1 file changed, 1 deletion(-) diff --git a/yarn-audit-known-issues b/yarn-audit-known-issues index 6cf0f2f6..e69de29b 100644 --- a/yarn-audit-known-issues +++ b/yarn-audit-known-issues @@ -1 +0,0 @@ -{"type":"auditAdvisory","data":{"resolution":{"id":1070492,"path":"socket.io>engine.io","dev":false,"optional":false,"bundled":false},"advisory":{"findings":[{"version":"5.1.1","paths":["socket.io>engine.io"]}],"metadata":null,"vulnerable_versions":">=5.0.0 <5.2.1","module_name":"engine.io","severity":"high","github_advisory_id":"GHSA-273r-mgr4-v34f","cves":["CVE-2022-21676"],"access":"public","patched_versions":">=5.2.1","cvss":{"score":7.5,"vectorString":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"},"updated":"2022-06-15T18:39:17.000Z","recommendation":"Upgrade to version 5.2.1 or later","cwe":["CWE-754"],"found_by":null,"deleted":null,"id":1070492,"references":"- https://github.com/socketio/engine.io/security/advisories/GHSA-273r-mgr4-v34f\n- https://nvd.nist.gov/vuln/detail/CVE-2022-21676\n- https://github.com/socketio/engine.io/commit/66f889fc1d966bf5bfa0de1939069153643874ab\n- https://github.com/socketio/engine.io/commit/a70800d7e96da32f6e6622804ef659ebc58659db\n- https://github.com/socketio/engine.io/commit/c0e194d44933bd83bf9a4b126fca68ba7bf5098c\n- https://github.com/socketio/engine.io/releases/tag/4.1.2\n- https://github.com/socketio/engine.io/releases/tag/5.2.1\n- https://github.com/socketio/engine.io/releases/tag/6.1.1\n- https://security.netapp.com/advisory/ntap-20220209-0002/\n- https://github.com/advisories/GHSA-273r-mgr4-v34f","created":"2022-01-13T16:14:17.000Z","reported_by":null,"title":"Uncaught Exception in engine.io","npm_advisory_id":null,"overview":"### Impact\n\nA specially crafted HTTP request can trigger an uncaught exception on the Engine.IO server, thus killing the Node.js process.\n\n> RangeError: Invalid WebSocket frame: RSV2 and RSV3 must be clear\n> at Receiver.getInfo (/.../node_modules/ws/lib/receiver.js:176:14)\n> at Receiver.startLoop (/.../node_modules/ws/lib/receiver.js:136:22)\n> at Receiver._write (/.../node_modules/ws/lib/receiver.js:83:10)\n> at writeOrBuffer (internal/streams/writable.js:358:12)\n\nThis impacts all the users of the [`engine.io`](https://www.npmjs.com/package/engine.io) package starting from version `4.0.0`, including those who uses depending packages like [`socket.io`](https://www.npmjs.com/package/socket.io).\n\n### Patches\n\nA fix has been released for each major branch:\n\n| Version range | Fixed version |\n| --- | --- |\n| `engine.io@4.x.x` | `4.1.2` |\n| `engine.io@5.x.x` | `5.2.1` |\n| `engine.io@6.x.x` | `6.1.1` |\n\nPrevious versions (`< 4.0.0`) are not impacted.\n\nFor `socket.io` users:\n\n| Version range | `engine.io` version | Needs minor update? |\n| --- | --- | --- |\n| `socket.io@4.4.x` | `~6.1.0` | -\n| `socket.io@4.3.x` | `~6.0.0` | Please upgrade to `socket.io@4.4.x`\n| `socket.io@4.2.x` | `~5.2.0` | -\n| `socket.io@4.1.x` | `~5.1.1` | Please upgrade to `socket.io@4.4.x`\n| `socket.io@4.0.x` | `~5.0.0` | Please upgrade to `socket.io@4.4.x`\n| `socket.io@3.1.x` | `~4.1.0` | -\n| `socket.io@3.0.x` | `~4.0.0` | Please upgrade to `socket.io@3.1.x` or `socket.io@4.4.x` (see [here](https://socket.io/docs/v4/migrating-from-3-x-to-4-0/))\n\nIn most cases, running `npm audit fix` should be sufficient. You can also use `npm update engine.io --depth=9999`.\n\n### Workarounds\n\nThere is no known workaround except upgrading to a safe version.\n\n### For more information\n\nIf you have any questions or comments about this advisory:\n\n* Open an issue in [`engine.io`](https://github.com/socketio/engine.io)\n\nThanks to Marcus Wejderot from Mevisio for the responsible disclosure.\n","url":"https://github.com/advisories/GHSA-273r-mgr4-v34f"}}} From bf49b0c288b65a496cf1c95ec3ef149e61b85b64 Mon Sep 17 00:00:00 2001 From: Phillip Whitaker Date: Fri, 7 Oct 2022 15:07:18 +0100 Subject: [PATCH 50/58] Updated CVE audit known issues file --- yarn-audit-known-issues | 1 + 1 file changed, 1 insertion(+) diff --git a/yarn-audit-known-issues b/yarn-audit-known-issues index e69de29b..6cf0f2f6 100644 --- a/yarn-audit-known-issues +++ b/yarn-audit-known-issues @@ -0,0 +1 @@ +{"type":"auditAdvisory","data":{"resolution":{"id":1070492,"path":"socket.io>engine.io","dev":false,"optional":false,"bundled":false},"advisory":{"findings":[{"version":"5.1.1","paths":["socket.io>engine.io"]}],"metadata":null,"vulnerable_versions":">=5.0.0 <5.2.1","module_name":"engine.io","severity":"high","github_advisory_id":"GHSA-273r-mgr4-v34f","cves":["CVE-2022-21676"],"access":"public","patched_versions":">=5.2.1","cvss":{"score":7.5,"vectorString":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"},"updated":"2022-06-15T18:39:17.000Z","recommendation":"Upgrade to version 5.2.1 or later","cwe":["CWE-754"],"found_by":null,"deleted":null,"id":1070492,"references":"- https://github.com/socketio/engine.io/security/advisories/GHSA-273r-mgr4-v34f\n- https://nvd.nist.gov/vuln/detail/CVE-2022-21676\n- https://github.com/socketio/engine.io/commit/66f889fc1d966bf5bfa0de1939069153643874ab\n- https://github.com/socketio/engine.io/commit/a70800d7e96da32f6e6622804ef659ebc58659db\n- https://github.com/socketio/engine.io/commit/c0e194d44933bd83bf9a4b126fca68ba7bf5098c\n- https://github.com/socketio/engine.io/releases/tag/4.1.2\n- https://github.com/socketio/engine.io/releases/tag/5.2.1\n- https://github.com/socketio/engine.io/releases/tag/6.1.1\n- https://security.netapp.com/advisory/ntap-20220209-0002/\n- https://github.com/advisories/GHSA-273r-mgr4-v34f","created":"2022-01-13T16:14:17.000Z","reported_by":null,"title":"Uncaught Exception in engine.io","npm_advisory_id":null,"overview":"### Impact\n\nA specially crafted HTTP request can trigger an uncaught exception on the Engine.IO server, thus killing the Node.js process.\n\n> RangeError: Invalid WebSocket frame: RSV2 and RSV3 must be clear\n> at Receiver.getInfo (/.../node_modules/ws/lib/receiver.js:176:14)\n> at Receiver.startLoop (/.../node_modules/ws/lib/receiver.js:136:22)\n> at Receiver._write (/.../node_modules/ws/lib/receiver.js:83:10)\n> at writeOrBuffer (internal/streams/writable.js:358:12)\n\nThis impacts all the users of the [`engine.io`](https://www.npmjs.com/package/engine.io) package starting from version `4.0.0`, including those who uses depending packages like [`socket.io`](https://www.npmjs.com/package/socket.io).\n\n### Patches\n\nA fix has been released for each major branch:\n\n| Version range | Fixed version |\n| --- | --- |\n| `engine.io@4.x.x` | `4.1.2` |\n| `engine.io@5.x.x` | `5.2.1` |\n| `engine.io@6.x.x` | `6.1.1` |\n\nPrevious versions (`< 4.0.0`) are not impacted.\n\nFor `socket.io` users:\n\n| Version range | `engine.io` version | Needs minor update? |\n| --- | --- | --- |\n| `socket.io@4.4.x` | `~6.1.0` | -\n| `socket.io@4.3.x` | `~6.0.0` | Please upgrade to `socket.io@4.4.x`\n| `socket.io@4.2.x` | `~5.2.0` | -\n| `socket.io@4.1.x` | `~5.1.1` | Please upgrade to `socket.io@4.4.x`\n| `socket.io@4.0.x` | `~5.0.0` | Please upgrade to `socket.io@4.4.x`\n| `socket.io@3.1.x` | `~4.1.0` | -\n| `socket.io@3.0.x` | `~4.0.0` | Please upgrade to `socket.io@3.1.x` or `socket.io@4.4.x` (see [here](https://socket.io/docs/v4/migrating-from-3-x-to-4-0/))\n\nIn most cases, running `npm audit fix` should be sufficient. You can also use `npm update engine.io --depth=9999`.\n\n### Workarounds\n\nThere is no known workaround except upgrading to a safe version.\n\n### For more information\n\nIf you have any questions or comments about this advisory:\n\n* Open an issue in [`engine.io`](https://github.com/socketio/engine.io)\n\nThanks to Marcus Wejderot from Mevisio for the responsible disclosure.\n","url":"https://github.com/advisories/GHSA-273r-mgr4-v34f"}}} From 799da22d8f1a1ff058b66a7744fd8a179d4f52cc Mon Sep 17 00:00:00 2001 From: LucaDelBuonoHMCTS Date: Fri, 10 Mar 2023 14:27:09 +0000 Subject: [PATCH 51/58] EUI-2976: Fix linting --- app/socket/redis/pub-sub.js | 2 +- app/socket/router/index.js | 2 ++ app/socket/service/handlers.js | 3 ++- app/socket/utils/index.js | 12 ++++++++---- app/socket/utils/other.js | 2 +- app/socket/utils/store.js | 14 +++++++------- 6 files changed, 21 insertions(+), 14 deletions(-) diff --git a/app/socket/redis/pub-sub.js b/app/socket/redis/pub-sub.js index 7cb287ad..271e0e2b 100644 --- a/app/socket/redis/pub-sub.js +++ b/app/socket/redis/pub-sub.js @@ -2,7 +2,7 @@ const keys = require('./keys'); module.exports = () => { return { - init: (watcher, caseNotifier) => { + init: (watcher, caseNotifier) => { if (watcher && typeof caseNotifier === 'function') { watcher.psubscribe(`${keys.prefixes.case}:*`); watcher.on('pmessage', (_, room) => { diff --git a/app/socket/router/index.js b/app/socket/router/index.js index c6cbb2e0..609eeb74 100644 --- a/app/socket/router/index.js +++ b/app/socket/router/index.js @@ -53,6 +53,7 @@ const router = { router.addConnection(socket); router.addUser(socket.id, JSON.parse(socket.handshake.query.user)); utils.log(socket, '', `connected (${router.getConnections().length} total)`); + // eslint-disable-next-line no-console utils.log(socket, '', `connected (${router.getConnections().length} total)`, console.log, Date.now()); socket.use((packet, next) => { iorouter.attach(socket, packet, next); @@ -60,6 +61,7 @@ const router = { // When the socket disconnects, do an appropriate teardown. socket.on('disconnect', () => { utils.log(socket, '', `disconnected (${router.getConnections().length - 1} total)`); + // eslint-disable-next-line no-console utils.log(socket, '', `disconnected (${router.getConnections().length - 1} total)`, console.log, Date.now()); handlers.removeSocketActivity(socket.id); router.removeUser(socket.id); diff --git a/app/socket/service/handlers.js b/app/socket/service/handlers.js index 09c5348f..a41fda84 100644 --- a/app/socket/service/handlers.js +++ b/app/socket/service/handlers.js @@ -19,7 +19,8 @@ module.exports = (activityService, socketServer) => { /** * Notify all users in a case room about any change to activity on a case. - * @param {*} caseId The id of the case that has activity and that people should be notified about. + * @param {*} caseId The id of the case that has activity and that people should be + * notified about. */ async function notify(caseId) { const cs = await activityService.getActivityForCases([caseId]); diff --git a/app/socket/utils/index.js b/app/socket/utils/index.js index c54179ce..5f906ecb 100644 --- a/app/socket/utils/index.js +++ b/app/socket/utils/index.js @@ -1,9 +1,13 @@ const other = require('./other'); +const get = require('./get'); +const remove = require('./remove'); +const store = require('./store'); +const watch = require('./watch'); module.exports = { ...other, - get: require('./get'), - remove: require('./remove'), - store: require('./store'), - watch: require('./watch') + get, + remove, + store, + watch }; diff --git a/app/socket/utils/other.js b/app/socket/utils/other.js index 82ca055f..8a9bcaa2 100644 --- a/app/socket/utils/other.js +++ b/app/socket/utils/other.js @@ -56,7 +56,7 @@ const other = { 'caseworker-employment-leeds', 'caseworker' ], - name: name, + name, given_name: givenName, family_name: familyName }; diff --git a/app/socket/utils/store.js b/app/socket/utils/store.js index f139bdfe..c7300894 100644 --- a/app/socket/utils/store.js +++ b/app/socket/utils/store.js @@ -1,6 +1,6 @@ const debug = require('debug')('ccd-case-activity-api:socket-utils-store'); const redisActivityKeys = require('../redis/keys'); -const toUserString = require('./other').toUserString; +const { toUserString } = require('./other'); const store = { userActivity: (activityKey, userId, score) => { @@ -9,15 +9,15 @@ const store = { }, userDetails: (user, ttl) => { const key = redisActivityKeys.user(user.uid); - const store = toUserString(user); - debug(`about to store details "${key}" for user "${user.uid}": ${store}`); - return ['set', key, store, 'EX', ttl]; + const userString = toUserString(user); + debug(`about to store details "${key}" for user "${user.uid}": ${userString}`); + return ['set', key, userString, 'EX', ttl]; }, socketActivity: (socketId, activityKey, caseId, userId, ttl) => { const key = redisActivityKeys.socket(socketId); - const store = JSON.stringify({ activityKey, caseId, userId }); - debug(`about to store activity "${key}" for socket "${socketId}": ${store}`); - return ['set', key, store, 'EX', ttl]; + const userString = JSON.stringify({ activityKey, caseId, userId }); + debug(`about to store activity "${key}" for socket "${socketId}": ${userString}`); + return ['set', key, userString, 'EX', ttl]; } }; From 61ba69ffa2f2b59c9f369bf66647f8294bd08a88 Mon Sep 17 00:00:00 2001 From: LucaDelBuonoHMCTS Date: Tue, 14 Mar 2023 14:39:07 +0000 Subject: [PATCH 52/58] EUI-2976: Force rebuild --- app/health.js | 1 - 1 file changed, 1 deletion(-) diff --git a/app/health.js b/app/health.js index 1eeb39d3..4bc2ede6 100644 --- a/app/health.js +++ b/app/health.js @@ -14,5 +14,4 @@ const activityHealth = healthcheck.configure({ .catch(() => healthcheck.down())), }, }); - module.exports = activityHealth; From b4f084534f221c4ef90ff200ff7dfc97bd75254e Mon Sep 17 00:00:00 2001 From: Dan Lysiak <50049163+danlysiak@users.noreply.github.com> Date: Wed, 15 Mar 2023 17:50:25 +0000 Subject: [PATCH 53/58] Resolve preview issue --- charts/ccd-case-activity-api/values.preview.template.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/charts/ccd-case-activity-api/values.preview.template.yaml b/charts/ccd-case-activity-api/values.preview.template.yaml index 7c2319a5..e82a6d81 100644 --- a/charts/ccd-case-activity-api/values.preview.template.yaml +++ b/charts/ccd-case-activity-api/values.preview.template.yaml @@ -11,3 +11,7 @@ nodejs: redis: enabled: true + architecture: standalone + auth: + enabled: true + password: "fake-password" From 2c4f0949e0105eb6dcf949f5ac8b7043252b641b Mon Sep 17 00:00:00 2001 From: LucaDelBuonoHMCTS Date: Mon, 20 Mar 2023 10:43:26 +0000 Subject: [PATCH 54/58] EUI-2976: Whitelist everything --- charts/ccd-case-activity-api/values.preview.template.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/charts/ccd-case-activity-api/values.preview.template.yaml b/charts/ccd-case-activity-api/values.preview.template.yaml index e82a6d81..23efe2bd 100644 --- a/charts/ccd-case-activity-api/values.preview.template.yaml +++ b/charts/ccd-case-activity-api/values.preview.template.yaml @@ -6,7 +6,7 @@ nodejs: REDIS_PORT: 6379 REDIS_PASSWORD: fake-password REDIS_SSL_ENABLED: "" - CORS_ORIGIN_WHITELIST: https://www-ccd.{{ .Values.global.environment }}.platform.hmcts.net,https://xui-webapp-pr-1172.service.core-compute-preview.internal + CORS_ORIGIN_WHITELIST: * keyVaults: redis: From 685355f22c261e8353ccea95a45f93e859c43c10 Mon Sep 17 00:00:00 2001 From: LucaDelBuonoHMCTS Date: Mon, 20 Mar 2023 10:58:29 +0000 Subject: [PATCH 55/58] EUI-2976: Whitelist everything --- charts/ccd-case-activity-api/values.preview.template.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/charts/ccd-case-activity-api/values.preview.template.yaml b/charts/ccd-case-activity-api/values.preview.template.yaml index 23efe2bd..555198fe 100644 --- a/charts/ccd-case-activity-api/values.preview.template.yaml +++ b/charts/ccd-case-activity-api/values.preview.template.yaml @@ -6,7 +6,7 @@ nodejs: REDIS_PORT: 6379 REDIS_PASSWORD: fake-password REDIS_SSL_ENABLED: "" - CORS_ORIGIN_WHITELIST: * + CORS_ORIGIN_WHITELIST: https://*:*,http://*:* keyVaults: redis: From 8ee832d5fa59de51c10783e555773bda8d5fe5ac Mon Sep 17 00:00:00 2001 From: LucaDelBuonoHMCTS Date: Mon, 20 Mar 2023 11:08:57 +0000 Subject: [PATCH 56/58] EUI-2976: Whitelist everything --- charts/ccd-case-activity-api/values.preview.template.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/charts/ccd-case-activity-api/values.preview.template.yaml b/charts/ccd-case-activity-api/values.preview.template.yaml index 555198fe..487af142 100644 --- a/charts/ccd-case-activity-api/values.preview.template.yaml +++ b/charts/ccd-case-activity-api/values.preview.template.yaml @@ -6,7 +6,7 @@ nodejs: REDIS_PORT: 6379 REDIS_PASSWORD: fake-password REDIS_SSL_ENABLED: "" - CORS_ORIGIN_WHITELIST: https://*:*,http://*:* + CORS_ORIGIN_WHITELIST: "*" keyVaults: redis: From ea379e7fdd356df9411ad3a711022e93d8864bd8 Mon Sep 17 00:00:00 2001 From: LucaDelBuonoHMCTS Date: Tue, 21 Mar 2023 10:17:18 +0000 Subject: [PATCH 57/58] EUI-2976: Update node-fetch --- package.json | 2 +- yarn-audit-known-issues | 1 - yarn.lock | 4 ++-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index a8371e11..6b4a5c5d 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "nocache": "^2.1.0", "node-cache": "^5.1.0", "node-cron": "^1.2.1", - "node-fetch": "^2.6.1", + "node-fetch": "^2.6.7", "socket.io": "^4.1.2", "socket.io-router-middleware": "^1.1.2" }, diff --git a/yarn-audit-known-issues b/yarn-audit-known-issues index 6cf0f2f6..e69de29b 100644 --- a/yarn-audit-known-issues +++ b/yarn-audit-known-issues @@ -1 +0,0 @@ -{"type":"auditAdvisory","data":{"resolution":{"id":1070492,"path":"socket.io>engine.io","dev":false,"optional":false,"bundled":false},"advisory":{"findings":[{"version":"5.1.1","paths":["socket.io>engine.io"]}],"metadata":null,"vulnerable_versions":">=5.0.0 <5.2.1","module_name":"engine.io","severity":"high","github_advisory_id":"GHSA-273r-mgr4-v34f","cves":["CVE-2022-21676"],"access":"public","patched_versions":">=5.2.1","cvss":{"score":7.5,"vectorString":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"},"updated":"2022-06-15T18:39:17.000Z","recommendation":"Upgrade to version 5.2.1 or later","cwe":["CWE-754"],"found_by":null,"deleted":null,"id":1070492,"references":"- https://github.com/socketio/engine.io/security/advisories/GHSA-273r-mgr4-v34f\n- https://nvd.nist.gov/vuln/detail/CVE-2022-21676\n- https://github.com/socketio/engine.io/commit/66f889fc1d966bf5bfa0de1939069153643874ab\n- https://github.com/socketio/engine.io/commit/a70800d7e96da32f6e6622804ef659ebc58659db\n- https://github.com/socketio/engine.io/commit/c0e194d44933bd83bf9a4b126fca68ba7bf5098c\n- https://github.com/socketio/engine.io/releases/tag/4.1.2\n- https://github.com/socketio/engine.io/releases/tag/5.2.1\n- https://github.com/socketio/engine.io/releases/tag/6.1.1\n- https://security.netapp.com/advisory/ntap-20220209-0002/\n- https://github.com/advisories/GHSA-273r-mgr4-v34f","created":"2022-01-13T16:14:17.000Z","reported_by":null,"title":"Uncaught Exception in engine.io","npm_advisory_id":null,"overview":"### Impact\n\nA specially crafted HTTP request can trigger an uncaught exception on the Engine.IO server, thus killing the Node.js process.\n\n> RangeError: Invalid WebSocket frame: RSV2 and RSV3 must be clear\n> at Receiver.getInfo (/.../node_modules/ws/lib/receiver.js:176:14)\n> at Receiver.startLoop (/.../node_modules/ws/lib/receiver.js:136:22)\n> at Receiver._write (/.../node_modules/ws/lib/receiver.js:83:10)\n> at writeOrBuffer (internal/streams/writable.js:358:12)\n\nThis impacts all the users of the [`engine.io`](https://www.npmjs.com/package/engine.io) package starting from version `4.0.0`, including those who uses depending packages like [`socket.io`](https://www.npmjs.com/package/socket.io).\n\n### Patches\n\nA fix has been released for each major branch:\n\n| Version range | Fixed version |\n| --- | --- |\n| `engine.io@4.x.x` | `4.1.2` |\n| `engine.io@5.x.x` | `5.2.1` |\n| `engine.io@6.x.x` | `6.1.1` |\n\nPrevious versions (`< 4.0.0`) are not impacted.\n\nFor `socket.io` users:\n\n| Version range | `engine.io` version | Needs minor update? |\n| --- | --- | --- |\n| `socket.io@4.4.x` | `~6.1.0` | -\n| `socket.io@4.3.x` | `~6.0.0` | Please upgrade to `socket.io@4.4.x`\n| `socket.io@4.2.x` | `~5.2.0` | -\n| `socket.io@4.1.x` | `~5.1.1` | Please upgrade to `socket.io@4.4.x`\n| `socket.io@4.0.x` | `~5.0.0` | Please upgrade to `socket.io@4.4.x`\n| `socket.io@3.1.x` | `~4.1.0` | -\n| `socket.io@3.0.x` | `~4.0.0` | Please upgrade to `socket.io@3.1.x` or `socket.io@4.4.x` (see [here](https://socket.io/docs/v4/migrating-from-3-x-to-4-0/))\n\nIn most cases, running `npm audit fix` should be sufficient. You can also use `npm update engine.io --depth=9999`.\n\n### Workarounds\n\nThere is no known workaround except upgrading to a safe version.\n\n### For more information\n\nIf you have any questions or comments about this advisory:\n\n* Open an issue in [`engine.io`](https://github.com/socketio/engine.io)\n\nThanks to Marcus Wejderot from Mevisio for the responsible disclosure.\n","url":"https://github.com/advisories/GHSA-273r-mgr4-v34f"}}} diff --git a/yarn.lock b/yarn.lock index 49e81ea7..b611ff02 100644 --- a/yarn.lock +++ b/yarn.lock @@ -848,7 +848,7 @@ __metadata: nock: ^12.0.3 node-cache: ^5.1.0 node-cron: ^1.2.1 - node-fetch: ^2.6.1 + node-fetch: ^2.6.7 node-mocks-http: ^1.7.0 nyc: ^15.0.0 proxyquire: ^2.1.3 @@ -3631,7 +3631,7 @@ __metadata: languageName: node linkType: hard -"node-fetch@npm:^2.6.1": +"node-fetch@npm:^2.6.7": version: 2.6.9 resolution: "node-fetch@npm:2.6.9" dependencies: From 0a79c13c32e884e73d3a65f89a2d89103328d7a4 Mon Sep 17 00:00:00 2001 From: LucaDelBuonoHMCTS Date: Thu, 30 Mar 2023 10:00:02 +0100 Subject: [PATCH 58/58] EUI-2976: Reduce ttl to 30 secs for testing --- config/default.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/default.yaml b/config/default.yaml index 30f811b1..a4251053 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -13,7 +13,7 @@ redis: activityTtlSec: 5 userDetailsTtlSec: 2 socket: - activityTtlSec: 600 + activityTtlSec: 30 userDetailsTtlSec: 3600 cache: user_info_enabled: true