diff --git a/docs/contribute/workflows/device-editor.md b/docs/contribute/workflows/device-editor.md new file mode 100644 index 0000000000..c790e6533d --- /dev/null +++ b/docs/contribute/workflows/device-editor.md @@ -0,0 +1,29 @@ +--- +navTitle: Device Editor +--- + +# Enabling the device editor + +```mermaid +sequenceDiagram + User->>FrontEnd: Clicks 'open editor' against device + FrontEnd->>+Forge: PUT /api/v1/devices/:id/editor { tunnel: 'enable' } + Forge->Forge: Generates + Forge--)Device: Publishes command to establish connection with + Device--)Forge: WS Connect /api/v1/devices/:id/editor/comms/:token + + Forge->>-FrontEnd: Returns session identifier + FrontEnd->>FrontEnd: Opens /device//editor/ + FrontEnd-->+Forge: Sends requests to /device//editor/** + Forge--)+Device: Request proxied over WebSocket + Device-->>Editor: Performs request on local Node-RED + Editor-->>Device: Returns response + Device-->>-Forge: Streams response back + Forge-->>-FrontEnd: Streams response back + User->>FrontEnd: User navigates away + FrontEnd-->Forge: Node-RED WebSocket closes + Note over Forge: if no active WebSockets for this device + Forge--)Device: Close WebSocket +``` + + diff --git a/forge/ee/lib/deviceEditor/DeviceTunnelManager.js b/forge/ee/lib/deviceEditor/DeviceTunnelManager.js index ff1f337833..fb606efc32 100644 --- a/forge/ee/lib/deviceEditor/DeviceTunnelManager.js +++ b/forge/ee/lib/deviceEditor/DeviceTunnelManager.js @@ -10,8 +10,9 @@ /** * A DeviceTunnel object keeps track of connections to a device. * @typedef {Object} DeviceTunnel + * @property {numner} id - A unique identifier for this tunnel instance * @property {string} deviceId - Device ID - * @property {number} counter - Number of active connections + * @property {number} nextRequestId - Next available request id * @property {SocketStream} socket - Socket connection to device * @property {Array} requests - List of pending requests * @property {Object} forwardedWS - List of forwarded websocket connections @@ -46,6 +47,12 @@ class DeviceTunnelManager { /** @type {ForgeApplication} Forge application (Fastify app) */ this.app = app this.#tunnels = new Map() + + this.app.addHook('onClose', async (_) => { + Object.keys(this.#tunnels).forEach(deviceId => { + this.closeTunnel(deviceId) + }) + }) } /** @@ -58,7 +65,6 @@ class DeviceTunnelManager { * @param {String} token Token to use for tunnel * @see DeviceTunnelManager#initTunnel * @see DeviceTunnelManager#closeTunnel - * @see DeviceTunnelManager#removeTunnel */ newTunnel (deviceId, token) { const manager = this @@ -67,7 +73,7 @@ class DeviceTunnelManager { manager.closeTunnel(deviceId) // create a new tunnel object & add to list - manager.#tunnels.set(deviceId, newTunnel(deviceId, token)) + manager.#tunnels.set(deviceId, createNewTunnel(deviceId, token)) return !!manager.#getTunnel(deviceId) } @@ -93,9 +99,9 @@ class DeviceTunnelManager { getTunnelStatus (deviceId) { const exists = this.#tunnels.has(deviceId) if (!exists) { - return null + return { enabled: false } } - const url = this.getTunnelUrl(deviceId, true) + const url = this.getTunnelUrl(deviceId) const enabled = this.isEnabled(deviceId) const connected = this.isConnected(deviceId) return { url, enabled, connected } @@ -103,26 +109,23 @@ class DeviceTunnelManager { closeTunnel (deviceId) { const tunnel = this.#getTunnel(deviceId) - if (tunnel?.socket) { - tunnel.socket.close() - tunnel.socket.removeAllListeners() + if (tunnel) { + tunnel.socket?.close() + // Close all of the editor websockets that were using this tunnel + Object.keys(tunnel?.forwardedWS).forEach(reqId => { + const wsClient = tunnel.forwardedWS[reqId] + wsClient.close() + }) } - this.removeTunnel(deviceId) - } - - removeTunnel (deviceId) { if (this.#tunnels.has(deviceId)) { return this.#tunnels.delete(deviceId) } } - getTunnelUrl (deviceId, includeToken = false) { + getTunnelUrl (deviceId) { const tunnel = this.#getTunnel(deviceId) if (tunnel) { - if (includeToken) { - return `/api/v1/devices/${deviceId}/editor/proxy/?access_token=${tunnel.token}` - } - return `/api/v1/devices/${deviceId}/editor/proxy/` + return `/api/v1/devices/${deviceId}/editor/proxy/?access_token=${tunnel.token}` } return '' } @@ -152,15 +155,13 @@ class DeviceTunnelManager { return false } - // ensure tunnel is not already open + // Close any existing tunnel if (tunnel.socket) { tunnel.socket.close() - tunnel.socket.removeAllListeners() } - tunnel.socket = inboundDeviceConnection.socket - // handle messages from device + // Handle messages sent from the device tunnel.socket.on('message', msg => { const response = JSON.parse(msg.toString()) if (response.id === undefined) { @@ -178,9 +179,27 @@ class DeviceTunnelManager { reply.send() } } else if (response.ws) { - // Send message to device editor websocket const wsSocket = tunnel.forwardedWS[response.id] - wsSocket.send(response.body) + if (wsSocket) { + if (response.closed) { + // The runtime has closed this session's websocket on the device + // Pass that back to the editor so it knows something is up + if (wsSocket) { + wsSocket.close() + } + delete tunnel.forwardedWS[response.id] + } else { + // Send message to device editor websocket + wsSocket.send(response.body) + } + } else { + // This is a message for a editor we don't know about. + // This can happen with Device Agent <= 1.9.4 if multiple + // editors were opened in a single session and then one + // of them is closed. Older Agents don't know to disconnect + // their local comms link for the closed editor, so continue + // sending messages to everyone who was ever connected + } } else { // TODO: remove/change temp debug console.warn('device editor websocket message has no reply') @@ -188,12 +207,19 @@ class DeviceTunnelManager { }) tunnel.socket.on('close', () => { - manager.removeTunnel(deviceId) + // The ws connection from the device has closed. + delete tunnel.socket + + // Close all of the editor websockets + for (const [id, wsSocket] of Object.entries(tunnel.forwardedWS)) { + wsSocket.close() + delete tunnel.forwardedWS[id] + } }) /** @type {httpHandler} */ tunnel._handleHTTPGet = (request, reply) => { - const id = tunnel.counter++ + const id = tunnel.nextRequestId++ tunnel.requests[id] = reply tunnel.socket.send(JSON.stringify({ id, @@ -208,7 +234,7 @@ class DeviceTunnelManager { tunnel._handleHTTPGet(request, reply) return } - const requestId = tunnel.counter++ + const requestId = tunnel.nextRequestId++ tunnel.requests[requestId] = reply tunnel.socket.send(JSON.stringify({ id: requestId, @@ -220,7 +246,8 @@ class DeviceTunnelManager { } tunnel._handleWS = (connection, request) => { - const requestId = tunnel.counter++ + // A new editor websocket is connecting + const requestId = tunnel.nextRequestId++ tunnel.socket.send(JSON.stringify({ id: requestId, ws: true, @@ -230,20 +257,28 @@ class DeviceTunnelManager { const wsToDevice = connection.socket tunnel.forwardedWS[requestId] = wsToDevice - // forward messages from device to client wsToDevice.on('message', msg => { + // Forward messages sent by the editor down to the device + // console.log(`[${tunnel.id}] [${requestId}] E>R`, msg.toString()) tunnel.socket.send(JSON.stringify({ id: requestId, ws: true, body: msg.toString() })) }) - - connection.on('close', () => { - if (tunnel.forwardedWS[requestId]) { - tunnel.forwardedWS[requestId].close() + wsToDevice.on('close', msg => { + // The editor has closed its websocket. Send notification to the + // device so it can close its corresponing connection + // console.log(`[${tunnel.id}] [${requestId}] E>R closed`) + if (tunnel.forwardedWS[requestId] && tunnel.socket) { + // console.log(`[${tunnel.id}] [${requestId}] E>R closed - notifying the device`) + tunnel.socket.send(JSON.stringify({ + id: requestId, + ws: true, + closed: true + })) + delete tunnel.forwardedWS[requestId] } - delete tunnel.forwardedWS[requestId] }) } return true @@ -304,6 +339,7 @@ class DeviceTunnelManager { } } +let tunnelCounter = 0 /** * Create new tunnel * @param {String} deviceId Device ID @@ -311,12 +347,13 @@ class DeviceTunnelManager { * @returns {DeviceTunnel} * @memberof DeviceTunnelManager * @static - * @method newTunnel + * @method createNewTunnel */ -function newTunnel (deviceId, token) { +function createNewTunnel (deviceId, token) { const tunnel = { + id: ++tunnelCounter, deviceId, - counter: 0, + nextRequestId: 1, socket: null, requests: {}, forwardedWS: {}, diff --git a/forge/ee/routes/deviceEditor/index.js b/forge/ee/routes/deviceEditor/index.js index d2ba71077b..ca67b17837 100644 --- a/forge/ee/routes/deviceEditor/index.js +++ b/forge/ee/routes/deviceEditor/index.js @@ -28,6 +28,13 @@ module.exports = async function (app) { } if (request.session.User) { request.teamMembership = await request.session.User.getTeamMembership(request.device.Team.id) + // If the user isn't in the team, only give 404 error if this + // is not a 'allowAnonymous' route. This allows the proxy routes + // to return a redirect for this auth fail rather than an API error + if (!request.routeConfig.allowAnonymous && !request.teamMembership && !request.session.User.admin) { + reply.code(404).send({ code: 'not_found', error: 'Not Found' }) + return // eslint-disable-line no-useless-return + } } } catch (err) { reply.code(404).send({ code: 'not_found', error: 'Not Found' }) @@ -43,15 +50,37 @@ module.exports = async function (app) { * @memberof module:forge/routes/api/device */ app.put('/', { - preHandler: app.needsPermission('device:editor') + preHandler: app.needsPermission('device:editor'), + schema: { + params: { + type: 'object', + properties: { + deviceId: { type: 'string' } + } + }, + body: { + type: 'object', + properties: { + enabled: { type: 'boolean' } + }, + required: ['enabled'] + } + } }, async (request, reply) => { - const mode = request.body.tunnel || 'disable' + const mode = request.body.enabled const team = await app.db.models.Team.byId(request.device.TeamId) /** @type {DeviceTunnelManager} */ const tunnelManager = app.comms.devices.tunnelManager const deviceId = request.device.hashid const teamId = team.hashid - if (mode === 'enable') { + + const currentState = tunnelManager.getTunnelStatus(deviceId) + if (currentState.enabled === mode) { + reply.code(400).send({ code: 'invalid_request', error: 'Device Editor already ' + (mode ? 'enabled' : 'disabled') }) + return + } + + if (mode) { // Generate a random access token for editor, open a tunnel and start the editor const accessToken = await generateToken(16, `ffde_${deviceId}`) // prepare the tunnel but dont start it (the remote device will initiate the connection) @@ -80,13 +109,11 @@ module.exports = async function (app) { await app.auditLog.Team.team.device.remoteAccess.enabled(request.session.User, null, team, request.device) reply.send(tunnelStatus) } - } else if (mode === 'disable') { + } else if (!mode) { await app.comms.devices.disableEditor(teamId, deviceId) tunnelManager.closeTunnel(deviceId) await app.auditLog.Team.team.device.remoteAccess.disabled(request.session.User, null, team, request.device) reply.send({ enabled: false }) - } else { - reply.code(400).send({ code: 'invalid_request', error: 'Expected device editor tunnel mode option to be either "enabled" or "disabled"' }) } }) @@ -121,7 +148,8 @@ module.exports = async function (app) { }) /** - * Initiate inbound websocket connection from device + * End-point used by devices to create their websocket tunnel back to the + * platform * @name /api/v1/devices/:deviceId/editor/comms/:access_token */ app.get('/comms/:access_token', { @@ -134,7 +162,7 @@ module.exports = async function (app) { const token = request.params.access_token const tunnelManager = getTunnelManager() const tunnelInfo = tunnelManager.getTunnelStatus(deviceId) - if (tunnelInfo) { + if (tunnelInfo && tunnelInfo.enabled) { if (tunnelManager.verifyToken(deviceId, token)) { const tunnelSetupOK = tunnelManager.initTunnel(deviceId, token, connection) if (!tunnelSetupOK) { @@ -155,17 +183,39 @@ module.exports = async function (app) { app.route({ method: 'GET', // only GET is permitted for WS url: '/proxy/*', + // Set 'allowAnonymous' as we don't want to return the standard API + // response object. Instead, we will use the preHandler to detect + // there's no session user and redirect to the device overview + config: { allowAnonymous: true }, + preHandler: async (request, reply) => { + if (!request.teamMembership) { + // Failed authentication. Redirect to the device overview page + reply.redirect(303, '/') + } else if (!request.session?.User) { + // Failed authentication. Redirect to the device overview page + reply.redirect(303, `/device/${request.params.deviceId}/overview`) + } else { + // For a websocket comms request + if (/\/comms$/.test(request.url)) { + const status = getTunnelManager().getTunnelStatus(request.params.deviceId) + if (!status?.connected) { + reply.code(502).send('The connection to the editor is currently unavailable') + } + } + } + }, handler: (request, reply) => { // Handle HTTP GET requests from the device const tunnelManager = getTunnelManager() if (tunnelManager.handleHTTP(request.params.deviceId, request, reply)) { return - } else if (tunnelManager.getTunnelStatus(request.params.deviceId)) { - reply.code(502).send() // Bad Gateway (tunnel exists but it has lost connection or is in an intermediate state) + } else if (tunnelManager.getTunnelStatus(request.params.deviceId)?.enabled) { + // Enabled, but not connected + reply.code(502).send('The connection to the editor is currently unavailable') // Bad Gateway (tunnel exists but it has lost connection or is in an intermediate state) return } // tunnel does not exist - reply.code(503).send() // Service Unavailable + reply.code(503).send('The editor is not currently enabled for this device') // Service Unavailable }, wsHandler: (connection, request) => { // Handle WS connection from the device @@ -185,11 +235,29 @@ module.exports = async function (app) { app.route({ method: ['POST', 'DELETE', 'PUT', 'HEAD', 'OPTIONS'], url: '/proxy/*', + config: { allowAnonymous: true }, + preHandler: async (request, reply) => { + if (!request.teamMembership) { + // Failed authentication. Redirect to the device overview page + reply.redirect(303, '/') + } else if (!request.session?.User) { + // Failed authentication. Redirect to the device overview page + reply.redirect(303, `/device/${request.params.deviceId}/overview`) + } else { + // For a websocket comms request + if (/\/comms$/.test(request.url)) { + const status = getTunnelManager().getTunnelStatus(request.params.deviceId) + if (!status?.connected) { + reply.code(502).send('The connection to the editor is currently unavailable') + } + } + } + }, handler: (request, reply) => { const tunnelManager = getTunnelManager() if (tunnelManager.handleHTTP(request.params.deviceId, request, reply)) { return // handled - } else if (tunnelManager.getTunnelStatus(request.params.deviceId)) { + } else if (tunnelManager.getTunnelStatus(request.params.deviceId)?.enabled) { reply.code(502).send() // Bad Gateway (tunnel exists but it has lost connection or is in an intermediate state) return } diff --git a/frontend/src/api/devices.js b/frontend/src/api/devices.js index 665ae214a8..fc51e57ab0 100644 --- a/frontend/src/api/devices.js +++ b/frontend/src/api/devices.js @@ -73,8 +73,8 @@ const updateSettings = async (deviceId, settings) => { } const enableEditorTunnel = async (deviceId) => { - // * Enable Device Editor (Step 2) - (frontendApi->forge:HTTP) {put} /api/v1/devices/{deviceId}/editor { tunnel: 'enable' } - return client.put(`/api/v1/devices/${deviceId}/editor`, { tunnel: 'enable' }).then(res => { + // * Enable Device Editor (Step 2) - (frontendApi->forge:HTTP) {put} /api/v1/devices/{deviceId}/editor { enabled: true } + return client.put(`/api/v1/devices/${deviceId}/editor`, { enabled: true }).then(res => { // * Enable Device Editor (Step 12) - (frontendApi->browser) return result step 1 (THE END) return res.data }) @@ -82,7 +82,7 @@ const enableEditorTunnel = async (deviceId) => { const disableEditorTunnel = async (deviceId) => { // (api->forge) {put} /api/v1/devices/{deviceId}/editor { tunnel: 'disable' } - return client.put(`/api/v1/devices/${deviceId}/editor`, { tunnel: 'disable' }).then(res => { + return client.put(`/api/v1/devices/${deviceId}/editor`, { enabled: false }).then(res => { return res.data }) } diff --git a/frontend/src/pages/device/Overview.vue b/frontend/src/pages/device/Overview.vue index 18a80ff00c..7fa7d3f22a 100644 --- a/frontend/src/pages/device/Overview.vue +++ b/frontend/src/pages/device/Overview.vue @@ -120,9 +120,13 @@ - - +
Editor Access -
- enableddisabled +
+
+ + enabled + not connected + + disabled
@@ -153,7 +157,7 @@
Device Flows   { + this.$emit('device-refresh') + }, 5000) + } else { + clearInterval(this.polling) + } + } } }, mounted () { @@ -248,12 +278,6 @@ export default { methods: { refreshDevice: function () { this.$emit('device-refresh') // cause parent to refresh device - // on next tick, update our local data - this.$nextTick(() => { - this.lastSeenAt = this.device?.lastSeenAt || '' - this.lastSeenMs = this.device?.lastSeenMs || 0 - this.lastSeenSince = this.device?.lastSeenSince || '' - }) }, showCreateSnapshotDialog () { this.busy = true @@ -314,6 +338,8 @@ export default { this.device.editor.url = status.url // eslint-disable-next-line vue/no-mutating-props this.device.editor.enabled = !!status.enabled + // eslint-disable-next-line vue/no-mutating-props + this.device.editor.connected = !!status.connected // use the tunnel-changed event to notify the parent component } } diff --git a/frontend/src/pages/device/index.vue b/frontend/src/pages/device/index.vue index af8fa8064c..02f69836b3 100644 --- a/frontend/src/pages/device/index.vue +++ b/frontend/src/pages/device/index.vue @@ -130,7 +130,13 @@ export default { return this.device && this.agentSupportsDeviceAccess && this.device.mode === 'developer' }, editorAvailable: function () { - return this.isDevModeAvailable && this.device && this.agentSupportsDeviceAccess && this.developerMode && this.device.status === 'running' && this.deviceEditorURL + return this.isDevModeAvailable && + this.device && + this.agentSupportsDeviceAccess && + this.developerMode && + this.device.status === 'running' && + this.deviceEditorURL && + this.device.editor?.connected }, deviceEditorURL: function () { return this.device.editor?.url || '' diff --git a/package-lock.json b/package-lock.json index 1f7c76aa85..ef2973de5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -104,7 +104,8 @@ "vue-template-compiler": "^2.7.14", "webpack": "^5.84.1", "webpack-cli": "^5.1.1", - "webpack-dev-server": "^4.15.0" + "webpack-dev-server": "^4.15.0", + "ws": "^8.13.0" }, "engines": { "node": ">=16.x" diff --git a/package.json b/package.json index 32de52e455..618b9bca17 100644 --- a/package.json +++ b/package.json @@ -144,7 +144,8 @@ "vue-template-compiler": "^2.7.14", "webpack": "^5.84.1", "webpack-cli": "^5.1.1", - "webpack-dev-server": "^4.15.0" + "webpack-dev-server": "^4.15.0", + "ws": "^8.13.0" }, "engines": { "node": ">=16.x" diff --git a/test/unit/forge/ee/routes/deviceEditor/index_spec.js b/test/unit/forge/ee/routes/deviceEditor/index_spec.js new file mode 100644 index 0000000000..0af47bad75 --- /dev/null +++ b/test/unit/forge/ee/routes/deviceEditor/index_spec.js @@ -0,0 +1,319 @@ +const sleep = require('util').promisify(setTimeout) + +const should = require('should') // eslint-disable-line +const sinon = require('sinon') +const WebSocket = require('ws') + +const setup = require('../../setup') + +const FF_UTIL = require('flowforge-test-utils') + +const { Roles } = FF_UTIL.require('forge/lib/roles') + +describe('Device Editor API', function () { + let app + const TestObjects = { tokens: {} } + + before(async function () { + app = await setup({ billing: null }) + await login('alice', 'aaPassword') + + app.device = await app.factory.createDevice({ + name: 'device1' + }, app.team, app.instance) + + app.failingDevice = await app.factory.createDevice({ + name: 'failingDevice' + }, app.team, app.instance) + + const userBob = await app.factory.createUser({ + username: 'bob', + name: 'Bob', + email: 'bob@example.com', + password: 'bbPassword' + }) + await login('bob', 'bbPassword') + + const team2 = await app.factory.createTeam({ name: 'BTeam' }) + await team2.addUser(userBob, { through: { role: Roles.Owner } }) + + sinon.stub(app.comms.devices, 'enableEditor').callsFake(async function (teamId, deviceId, accessToken) { + if (deviceId === app.failingDevice.hashid) { + return { + error: true + } + } + TestObjects.tokens[deviceId] = accessToken + return {} + }) + + sinon.stub(app.comms.devices, 'disableEditor').callsFake(async function (teamId, deviceId) { + delete TestObjects.tokens[deviceId] + return {} + }) + + // Need to actually listen on a port so we can test websockets + await app.listen({ port: 0 }) + }) + + after(async function () { + await app.close() + }) + + async function login (username, password) { + const response = await app.inject({ + method: 'POST', + url: '/account/login', + payload: { username, password, remember: false } + }) + response.cookies.should.have.length(1) + response.cookies[0].should.have.property('name', 'sid') + TestObjects.tokens[username] = response.cookies[0].value + } + + async function getDeviceEditorStatus (device, token) { + const response = await app.inject({ + method: 'GET', + url: `/api/v1/devices/${device}/editor`, + cookies: { sid: token } + }) + response.statusCode.should.equal(200) + return JSON.parse(response.body) + } + + async function setDeviceEditorStatus (device, token, enabled) { + const response = await app.inject({ + method: 'PUT', + url: `/api/v1/devices/${device}/editor`, + payload: { + enabled + }, + cookies: { sid: token } + }) + response.statusCode.should.equal(200) + return JSON.parse(response.body) + } + describe('editor mode', function () { + it('get the device editor status', async function () { + const result = await getDeviceEditorStatus(app.device.hashid, TestObjects.tokens.alice) + result.should.have.property('enabled', false) + }) + + it('enable editor mode fails if device does not respond', async function () { + const response = await app.inject({ + method: 'PUT', + url: `/api/v1/devices/${app.failingDevice.hashid}/editor`, + payload: { + enabled: true + }, + cookies: { sid: TestObjects.tokens.alice } + }) + response.statusCode.should.equal(503) + const result = JSON.parse(response.body) + result.should.have.property('enabled', false) + result.should.have.property('error') + result.should.have.property('code') + }) + + it('enable editor mode', async function () { + const result = await setDeviceEditorStatus(app.device.hashid, TestObjects.tokens.alice, true) + result.should.have.property('enabled', true) + result.should.have.property('connected', false) + result.should.have.property('url') + TestObjects.tokens.should.have.property(app.device.hashid) + }) + + it('disable editor mode', async function () { + const result = await setDeviceEditorStatus(app.device.hashid, TestObjects.tokens.alice, false) + result.should.have.property('enabled', false) + result.should.not.have.property('connected') + result.should.not.have.property('url') + TestObjects.tokens.should.not.have.property(app.device.hashid) + }) + }) + + describe('device comms ws', function () { + it('device websocket cannot connect if device editor not enabled', async function () { + const result = await getDeviceEditorStatus(app.device.hashid, TestObjects.tokens.alice) + result.should.have.property('enabled', false) + + const ws = new WebSocket(`ws://localhost:${app.server.address().port}/api/v1/devices/${app.device.hashid}/editor/comms/ABCED`) + + let closeReason, closeCode + + ws.on('error', function () { }) + ws.on('close', function (code, reason) { + closeCode = code + closeReason = reason?.toString() || '' + }) + + await sleep(200) + + ws.readyState.should.equal(WebSocket.CLOSED) + closeCode.should.equal(1008) + closeReason.should.equal('No tunnel') + }) + it('device websocket cannot connect with incorrect token', async function () { + const enableResult = await setDeviceEditorStatus(app.device.hashid, TestObjects.tokens.alice, true) + enableResult.should.have.property('enabled', true) + + const ws = new WebSocket(`ws://localhost:${app.server.address().port}/api/v1/devices/${app.device.hashid}/editor/comms/ABCED`) + + let closeReason, closeCode + + ws.on('error', function () { }) + ws.on('close', function (code, reason) { + closeCode = code + closeReason = reason?.toString() || '' + }) + + await sleep(200) + + ws.readyState.should.equal(WebSocket.CLOSED) + closeCode.should.equal(1008) + closeReason.should.equal('Invalid token') + }) + it('Proxy HTTP GET - fails if device ws not connected', async function () { + const status = await getDeviceEditorStatus(app.device.hashid, TestObjects.tokens.alice) + status.should.have.property('connected', false) + const response = await app.inject({ + method: 'GET', + url: `/api/v1/devices/${app.device.hashid}/editor/proxy/GET`, + cookies: { sid: TestObjects.tokens.alice } + }) + response.statusCode.should.equal(502) + }) + it('Proxy HTTP PUT - fails if device ws not connected', async function () { + // Only checking PUT as it shares the same route handler as the other + // non-GET methods + const response = await app.inject({ + method: 'PUT', + url: `/api/v1/devices/${app.device.hashid}/editor/proxy/PUT`, + payload: { a: 1 }, + cookies: { sid: TestObjects.tokens.alice } + }) + response.statusCode.should.equal(502) + }) + + describe('device websocket can connect with correct token', async function () { + let ws + it('connects the websocket', async function () { + // We should still have the valid token from the previous test + TestObjects.tokens.should.have.property(app.device.hashid) + const token = TestObjects.tokens[app.device.hashid] + ws = new WebSocket(`ws://localhost:${app.server.address().port}/api/v1/devices/${app.device.hashid}/editor/comms/${token}`) + + ws.on('error', function () { }) + ws.on('message', payload => { + const msg = JSON.parse(payload.toString()) + ws.send(JSON.stringify({ + id: msg.id, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(msg), + status: 200 + })) + }) + + await sleep(200) + + ws.readyState.should.equal(WebSocket.OPEN) + + const result = await getDeviceEditorStatus(app.device.hashid, TestObjects.tokens.alice) + result.should.have.property('enabled', true) + result.should.have.property('connected', true) + }) + + it('Proxy HTTP GET - fails without valid user token', async function () { + const noCookieResponse = await app.inject({ + method: 'GET', + url: `/api/v1/devices/${app.device.hashid}/editor/proxy/GET` + }) + noCookieResponse.statusCode.should.equal(303) + const wrongTeamResponse = await app.inject({ + method: 'GET', + url: `/api/v1/devices/${app.device.hashid}/editor/proxy/GET`, + cookies: { sid: TestObjects.tokens.bob } + }) + wrongTeamResponse.statusCode.should.equal(303) + }) + it('Proxy HTTP PUT - fails without valid user token', async function () { + const noCookieResponse = await app.inject({ + method: 'GET', + url: `/api/v1/devices/${app.device.hashid}/editor/proxy/PUT`, + payload: { a: 1 } + }) + noCookieResponse.statusCode.should.equal(303) + const wrongTeamResponse = await app.inject({ + method: 'PUT', + url: `/api/v1/devices/${app.device.hashid}/editor/proxy/PUT`, + payload: { a: 1 }, + cookies: { sid: TestObjects.tokens.bob } + }) + wrongTeamResponse.statusCode.should.equal(303) + }) + + it('Proxy HTTP GET - succeeds', async function () { + const response = await app.inject({ + method: 'GET', + url: `/api/v1/devices/${app.device.hashid}/editor/proxy/GET`, + cookies: { sid: TestObjects.tokens.alice } + }) + response.statusCode.should.equal(200) + const result = JSON.parse(response.body) + result.should.have.property('method', 'GET') + result.should.have.property('headers') + result.should.have.property('url', '/GET') + }) + it('Proxy HTTP PUT - succeeds', async function () { + const response = await app.inject({ + method: 'PUT', + url: `/api/v1/devices/${app.device.hashid}/editor/proxy/PUT`, + payload: { a: 1 }, + cookies: { sid: TestObjects.tokens.alice } + }) + response.statusCode.should.equal(200) + const result = JSON.parse(response.body) + result.should.have.property('method', 'PUT') + result.should.have.property('headers') + result.should.have.property('url', '/PUT') + result.should.have.property('body', '{"a":1}') + }) + it('Proxy HTTP POST - succeeds', async function () { + const response = await app.inject({ + method: 'POST', + url: `/api/v1/devices/${app.device.hashid}/editor/proxy/POST`, + payload: { a: 1 }, + cookies: { sid: TestObjects.tokens.alice } + }) + response.statusCode.should.equal(200) + const result = JSON.parse(response.body) + result.should.have.property('method', 'POST') + result.should.have.property('headers') + result.should.have.property('url', '/POST') + result.should.have.property('body', '{"a":1}') + }) + it('Proxy HTTP DELETE - succeeds', async function () { + const response = await app.inject({ + method: 'DELETE', + url: `/api/v1/devices/${app.device.hashid}/editor/proxy/DELETE`, + cookies: { sid: TestObjects.tokens.alice } + }) + response.statusCode.should.equal(200) + const result = JSON.parse(response.body) + result.should.have.property('method', 'DELETE') + result.should.have.property('headers') + result.should.have.property('url', '/DELETE') + }) + + it('disconnects the websocket when device mode disabled', async function () { + const result = await setDeviceEditorStatus(app.device.hashid, TestObjects.tokens.alice, false) + result.should.have.property('enabled', false) + await sleep(100) + ws.readyState.should.equal(WebSocket.CLOSED) + }) + + it.skip('editor websocket handling') + }) + it.skip('GET /api/v1/devices/:deviceId/editor/token') + }) +}) diff --git a/test/unit/forge/ee/routes/ha/index.js b/test/unit/forge/ee/routes/ha/index_spec.js similarity index 100% rename from test/unit/forge/ee/routes/ha/index.js rename to test/unit/forge/ee/routes/ha/index_spec.js