UNPKG

@parse/node-apn

Version:

An interface to the Apple Push Notification service for Node.js

1,297 lines (1,227 loc) 108 kB
const VError = require('verror'); const net = require('net'); const http2 = require('http2'); const { HTTP2_METHOD_POST, HTTP2_METHOD_GET, HTTP2_METHOD_DELETE } = http2.constants; const debug = require('debug')('apn'); const credentials = require('../lib/credentials')({ logger: debug, }); const TEST_PORT = 30939; const CLIENT_TEST_PORT = TEST_PORT + 1; 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'; const BUNDLE_ID = 'com.node.apn'; const PATH_DEVICE = `/3/device/${MOCK_DEVICE_TOKEN}`; const PATH_BROADCASTS = `/4/broadcasts/apps/${BUNDLE_ID}`; // 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, heartBeat = 6000) => { const c = new Client({ port: TEST_PORT, address: '127.0.0.1', heartBeat: heartBeat, requestTimeout: timeout, }); c._mockOverrideUrl = `http://127.0.0.1:${port}`; return c; }; // Create an insecure server for unit testing. const createAndStartMockServer = (port, cb) => { server = http2.createServer((req, res) => { const 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; }; afterEach(async () => { const closeServer = async () => { if (server) { await new Promise(resolve => { server.close(() => { resolve(); }); }); server = null; } }; if (client) { await client.shutdown(); client = null; } await closeServer(); }); it('Treats HTTP 200 responses as successful for device', async () => { let didRequest = false; let establishedConnections = 0; let requestsServed = 0; const method = HTTP2_METHOD_POST; const path = PATH_DEVICE; server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { expect(req.headers).to.deep.equal({ ':authority': '127.0.0.1', ':method': method, ':path': path, ':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(CLIENT_TEST_PORT); const runSuccessfulRequest = async () => { const mockHeaders = { 'apns-someheader': 'somevalue' }; const mockNotification = { headers: mockHeaders, body: MOCK_BODY, }; const device = MOCK_DEVICE_TOKEN; const result = await client.write(mockNotification, device, 'device', 'post'); expect(result).to.deep.equal({ device }); 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; client.destroySession(); // Don't pass in session to destroy, should not force a disconnection. await runSuccessfulRequest(); expect(establishedConnections).to.equal(1); // should establish a connection to the server and reuse it expect(requestsServed).to.equal(6); }); it('Treats HTTP 200 responses as successful for broadcasts', async () => { let didRequest = false; let establishedConnections = 0; let requestsServed = 0; const method = HTTP2_METHOD_POST; const path = PATH_BROADCASTS; server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { expect(req.headers).to.deep.equal({ ':authority': '127.0.0.1', ':method': method, ':path': path, ':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(CLIENT_TEST_PORT); const runSuccessfulRequest = async () => { const mockHeaders = { 'apns-someheader': 'somevalue' }; const mockNotification = { headers: mockHeaders, body: MOCK_BODY, }; const bundleId = BUNDLE_ID; const result = await client.write(mockNotification, bundleId, 'broadcasts', 'post'); expect(result).to.deep.equal({ bundleId }); 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); }); // 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; const method = HTTP2_METHOD_POST; const path = PATH_DEVICE; server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { expect(req.headers).to.deep.equal({ ':authority': '127.0.0.1', ':method': method, ':path': path, ':scheme': 'https', 'apns-someheader': 'somevalue', }); 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(CLIENT_TEST_PORT, 1500); const runSuccessfulRequest = async () => { const mockHeaders = { 'apns-someheader': 'somevalue' }; const mockNotification = { headers: mockHeaders, body: MOCK_BODY, }; const device = MOCK_DEVICE_TOKEN; const result = await client.write(mockNotification, device, 'device', 'post'); expect(result).to.deep.equal({ device }); }; 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); }); it('Log pings for session', async () => { let establishedConnections = 0; let requestsServed = 0; const method = HTTP2_METHOD_POST; const path = PATH_DEVICE; const pingDelay = 50; const responseDelay = pingDelay * 2; server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { expect(req.headers).to.deep.equal({ ':authority': '127.0.0.1', ':method': method, ':path': path, ':scheme': 'https', 'apns-someheader': 'somevalue', }); expect(requestBody).to.equal(MOCK_BODY); // Set a timeout of responseDelay to simulate latency to a remote server. setTimeout(() => { res.writeHead(200); res.end(''); requestsServed += 1; }, responseDelay); }); server.on('connection', () => (establishedConnections += 1)); await new Promise(resolve => server.on('listening', resolve)); client = createClient(CLIENT_TEST_PORT, 500, pingDelay); // Setup logger. const infoMessages = []; const errorMessages = []; const mockInfoLogger = message => { infoMessages.push(message); }; const mockErrorLogger = message => { errorMessages.push(message); }; mockInfoLogger.enabled = true; mockErrorLogger.enabled = true; client.setLogger(mockInfoLogger, mockErrorLogger); const runSuccessfulRequest = async () => { const mockHeaders = { 'apns-someheader': 'somevalue' }; const mockNotification = { headers: mockHeaders, body: MOCK_BODY, }; const device = MOCK_DEVICE_TOKEN; const result = await client.write(mockNotification, device, 'device', 'post'); expect(result).to.deep.equal({ device }); }; expect(establishedConnections).to.equal(0); // should not establish a connection until it's needed await runSuccessfulRequest(); expect(establishedConnections).to.equal(1); // should establish a connection to the server and reuse it expect(requestsServed).to.equal(1); expect(infoMessages).to.not.be.empty; let infoMessagesContainsPing = false; // Search for message, in older node, may be in random order. for (const message of infoMessages) { if (message.includes('Ping response')) { infoMessagesContainsPing = true; break; } } expect(infoMessagesContainsPing).to.be.true; expect(errorMessages).to.be.empty; }); // 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(CLIENT_TEST_PORT); const infoMessages = []; const errorMessages = []; const mockInfoLogger = message => { infoMessages.push(message); }; const mockErrorLogger = message => { errorMessages.push(message); }; mockInfoLogger.enabled = true; mockErrorLogger.enabled = true; client.setLogger(mockInfoLogger, mockErrorLogger); const runRequestWithBadDeviceToken = async () => { const mockHeaders = { 'apns-someheader': 'somevalue' }; const mockNotification = { headers: mockHeaders, body: MOCK_BODY, }; const device = MOCK_DEVICE_TOKEN; let receivedError; try { await client.write(mockNotification, device, 'device', 'post'); } catch (e) { receivedError = e; } expect(receivedError).to.exist; expect(receivedError).to.deep.equal({ device, 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 expect(infoMessages).to.deep.equal([ 'Session connected', 'Request ended with status 400 and responseData: {"reason": "BadDeviceToken"}', 'Request ended with status 400 and responseData: {"reason": "BadDeviceToken"}', ]); expect(errorMessages).to.be.empty; }); it('Attempts to regenerate token when HTTP 403 responses are received', async () => { let establishedConnections = 0; const 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(403); res.end('{"reason": "ExpiredProviderToken"}'); }, responseDelay); }); server.on('connection', () => (establishedConnections += 1)); await new Promise(resolve => server.on('listening', resolve)); client = createClient(CLIENT_TEST_PORT); // Setup logger. const infoMessages = []; const errorMessages = []; const mockInfoLogger = message => { infoMessages.push(message); }; const mockErrorLogger = message => { errorMessages.push(message); }; mockInfoLogger.enabled = true; mockErrorLogger.enabled = true; client.setLogger(mockInfoLogger, mockErrorLogger); const runRequestWithExpiredProviderToken = async () => { const mockHeaders = { 'apns-someheader': 'somevalue' }; const mockNotification = { headers: mockHeaders, body: MOCK_BODY, }; const device = MOCK_DEVICE_TOKEN; let receivedError; try { await client.write(mockNotification, device, 'device', 'post'); } catch (e) { receivedError = e; } expect(receivedError).to.exist; expect(receivedError.device).to.equal(device); expect(receivedError.error).to.be.an.instanceof(VError); expect(receivedError.error.message).to.have.string('APNs response'); }; await runRequestWithExpiredProviderToken(); await runRequestWithExpiredProviderToken(); await runRequestWithExpiredProviderToken(); expect(establishedConnections).to.equal(1); await Promise.allSettled([ runRequestWithExpiredProviderToken(), runRequestWithExpiredProviderToken(), runRequestWithExpiredProviderToken(), runRequestWithExpiredProviderToken(), ]); expect(establishedConnections).to.equal(1); // should close and establish new connections on http 500 expect(errorMessages).to.not.be.empty; let errorMessagesContainsAPN = false; // Search for message, in older node, may be in random order. for (const message of errorMessages) { if (message.includes('APNs response')) { errorMessagesContainsAPN = true; break; } } expect(errorMessagesContainsAPN).to.be.true; expect(infoMessages).to.not.be.empty; let infoMessagesContainsStatus = false; // Search for message, in older node, may be in random order. for (const message of infoMessages) { if (message.includes('status 403')) { infoMessagesContainsStatus = true; break; } } expect(infoMessagesContainsStatus).to.be.true; }); // 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(CLIENT_TEST_PORT); const runRequestWithInternalServerError = async () => { const mockHeaders = { 'apns-someheader': 'somevalue' }; const mockNotification = { headers: mockHeaders, body: MOCK_BODY, }; const device = MOCK_DEVICE_TOKEN; let receivedError; try { await client.write(mockNotification, device, 'device', 'post'); } catch (e) { receivedError = e; } expect(receivedError).to.exist; expect(receivedError.device).to.equal(device); expect(receivedError.error).to.be.an.instanceof(VError); expect(receivedError.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.allSettled([ 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; const 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(CLIENT_TEST_PORT); const runRequestWithInternalServerError = async () => { const mockHeaders = { 'apns-someheader': 'somevalue' }; const mockNotification = { headers: mockHeaders, body: MOCK_BODY, }; const device = MOCK_DEVICE_TOKEN; let receivedError; try { await client.write(mockNotification, device, 'device', 'post'); } catch (e) { receivedError = e; } // Should not happen, but if it does, the promise should resolve with an error expect(receivedError).to.exist; expect(receivedError.device).to.equal(device); expect( receivedError.error.message.startsWith( 'Unexpected error processing APNs response: Unexpected token' ) ).to.equal(true); }; 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(CLIENT_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 performRequestExpectingTimeout = async () => { const device = MOCK_DEVICE_TOKEN; let receivedError; try { await client.write(mockNotification, device, 'device', 'post'); } catch (e) { receivedError = e; } expect(receivedError).to.exist; expect(receivedError).to.deep.equal({ device, 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; const errorCode = 1; didGetRequest = true; session.goaway(errorCode); }); server.on('connection', () => (establishedConnections += 1)); client = createClient(CLIENT_TEST_PORT); // Setup logger. const infoMessages = []; const errorMessages = []; const mockInfoLogger = message => { infoMessages.push(message); }; const mockErrorLogger = message => { errorMessages.push(message); }; mockInfoLogger.enabled = true; mockErrorLogger.enabled = true; client.setLogger(mockInfoLogger, mockErrorLogger); const onListeningPromise = new Promise(resolve => server.on('listening', resolve)); await onListeningPromise; const mockHeaders = { 'apns-someheader': 'somevalue' }; const mockNotification = { headers: mockHeaders, body: MOCK_BODY, }; const performRequestExpectingGoAway = async () => { const device = MOCK_DEVICE_TOKEN; let receivedError; try { await client.write(mockNotification, device, 'device', 'post'); } catch (e) { receivedError = e; } expect(receivedError).to.exist; expect(receivedError.device).to.equal(device); expect(receivedError.error).to.be.an.instanceof(VError); expect(didGetRequest).to.be.true; didGetRequest = false; }; await performRequestExpectingGoAway(); await performRequestExpectingGoAway(); expect(establishedConnections).to.equal(2); expect(errorMessages).to.not.be.empty; let errorMessagesContainsGoAway = false; // Search for message, in older node, may be in random order. for (const message of errorMessages) { if (message.includes('GOAWAY')) { errorMessagesContainsGoAway = true; break; } } expect(errorMessagesContainsGoAway).to.be.true; expect(infoMessages).to.not.be.empty; }); 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; didGetRequest = true; if (session) { session.destroy(); } }, responseTimeout); }); server.on('connection', () => (establishedConnections += 1)); client = createClient(CLIENT_TEST_PORT); // Setup logger. const infoMessages = []; const errorMessages = []; const mockInfoLogger = message => { infoMessages.push(message); }; const mockErrorLogger = message => { errorMessages.push(message); }; mockInfoLogger.enabled = true; mockErrorLogger.enabled = true; client.setLogger(mockInfoLogger, mockErrorLogger); const onListeningPromise = new Promise(resolve => server.on('listening', resolve)); await onListeningPromise; const mockHeaders = { 'apns-someheader': 'somevalue' }; const mockNotification = { headers: mockHeaders, body: MOCK_BODY, }; const performRequestExpectingDisconnect = async () => { const device = MOCK_DEVICE_TOKEN; let receivedError; try { await client.write(mockNotification, device, 'device', 'post'); } catch (e) { receivedError = e; } expect(receivedError).to.exist; expect(receivedError).to.deep.equal({ device, error: new VError('stream ended unexpectedly with status null and empty body'), }); 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); expect(errorMessages).to.not.be.empty; let errorMessagesContainsGoAway = false; // Search for message, in older node, may be in random order. for (const message of errorMessages) { if (message.includes('GOAWAY')) { errorMessagesContainsGoAway = true; break; } } expect(errorMessagesContainsGoAway).to.be.true; expect(infoMessages).to.not.be.empty; let infoMessagesContainsStatus = false; // Search for message, in older node, may be in random order. for (const message of infoMessages) { if (message.includes('status null')) { infoMessagesContainsStatus = true; break; } } expect(infoMessagesContainsStatus).to.be.true; }); it('Establishes a connection through a proxy server', async () => { let didRequest = false; let establishedConnections = 0; let requestsServed = 0; const method = HTTP2_METHOD_POST; const path = PATH_DEVICE; const proxyPort = TEST_PORT - 1; server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { expect(req.headers).to.deep.equal({ ':authority': '127.0.0.1', ':method': method, ':path': path, ':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.once('listening', resolve)); // Proxy forwards all connections to TEST_PORT. const sockets = []; let proxy = net.createServer(clientSocket => { clientSocket.once('data', () => { const serverSocket = net.createConnection(TEST_PORT, () => { clientSocket.write('HTTP/1.1 200 OK\r\n\r\n'); clientSocket.pipe(serverSocket); setTimeout(() => { serverSocket.pipe(clientSocket); }, 1); }); sockets.push(clientSocket, serverSocket); }); clientSocket.on('error', () => {}); }); await new Promise(resolve => proxy.listen(proxyPort, resolve)); // Don't block the tests if this server doesn't shut down properly. proxy.unref(); // Client configured with a port that the server is not listening on. client = createClient(CLIENT_TEST_PORT); // Not adding a proxy config will cause a failure with a network error. client.config.proxy = { host: '127.0.0.1', port: proxyPort }; const runSuccessfulRequest = async () => { const mockHeaders = { 'apns-someheader': 'somevalue' }; const mockNotification = { headers: mockHeaders, body: MOCK_BODY, }; const device = MOCK_DEVICE_TOKEN; const result = await client.write(mockNotification, device, 'device', 'post'); expect(result).to.deep.equal({ device }); 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); // Shut down proxy server properly. await new Promise(resolve => { sockets.forEach(socket => socket.end('')); proxy.close(() => { resolve(); }); }); proxy = null; }); it('Throws an error when there is a bad proxy server', async () => { // Client configured with a port that the server is not listening on. client = createClient(CLIENT_TEST_PORT); // Not adding a proxy config will cause a failure with a network error. client.config.proxy = { host: '127.0.0.1', port: 'NOT_A_PORT' }; // Setup logger. const infoMessages = []; const errorMessages = []; const mockInfoLogger = message => { infoMessages.push(message); }; const mockErrorLogger = message => { errorMessages.push(message); }; mockInfoLogger.enabled = true; mockErrorLogger.enabled = true; client.setLogger(mockInfoLogger, mockErrorLogger); const runUnsuccessfulRequest = async () => { const mockHeaders = { 'apns-someheader': 'somevalue' }; const mockNotification = { headers: mockHeaders, body: MOCK_BODY, }; const device = MOCK_DEVICE_TOKEN; let receivedError; try { await client.write(mockNotification, device, 'device', 'post'); } catch (e) { receivedError = e; } expect(receivedError).to.exist; expect(receivedError.device).to.equal(device); expect(receivedError.error.code).to.equal('ERR_SOCKET_BAD_PORT'); }; await runUnsuccessfulRequest(); expect(errorMessages).to.not.be.empty; let errorMessagesContainsStatus = false; // Search for message, in older node, may be in random order. for (const message of errorMessages) { if (message.includes('NOT_A_PORT')) { errorMessagesContainsStatus = true; break; } } expect(errorMessagesContainsStatus).to.be.true; expect(infoMessages).to.be.empty; }); // let fakes, Client; // beforeEach(() => { // fakes = { // config: sinon.stub(), // EndpointManager: sinon.stub(), // endpointManager: new EventEmitter(), // }; // fakes.EndpointManager.returns(fakes.endpointManager); // fakes.endpointManager.shutdown = sinon.stub(); // Client = require("../lib/client")(fakes); // }); // describe("constructor", () => { // it("prepares the configuration with passed options", () => { // let options = { production: true }; // let client = new Client(options); // expect(fakes.config).to.be.calledWith(options); // }); // describe("EndpointManager instance", 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", () => { // const returnSentinel = { "configKey": "configValue"}; // fakes.config.returns(returnSentinel); // let client = new Client({}); // expect(fakes.EndpointManager).to.be.calledWith(returnSentinel); // }); // }); // }); describe('write', () => { // beforeEach(() => { // fakes.config.returnsArg(0); // fakes.endpointManager.getStream = sinon.stub(); // fakes.EndpointManager.returns(fakes.endpointManager); // }); // context("a stream is available", () => { // let client; // 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", () => { // return client.write(builtNotification(), "abcd1234") // .then(() => { // expect(fakes.endpointManager.getStream).to.be.calledOnce; // }); // }); // describe("headers", () => { // it("sends the required HTTP/2 headers", () => { // return client.write(builtNotification(), "abcd1234") // .then(() => { // expect(fakes.stream.headers).to.be.calledWithMatch( { // ":scheme": "https", // ":method": "POST", // ":authority": "testapi", // ":path": "/3/device/abcd1234", // }); // }); // }); // it("does not include apns headers when not required", () => { // return client.write(builtNotification(), "abcd1234") // .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", () => { // let notification = builtNotification(); // notification.headers = { // "apns-id": "123e4567-e89b-12d3-a456-42665544000", // "apns-priority": 5, // "apns-expiration": 123, // "apns-topic": "io.apn.node", // }; // return client.write(notification, "abcd1234") // .then(() => { // expect(fakes.stream.headers).to.be.calledWithMatch( { // "apns-id": "123e4567-e89b-12d3-a456-42665544000", // "apns-priority": 5, // "apns-expiration": 123, // "apns-topic": "io.apn.node", // }); // }); // }); // context("when token authentication is enabled", () => { // beforeEach(() => { // fakes.token = { // generation: 0, // current: "fake-token", // regenerate: sinon.stub(), // isExpired: sinon.stub() // }; // client = new Client( { address: "testapi", token: fakes.token } ); // fakes.stream = new FakeStream("abcd1234", "200"); // fakes.endpointManager.getStream.onCall(0).returns(fakes.stream); // }); // it("sends the bearer token", () => { // let notification = builtNotification(); // return client.write(notification, "abcd1234").then(() => { // expect(fakes.stream.headers).to.be.calledWithMatch({ // authorization: "bearer fake-token", // }); // }); // }); // }); // 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", () => { // let notification = builtNotification(); // 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", () => { // const notification = builtNotification(); // return client.write(notification, "abcd1234") // .then(() => { // expect(fakes.stream._transform).to.be.calledWithMatch(actual => actual.equals(Buffer.from(notification.body))); // }); // }); // it("ends the stream", () => { // sinon.spy(fakes.stream, "end"); // return client.write(builtNotification(), "abcd1234") // .then(() => { // expect(fakes.stream.end).to.be.calledOnce; // }); // }); // it("resolves with the device token", () => { // return expect(client.write(builtNotification(), "abcd1234")) // .to.become({ device: "abcd1234" }); // }); // }); // context("error occurs", () => { // let promise; // context("general case", () => { // beforeEach(() => { // const client = new Client( { address: "testapi" } ); // fakes.stream = new FakeStream("abcd1234", "400", { "reason" : "BadDeviceToken" }); // fakes.endpointManager.getStream.onCall(0).returns(fakes.stream); // promise = client.write(builtNotification(), "abcd1234"); // }); // 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", () => { // beforeEach(() => { // let tokenGenerator = sinon.stub().returns("fake-token"); // const client = new Client( { address: "testapi", token: tokenGenerator }); // }) // }); // }); // context("stream ends without completing request", () => { // let promise; // beforeEach(() => { // const client = new Client( { address: "testapi" } ); // fakes.stream = new stream.Transform({ // transform: function(chunk, encoding, callback) {} // }); // fakes.stream.headers = sinon.stub(); // fakes.endpointManager.getStream.onCall(0).returns(fakes.stream); // promise = client.write(builtNotification(), "abcd1234"); // fakes.stream.push(null); // }); // 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", () => { // return promise.then( (response) => { // expect(response).to.have.property("error"); // expect(response.error).to.be.an.instanceOf(Error); // expect(response.error).to.match(/stream ended unexpectedly/); // }); // }); // }); // context("stream is unprocessed", () => { // let promise; // beforeEach(() => { // const client = new Client( { address: "testapi" } ); // fakes.stream = new stream.Transform({ // transform: function(chunk, encoding, callback) {} // }); // fakes.stream.headers = sinon.stub(); // fakes.secondStream = FakeStream("abcd1234", "200"); // fakes.endpointManager.getStream.onCall(0).returns(fakes.stream); // fakes.endpointManager.getStream.onCall(1).returns(fakes.secondStream); // promise = client.write(builtNotification(), "abcd1234"); // setImmediate(() => { // fakes.stream.emit("unprocessed"); // }); // }); // it("attempts to resend on a new stream", function (done) { // setImmediate(() => { // expect(fakes.endpointManager.getStream).to.be.calledTwice; // done(); // }); // }); // it("fulfills the promise", () => { // return expect(promise).to.eventually.deep.equal({ device: "abcd1234" }); // }); // }); // context("stream error occurs", () => { // let promise; // beforeEach(() => { // const client = new Client( { address: "testapi" } ); // fakes.stream = new stream.Transform({ // transform: function(chunk, encoding, callback) {} // }); // fakes.stream.headers = sinon.stub(); // fakes.endpointManager.getStream.onCall(0).returns(fakes.stream); // promise = client.write(builtNotification(), "abcd1234"); // }); // context("passing an Error", () => { // beforeEach(() => { // fakes.stream.emit("error", new Error("stream error")); // }); // 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", () => { // return promise.then( (response) => { // expect(response.error).to.be.an.instanceOf(Error); // expect(response.error).to.match(/apn write failed/); // expect(response.error.cause()).to.be.an.instanceOf(Error).and.match(/stream error/); // }); // }); // }); // 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"); // expect(response.error).to.to.be.an.instanceOf(Error); // expect(response.error).to.match(/apn write failed/); // expect(response.error).to.match(/stream error/); // }); // }); // }); // }); // }); // context("no new stream is returned but the endpoint later wakes up", () => { // let notification, promise; // beforeEach( () => { // const client = new Client( { address: "testapi" } ); // fakes.stream = new FakeStream("abcd1234", "200"); // fakes.endpointManager.getStream.onCall(0).returns(null); // fakes.endpointManager.getStream.onCall(1).returns(fakes.stream); // notification = builtNotification(); // promise = client.write(notification, "abcd1234"); // expect(fakes.stream.headers).to.not.be.called; // fakes.endpointManager.emit("wakeup"); // return promise; // }); // it("sends the required headers to the newly available stream", () => { // expect(fakes.stream.headers).to.be.calledWithMatch( { // ":scheme": "https", // ":method": "POST", // ":authority": "testapi", // ":path": "/3/device/abcd1234", // }); // }); // 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", () => { // beforeEach(() => { // fakes.streams = [ // new FakeStream("abcd1234", "200"), // new FakeStream("adfe5969", "400", { reason: "MissingTopic" }), // new FakeStream("abcd1335", "410", { reason: "BadDeviceToken", timestamp: 123456789 }), // new FakeStream("bcfe4433", "200"), // new FakeStream("aabbc788", "413", { reason: "PayloadTooLarge" }), // ]; // }); // context("streams are always returned", () => { // let promises; // beforeEach( () => { // const client = new Client( { address: "testapi" } ); // fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[0]); // fakes.endpointManager.getStream.onCall(1).returns(fakes.streams[1]); // fakes.endpointManager.getStream.onCall(2).returns(fakes.streams[2]); // fakes.endpointManager.getStream.onCall(3).returns(fakes.streams[3]); // fakes.endpointManager.getStream.onCall(4).returns(fakes.streams[4]); // promises = Promise.all([ // client.write(builtNotification(), "abcd1234"), // client.write(builtNotification(), "adfe5969"), // client.write(builtNotification(), "abcd1335"), // client.write(builtNotification(), "bcfe4433"), // client.write(builtNotification(), "aabbc788"), // ]); // return promises; // }); // 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" } ); // expect(fakes.streams[3].headers).to.be.calledWithMatch( { ":path": "/3/device/bcfe4433" } ); // expect(fakes.streams[4].headers).to.be.calledWithMatch( { ":path": "/3/device/aabbc788" } ); // }); // 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", () => { // return expect(promises).to.eventually.deep.equal([ // { device: "abcd1234"}, // { device: "adfe5969", status: "400", response: { reason: "MissingTopic" } }, // { device: "abcd1335", status: "410", response: { reason: "BadDeviceToken", timestamp: 123456789 } }, // { device: "bcfe4433"}, // { device: "aabbc788", status: "413", response: { reason: "PayloadTooLarge" } }, // ]); // }); // }); // context("some streams return, others wake up later", () => { // let promises; // beforeEach( function() { // const client = new Client( { address: "testapi" } ); // fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[0]); // fakes.endpointManager.getStream.onCall(1).returns(fakes.streams[1]); // promises = Promise.all([ // client.write(builtNotification(), "abcd1234"), // client.write(builtNotification(), "adfe5969"), // client.write(builtNotification(), "abcd1335"), // client.write(builtNotification(), "bcfe4433"), // client.write(builtNotification(), "aabbc788"), // ]); // 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(() => { // 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"); // }, 2); // return promises; // });