diff --git a/package-lock.json b/package-lock.json index b2bcfe19..6195c9c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -885,6 +885,74 @@ "@types/node": ">= 8" } }, + "@peculiar/asn1-schema": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.0.17.tgz", + "integrity": "sha512-7rJD8bR1r6NFE4skDxXsLsFEO3zM2TfjX9wdq5SERoBNEuxGkAJ3uIH84sIMxvDgJtb3cMfLsv8iNpGN0nAWdw==", + "requires": { + "@types/asn1js": "^0.0.1", + "asn1js": "^2.0.26", + "pvtsutils": "^1.0.11", + "tslib": "^2.0.1" + }, + "dependencies": { + "tslib": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.1.tgz", + "integrity": "sha512-SgIkNheinmEBgx1IUNirK0TUD4X9yjjBRTqqjggWCU3pUEqIk3/Uwl3yRixYKT6WjQuGiwDv4NomL3wqRCj+CQ==" + } + } + }, + "@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "requires": { + "tslib": "^2.0.0" + }, + "dependencies": { + "tslib": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.1.tgz", + "integrity": "sha512-SgIkNheinmEBgx1IUNirK0TUD4X9yjjBRTqqjggWCU3pUEqIk3/Uwl3yRixYKT6WjQuGiwDv4NomL3wqRCj+CQ==" + } + } + }, + "@peculiar/webcrypto": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.1.3.tgz", + "integrity": "sha512-M1mipPJkWzIf92w3T1Vx5ir3kV9c0oWCcLkeh4vNa/3XDEtQ7xxj5NRKyq67NuVNKLH2/0JD1crlLJyqfYbfBA==", + "requires": { + "@peculiar/asn1-schema": "^2.0.13", + "@peculiar/json-schema": "^1.1.12", + "pvtsutils": "^1.0.11", + "tslib": "^2.0.1", + "webcrypto-core": "^1.1.6" + }, + "dependencies": { + "tslib": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.1.tgz", + "integrity": "sha512-SgIkNheinmEBgx1IUNirK0TUD4X9yjjBRTqqjggWCU3pUEqIk3/Uwl3yRixYKT6WjQuGiwDv4NomL3wqRCj+CQ==" + } + } + }, + "@relaycorp/relaynet-core": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/@relaycorp/relaynet-core/-/relaynet-core-1.29.1.tgz", + "integrity": "sha512-K0aSQ4NOk1sWolnRofOQUWMqM5Q6nd2JUJIOG9ekdwePnhklHXv6XM4b9omGSJcTsEi/9OHu2qhHBML4bwb4Jg==", + "requires": { + "@peculiar/webcrypto": "^1.1.3", + "asn1js": "^2.0.26", + "binary-parser": "^1.5.0", + "buffer-to-arraybuffer": "0.0.5", + "moment": "^2.27.0", + "pkijs": "2.1.84", + "smart-buffer": "^4.1.0", + "uuid4": "^2.0.2", + "verror": "^1.10.0" + } + }, "@relaycorp/shared-config": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/@relaycorp/shared-config/-/shared-config-1.3.7.tgz", @@ -1016,6 +1084,14 @@ "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", "dev": true }, + "@types/asn1js": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@types/asn1js/-/asn1js-0.0.1.tgz", + "integrity": "sha1-74uflwjLFjKhw6nNJ3F8qr55O8I=", + "requires": { + "@types/pvutils": "*" + } + }, "@types/babel__core": { "version": "7.1.9", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.9.tgz", @@ -1247,6 +1323,21 @@ "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==", "dev": true }, + "@types/pkijs": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@types/pkijs/-/pkijs-0.0.4.tgz", + "integrity": "sha512-GIQMA1YVbSmfJo0mEcvL+kIUYnIXQRyRRq+mru8+6K0Dmud3/F+UnfilDU7bAo352eO6V3BVxyPpTMlnIhsVOQ==", + "dev": true, + "requires": { + "@types/asn1js": "*", + "@types/pvutils": "*" + } + }, + "@types/pvutils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@types/pvutils/-/pvutils-0.0.2.tgz", + "integrity": "sha512-CgQAm7pjyeF3Gnv78ty4RBVIfluB+Td+2DR8iPaU0prF18pkzptHHP+DoKPfpsJYknKsVZyVsJEu5AuGgAqQ5w==" + }, "@types/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", @@ -1259,6 +1350,12 @@ "integrity": "sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw==", "dev": true }, + "@types/verror": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.4.tgz", + "integrity": "sha512-OjJdqx6QlbyZw9LShPwRW+Kmiegeg3eWNI41MQQKaG3vjdU2L9SRElntM51HmHBY1cu7izxQJ1lMYioQh3XMBg==", + "dev": true + }, "@types/yargs": { "version": "13.0.10", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-13.0.10.tgz", @@ -1588,11 +1685,18 @@ "safer-buffer": "~2.1.0" } }, + "asn1js": { + "version": "2.0.26", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-2.0.26.tgz", + "integrity": "sha512-yG89F0j9B4B0MKIcFyWWxnpZPLaNTjCj4tkE3fjbAoo0qmpGw0PYYqSbX/4ebnd9Icn8ZgK4K1fvDyEtW1JYtQ==", + "requires": { + "pvutils": "^1.0.17" + } + }, "assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", - "dev": true + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" }, "assign-symbols": { "version": "1.0.0", @@ -1659,6 +1763,24 @@ "follow-redirects": "^1.10.0" } }, + "axios-mock-adapter": { + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/axios-mock-adapter/-/axios-mock-adapter-1.18.2.tgz", + "integrity": "sha512-e5aTsPy2Viov22zNpFTlid76W1Scz82pXeEwwCXdtO85LROhHAF8pHF2qDhiyMONLxKyY3lQ+S4UCsKgrlx8Hw==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "is-buffer": "^2.0.3" + }, + "dependencies": { + "is-buffer": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.4.tgz", + "integrity": "sha512-Kq1rokWXOPXWuaMAqZiJW4XxsmD9zGx9q4aePabbn3qCRGedtH7Cm+zV8WETitMfu1wdh+Rvd6w5egwSngUX2A==", + "dev": true + } + } + }, "babel-jest": { "version": "24.9.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-24.9.0.tgz", @@ -1825,6 +1947,11 @@ "integrity": "sha512-IWIbu7pMqyw3EAJHzzHbWa85b6oud/yfKYg5rqB5hNE8CeMi3nX+2C2sj0HswfblST86hpVEOAb9x34NZd6P7A==", "dev": true }, + "binary-parser": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/binary-parser/-/binary-parser-1.6.2.tgz", + "integrity": "sha512-cYAhKB51A9T/uylDvMK7uAYaPLWLwlferNOpnQ0E0fuO73yPi7kWaWiOm22BvuKxCbggmkiFN0VkuLg6gc+KQQ==" + }, "bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -1907,12 +2034,22 @@ "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==", "dev": true }, + "buffer-to-arraybuffer": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/buffer-to-arraybuffer/-/buffer-to-arraybuffer-0.0.5.tgz", + "integrity": "sha1-YGSkD6dutDxyOrqe+PbhIW0QURo=" + }, "builtin-modules": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz", "integrity": "sha1-Jw8HbFpywC9bZaR9+Uxf46J4iS8=", "dev": true }, + "bytestreamjs": { + "version": "1.0.29", + "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-1.0.29.tgz", + "integrity": "sha512-Mri3yqoo9YvdaSvD5OYl4Rdu9zCBJInW/Ez31sdlNY4ikMy//EvTTmidfLcs0e+NBvKVEpPzYvJAesjgMdjnZg==" + }, "cache-base": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz", @@ -2203,8 +2340,7 @@ "core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", - "dev": true + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" }, "cosmiconfig": { "version": "6.0.0", @@ -2845,8 +2981,7 @@ "extsprintf": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", - "dev": true + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" }, "fast-deep-equal": { "version": "3.1.3", @@ -5702,6 +5837,11 @@ "integrity": "sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==", "dev": true }, + "moment": { + "version": "2.27.0", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.27.0.tgz", + "integrity": "sha512-al0MUK7cpIcglMv3YF13qSgdAIqxHTO7brRtaz3DlSULbqfazqkc5kEjNrLDOM7fsjshoFIihnU8snrP7zUvhQ==" + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -9841,6 +9981,26 @@ "find-up": "^4.0.0" } }, + "pkijs": { + "version": "2.1.84", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-2.1.84.tgz", + "integrity": "sha512-DRfTYTBaoVv65SkBggpr8nBEt18U9Is97pbGqi4Mu3ZoIsgAmqufQlMMNwPXit9mVS/3H3+cvvwDg77ICDi4Hw==", + "requires": { + "asn1js": "^2.0.26", + "bytestreamjs": "^1.0.29", + "pvutils": "^1.0.17" + }, + "dependencies": { + "asn1js": { + "version": "2.0.26", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-2.0.26.tgz", + "integrity": "sha512-yG89F0j9B4B0MKIcFyWWxnpZPLaNTjCj4tkE3fjbAoo0qmpGw0PYYqSbX/4ebnd9Icn8ZgK4K1fvDyEtW1JYtQ==", + "requires": { + "pvutils": "^1.0.17" + } + } + } + }, "pn": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/pn/-/pn-1.1.0.tgz", @@ -9935,6 +10095,16 @@ "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", "dev": true }, + "pvtsutils": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.0.11.tgz", + "integrity": "sha512-k040UEiUms7Ey8fwRvCBvlqcuOxK9IMBnnmIijk0jkAs+gdZQkayenRQ1a2Z574i2HyFVyJ+zwomZc5QEjzewg==" + }, + "pvutils": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.0.17.tgz", + "integrity": "sha512-wLHYUQxWaXVQvKnwIDWFVKDJku9XDCvyhhxoq8dc5MFdIlRenyPI9eSfEtcvgHgD7FlvCyGAlWgOzRnZD99GZQ==" + }, "q": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", @@ -10675,6 +10845,11 @@ "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", "dev": true }, + "smart-buffer": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.1.0.tgz", + "integrity": "sha512-iVICrxOzCynf/SNaBQCw34eM9jROU/s5rzIhpOvzhzuYHfJR/DhZfDkXiZSgKXfgv26HT3Yni3AV/DGw0cGnnw==" + }, "snapdragon": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz", @@ -11699,6 +11874,11 @@ "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", "dev": true }, + "uuid4": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/uuid4/-/uuid4-2.0.2.tgz", + "integrity": "sha512-TzsQS8sN1B2m9WojyNp0X/3JL8J2RScnrAJnooNPL6lq3lA02/XdoWysyUgI6rAif0DzkkWk51N6OggujPy2RA==" + }, "validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -11713,7 +11893,6 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", - "dev": true, "requires": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", @@ -11738,6 +11917,25 @@ "makeerror": "1.0.x" } }, + "webcrypto-core": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.1.6.tgz", + "integrity": "sha512-v74QssvJYBfQ5b5yZHgivEfZrZCE8ybJVvpWO0wGSU7jdiYdhAnUYmXOvzLkQCWH4DcGe7weEwV//6Z60dy8AA==", + "requires": { + "@peculiar/asn1-schema": "^2.0.12", + "@peculiar/json-schema": "^1.1.12", + "asn1js": "^2.0.26", + "pvtsutils": "^1.0.11", + "tslib": "^2.0.1" + }, + "dependencies": { + "tslib": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.1.tgz", + "integrity": "sha512-SgIkNheinmEBgx1IUNirK0TUD4X9yjjBRTqqjggWCU3pUEqIk3/Uwl3yRixYKT6WjQuGiwDv4NomL3wqRCj+CQ==" + } + } + }, "webidl-conversions": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", diff --git a/package.json b/package.json index 34001e36..5b22460a 100644 --- a/package.json +++ b/package.json @@ -40,11 +40,17 @@ "node": ">=10" }, "dependencies": { - "axios": "^0.20.0" + "@relaycorp/relaynet-core": "^1.29.1", + "axios": "^0.20.0", + "buffer-to-arraybuffer": "0.0.5", + "verror": "^1.10.0" }, "devDependencies": { "@relaycorp/shared-config": "^1.3.5", "@types/jest": "^26.0.10", + "@types/pkijs": "0.0.4", + "@types/verror": "^1.10.4", + "axios-mock-adapter": "^1.18.2", "del-cli": "^3.0.0", "gh-pages": "^3.1.0", "jest": "^24.9.0", diff --git a/src/lib/PoWebClient.spec.ts b/src/lib/PoWebClient.spec.ts index 765f23f9..459b17ff 100644 --- a/src/lib/PoWebClient.spec.ts +++ b/src/lib/PoWebClient.spec.ts @@ -1,6 +1,38 @@ -import { PoWebClient } from './PoWebClient'; +/* tslint:disable:no-let */ + +import { + generateRSAKeyPair, + issueEndpointCertificate, + issueGatewayCertificate, + PrivateNodeRegistration, +} from '@relaycorp/relaynet-core'; +import MockAdapter from 'axios-mock-adapter'; +import bufferToArray from 'buffer-to-arraybuffer'; + +import { ServerError } from './errors'; +import { PNR_CONTENT_TYPE, PNRA_CONTENT_TYPE, PoWebClient } from './PoWebClient'; describe('PoWebClient', () => { + describe('Common Axios instance defaults', () => { + test('responseType should be ArrayBuffer', () => { + const client = PoWebClient.initLocal(); + + expect(client.internalAxios.defaults.responseType).toEqual('arraybuffer'); + }); + + test('maxContentLength should be 1 MiB', () => { + const client = PoWebClient.initLocal(); + + expect(client.internalAxios.defaults.maxContentLength).toEqual(1048576); + }); + + test('Redirects should be disabled', () => { + const client = PoWebClient.initLocal(); + + expect(client.internalAxios.defaults.maxRedirects).toEqual(0); + }); + }); + describe('initLocal', () => { test('Host name should be the localhost IP address', () => { const client = PoWebClient.initLocal(); @@ -93,17 +125,162 @@ describe('PoWebClient', () => { }); }); - describe('preRegister', () => { - test.todo('Request method should be POST'); + describe('preRegisterNode', () => { + let client: PoWebClient; + let mockAxios: MockAdapter; + beforeEach(() => { + client = PoWebClient.initLocal(); + mockAxios = new MockAdapter(client.internalAxios); + }); + + test('Empty request should be POSTed to /v1/pre-registrations', async () => { + mockAxios + .onPost('/pre-registrations') + .reply(200, null, { 'content-type': PNRA_CONTENT_TYPE }); + + await client.preRegisterNode(); - test.todo('Endpoint should be /v1/pre-registrations'); + expect(mockAxios.history.post).toHaveLength(1); + expect(mockAxios.history.post[0].url).toEqual('/pre-registrations'); + expect(mockAxios.history.post[0].data).toBeUndefined(); + }); + + test('An invalid response content type should be refused', async () => { + const invalidContentType = 'application/json'; + mockAxios + .onPost('/pre-registrations') + .reply(200, null, { 'content-type': invalidContentType }); + + await expect(client.preRegisterNode()).rejects.toEqual( + new ServerError(`Server responded with invalid content type (${invalidContentType})`), + ); + }); + + test('20X response status other than 200 should throw an error', async () => { + const statusCode = 201; + mockAxios + .onPost('/pre-registrations') + .reply(statusCode, null, { 'content-type': PNRA_CONTENT_TYPE }); - test.todo('Request body should be empty'); + await expect(client.preRegisterNode()).rejects.toEqual( + new ServerError(`Unexpected response status (${statusCode})`), + ); + }); - test.todo('Response Content-Type other than application/vnd.relaynet.cra should be refused'); + test('Authorization should be output serialized if status is 200', async () => { + const expectedAuthorizationSerialized = Buffer.from('the PNRA'); + mockAxios + .onPost('/pre-registrations') + .reply(200, bufferToArray(expectedAuthorizationSerialized), { + 'content-type': PNRA_CONTENT_TYPE, + }); - test.todo('20X response status other than 200 should throw an error'); + const authorizationSerialized = await client.preRegisterNode(); - test.todo('CRA should be output serialized if status is 200'); + expect( + expectedAuthorizationSerialized.equals(Buffer.from(authorizationSerialized)), + ).toBeTruthy(); + }); + }); + + describe('registerNode', () => { + let client: PoWebClient; + let mockAxios: MockAdapter; + beforeEach(() => { + client = PoWebClient.initLocal(); + mockAxios = new MockAdapter(client.internalAxios); + }); + + const pnraSerialized = bufferToArray(Buffer.from('the authorization')); + + let expectedRegistration: PrivateNodeRegistration; + let expectedRegistrationSerialized: ArrayBuffer; + beforeAll(async () => { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + const gatewayKeyPair = await generateRSAKeyPair(); + const gatewayCertificate = await issueGatewayCertificate({ + issuerPrivateKey: gatewayKeyPair.privateKey, + subjectPublicKey: gatewayKeyPair.publicKey, + validityEndDate: tomorrow, + }); + + const privateNodeKeyPair = await generateRSAKeyPair(); + const privateNodeCertificate = await issueEndpointCertificate({ + issuerCertificate: gatewayCertificate, + issuerPrivateKey: gatewayKeyPair.privateKey, + subjectPublicKey: privateNodeKeyPair.publicKey, + validityEndDate: tomorrow, + }); + + expectedRegistration = new PrivateNodeRegistration( + privateNodeCertificate, + gatewayCertificate, + ); + expectedRegistrationSerialized = expectedRegistration.serialize(); + }); + + test('PNRA should be POSTed to /v1/nodes', async () => { + mockAxios + .onPost('/nodes') + .reply(200, expectedRegistrationSerialized, { 'content-type': PNR_CONTENT_TYPE }); + + await client.registerNode(pnraSerialized); + + expect(mockAxios.history.post).toHaveLength(1); + expect(mockAxios.history.post[0].url).toEqual('/nodes'); + expect( + Buffer.from(mockAxios.history.post[0].data).equals(Buffer.from(pnraSerialized)), + ).toBeTruthy(); + }); + + test('An invalid response content type should be refused', async () => { + const invalidContentType = 'text/plain'; + mockAxios + .onPost('/nodes') + .reply(200, expectedRegistrationSerialized, { 'content-type': invalidContentType }); + + await expect(client.registerNode(pnraSerialized)).rejects.toEqual( + new ServerError(`Server responded with invalid content type (${invalidContentType})`), + ); + }); + + test('20X response status other than 200 should throw an error', async () => { + const statusCode = 201; + mockAxios + .onPost('/nodes') + .reply(statusCode, expectedRegistrationSerialized, { 'content-type': PNR_CONTENT_TYPE }); + + await expect(client.registerNode(pnraSerialized)).rejects.toEqual( + new ServerError(`Unexpected response status (${statusCode})`), + ); + }); + + test('Malformed registrations should be refused', async () => { + const invalidRegistration = Buffer.from('invalid'); + mockAxios + .onPost('/nodes') + .reply(200, bufferToArray(invalidRegistration), { 'content-type': PNR_CONTENT_TYPE }); + + await expect(client.registerNode(pnraSerialized)).rejects.toMatchObject({ + message: /^Malformed registration received/, + }); + }); + + test('Registration should be output if response status is 200', async () => { + mockAxios + .onPost('/nodes') + .reply(200, expectedRegistrationSerialized, { 'content-type': PNR_CONTENT_TYPE }); + + const registration = await client.registerNode(pnraSerialized); + + expect( + expectedRegistration.privateNodeCertificate.isEqual(registration.privateNodeCertificate), + ).toBeTruthy(); + expect( + expectedRegistration.gatewayCertificate.isEqual(registration.gatewayCertificate), + ).toBeTruthy(); + }); }); }); diff --git a/src/lib/PoWebClient.ts b/src/lib/PoWebClient.ts index 99ac3f46..6e84f0b0 100644 --- a/src/lib/PoWebClient.ts +++ b/src/lib/PoWebClient.ts @@ -1,6 +1,19 @@ +import { PrivateNodeRegistration } from '@relaycorp/relaynet-core'; import axios, { AxiosInstance } from 'axios'; import { Agent as HttpAgent } from 'http'; import { Agent as HttpsAgent } from 'https'; +import { ServerError } from './errors'; + +const DEFAULT_LOCAL_PORT = 276; +const DEFAULT_REMOVE_PORT = 443; + +const DEFAULT_LOCAL_TIMEOUT_MS = 3_000; +const DEFAULT_REMOTE_TIMEOUT_MS = 5_000; + +const OCTETS_IN_ONE_MIB = 2 ** 20; + +export const PNRA_CONTENT_TYPE = 'application/vnd.relaynet.node-registration.authorization'; +export const PNR_CONTENT_TYPE = 'application/vnd.relaynet.node-registration.registration'; /** * PoWeb client. @@ -13,8 +26,8 @@ export class PoWebClient { * * TLS won't be used. */ - public static initLocal(port: number = PoWebClient.DEFAULT_LOCAL_PORT): PoWebClient { - return new PoWebClient('127.0.0.1', port, false, PoWebClient.DEFAULT_LOCAL_TIMEOUT_MS); + public static initLocal(port: number = DEFAULT_LOCAL_PORT): PoWebClient { + return new PoWebClient('127.0.0.1', port, false, DEFAULT_LOCAL_TIMEOUT_MS); } /** @@ -23,18 +36,24 @@ export class PoWebClient { * @param hostName The IP address or domain for the PoWeb server * @param port The port for the PoWeb server */ - public static initRemote( - hostName: string, - port: number = PoWebClient.DEFAULT_REMOVE_PORT, - ): PoWebClient { - return new PoWebClient(hostName, port, true, PoWebClient.DEFAULT_REMOTE_TIMEOUT_MS); + public static initRemote(hostName: string, port: number = DEFAULT_REMOVE_PORT): PoWebClient { + return new PoWebClient(hostName, port, true, DEFAULT_REMOTE_TIMEOUT_MS); } - private static readonly DEFAULT_LOCAL_PORT = 276; - private static readonly DEFAULT_REMOVE_PORT = 443; + private static requireResponseStatusToEqual(actualStatus: number, expectedStatus: number): void { + if (actualStatus !== expectedStatus) { + throw new ServerError(`Unexpected response status (${actualStatus})`); + } + } - private static readonly DEFAULT_LOCAL_TIMEOUT_MS = 3_000; - private static readonly DEFAULT_REMOTE_TIMEOUT_MS = 5_000; + private static requireResponseContentTypeToEqual( + actualContentType: string, + expectedContentType: string, + ): void { + if (actualContentType !== expectedContentType) { + throw new ServerError(`Server responded with invalid content type (${actualContentType})`); + } + } /** * @internal @@ -49,11 +68,52 @@ export class PoWebClient { ) { const httpSchema = useTLS ? 'https' : 'http'; const agentName = useTLS ? 'httpsAgent' : 'httpAgent'; - const agent = useTLS ? new HttpsAgent({ keepAlive: true }) : new HttpAgent({ keepAlive: true }); + const agentClass = useTLS ? HttpsAgent : HttpAgent; this.internalAxios = axios.create({ + [agentName]: new agentClass({ keepAlive: true }), baseURL: `${httpSchema}://${hostName}:${port}/v1`, - [agentName]: agent, + maxContentLength: OCTETS_IN_ONE_MIB, + maxRedirects: 0, + responseType: 'arraybuffer', timeout: timeoutMs, }); } + + /** + * Request a Private Node Registration Authorization (PNRA). + * + * @return The PNRA serialized + * @throws [ServerError] If the server doesn't adhere to the protocol + */ + public async preRegisterNode(): Promise { + const response = await this.internalAxios.post('/pre-registrations'); + + PoWebClient.requireResponseStatusToEqual(response.status, 200); + PoWebClient.requireResponseContentTypeToEqual( + response.headers['content-type'], + PNRA_CONTENT_TYPE, + ); + + return response.data; + } + + /** + * Register a private node. + * + * @param pnrrSerialized The Private Node Registration Request + */ + public async registerNode(pnrrSerialized: ArrayBuffer): Promise { + const response = await this.internalAxios.post('/nodes', pnrrSerialized); + PoWebClient.requireResponseStatusToEqual(response.status, 200); + PoWebClient.requireResponseContentTypeToEqual( + response.headers['content-type'], + PNR_CONTENT_TYPE, + ); + + try { + return PrivateNodeRegistration.deserialize(response.data); + } catch (exc) { + throw new ServerError(exc, 'Malformed registration received'); + } + } } diff --git a/src/lib/errors.ts b/src/lib/errors.ts new file mode 100644 index 00000000..7c8c23e2 --- /dev/null +++ b/src/lib/errors.ts @@ -0,0 +1,7 @@ +// tslint:disable:max-classes-per-file + +import { VError } from 'verror'; + +abstract class PoWebError extends VError {} + +export class ServerError extends PoWebError {} diff --git a/src/types/buffer-to-arraybuffer.d.ts b/src/types/buffer-to-arraybuffer.d.ts new file mode 100644 index 00000000..30a97d0d --- /dev/null +++ b/src/types/buffer-to-arraybuffer.d.ts @@ -0,0 +1,4 @@ +declare module 'buffer-to-arraybuffer' { + function bufferToArray(buffer: Buffer): ArrayBuffer; + export = bufferToArray; +} diff --git a/src/types/pkijs.d.ts b/src/types/pkijs.d.ts new file mode 100644 index 00000000..dc9ebb3c --- /dev/null +++ b/src/types/pkijs.d.ts @@ -0,0 +1,8 @@ +/* tslint:disable:no-implicit-dependencies no-submodule-imports */ + +// Workaround for https://github.com/relaycorp/relaynet-core-js/issues/52 + +declare module 'pkijs' { + export { default as Certificate } from 'pkijs/src/Certificate'; + export { default as EnvelopedData } from 'pkijs/src/EnvelopedData'; +}