diff --git a/doc/provider.markdown b/doc/provider.markdown index 550e88bd..a5059094 100644 --- a/doc/provider.markdown +++ b/doc/provider.markdown @@ -101,7 +101,7 @@ If you wish to send notifications containing emoji or other multi-byte character Indicate to node-apn that it should close all open connections when the queue of pending notifications is fully drained. This will allow your application to terminate. -**Note:** If notifications are pushed after the connection has completely shutdown a new connection will be established. However, the shutdown flag will remain and after the notifications are sent the connections will be optimistically shutdown again. Do not rely on this behaviour, it's more of a quirk. +**Note:** If notifications are pushed after the connection has started, an error will be thrown. [provider-api]: https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingwithAPNs.html [provider-auth-tokens]: https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingwithAPNs.html#//apple_ref/doc/uid/TP40008194-CH11-SW1 diff --git a/lib/client.js b/lib/client.js index f45b4261..874fa4d7 100644 --- a/lib/client.js +++ b/lib/client.js @@ -14,64 +14,108 @@ module.exports = function (dependencies) { HTTP2_HEADER_METHOD, HTTP2_HEADER_AUTHORITY, HTTP2_HEADER_PATH, - HTTP2_METHOD_POST + HTTP2_METHOD_POST, + NGHTTP2_CANCEL, } = http2.constants; + const TIMEOUT_STATUS = '(timeout)'; + const ABORTED_STATUS = '(aborted)'; + const ERROR_STATUS = '(error)'; + function Client (options) { this.config = config(options); this.healthCheckInterval = setInterval(() => { - if (this.session && !this.session.destroyed) { - this.session.ping((error, duration) => { - if (error) { - logger("No Ping response after " + duration + " ms"); - } - logger("Ping response after " + duration + " ms"); - }) - } - }, this.config.heartBeat).unref(); + if (this.session && !this.session.closed && !this.session.destroyed && !this.isDestroyed) { + this.session.ping((error, duration) => { + if (error) { + logger("No Ping response after " + duration + " ms with error:" + error.message); + return; + } + logger("Ping response after " + duration + " ms"); + }); + } + }, this.config.heartBeat).unref(); + } + + // Session should be passed except when destroying the client + Client.prototype.destroySession = function (session, callback) { + if (!session) { + session = this.session; + } + if (session) { + if (this.session === session) { + this.session = null; + } + if (!session.destroyed) { + session.destroy(); + } + } + if (callback) { + callback(); + } + } + + // Session should be passed except when destroying the client + Client.prototype.closeAndDestroySession = function (session, callback) { + if (!session) { + session = this.session; + } + if (session) { + if (this.session === session) { + this.session = null; + } + if (!session.closed) { + session.close(() => this.destroySession(session, callback)); + } else { + this.destroySession(session, callback) + } + } else if (callback) { + callback(); + } } Client.prototype.write = function write (notification, device, count) { + if (this.isDestroyed) { + return Promise.resolve({ device, error: new VError("client is destroyed") }); + } + // Connect session - if (!this.session || this.session.destroyed) { - this.session = http2.connect(`https://${this.config.address}`, this.config); + if (!this.session || this.session.closed || this.session.destroyed) { + const session = this.session = http2.connect(this._mockOverrideUrl || `https://${this.config.address}`, this.config); + + this.session.on("close", () => { + if (logger.enabled) { + logger("Session closed"); + } + this.destroySession(session); + }); this.session.on("socketError", (error) => { if (logger.enabled) { logger(`Socket error: ${error}`); } - if (this.session && !this.session.destroyed) { - this.session.destroy(); - } + this.closeAndDestroySession(session); }); + this.session.on("error", (error) => { if (logger.enabled) { logger(`Session error: ${error}`); } - if (this.session && !this.session.destroyed) { - this.session.destroy(); - } + this.closeAndDestroySession(session); }); this.session.on("goaway", (errorCode, lastStreamId, opaqueData) => { - logger(`GOAWAY received: (errorCode ${errorCode}, lastStreamId: ${lastStreamId}, opaqueData: ${opaqueData})`); - // gracefully stop accepting new streams - const session = this.session; - this.session = undefined; - if (session && !session.destroyed) { - session.close(() => { - session.destroy(); - }); - } - }); + if (logger.enabled) { + logger(`GOAWAY received: (errorCode ${errorCode}, lastStreamId: ${lastStreamId}, opaqueData: ${opaqueData})`); + } + this.closeAndDestroySession(session); + }); if (logger.enabled) { this.session.on("connect", () => { logger("Session connected"); }); - this.session.on("close", () => { - logger("Session closed"); - }); + this.session.on("frameError", (frameType, errorCode, streamId) => { logger(`Frame error: (frameType: ${frameType}, errorCode ${errorCode}, streamId: ${streamId})`); }); @@ -114,62 +158,107 @@ module.exports = function (dependencies) { return new Promise ( resolve => { request.on("end", () => { - if (logger.enabled) { - logger(`Request ended with status ${status} and responseData: ${responseData}`); - } - - if (status === 200) { - resolve({ device }); - } else if (responseData !== "") { - const response = JSON.parse(responseData); + try { + if (logger.enabled) { + logger(`Request ended with status ${status} and responseData: ${responseData}`); + } - if (status === 403 && response.reason === "ExpiredProviderToken" && retryCount < 2) { - this.config.token.regenerate(tokenGeneration); - resolve(this.write(notification, device, retryCount + 1)); + if (status === 200) { + resolve({ device }); + } else if ([TIMEOUT_STATUS, ABORTED_STATUS, ERROR_STATUS].includes(status)) { return; - } else if (status === 500 && response.reason === "InternalServerError") { - this.session.destroy(); - let error = new VError("Error 500, stream ended unexpectedly"); + } else if (responseData !== "") { + const response = JSON.parse(responseData); + + if (status === 403 && response.reason === "ExpiredProviderToken" && retryCount < 2) { + this.config.token.regenerate(tokenGeneration); + resolve(this.write(notification, device, retryCount + 1)); + return; + } else if (status === 500 && response.reason === "InternalServerError") { + this.closeAndDestroySession(); + let error = new VError("Error 500, stream ended unexpectedly"); + resolve({ device, error }); + return; + } + + resolve({ device, status, response }); + } else { + let error = new VError("stream ended unexpectedly"); resolve({ device, error }); - return; } - - resolve({ device, status, response }); - } else { - let error = new VError("stream ended unexpectedly"); + } catch (e) { + const error = new VError(e, 'Unexpected error processing APNs response'); + if (logger.enabled) { + logger(`Unexpected error processing APNs response: ${e.message}`); + } resolve({ device, error }); } - }) + }); + + request.setTimeout(this.config.requestTimeout, () => { + if (logger.enabled) { + logger('Request timeout'); + } + + status = TIMEOUT_STATUS; + + request.close(NGHTTP2_CANCEL); + + resolve({ device, error: new VError("apn write timeout") }); + }); + + request.on("aborted", () => { + if (logger.enabled) { + logger('Request aborted'); + } + + status = ABORTED_STATUS; + + resolve({ device, error: new VError("apn write aborted") }); + }); request.on("error", (error) => { if (logger.enabled) { logger(`Request error: ${error}`); } + status = ERROR_STATUS; + if (typeof error === "string") { - error = new VError("apn write failed: %s", err); + error = new VError("apn write failed: %s", error); } else { error = new VError(error, "apn write failed"); } + resolve({ device, error }); }); + if (logger.enabled) { + request.on("frameError", (frameType, errorCode, streamId) => { + logger(`Request frame error: (frameType: ${frameType}, errorCode ${errorCode}, streamId: ${streamId})`); + }); + } + request.end(); }); }; Client.prototype.shutdown = function shutdown(callback) { + if (this.isDestroyed) { + if (callback) { + callback(); + } + return; + } + if (logger.enabled) { + logger('Called client.shutdown()'); + } + this.isDestroyed = true; if (this.healthCheckInterval) { clearInterval(this.healthCheckInterval); + this.healthCheckInterval = null; } - if (this.session && !this.session.destroyed) { - this.session.close(() => { - this.session.destroy(); - if (callback) { - callback(); - } - }); - } + this.closeAndDestroySession(undefined, callback); }; return Client; diff --git a/lib/config.js b/lib/config.js index 4451cb5c..7720da61 100644 --- a/lib/config.js +++ b/lib/config.js @@ -28,6 +28,7 @@ module.exports = function(dependencies) { rejectUnauthorized: true, connectionRetryLimit: 10, heartBeat: 60000, + requestTimeout: 5000, }; validateOptions(options); diff --git a/test/client.js b/test/client.js index 1b00ebeb..2101dc1d 100644 --- a/test/client.js +++ b/test/client.js @@ -1,39 +1,501 @@ "use strict"; -const sinon = require("sinon"); -const stream = require("stream"); -const EventEmitter = require("events"); - -function builtNotification() { - return { - headers: {}, - body: JSON.stringify({ aps: { badge: 1 } }), +const VError = require("verror"); +const http2 = require("http2"); +const util = require("util"); + +const debug = require("debug")("apn"); +const credentials = require("../lib/credentials")({ + logger: debug +}); + +const TEST_PORT = 30939; +const LOAD_TEST_BATCH_SIZE = 2000; + +const config = require("../lib/config")({ + logger: debug, + prepareCertificate: () => ({}), // credentials.certificate, + prepareToken: credentials.token, + prepareCA: credentials.ca, +}); +const Client = require("../lib/client")({ + logger: debug, + config, + http2, +}); +debug.log = console.log.bind(console); + +// function builtNotification() { +// return { +// headers: {}, +// body: JSON.stringify({ aps: { badge: 1 } }), +// }; +// } + +// function FakeStream(deviceId, statusCode, response) { +// const fakeStream = new stream.Transform({ +// transform: sinon.spy(function(chunk, encoding, callback) { +// expect(this.headers).to.be.calledOnce; +// +// const headers = this.headers.firstCall.args[0]; +// expect(headers[":path"].substring(10)).to.equal(deviceId); +// +// this.emit("headers", { +// ":status": statusCode +// }); +// callback(null, Buffer.from(JSON.stringify(response) || "")); +// }) +// }); +// fakeStream.headers = sinon.stub(); +// +// return fakeStream; +// } + +// XXX these may be flaky in CI due to being sensitive to timing, +// and if a test case crashes, then others may get stuck. +// +// Try to fix this if any issues come up. +describe("Client", () => { + let server; + let client; + const MOCK_BODY = '{"mock-key":"mock-value"}'; + const MOCK_DEVICE_TOKEN = 'abcf0123abcf0123abcf0123abcf0123abcf0123abcf0123abcf0123abcf0123'; + + // Create an insecure http2 client for unit testing. + // (APNS would use https://, not http://) + // (It's probably possible to allow accepting invalid certificates instead, + // but that's not the most important point of these tests) + const createClient = (port, timeout = 500) => { + let c = new Client({ + port: TEST_PORT, + address: '127.0.0.1', + }); + c._mockOverrideUrl = `http://127.0.0.1:${port}`; + c.config.port = port; + c.config.address = '127.0.0.1'; + c.config.requestTimeout = timeout; + return c; + }; + // Create an insecure server for unit testing. + const createAndStartMockServer = (port, cb) => { + server = http2.createServer((req, res) => { + var buffers = []; + req.on('data', (data) => buffers.push(data)); + req.on('end', () => { + const requestBody = Buffer.concat(buffers).toString('utf-8'); + cb(req, res, requestBody); + }); + }); + server.listen(port); + server.on('error', (err) => { + expect.fail(`unexpected error ${err}`); + }); + // Don't block the tests if this server doesn't shut down properly + server.unref(); + return server; + }; + const createAndStartMockLowLevelServer = (port, cb) => { + server = http2.createServer(); + server.on('stream', cb); + server.listen(port); + server.on('error', (err) => { + expect.fail(`unexpected error ${err}`); + }); + // Don't block the tests if this server doesn't shut down properly + server.unref(); + return server; }; -} -function FakeStream(deviceId, statusCode, response) { - const fakeStream = new stream.Transform({ - transform: sinon.spy(function(chunk, encoding, callback) { - expect(this.headers).to.be.calledOnce; + afterEach((done) => { + let closeServer = () => { + if (server) { + server.close(); + server = null; + } + done(); + }; + if (client) { + client.shutdown(closeServer); + client = null; + } else { + closeServer(); + } + }); - const headers = this.headers.firstCall.args[0]; - expect(headers[":path"].substring(10)).to.equal(deviceId); + it("Treats HTTP 200 responses as successful", async () => { + let didRequest = false; + let establishedConnections = 0; + let requestsServed = 0; + server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { + expect(req.headers).to.deep.equal({ + ':authority': '127.0.0.1', + ':method': 'POST', + ':path': `/3/device/${MOCK_DEVICE_TOKEN}`, + ':scheme': 'https', + 'apns-someheader': 'somevalue', + }); + expect(requestBody).to.equal(MOCK_BODY); + // res.setHeader('X-Foo', 'bar'); + // res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.writeHead(200); + res.end(''); + requestsServed += 1; + didRequest = true; + }); + server.on('connection', () => establishedConnections += 1); + await new Promise((resolve) => server.on('listening', resolve)); + + client = createClient(TEST_PORT); + + const runSuccessfulRequest = async () => { + const mockHeaders = {'apns-someheader': 'somevalue'}; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const mockDevice = MOCK_DEVICE_TOKEN; + const result = await client.write( + mockNotification, + mockDevice, + ); + expect(result).to.deep.equal({ device: MOCK_DEVICE_TOKEN }); + expect(didRequest).to.be.true; + }; + expect(establishedConnections).to.equal(0); // should not establish a connection until it's needed + // Validate that when multiple valid requests arrive concurrently, + // only one HTTP/2 connection gets established + await Promise.all([ + runSuccessfulRequest(), + runSuccessfulRequest(), + runSuccessfulRequest(), + runSuccessfulRequest(), + runSuccessfulRequest(), + ]); + didRequest = false; + await runSuccessfulRequest(); + expect(establishedConnections).to.equal(1); // should establish a connection to the server and reuse it + expect(requestsServed).to.equal(6); + }); - this.emit("headers", { - ":status": statusCode + // Assert that this doesn't crash when a large batch of requests are requested simultaneously + it("Treats HTTP 200 responses as successful (load test for a batch of requests)", async function () { + this.timeout(10000); + let establishedConnections = 0; + let requestsServed = 0; + server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { + expect(req.headers).to.deep.equal({ + ':authority': '127.0.0.1', + ':method': 'POST', + ':path': `/3/device/${MOCK_DEVICE_TOKEN}`, + ':scheme': 'https', + 'apns-someheader': 'somevalue', }); - callback(null, Buffer.from(JSON.stringify(response) || "")); - }) + expect(requestBody).to.equal(MOCK_BODY); + // Set a timeout of 100 to simulate latency to a remote server. + setTimeout(() => { + res.writeHead(200); + res.end(''); + requestsServed += 1; + }, 100); + }); + server.on('connection', () => establishedConnections += 1); + await new Promise((resolve) => server.on('listening', resolve)); + + client = createClient(TEST_PORT, 1500); + + const runSuccessfulRequest = async () => { + const mockHeaders = {'apns-someheader': 'somevalue'}; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const mockDevice = MOCK_DEVICE_TOKEN; + const result = await client.write( + mockNotification, + mockDevice, + ); + expect(result).to.deep.equal({ device: MOCK_DEVICE_TOKEN }); + }; + expect(establishedConnections).to.equal(0); // should not establish a connection until it's needed + // Validate that when multiple valid requests arrive concurrently, + // only one HTTP/2 connection gets established + const promises = []; + for (let i = 0; i < LOAD_TEST_BATCH_SIZE; i++) { + promises.push(runSuccessfulRequest()); + } + + await Promise.all(promises); + expect(establishedConnections).to.equal(1); // should establish a connection to the server and reuse it + expect(requestsServed).to.equal(LOAD_TEST_BATCH_SIZE); }); - fakeStream.headers = sinon.stub(); - return fakeStream; -} + // https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/handling_notification_responses_from_apns + it("JSON decodes HTTP 400 responses", async () => { + let didRequest = false; + let establishedConnections = 0; + server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { + expect(requestBody).to.equal(MOCK_BODY); + // res.setHeader('X-Foo', 'bar'); + // res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.writeHead(400); + res.end('{"reason": "BadDeviceToken"}'); + didRequest = true; + }); + server.on('connection', () => establishedConnections += 1); + await new Promise((resolve) => server.on('listening', resolve)); + + client = createClient(TEST_PORT); + + const runRequestWithBadDeviceToken = async () => { + const mockHeaders = {'apns-someheader': 'somevalue'}; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const mockDevice = MOCK_DEVICE_TOKEN; + const result = await client.write( + mockNotification, + mockDevice, + ); + expect(result).to.deep.equal({ + device: MOCK_DEVICE_TOKEN, + response: { + "reason": "BadDeviceToken", + }, + status: 400, + }); + expect(didRequest).to.be.true; + didRequest = false; + }; + await runRequestWithBadDeviceToken(); + await runRequestWithBadDeviceToken(); + expect(establishedConnections).to.equal(1); // should establish a connection to the server and reuse it + }); + + // node-apn started closing connections in response to a bug report where HTTP 500 responses + // persisted until a new connection was reopened + it("Closes connections when HTTP 500 responses are received", async () => { + let establishedConnections = 0; + let responseDelay = 50; + server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { + // Wait 50ms before sending the responses in parallel + setTimeout(() => { + expect(requestBody).to.equal(MOCK_BODY); + res.writeHead(500); + res.end('{"reason": "InternalServerError"}'); + }, responseDelay); + }); + server.on('connection', () => establishedConnections += 1); + await new Promise((resolve) => server.on('listening', resolve)); + + client = createClient(TEST_PORT); + + const runRequestWithInternalServerError = async () => { + const mockHeaders = {'apns-someheader': 'somevalue'}; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const mockDevice = MOCK_DEVICE_TOKEN; + const result = await client.write( + mockNotification, + mockDevice, + ); + expect(result).to.exist; + expect(result.device).to.equal(MOCK_DEVICE_TOKEN); + expect(result.error).to.be.an.instanceof(VError); + expect(result.error.message).to.have.string('stream ended unexpectedly'); + }; + await runRequestWithInternalServerError(); + await runRequestWithInternalServerError(); + await runRequestWithInternalServerError(); + expect(establishedConnections).to.equal(3); // should close and establish new connections on http 500 + // Validate that nothing wrong happens when multiple HTTP 500s are received simultaneously. + // (no segfaults, all promises get resolved, etc.) + responseDelay = 50; + await Promise.all([ + runRequestWithInternalServerError(), + runRequestWithInternalServerError(), + runRequestWithInternalServerError(), + runRequestWithInternalServerError(), + ]); + expect(establishedConnections).to.equal(4); // should close and establish new connections on http 500 + }); + + it("Handles unexpected invalid JSON responses", async () => { + let establishedConnections = 0; + let responseDelay = 0; + server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { + // Wait 50ms before sending the responses in parallel + setTimeout(() => { + expect(requestBody).to.equal(MOCK_BODY); + res.writeHead(500); + res.end('PC LOAD LETTER'); + }, responseDelay); + }); + server.on('connection', () => establishedConnections += 1); + await new Promise((resolve) => server.on('listening', resolve)); + + client = createClient(TEST_PORT); + + const runRequestWithInternalServerError = async () => { + const mockHeaders = {'apns-someheader': 'somevalue'}; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const mockDevice = MOCK_DEVICE_TOKEN; + const result = await client.write( + mockNotification, + mockDevice, + ); + // Should not happen, but if it does, the promise should resolve with an error + expect(result.device).to.equal(MOCK_DEVICE_TOKEN); + expect(result.error.message).to.equal('Unexpected error processing APNs response: Unexpected token P in JSON at position 0'); + }; + await runRequestWithInternalServerError(); + await runRequestWithInternalServerError(); + expect(establishedConnections).to.equal(1); // Currently reuses the connection. + }); + + it("Handles APNs timeouts", async () => { + let didGetRequest = false; + let didGetResponse = false; + server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { + didGetRequest = true; + setTimeout(() => { + res.writeHead(200); + res.end(''); + didGetResponse = true; + }, 1900); + }); + client = createClient(TEST_PORT); + + const onListeningPromise = new Promise((resolve) => server.on('listening', resolve));; + await onListeningPromise; + + const mockHeaders = {'apns-someheader': 'somevalue'}; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const mockDevice = MOCK_DEVICE_TOKEN; + const performRequestExpectingTimeout = async () => { + const result = await client.write( + mockNotification, + mockDevice, + ); + expect(result).to.deep.equal({ + device: MOCK_DEVICE_TOKEN, + error: new VError('apn write timeout'), + }); + expect(didGetRequest).to.be.true; + expect(didGetResponse).to.be.false; + }; + await performRequestExpectingTimeout(); + didGetResponse = false; + didGetRequest = false; + // Should be able to have multiple in flight requests all get notified that the server is shutting down + await Promise.all([ + performRequestExpectingTimeout(), + performRequestExpectingTimeout(), + performRequestExpectingTimeout(), + performRequestExpectingTimeout(), + ]); + }); + + it("Handles goaway frames", async () => { + let didGetRequest = false; + let establishedConnections = 0; + server = createAndStartMockLowLevelServer(TEST_PORT, (stream) => { + const session = stream.session; + const errorCode = 1; + didGetRequest = true; + session.goaway(errorCode); + }); + server.on('connection', () => establishedConnections += 1); + client = createClient(TEST_PORT); + + const onListeningPromise = new Promise((resolve) => server.on('listening', resolve));; + await onListeningPromise; + + const mockHeaders = {'apns-someheader': 'somevalue'}; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const mockDevice = MOCK_DEVICE_TOKEN; + const performRequestExpectingGoAway = async () => { + const result = await client.write( + mockNotification, + mockDevice, + ); + expect(result).to.deep.equal({ + device: MOCK_DEVICE_TOKEN, + error: new VError('stream ended unexpectedly'), + }); + expect(didGetRequest).to.be.true; + didGetRequest = false; + }; + await performRequestExpectingGoAway(); + await performRequestExpectingGoAway(); + expect(establishedConnections).to.equal(2); + }); + + it("Handles unexpected protocol errors (no response sent)", async () => { + let didGetRequest = false; + let establishedConnections = 0; + let responseTimeout = 0; + server = createAndStartMockLowLevelServer(TEST_PORT, (stream) => { + setTimeout(() => { + const session = stream.session; + didGetRequest = true; + if (session) { + session.destroy(); + } + }, responseTimeout); + }); + server.on('connection', () => establishedConnections += 1); + client = createClient(TEST_PORT); + + const onListeningPromise = new Promise((resolve) => server.on('listening', resolve));; + await onListeningPromise; + + const mockHeaders = {'apns-someheader': 'somevalue'}; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const mockDevice = MOCK_DEVICE_TOKEN; + const performRequestExpectingDisconnect = async () => { + const result = await client.write( + mockNotification, + mockDevice, + ); + expect(result).to.deep.equal({ + device: MOCK_DEVICE_TOKEN, + error: new VError('stream ended unexpectedly'), + }); + expect(didGetRequest).to.be.true; + }; + await performRequestExpectingDisconnect(); + didGetRequest = false; + await performRequestExpectingDisconnect(); + didGetRequest = false; + expect(establishedConnections).to.equal(2); + responseTimeout = 10; + await Promise.all([ + performRequestExpectingDisconnect(), + performRequestExpectingDisconnect(), + performRequestExpectingDisconnect(), + performRequestExpectingDisconnect(), + ]); + expect(establishedConnections).to.equal(3); + }); -describe("Client", function () { // let fakes, Client; - // beforeEach(function () { + // beforeEach(() => { // fakes = { // config: sinon.stub(), // EndpointManager: sinon.stub(), @@ -46,8 +508,8 @@ describe("Client", function () { // Client = require("../lib/client")(fakes); // }); - // describe("constructor", function () { - // it("prepares the configuration with passed options", function () { + // describe("constructor", () => { + // it("prepares the configuration with passed options", () => { // let options = { production: true }; // let client = new Client(options); @@ -55,14 +517,14 @@ describe("Client", function () { // }); // describe("EndpointManager instance", function() { - // it("is created", function () { + // it("is created", () => { // let client = new Client(); // expect(fakes.EndpointManager).to.be.calledOnce; // expect(fakes.EndpointManager).to.be.calledWithNew; // }); - // it("is passed the prepared configuration", function () { + // it("is passed the prepared configuration", () => { // const returnSentinel = { "configKey": "configValue"}; // fakes.config.returns(returnSentinel); @@ -72,37 +534,37 @@ describe("Client", function () { // }); // }); - describe("write", function () { - // beforeEach(function () { + describe("write", () => { + // beforeEach(() => { // fakes.config.returnsArg(0); // fakes.endpointManager.getStream = sinon.stub(); // fakes.EndpointManager.returns(fakes.endpointManager); // }); - // context("a stream is available", function () { + // context("a stream is available", () => { // let client; - // context("transmission succeeds", function () { - // beforeEach( function () { + // context("transmission succeeds", () => { + // beforeEach( () => { // client = new Client( { address: "testapi" } ); // fakes.stream = new FakeStream("abcd1234", "200"); // fakes.endpointManager.getStream.onCall(0).returns(fakes.stream); // }); - // it("attempts to acquire one stream", function () { + // it("attempts to acquire one stream", () => { // return client.write(builtNotification(), "abcd1234") - // .then(function () { + // .then(() => { // expect(fakes.endpointManager.getStream).to.be.calledOnce; // }); // }); - // describe("headers", function () { + // describe("headers", () => { - // it("sends the required HTTP/2 headers", function () { + // it("sends the required HTTP/2 headers", () => { // return client.write(builtNotification(), "abcd1234") - // .then(function () { + // .then(() => { // expect(fakes.stream.headers).to.be.calledWithMatch( { // ":scheme": "https", // ":method": "POST", @@ -112,16 +574,16 @@ describe("Client", function () { // }); // }); - // it("does not include apns headers when not required", function () { + // it("does not include apns headers when not required", () => { // return client.write(builtNotification(), "abcd1234") - // .then(function () { + // .then(() => { // ["apns-id", "apns-priority", "apns-expiration", "apns-topic"].forEach( header => { // expect(fakes.stream.headers).to.not.be.calledWithMatch(sinon.match.has(header)); // }); // }); // }); - // it("sends the notification-specific apns headers when specified", function () { + // it("sends the notification-specific apns headers when specified", () => { // let notification = builtNotification(); // notification.headers = { @@ -132,7 +594,7 @@ describe("Client", function () { // }; // return client.write(notification, "abcd1234") - // .then(function () { + // .then(() => { // expect(fakes.stream.headers).to.be.calledWithMatch( { // "apns-id": "123e4567-e89b-12d3-a456-42665544000", // "apns-priority": 5, @@ -142,8 +604,8 @@ describe("Client", function () { // }); // }); - // context("when token authentication is enabled", function () { - // beforeEach(function () { + // context("when token authentication is enabled", () => { + // beforeEach(() => { // fakes.token = { // generation: 0, // current: "fake-token", @@ -157,10 +619,10 @@ describe("Client", function () { // fakes.endpointManager.getStream.onCall(0).returns(fakes.stream); // }); - // it("sends the bearer token", function () { + // it("sends the bearer token", () => { // let notification = builtNotification(); - // return client.write(notification, "abcd1234").then(function () { + // return client.write(notification, "abcd1234").then(() => { // expect(fakes.stream.headers).to.be.calledWithMatch({ // authorization: "bearer fake-token", // }); @@ -168,51 +630,51 @@ describe("Client", function () { // }); // }); - // context("when token authentication is disabled", function () { - // beforeEach(function () { + // context("when token authentication is disabled", () => { + // beforeEach(() => { // client = new Client( { address: "testapi" } ); // fakes.stream = new FakeStream("abcd1234", "200"); // fakes.endpointManager.getStream.onCall(0).returns(fakes.stream); // }); - // it("does not set an authorization header", function () { + // it("does not set an authorization header", () => { // let notification = builtNotification(); - // return client.write(notification, "abcd1234").then(function () { + // return client.write(notification, "abcd1234").then(() => { // expect(fakes.stream.headers.firstCall.args[0]).to.not.have.property("authorization"); // }) // }); // }) // }); - // it("writes the notification data to the pipe", function () { + // it("writes the notification data to the pipe", () => { // const notification = builtNotification(); // return client.write(notification, "abcd1234") - // .then(function () { + // .then(() => { // expect(fakes.stream._transform).to.be.calledWithMatch(actual => actual.equals(Buffer.from(notification.body))); // }); // }); - // it("ends the stream", function () { + // it("ends the stream", () => { // sinon.spy(fakes.stream, "end"); // return client.write(builtNotification(), "abcd1234") - // .then(function () { + // .then(() => { // expect(fakes.stream.end).to.be.calledOnce; // }); // }); - // it("resolves with the device token", function () { + // it("resolves with the device token", () => { // return expect(client.write(builtNotification(), "abcd1234")) // .to.become({ device: "abcd1234" }); // }); // }); - // context("error occurs", function () { + // context("error occurs", () => { // let promise; - // context("general case", function () { - // beforeEach(function () { + // context("general case", () => { + // beforeEach(() => { // const client = new Client( { address: "testapi" } ); // fakes.stream = new FakeStream("abcd1234", "400", { "reason" : "BadDeviceToken" }); @@ -221,23 +683,23 @@ describe("Client", function () { // promise = client.write(builtNotification(), "abcd1234"); // }); - // it("resolves with the device token, status code and response", function () { + // it("resolves with the device token, status code and response", () => { // return expect(promise).to.eventually.deep.equal({ status: "400", device: "abcd1234", response: { reason: "BadDeviceToken" }}); // }); // }) - // context("ExpiredProviderToken", function () { - // beforeEach(function () { + // context("ExpiredProviderToken", () => { + // beforeEach(() => { // let tokenGenerator = sinon.stub().returns("fake-token"); // const client = new Client( { address: "testapi", token: tokenGenerator }); // }) // }); // }); - // context("stream ends without completing request", function () { + // context("stream ends without completing request", () => { // let promise; - // beforeEach(function () { + // beforeEach(() => { // const client = new Client( { address: "testapi" } ); // fakes.stream = new stream.Transform({ // transform: function(chunk, encoding, callback) {} @@ -251,11 +713,11 @@ describe("Client", function () { // fakes.stream.push(null); // }); - // it("resolves with an object containing the device token", function () { + // it("resolves with an object containing the device token", () => { // return expect(promise).to.eventually.have.property("device", "abcd1234"); // }); - // it("resolves with an object containing an error", function () { + // it("resolves with an object containing an error", () => { // return promise.then( (response) => { // expect(response).to.have.property("error"); // expect(response.error).to.be.an.instanceOf(Error); @@ -264,10 +726,10 @@ describe("Client", function () { // }); // }); - // context("stream is unprocessed", function () { + // context("stream is unprocessed", () => { // let promise; - // beforeEach(function () { + // beforeEach(() => { // const client = new Client( { address: "testapi" } ); // fakes.stream = new stream.Transform({ // transform: function(chunk, encoding, callback) {} @@ -293,15 +755,15 @@ describe("Client", function () { // }); // }); - // it("fulfills the promise", function () { + // it("fulfills the promise", () => { // return expect(promise).to.eventually.deep.equal({ device: "abcd1234" }); // }); // }); - // context("stream error occurs", function () { + // context("stream error occurs", () => { // let promise; - // beforeEach(function () { + // beforeEach(() => { // const client = new Client( { address: "testapi" } ); // fakes.stream = new stream.Transform({ // transform: function(chunk, encoding, callback) {} @@ -313,16 +775,16 @@ describe("Client", function () { // promise = client.write(builtNotification(), "abcd1234"); // }); - // context("passing an Error", function () { - // beforeEach(function () { + // context("passing an Error", () => { + // beforeEach(() => { // fakes.stream.emit("error", new Error("stream error")); // }); - // it("resolves with an object containing the device token", function () { + // it("resolves with an object containing the device token", () => { // return expect(promise).to.eventually.have.property("device", "abcd1234"); // }); - // it("resolves with an object containing a wrapped error", function () { + // it("resolves with an object containing a wrapped error", () => { // return promise.then( (response) => { // expect(response.error).to.be.an.instanceOf(Error); // expect(response.error).to.match(/apn write failed/); @@ -331,8 +793,8 @@ describe("Client", function () { // }); // }); - // context("passing a string", function () { - // it("resolves with the device token and an error", function () { + // context("passing a string", () => { + // it("resolves with the device token and an error", () => { // fakes.stream.emit("error", "stream error"); // return promise.then( (response) => { // expect(response).to.have.property("device", "abcd1234"); @@ -345,10 +807,10 @@ describe("Client", function () { // }); // }); - // context("no new stream is returned but the endpoint later wakes up", function () { + // context("no new stream is returned but the endpoint later wakes up", () => { // let notification, promise; - // beforeEach( function () { + // beforeEach( () => { // const client = new Client( { address: "testapi" } ); // fakes.stream = new FakeStream("abcd1234", "200"); @@ -365,7 +827,7 @@ describe("Client", function () { // return promise; // }); - // it("sends the required headers to the newly available stream", function () { + // it("sends the required headers to the newly available stream", () => { // expect(fakes.stream.headers).to.be.calledWithMatch( { // ":scheme": "https", // ":method": "POST", @@ -374,14 +836,14 @@ describe("Client", function () { // }); // }); - // it("writes the notification data to the pipe", function () { + // it("writes the notification data to the pipe", () => { // expect(fakes.stream._transform).to.be.calledWithMatch(actual => actual.equals(Buffer.from(notification.body))); // }); // }); - // context("when 5 successive notifications are sent", function () { + // context("when 5 successive notifications are sent", () => { - // beforeEach(function () { + // beforeEach(() => { // fakes.streams = [ // new FakeStream("abcd1234", "200"), // new FakeStream("adfe5969", "400", { reason: "MissingTopic" }), @@ -391,10 +853,10 @@ describe("Client", function () { // ]; // }); - // context("streams are always returned", function () { + // context("streams are always returned", () => { // let promises; - // beforeEach( function () { + // beforeEach( () => { // const client = new Client( { address: "testapi" } ); // fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[0]); @@ -414,7 +876,7 @@ describe("Client", function () { // return promises; // }); - // it("sends the required headers for each stream", function () { + // it("sends the required headers for each stream", () => { // expect(fakes.streams[0].headers).to.be.calledWithMatch( { ":path": "/3/device/abcd1234" } ); // expect(fakes.streams[1].headers).to.be.calledWithMatch( { ":path": "/3/device/adfe5969" } ); // expect(fakes.streams[2].headers).to.be.calledWithMatch( { ":path": "/3/device/abcd1335" } ); @@ -422,13 +884,13 @@ describe("Client", function () { // expect(fakes.streams[4].headers).to.be.calledWithMatch( { ":path": "/3/device/aabbc788" } ); // }); - // it("writes the notification data for each stream", function () { + // it("writes the notification data for each stream", () => { // fakes.streams.forEach( stream => { // expect(stream._transform).to.be.calledWithMatch(actual => actual.equals(Buffer.from(builtNotification().body))); // }); // }); - // it("resolves with the notification outcomes", function () { + // it("resolves with the notification outcomes", () => { // return expect(promises).to.eventually.deep.equal([ // { device: "abcd1234"}, // { device: "adfe5969", status: "400", response: { reason: "MissingTopic" } }, @@ -439,7 +901,7 @@ describe("Client", function () { // }); // }); - // context("some streams return, others wake up later", function () { + // context("some streams return, others wake up later", () => { // let promises; // beforeEach( function() { @@ -456,14 +918,14 @@ describe("Client", function () { // client.write(builtNotification(), "aabbc788"), // ]); - // setTimeout(function () { + // setTimeout(() => { // fakes.endpointManager.getStream.reset(); // fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[2]); // fakes.endpointManager.getStream.onCall(1).returns(null); // fakes.endpointManager.emit("wakeup"); // }, 1); - // setTimeout(function () { + // setTimeout(() => { // fakes.endpointManager.getStream.reset(); // fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[3]); // fakes.endpointManager.getStream.onCall(1).returns(fakes.streams[4]); @@ -473,7 +935,7 @@ describe("Client", function () { // return promises; // }); - // it("sends the correct device ID for each stream", function () { + // it("sends the correct device ID for each stream", () => { // expect(fakes.streams[0].headers).to.be.calledWithMatch({":path": "/3/device/abcd1234"}); // expect(fakes.streams[1].headers).to.be.calledWithMatch({":path": "/3/device/adfe5969"}); // expect(fakes.streams[2].headers).to.be.calledWithMatch({":path": "/3/device/abcd1335"}); @@ -481,13 +943,13 @@ describe("Client", function () { // expect(fakes.streams[4].headers).to.be.calledWithMatch({":path": "/3/device/aabbc788"}); // }); - // it("writes the notification data for each stream", function () { + // it("writes the notification data for each stream", () => { // fakes.streams.forEach( stream => { // expect(stream._transform).to.be.calledWithMatch(actual => actual.equals(Buffer.from(builtNotification().body))); // }); // }); - // it("resolves with the notification reponses", function () { + // it("resolves with the notification reponses", () => { // return expect(promises).to.eventually.deep.equal([ // { device: "abcd1234"}, // { device: "adfe5969", status: "400", response: { reason: "MissingTopic" } }, @@ -498,7 +960,7 @@ describe("Client", function () { // }); // }); - // context("connection fails", function () { + // context("connection fails", () => { // let promises, client; // beforeEach( function() { @@ -512,7 +974,7 @@ describe("Client", function () { // client.write(builtNotification(), "abcd1335"), // ]); - // setTimeout(function () { + // setTimeout(() => { // fakes.endpointManager.getStream.reset(); // fakes.endpointManager.emit("error", new Error("endpoint failed")); // }, 1); @@ -520,20 +982,20 @@ describe("Client", function () { // return promises; // }); - // it("resolves with 1 success", function () { + // it("resolves with 1 success", () => { // return promises.then( response => { // expect(response[0]).to.deep.equal({ device: "abcd1234" }); // }); // }); - // it("resolves with 2 errors", function () { + // it("resolves with 2 errors", () => { // return promises.then( response => { // expect(response[1]).to.deep.equal({ device: "adfe5969", error: new Error("endpoint failed") }); // expect(response[2]).to.deep.equal({ device: "abcd1335", error: new Error("endpoint failed") }); // }) // }); - // it("clears the queue", function () { + // it("clears the queue", () => { // return promises.then( () => { // expect(client.queue.length).to.equal(0); // }); @@ -542,8 +1004,8 @@ describe("Client", function () { // }); - // describe("token generator behaviour", function () { - // beforeEach(function () { + // describe("token generator behaviour", () => { + // beforeEach(() => { // fakes.token = { // generation: 0, // current: "fake-token", @@ -558,10 +1020,10 @@ describe("Client", function () { // ]; // }); - // it("reuses the token", function () { + // it("reuses the token", () => { // const client = new Client( { address: "testapi", token: fakes.token } ); - // fakes.token.regenerate = function () { + // fakes.token.regenerate = () => { // fakes.token.generation = 1; // fakes.token.current = "second-token"; // } @@ -574,16 +1036,16 @@ describe("Client", function () { // client.write(builtNotification(), "abcd1234"), // client.write(builtNotification(), "adfe5969"), // client.write(builtNotification(), "abcd1335"), - // ]).then(function () { + // ]).then(() => { // expect(fakes.streams[0].headers).to.be.calledWithMatch({ authorization: "bearer fake-token" }); // expect(fakes.streams[1].headers).to.be.calledWithMatch({ authorization: "bearer fake-token" }); // expect(fakes.streams[2].headers).to.be.calledWithMatch({ authorization: "bearer fake-token" }); // }); // }); - // context("token expires", function () { + // context("token expires", () => { - // beforeEach(function () { + // beforeEach(() => { // fakes.token.regenerate = function (generation) { // if (generation === fakes.token.generation) { // fakes.token.generation += 1; @@ -592,7 +1054,7 @@ describe("Client", function () { // } // }); - // it("resends the notification with a new token", function () { + // it("resends the notification with a new token", () => { // fakes.streams = [ // new FakeStream("adfe5969", "403", { reason: "ExpiredProviderToken" }), // new FakeStream("adfe5969", "200"), @@ -604,19 +1066,19 @@ describe("Client", function () { // const promise = client.write(builtNotification(), "adfe5969"); - // setTimeout(function () { + // setTimeout(() => { // fakes.endpointManager.getStream.reset(); // fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[1]); // fakes.endpointManager.emit("wakeup"); // }, 1); - // return promise.then(function () { + // return promise.then(() => { // expect(fakes.streams[0].headers).to.be.calledWithMatch({ authorization: "bearer fake-token" }); // expect(fakes.streams[1].headers).to.be.calledWithMatch({ authorization: "bearer token-1" }); // }); // }); - // it("only regenerates the token once per-expiry", function () { + // it("only regenerates the token once per-expiry", () => { // fakes.streams = [ // new FakeStream("abcd1234", "200"), // new FakeStream("adfe5969", "403", { reason: "ExpiredProviderToken" }), @@ -637,14 +1099,14 @@ describe("Client", function () { // client.write(builtNotification(), "abcd1335"), // ]); - // setTimeout(function () { + // setTimeout(() => { // fakes.endpointManager.getStream.reset(); // fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[3]); // fakes.endpointManager.getStream.onCall(1).returns(fakes.streams[4]); // fakes.endpointManager.emit("wakeup"); // }, 1); - // return promises.then(function () { + // return promises.then(() => { // expect(fakes.streams[0].headers).to.be.calledWithMatch({ authorization: "bearer fake-token" }); // expect(fakes.streams[1].headers).to.be.calledWithMatch({ authorization: "bearer fake-token" }); // expect(fakes.streams[2].headers).to.be.calledWithMatch({ authorization: "bearer fake-token" }); @@ -653,7 +1115,7 @@ describe("Client", function () { // }); // }); - // it("abandons sending after 3 ExpiredProviderToken failures", function () { + // it("abandons sending after 3 ExpiredProviderToken failures", () => { // fakes.streams = [ // new FakeStream("adfe5969", "403", { reason: "ExpiredProviderToken" }), // new FakeStream("adfe5969", "403", { reason: "ExpiredProviderToken" }), @@ -669,7 +1131,7 @@ describe("Client", function () { // return expect(client.write(builtNotification(), "adfe5969")).to.eventually.have.property("status", "403"); // }); - // it("regenerate token", function () { + // it("regenerate token", () => { // fakes.stream = new FakeStream("abcd1234", "200"); // fakes.endpointManager.getStream.onCall(0).returns(fakes.stream); @@ -681,14 +1143,14 @@ describe("Client", function () { // address: "testapi", // token: fakes.token // }); - + // return client.write(builtNotification(), "abcd1234") - // .then(function () { + // .then(() => { // expect(fakes.token.generation).to.equal(1); // }); // }); - // it("internal server error", function () { + // it("internal server error", () => { // fakes.stream = new FakeStream("abcd1234", "500", { reason: "InternalServerError" }); // fakes.stream.connection = sinon.stub(); // fakes.stream.connection.close = sinon.stub(); @@ -698,23 +1160,23 @@ describe("Client", function () { // address: "testapi", // token: fakes.token // }); - + // return expect(client.write(builtNotification(), "abcd1234")).to.eventually.have.deep.property("error.jse_shortmsg","Error 500, stream ended unexpectedly"); // }); // }); // }); }); - describe("shutdown", function () { - // beforeEach(function () { + describe("shutdown", () => { + // beforeEach(() => { // fakes.config.returnsArg(0); // fakes.endpointManager.getStream = sinon.stub(); // fakes.EndpointManager.returns(fakes.endpointManager); // }); - // context("with no pending notifications", function () { - // it("invokes shutdown on endpoint manager", function () { + // context("with no pending notifications", () => { + // it("invokes shutdown on endpoint manager", () => { // let client = new Client(); // client.shutdown(); @@ -722,8 +1184,8 @@ describe("Client", function () { // }); // }); - // context("with pending notifications", function () { - // it("invokes shutdown on endpoint manager after queue drains", function () { + // context("with pending notifications", () => { + // it("invokes shutdown on endpoint manager after queue drains", () => { // let client = new Client({ address: "none" }); // fakes.streams = [ @@ -749,14 +1211,14 @@ describe("Client", function () { // expect(fakes.endpointManager.shutdown).to.not.be.called; - // setTimeout(function () { + // setTimeout(() => { // fakes.endpointManager.getStream.reset(); // fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[2]); // fakes.endpointManager.getStream.onCall(1).returns(null); // fakes.endpointManager.emit("wakeup"); // }, 1); - // setTimeout(function () { + // setTimeout(() => { // fakes.endpointManager.getStream.reset(); // fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[3]); // fakes.endpointManager.getStream.onCall(1).returns(fakes.streams[4]); diff --git a/test/config.js b/test/config.js index c00017cc..98f9bbc6 100644 --- a/test/config.js +++ b/test/config.js @@ -31,6 +31,7 @@ describe("config", function () { rejectUnauthorized: true, connectionRetryLimit: 10, heartBeat: 60000, + requestTimeout: 5000, }); });