UNPKG

ocpp-rpc

Version:

A client & server implementation of the WAMP-like RPC-over-websocket system defined in the OCPP protocols (e.g. OCPP1.6-J and OCPP2.0.1).

1,055 lines (800 loc) 32.4 kB
const assert = require('assert/strict'); const http = require('http'); const { once } = require('events'); const RPCClient = require("../lib/client"); const { TimeoutError, UnexpectedHttpResponse, WebsocketUpgradeError } = require('../lib/errors'); const RPCServer = require("../lib/server"); const { setTimeout } = require('timers/promises'); const { createValidator } = require('../lib/validator'); const { abortHandshake } = require('../lib/ws-util'); describe('RPCServer', function(){ this.timeout(500); async function createServer(options = {}, extra = {}) { const server = new RPCServer(options); const httpServer = await server.listen(0); const port = httpServer.address().port; const endpoint = `ws://localhost:${port}`; const close = (...args) => server.close(...args); server.on('client', client => { client.handle('Echo', async ({params}) => { return params; }); client.handle('Sleep', async ({params, signal}) => { await setTimeout(params.ms, null, {signal}); return `Waited ${params.ms}ms`; }); client.handle('Reject', async ({params}) => { const err = Error("Rejecting"); Object.assign(err, params); throw err; }); if (extra.withClient) { extra.withClient(client); } }); return {server, httpServer, port, endpoint, close}; } function getEchoValidator() { return createValidator('echo1.0', [ { "$schema": "http://json-schema.org/draft-07/schema", "$id": "urn:Echo.req", "type": "object", "properties": { "val": { "type": "string" } }, "additionalProperties": false, "required": ["val"] }, { "$schema": "http://json-schema.org/draft-07/schema", "$id": "urn:Echo.conf", "type": "object", "properties": { "val": { "type": "string" } }, "additionalProperties": false, "required": ["val"] } ]); } describe('#constructor', function(){ it('should throw if strictMode = true and not all protocol schemas found', async () => { assert.throws(() => { new RPCServer({ protocols: ['ocpp1.6', 'echo1.0', 'other0.1'], strictMode: true, }); }); assert.throws(() => { new RPCServer({ protocols: ['ocpp1.6', 'echo1.0', 'other0.1'], strictMode: ['ocpp1.6', 'other0.1'], }); }); assert.throws(() => { // trying to use strict mode with no protocols specified new RPCServer({ protocols: [], strictMode: true, }); }); assert.throws(() => { // trying to use strict mode with no protocols specified new RPCServer({ strictMode: true, }); }); assert.doesNotThrow(() => { new RPCServer({ protocols: ['ocpp1.6', 'echo1.0', 'other0.1'], strictModeValidators: [getEchoValidator()], strictMode: ['ocpp1.6', 'echo1.0'], }); }); assert.doesNotThrow(() => { new RPCServer({ protocols: ['ocpp1.6', 'echo1.0'], strictModeValidators: [getEchoValidator()], strictMode: true, }); }); }); }); describe('events', function(){ it('should emit "client" when client connects', async () => { const {endpoint, close, server} = await createServer(); const cli = new RPCClient({endpoint, identity: 'X'}); try { const clientProm = once(server, 'client'); await cli.connect(); const [client] = await clientProm; assert.equal(client.identity, 'X'); } finally { await cli.close(); close(); } }); it('should correctly decode the client identity', async () => { const identity = 'RPC/ /123'; const {endpoint, close, server} = await createServer(); const cli = new RPCClient({endpoint, identity}); try { const clientProm = once(server, 'client'); await cli.connect(); const [client] = await clientProm; assert.equal(client.identity, identity); } finally { await cli.close(); close(); } }); }); describe('#auth', function(){ it("should refuse client with error 400 when subprotocol incorrectly forced", async () => { const {endpoint, close, server} = await createServer({protocols: ['a', 'b']}); server.auth((accept, reject, handshake) => { accept({}, 'b'); }); const cli = new RPCClient({ endpoint, identity: 'X', protocols: ['a'], }); try { const err = await cli.connect().catch(e=>e); assert.equal(err.code, 400); } finally { close(); } }); it("should not throw on double-accept", async () => { const {endpoint, close, server} = await createServer(); let allOk; let waitOk = new Promise(r => {allOk = r;}); server.auth((accept, reject, handshake) => { accept(); accept(); allOk(); }); const cli = new RPCClient({ endpoint, identity: 'X' }); try { await cli.connect(); await waitOk; } finally { await cli.close(); close(); } }); it("should not throw on double-reject", async () => { const {endpoint, close, server} = await createServer(); let allOk; let waitOk = new Promise(r => {allOk = r;}); server.auth((accept, reject, handshake) => { reject(); reject(); allOk(); }); const cli = new RPCClient({ endpoint, identity: 'X' }); try { await assert.rejects(cli.connect(), {code: 404}); await waitOk; } finally { await cli.close(); close(); } }); it("should not throw on reject-after-accept", async () => { const {endpoint, close, server} = await createServer(); let allOk; let waitOk = new Promise(r => {allOk = r;}); server.auth((accept, reject, handshake) => { accept(); reject(); allOk(); }); const cli = new RPCClient({ endpoint, identity: 'X' }); try { await cli.connect(); await waitOk; } finally { await cli.close(); close(); } }); it("should not throw on accept-after-reject", async () => { const {endpoint, close, server} = await createServer(); let allOk; let waitOk = new Promise(r => {allOk = r;}); server.auth((accept, reject, handshake) => { reject(); accept(); allOk(); }); const cli = new RPCClient({ endpoint, identity: 'X' }); try { await assert.rejects(cli.connect(), {code: 404}); await waitOk; } finally { await cli.close(); close(); } }); it('should pass identity and endpoint path to auth', async () => { const identity = 'RPC/ /123'; const extraPath = '/extra/long/path'; const {endpoint, close, server} = await createServer({protocols: ['a', 'b']}); const cli = new RPCClient({ endpoint: endpoint + extraPath, identity, protocols: ['a', 'b'], }); try { let hs; server.auth((accept, reject, handshake) => { hs = handshake; accept(); }); const serverClientProm = once(server, 'client'); await cli.connect(); const [serverClient] = await serverClientProm; assert.equal(serverClient.identity, identity); assert.equal(hs.identity, identity); assert.equal(hs.endpoint, extraPath); assert.equal(serverClient.protocol, 'a'); assert.equal(cli.protocol, serverClient.protocol); assert.equal(cli.identity, serverClient.identity); } finally { await cli.close(); close(); } }); it('should correctly parse endpoints with double slashes and dots', async () => { const identity = 'XX'; const {endpoint, close, server} = await createServer({}); try { const endpointPaths = [ {append: '/ocpp', expect: '/ocpp'}, {append: '//', expect: '//'}, {append: '//ocpp', expect: '//ocpp'}, {append: '/ocpp/', expect: '/ocpp/'}, {append: '/', expect: '/'}, {append: '///', expect: '///'}, {append: '/../', expect: '/'}, {append: '//../', expect: '/'}, {append: '/ocpp/..', expect: '/'}, {append: '/ocpp/../', expect: '/'}, {append: '//ocpp/../', expect: '//'}, {append: '', expect: '/'}, ]; for (const endpointPath of endpointPaths) { const fullEndpoint = endpoint + endpointPath.append; let hs; server.auth((accept, reject, handshake) => { hs = handshake; accept(); }); const cli = new RPCClient({ endpoint: fullEndpoint, identity, }); await cli.connect(); await cli.close({force: true}); assert.equal(hs.endpoint, endpointPath.expect); assert.equal(hs.identity, identity); } } finally { close(); } }); it('should attach session properties to client', async () => { let serverClient; const extraPath = '/extra/path'; const identity = 'X'; const proto = 'a'; const sessionData = {a: 123, b: {c: 456}}; const {endpoint, close, server} = await createServer({protocols: ['x', 'b', proto]}, { withClient: client => { serverClient = client; } }); server.auth((accept, reject, handshake) => { accept(sessionData, proto); }); const cli = new RPCClient({ endpoint: endpoint + extraPath, identity, protocols: ['x', 'c', proto], }); try { await cli.connect(); assert.deepEqual(serverClient.session, sessionData); assert.equal(serverClient.protocol, proto); assert.equal(cli.protocol, proto); } finally { await cli.close(); close(); } }); it('should disconnect client if auth failed', async () => { const {endpoint, close, server} = await createServer(); server.auth((accept, reject) => { reject(500); }); const cli = new RPCClient({endpoint, identity: 'X'}); const err = await cli.connect().catch(e=>e); assert.ok(err instanceof UnexpectedHttpResponse); assert.equal(err.code, 500); close(); }); it("should disconnect client if server closes during auth", async () => { const {endpoint, close, server} = await createServer(); server.auth((accept, reject) => { close(); accept(); }); const cli = new RPCClient({endpoint, identity: 'X', reconnect: false}); const closeProm = once(cli, 'close'); await cli.connect(); const [closed] = await closeProm; assert.equal(closed.code, 1000); assert.equal(closed.reason, 'Server is no longer open'); }); it('should recognise passwords with colons', async () => { const password = 'hun:ter:2'; const {endpoint, close, server} = await createServer({}, {withClient: cli => { cli.handle('GetPassword', () => { return cli.session.pwd; }); }}); server.auth((accept, reject, handshake) => { accept({pwd: handshake.password.toString('utf8')}); }); const cli = new RPCClient({ endpoint, identity: 'X', password, }); try { await cli.connect(); const pass = await cli.call('GetPassword'); assert.equal(password, pass); } finally { cli.close(); close(); } }); it('should not get confused with identities and passwords containing colons', async () => { const identity = 'a:colonified:ident'; const password = 'a:colonified:p4ss'; let recIdent; let recPass; const {endpoint, close, server} = await createServer(); server.auth((accept, reject, handshake) => { recIdent = handshake.identity; recPass = handshake.password; accept(); }); const cli = new RPCClient({ endpoint, identity, password, }); try { await cli.connect(); assert.equal(password, recPass.toString('utf8')); assert.equal(identity, recIdent); } finally { cli.close(); close(); } }); it('should recognise empty passwords', async () => { const password = ''; let recPass; const {endpoint, close, server} = await createServer(); server.auth((accept, reject, handshake) => { recPass = handshake.password; accept(); }); const cli = new RPCClient({ endpoint, identity: 'X', password, }); try { await cli.connect(); assert.equal(password, recPass.toString('utf8')); } finally { cli.close(); close(); } }); it('should provide undefined password if no authorization header sent', async () => { let recPass; const {endpoint, close, server} = await createServer(); server.auth((accept, reject, handshake) => { recPass = handshake.password; accept(); }); const cli = new RPCClient({ endpoint, identity: 'X', }); try { await cli.connect(); assert.equal(undefined, recPass); } finally { cli.close(); close(); } }); it('should provide undefined password when identity mismatches username', async () => { let recPass; const {endpoint, close, server} = await createServer(); server.auth((accept, reject, handshake) => { recPass = handshake.password; accept(); }); const cli = new RPCClient({ endpoint, identity: 'X', headers: { 'authorization': 'Basic WjoxMjM=', } }); try { await cli.connect(); assert.equal(undefined, recPass); } finally { cli.close(); close(); } }); it('should provide undefined password on bad authorization header', async () => { let recPass; const {endpoint, close, server} = await createServer(); server.auth((accept, reject, handshake) => { recPass = handshake.password; accept(); }); const cli = new RPCClient({ endpoint, identity: 'X', headers: { 'authorization': 'Basic ?', } }); try { await cli.connect(); assert.equal(undefined, recPass); } finally { cli.close(); close(); } }); it('should recognise binary passwords', async () => { const password = Buffer.from([ 0,1,2,3,4,5,6,7,8,9, 65,66,67,68,69, 251,252,253,254,255, ]); const {endpoint, close, server} = await createServer({}, {withClient: cli => { cli.handle('GetPassword', () => { return cli.session.pwd; }); }}); server.auth((accept, reject, handshake) => { accept({pwd: handshake.password.toString('hex')}); }); const cli = new RPCClient({ endpoint, identity: 'X', password, }); try { await cli.connect(); const pass = await cli.call('GetPassword'); assert.equal(password.toString('hex'), pass); } finally { cli.close(); close(); } }); }); describe('#close', function(){ it('should not allow new connections after close (before clients kicked)', async () => { let callReceived; const callReceivedPromise = new Promise(r => {callReceived = r;}); const {endpoint, close, server} = await createServer({}, { withClient: client => { client.handle('Test', async () => { callReceived(); await setTimeout(50); return 123; }); } }); const cli1 = new RPCClient({ endpoint, identity: '1', reconnect: false, }); const cli2 = new RPCClient({ endpoint, identity: '2', }); try { await cli1.connect(); const callP = cli1.call('Test'); await callReceivedPromise; close({awaitPending: true}); const [callResult, connResult] = await Promise.allSettled([ callP, cli2.connect() ]); assert.equal(callResult.status, 'fulfilled'); assert.equal(callResult.value, 123); assert.equal(connResult.status, 'rejected'); assert.equal(connResult.reason.code, 'ECONNREFUSED'); } finally { close(); } }); }); describe('#listen', function(){ it('should attach to an existing http server', async () => { const server = new RPCServer(); server.on('client', client => { client.handle('Test', () => { return 123; }); }); const httpServer = http.createServer({}, (req, res) => res.end()); httpServer.on('upgrade', server.handleUpgrade); await new Promise((resolve, reject) => { httpServer.listen({port: 0}, err => err ? reject(err) : resolve()); }); const endpoint = 'ws://localhost:'+httpServer.address().port; const cli1 = new RPCClient({ endpoint, identity: '1' }); const cli2 = new RPCClient({ endpoint, identity: '2' }); await cli1.connect(); httpServer.close(); const [callResult, connResult] = await Promise.allSettled([ cli1.call('Test'), cli2.connect(), ]); await cli1.close(); // httpServer.close() won't kick clients assert.equal(callResult.status, 'fulfilled'); assert.equal(callResult.value, 123); assert.equal(connResult.status, 'rejected'); assert.equal(connResult.reason.code, 'ECONNREFUSED'); }); it('should create multiple http servers with listen()', async () => { const server = new RPCServer(); const s1 = await server.listen(); const e1 = 'ws://localhost:'+s1.address().port; const s2 = await server.listen(); const e2 = 'ws://localhost:'+s2.address().port; const cli1 = new RPCClient({endpoint: e1, identity: '1', reconnect: false}); const cli2 = new RPCClient({endpoint: e2, identity: '2', reconnect: false}); await cli1.connect(); await cli2.connect(); const droppedProm = Promise.all([ once(cli1, 'close'), once(cli2, 'close'), ]); server.close({code: 4050}); await droppedProm; }); it('should abort with signal', async () => { const ac = new AbortController(); const server = new RPCServer(); const httpServer = await server.listen(undefined, undefined, {signal: ac.signal}); const port = httpServer.address().port; const endpoint = 'ws://localhost:'+port; const cli = new RPCClient({endpoint, identity: 'X', reconnect: false}); await cli.connect(); const {code} = await cli.close({code: 4080}); assert.equal(code, 4080); ac.abort(); const err = await cli.connect().catch(e=>e); assert.equal(err.code, 'ECONNREFUSED'); await server.close(); }); it('should automatically ping clients', async () => { const pingIntervalMs = 40; let pingResolve; let pingPromise = new Promise(r => {pingResolve = r;}) const {endpoint, close, server} = await createServer({ pingIntervalMs, }, { withClient: async (client) => { const pingRes = await once(client, 'ping'); pingResolve(pingRes[0]); } }); const cli = new RPCClient({ endpoint, identity: 'X', }); try { const start = Date.now(); await cli.connect(); const ping = await pingPromise; const fin = Date.now() - start; assert.ok(fin >= pingIntervalMs); assert.ok(fin <= pingIntervalMs * 2); assert.ok(ping.rtt <= pingIntervalMs * 2); } finally { await cli.close(); close(); } }); it('should reject non-websocket requests with a 404', async () => { const {port, close, server} = await createServer(); try { const req = http.request('http://localhost:'+port); req.end(); const [res] = await once(req, 'response'); assert.equal(res.statusCode, 404); } catch (err) { console.log({err}); } finally { close(); } }); }); describe('#handleUpgrade', function() { it("should not throw if abortHandshake() called after socket already destroyed", async () => { const {endpoint, close, server} = await createServer(); const cli = new RPCClient({ endpoint, identity: 'X', }); let completeAuth; let authCompleted = new Promise(r => {completeAuth = r;}) server.auth(async (accept, reject, handshake) => { reject(400); abortHandshake(handshake.request.socket, 500); completeAuth(); }); try { const conn = cli.connect(); await assert.rejects(conn, {message: "Bad Request"}); await authCompleted; } finally { await cli.close(); close(); } }); it("should abort handshake if server not open", async () => { const server = new RPCServer(); let abortEvent; server.on('upgradeAborted', event => { abortEvent = event; }); let authed = false; server.auth((accept) => { // shouldn't get this far authed = true; accept() }); let onUpgrade; let upgradeProm = new Promise(r => {onUpgrade = r;}); const httpServer = http.createServer({}, (req, res) => res.end()); httpServer.on('upgrade', (...args) => onUpgrade(args)); await new Promise((resolve, reject) => { httpServer.listen({port: 0}, err => err ? reject(err) : resolve()); }); const endpoint = 'ws://localhost:'+httpServer.address().port; const cli = new RPCClient({ endpoint, identity: 'X' }); cli.connect(); const upgrade = await upgradeProm; await server.close(); assert.doesNotReject(server.handleUpgrade(...upgrade)); assert.equal(authed, false); assert.equal(abortEvent.error.code, 500); httpServer.close(); }); it("should abort handshake for non-websocket upgrades", async () => { const {endpoint, close, server} = await createServer(); let abortEvent; server.on('upgradeAborted', event => { abortEvent = event; }); let authed = false; server.auth((accept) => { // shouldn't get this far authed = true; accept() }); try { const req = http.request(endpoint.replace(/^ws/,'http') + '/X', { headers: { connection: 'Upgrade', upgrade: '_UNKNOWN_', 'user-agent': 'test/0', } }); req.end(); const [res] = await once(req, 'response'); assert.equal(res.statusCode, 400); assert.equal(authed, false); assert.ok(abortEvent.error instanceof WebsocketUpgradeError); assert.equal(abortEvent.request.headers['user-agent'], 'test/0'); } finally { close(); } }); it("should emit upgradeAborted event on auth reject", async () => { const {endpoint, close, server} = await createServer(); let abortEvent; server.on('upgradeAborted', event => { abortEvent = event; }); server.auth((accept, reject) => { reject(499); }); try { const cli = new RPCClient({ endpoint, identity: 'X' }); await assert.rejects(cli.connect()); assert.ok(abortEvent.error instanceof WebsocketUpgradeError); assert.equal(abortEvent.error.code, 499); } finally { close(); } }); it("should abort auth on upgrade error", async () => { const {endpoint, close, server} = await createServer(); const cli = new RPCClient({ endpoint, identity: 'X', }); let completeAuth; let authCompleted = new Promise(r => {completeAuth = r;}) server.auth(async (accept, reject, handshake, signal) => { const abortProm = once(signal, 'abort'); await cli.close({force: true, awaitPending: false}); await abortProm; completeAuth(); }); try { const connErr = await cli.connect().catch(e=>e); await authCompleted; assert.ok(connErr instanceof Error); } finally { await cli.close(); close(); } }); }); });