diff --git a/.travis.yml b/.travis.yml index f7da6ea4..f12f92df 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,12 +1,8 @@ language: node_js node_js: -- '4.1' -- '0.12' -- '0.10' -- '0.8' -- iojs -before_install: -- npm install -g npm@2.7.3 +- 4 +- 5 +- 6 after_success: - npm install -g istanbul - npm install coveralls @@ -20,4 +16,3 @@ deploy: secure: gs5H2MwW+AInpILDEmSsVWevzrMBapNHBJS/rvmHP9AtFnj0W5HE619F1duDZwhpwV/e6vdbo6xCWnxc+egR7gciW3OQrggsXSaKeWDFcJ9oNFvOsxKePOxk62fBjeSRD3LSPGyrv3XhmE4sm/6x4gCf4ZbzbYqvvOCvADCqJzA= on: tags: true - node: '4.1' diff --git a/README.md b/README.md index 83122557..df3efccf 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,20 @@ -#node-apn +[nodeapn][node-apn] -> A Node.js module for interfacing with the Apple Push Notification service. -[![NPM][npm-image] ][npm-url] +> A Node.js module for interfacing with the Apple Push Notification service. [![Build status][ci-image] ][ci-url] [![Code coverage][coverage-image]][coverage-url] [![Codacy][codacy-image]][codacy-url] - [![dependencies][dependencies-image]][dependencies-url] [![devdependencies][devdependencies-image]][devdependencies-url] -[![Issue Stats][issuestats-pr-image]][issuestats-url] -[![Issue Stats][issuestats-image]][issuestats-url] - +[logo]:doc/logo.png [npm-image]:https://nodei.co/npm/apn.png?downloads=true [npm-url]:https://npmjs.com/package/apn -[ci-image]:https://travis-ci.org/argon/node-apn.png?branch=master +[ci-image]:https://travis-ci.org/argon/node-apn.png?branch=develop [ci-url]:https://travis-ci.org/argon/node-apn -[coverage-image]:https://coveralls.io/repos/argon/node-apn/badge.svg?branch=master +[coverage-image]:https://coveralls.io/repos/argon/node-apn/badge.svg?branch=develop [coverage-url]:https://coveralls.io/r/argon/node-apn [codacy-image]:https://www.codacy.com/project/badge/e7735fbe0db244f3b310657d0dabaa11 [codacy-url]:https://www.codacy.com/public/argon/node-apn @@ -28,29 +24,17 @@ [devdependencies-image]:https://david-dm.org/argon/node-apn/dev-status.png [devdependencies-url]:https://david-dm.org/argon/node-apn#info=devDependencies -[issuestats-image]:http://issuestats.com/github/argon/node-apn/badge/issue -[issuestats-pr-image]:http://issuestats.com/github/argon/node-apn/badge/pr -[issuestats-url]:http://issuestats.com/github/argon/node-apn - ## Features -- Fast +- Based on HTTP/2 based provider API - Maintains a connection to the server to maximise notification batching and throughput. -- Enhanced binary interface support, with error handling -- Automatically sends unsent notifications if an error occurs -- Feedback service support -- Complies with all best practises laid out by Apple +- Automatically resends unsent notifications if an error occurs ## Installation -Via [npm][]: +[npm][] is the preferred installation method: $ npm install apn - -As a submodule of your project (you will also need to install [q][q]) - - $ git submodule add http://github.com/argon/node-apn.git apn - $ git submodule update --init ## Quick Start @@ -58,73 +42,61 @@ This is intended as a brief introduction, please refer to the documentation in ` ### Load in the module +```javascript var apn = require('apn'); +``` ### Connecting -Create a new connection to the APN gateway server, passing a dictionary of options to the constructor. If you name your certificate and key files appropriately (`cert.pem` and `key.pem`) then the defaults should be suitable to get you up and running. +Create a new connection to the Apple Push Notification provider API, passing a dictionary of options to the constructor. If you name your certificate and key files appropriately (`cert.pem` and `key.pem`) then the defaults should be suitable to get you up and running. ```javascript var options = { }; - var apnConnection = new apn.Connection(options); + var apnProvider = new apn.Provider(options); ``` -By default, if the environment variable `NODE_ENV=production` is set, the module will connect to the production gateway. Otherwise it will connect to the sandbox. This along with many other settings can be overriden with the options object. +By default, the provider will connect to the sandbox unless the environment variable `NODE_ENV=production` is set. For more information about configuration options consult the [documentation](doc/connection.markdown). Help with preparing the key and certificate files for connection can be found in the [wiki][certificateWiki] ### Sending a notification -To send a notification first create a `Device` object. Pass it the device token as either a hexadecimal string, or alternatively as a `Buffer` object containing the token in binary form. +To send a notification you will first need a token from your app as a string - var myDevice = new apn.Device(token); +```javascript + let deviceToken = "a9d0ed10e9cfd022a61cb08753f49c5a0b0dfb383697bf9f9d750a1003da19c7" +``` -Next, create a notification object, set the relevant parameters (See the [payload documentation][pl] for more details.) and use the `pushNotification` method on the connection to send it. +Create a notification object, configuring it with the relevant parameters (See the [payload documentation][pl] for more details.) +```javascript var note = new apn.Notification(); note.expiry = Math.floor(Date.now() / 1000) + 3600; // Expires 1 hour from now. note.badge = 3; note.sound = "ping.aiff"; note.alert = "\uD83D\uDCE7 \u2709 You have a new message"; - note.payload = {'messageFrom': 'Caroline'}; - - apnConnection.pushNotification(note, myDevice); - -The above options will compile the following dictionary to send to the device: - - {"messageFrom":"Caroline","aps":{"badge":3,"sound":"ping.aiff","alert":"\uD83D\uDCE7 \u2709 You have a new message"}} - -You should only create one `Connection` per-process for each certificate/key pair you have. You do not need to create a new `Connection` for each notification. If you are only sending notifications to one app then there is no need for more than one `Connection`, if throughput is a problem then look at the `maxConnections` property. - -### Setting up the feedback service - -Apple recommends checking the feedback service periodically for a list of devices for which there were failed delivery attempts. - -Using the `Feedback` object it is possible to periodically query the server for the list. Many of the options are similar to that of `Connection`, including the authentication configuration. It is recomended that you share the same configuration object between Connection and Feedback instances to ensure they stay in sync. - -Attach a listener to the `feedback` event to receive the output as two arguments, the `time` returned by the server (epoch time) and a `Buffer` object containing the device token - this event will be emitted for each device separately. Alternatively you can enable the `batchFeedback` option and the `feedback` event will provide an array of objects containing `time` and `device` properties. + note.payload = {'messageFrom': 'John Appleseed'}; +``` - var options = { - "batchFeedback": true, - "interval": 300 - }; +Send the notification to the API with `pushNotification`, which returns a promise. - var feedback = new apn.Feedback(options); - feedback.on("feedback", function(devices) { - devices.forEach(function(item) { - // Do something with item.device and item.time; - }); +```javascript + apnProvider.pushNotification(note, deviceToken).then( (result) => { + // see documentation for an explanation of result }); +``` -By specifying a time interval (in seconds) `Feedback` will periodically query the service without further intervention. +This will result in the the following notification payload being sent to the device -More information about the feedback service can be found in the [feedback service documentation][fs]. +```json + {"messageFrom":"John Appelseed","aps":{"badge":3,"sound":"ping.aiff","alert":"\uD83D\uDCE7 \u2709 You have a new message"}} +``` -## Debugging +You should only create one `Provider` per-process for each certificate/key pair you have. You do not need to create a new `Provider` for each notification. If you are only sending notifications to one app then there is no need for more than one `Provider`. -If you experience difficulties sending notifications or using the feedback service you can enable debug messages within the library by running your application with `DEBUG=apn` or `DEBUG=apnfb` set as an environment variable. +## Troubleshooting You are encouraged to read the extremely informative [Troubleshooting Push Notifications][tn2265] Tech Note in the first instance, in case your query is answered there. @@ -132,17 +104,17 @@ You are encouraged to read the extremely informative [Troubleshooting Push Notif If you have any questions or difficulties working with the module, the [node-apn Google group][googlegroup] should be your first port of call. -Please include as much detail as possible - especially debug logs, if the problem is reproducible sample code is also extremely helpful. GitHub Issues should only be created for verified problems and enhancements, this will allow them to be tracked more easily. +Please include as much detail as possible - especially debug logs. If the problem is reproducible, sample code is also extremely helpful. GitHub Issues should only be created for verified problems and enhancements, this will allow them to be tracked more easily. ## Resources -* [Local and Push Notification Programming Guide: Apple Push Notification Service][pl] +* [Local and Push Notification Programming Guide: APNs Provider API][pl] * [Apple Technical Note: Troubleshooting Push Notifications][tn2265] * [List of Projects, Applications and Companies Using Node-apn][pacapn] ## Credits -Written and maintained by [Andrew Naylor][andrewnaylor]. +Created by [Andrew Naylor][argon] Thanks to: [Ian Babrou][bobrik], [dgthistle][dgthistle], [Keith Larsen][keithnlarsen], [Mike P][mypark], [Greg Bergé][neoziro], [Asad ur Rehman][AsadR], [Nebojsa Sabovic][nsabovic], [Alberto Gimeno][gimenete], [Randall Tombaugh][rwtombaugh], [Michael Stewart][thegreatmichael], [Olivier Louvignes][mgcrea], [porsager][porsager], [Craig Hockenberry][chockenberry] @@ -150,31 +122,29 @@ Thanks to: [Ian Babrou][bobrik], [dgthistle][dgthistle], [Keith Larsen][keithnla Released under the MIT License -Copyright (c) 2013 Andrew Naylor - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +> Copyright (c) 2013 Andrew Naylor +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +[npm]: https://npmjs.org +[node-apn]: https://github.com/argon/node-apn [certificateWiki]:https://github.com/argon/node-apn/wiki/Preparing-Certificates "Preparing Certificates" -[errors]:https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/CommunicatingWIthAPS.html#//apple_ref/doc/uid/TP40008194-CH101-SW4 "The Binary Interface and Notification Formats" -[pl]: https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/ApplePushService.html#//apple_ref/doc/uid/TP40008194-CH100-SW1 "Local and Push Notification Programming Guide: Apple Push Notification Service" -[fs]: https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Appendixes/BinaryProviderAPI.html#//apple_ref/doc/uid/TP40008194-CH106-SW4 "The Feedback Service" +[pl]: https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/APNsProviderAPI.html#//apple_ref/doc/uid/TP40008194-CH101-SW1 "Local and Push Notification Programming Guide: APNs Provider API" [tn2265]: http://developer.apple.com/library/ios/#technotes/tn2265/_index.html "Troubleshooting Push Notifications" [googlegroup]:https://groups.google.com/group/node-apn "node-apn Google Group" [pacapn]:https://github.com/argon/node-apn/wiki/Projects,-Applications,-and-Companies-Using-Node-apn "List of Projects, Applications and Companies Using Node-apn" -[andrewnaylor]: http://andrewnaylor.co.uk -[bnoordhuis]: http://bnoordhuis.nl -[npm]: https://npmjs.org -[bobrik]: http://bobrik.name +[argon]: https://github.com/argon +[bobrik]: https://github.com/bobrik [dgthistle]: https://github.com/dgthistle [keithnlarsen]: https://github.com/keithnlarsen [mypark]: https://github.com/mypark @@ -186,6 +156,5 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI [thegreatmichael]: https://github.com/thegreatmichael [mgcrea]: https://github.com/mgcrea [porsager]: https://github.com/porsager -[q]: https://github.com/kriskowal/q [chockenberry]: https://github.com/chockenberry diff --git a/doc/connection.markdown b/doc/connection.markdown index fef6984d..21efb471 100644 --- a/doc/connection.markdown +++ b/doc/connection.markdown @@ -16,28 +16,16 @@ Options: - `production` {Boolean} Specifies which environment to connect to: Production (if true) or Sandbox - The hostname will be set automatically. (Defaults to NODE_ENV == "production", i.e. false unless the NODE_ENV environment variable is set accordingly) - - `voip` {Boolean} Enable when you are using a VoIP certificate to enable paylods up to 4096 bytes. - - `port` {Number} Gateway port (Defaults to: `2195`) - `rejectUnauthorized` {Boolean} Reject Unauthorized property to be passed through to tls.connect() (Defaults to `true`) - - `cacheLength` {Number} Number of notifications to cache for error purposes (See "Handling Errors" below, (Defaults to: 1000) - - - `autoAdjustCache` {Boolean} Whether the cache should grow in response to messages being lost after errors. (Will still emit a 'cacheTooSmall' event) (Defaults to: `true`) - - - `maxConnections` {Number} The maximum number of connections to create for sending messages. (Defaults to: `1`) - - `connectTimeout` {Number} The duration of time the module should wait, in milliseconds, when trying to establish a connection to Apple before failing. 0 = Disabled. {Defaults to: `10000`} - `connectionTimeout` {Number} The duration the socket should stay alive with no activity in milliseconds. 0 = Disabled. (Defaults to: `3600000` - 1h) - `connectionRetryLimit` {Number} The maximum number of connection failures that will be tolerated before `apn` will "terminate". [See below.](#connection-retry-limit) (Defaults to: 10) - - `buffersNotifications` {Boolean} Whether to buffer notifications and resend them after failure. (Defaults to: `true`) - - - `fastMode` {Boolean} Whether to aggresively empty the notification buffer while connected - if set to true node-apn may enter a tight loop under heavy load while delivering notifications. (Defaults to: `false`) - ##### Connection retry limit TLS errors such as expired or invalid certificates will cause an error to be emitted, but in this case it is futile for `apn` to continue attempting to connect. There may also be other cases where connectivity issues mean that a process attempting to send notifications may simply become blocked with an ever-increasing queue of notifications. To attempt to combat this a (configurable) retry limit of 10 has been introduced. If ten consecutive connection failures occur then `apn` will emit an `error` event for the connection, then a `transmissionError` event will be emitted for *each* notification in the queue, with the error code `connectionRetryLimitExceeded` (514). @@ -57,10 +45,6 @@ If you wish to send notifications containing emoji or other multi-byte character If in doubt, leave the encoding as default. If you experience any problems post a question in the [node-apn Google Group][googlegroup]. -### connection.setCacheLength(newLength) - -Used to manually adjust the "cacheLength" property in the options. This is ideal if you choose to use the `cacheTooSmall` event to tweak your environment. It is safe for increasing and reducing cache size. - ### connection.shutdown() Indicate to node-apn that when the queue of pending notifications is fully drained that it should close all open connections. This will mean that if there are no other pending resources (open sockets, running timers, etc.) the application will terminate. If notifications are pushed after the connection has completely shutdown a new connection will be established and, if applicable, `shutdown` will need to be called again. @@ -89,14 +73,6 @@ Emitted when a notification has been sent to Apple - not a guarantee that it has Emitted when all pending notifications have been transmitted to Apple and the pending queue is empty. This may be called more than once if a notification error occurs and notifications must be re-sent. -### Event: 'cacheTooSmall' - -`function (sizeDifference) { }` - -Emitted when Apple returns a notification as invalid but the notification has already been expunged from the cache - usually due to high throughput and indicates that notifications will be getting lost. The parameter is an estimate of how many notifications have been lost. You should experiment with increasing the cache size or enabling ```autoAdjustCache``` if you see this frequently. - -**Note**: With ```autoAdjustCache``` enabled this event will still be emitted when an adjustment is triggered. - ### Event: 'connected' `function (openSockets) { }` @@ -119,7 +95,7 @@ Emitted when the connectionTimeout option has been specified and no activity has `function(errorCode, notification, device) { }` -Emitted when a message has been received from Apple stating that a notification was invalid or if an internal error occurred before that notification could be pushed to Apple. If the notification is still in the cache it will be passed as the second argument, otherwise null. Where possible the associated `Device` object will be passed as a third parameter, however in cases where the token supplied to the module cannot be parsed into a `Buffer` the supplied value will be returned. +Emitted when a message has been received from Apple stating that a notification was invalid or if an internal error occurred before that notification could be pushed to Apple. Error codes smaller than 512 correspond to those returned by Apple as per their [docs][errors]. Other errors are applicable to `node-apn` itself. Definitions can be found in `lib/errors.js`. diff --git a/doc/logo.png b/doc/logo.png new file mode 100644 index 00000000..594334f4 Binary files /dev/null and b/doc/logo.png differ diff --git a/doc/notification.markdown b/doc/notification.markdown index b0bd35ad..2482e894 100644 --- a/doc/notification.markdown +++ b/doc/notification.markdown @@ -1,116 +1,173 @@ -## apn.Notification([payload]) - -Returns a new `Notification` object. You can optionally pass in an object representing the payload, or configure properties on the returned object. See below. - - ## Class: apn.Notification -As of version 1.2.0 it is possible to use a set of methods provided by Notification object (`setAlertText`, `setActionLocKey`, `setLocKey`, `setLocArgs`, `setLaunchImage`) to aid the creation of the alert parameters. For applications which provide Newsstand capability there is a new boolean parameter `note.newsstandAvailable` to specify `content-available` in the payload. - -For iOS 7 applications which support Silent Remote Notifications you can use the `note.contentAvailable` property. This is identical in functionality to `note.newsstandAvailable` without the confusion of the "Newstand" terminology. - -A `Notification` encapsulates the data to be compiled down to JSON and pushed to a device. See the [payload documentation][pl] for more details. At present the total length of the payload accepted by Apple is 2048 bytes. - -### notification.retryLimit - -The maximum number of retries which should be performed when sending a notification if an error occurs. A value of 0 will only allow one attempt at sending (0 retries). Set to -1 to disable (default). - -### notification.expiry - -The UNIX timestamp representing when the notification should expire. This does not contribute to the 2048 byte payload size limit. An expiry of 0 indicates that the notification expires immediately. - -### notification.priority - -From [Apple's Documentation][notificationFormat], Provide one of the following values: - - * 10 - The push message is sent immediately. (Default) - > The push notification must trigger an alert, sound, or badge on the device. It is an error use this priority for a push that contains only the content-available key. - * 5 - The push message is sent at a time that conserves power on the device receiving it. +A `Notification` enapsulates data to be sent to a device and handles JSON encoding for transmission. See the [payload documentation][pl] for more details. -### notification.encoding +### Initialization + +When initializing a `Notification` you can optionally pass an object to pre-populate properties as they are defined below. -The encoding to use when transmitting the notification to APNS, defaults to `utf8`. `utf16le` is also possible but as each character is represented by a minimum of 2 bytes, will at least halve the possible payload size. If in doubt leave as default. +```javascript +let notification = new apn.Notification({ + alert: "Hello, world!", + sound: "chime.caf", + mutableContent: 1, + payload: { + "sender": "node-apn", + }, +}); +``` -### notification.payload +### Payload -This object represents the root JSON object that you can add custom information for your application to. The properties below will only be added to the payload (under `aps`) when the notification is prepared for sending. +#### `notification.payload` -### notification.badge +This `Object` is JSON encoded and sent as the notification payload. When properties have been set on `notification.aps` (either directly or with convenience setters) these are added to the `payload` just before it is sent. If `payload` already contains an `aps` property it is replaced. -The value to specify for `payload.aps.badge` +**Example:** -### notification.sound +```javascript +let notification = new apn.Notification(); -The value to specify for `payload.aps.sound` +notification.payload = { + from: "node-apn", + source: "web", +}; -### notification.alert +notification.body = "Hello, world!"; +``` -The value to specify for `payload.aps.alert` can be either a `String` or an `Object` as outlined by the payload documentation. +**Output:** -### notification.category +```json +{ + "from":"node-apn", + "source":"web", + "aps":{ + "alert":"Hello, world!" + } +} +``` + +#### `notification.rawPayload` -The value to specify for `payload.aps.category` for use with custom actions. +If supplied this payload will be encoded and transmitted as-is. The convenience setters will have no effect on the JSON output. -### notification.contentAvailable +**Example:** -Set the `content-available` property of the `aps` object. +```javascript +let notification = new apn.Notification(); -### notification.mdm +notification.rawPayload = { + from: "node-apn", + source: "web", + aps: { + "content-available": 1 + } +}; -The value to specify for the `mdm` field where applicable. +notification.body = "Hello, world!"; +``` -### notification.urlArgs +**Output:** -The value to specify for `payload.aps['url-args']`. This used for Safari Push Notifications and should be an array of values in accordance with the [Web Payload Documentation][webpayloaddocs]. +```json +{ + "from":"node-apn", + "source":"web", + "aps":{ + "content-available":1 + } +} +``` -### notification.truncateAtWordEnd +### Convenience Setters -When this parameter is set and `notification#trim()` is called it will attempt to truncate the string at the nearest space. +The setters below provide a cleaner way to set properties defined by the Apple Push Notification Service (APNS). -### notification.setAlertText(alert) +This table shows the name of the setter, with the key-path of the underlying property it maps to and the expected value type. -Set the `aps.alert` text body. This will use the most space-efficient means. +| Setter Name | Target Property | Type | +|---------------------|-----------------------------|---------------------| +| `alert` | `aps.alert` | `String` or `Object`| +| `body` | `aps.alert.body` | `String` | +| `locKey` | `aps.alert.loc-key` | `String` | +| `locArgs` | `aps.alert.loc-args` | `String` | +| `title` | `aps.alert.title` | `String` | +| `titleLocKey` | `aps.alert.title-loc-key` | `String` | +| `titleLocArgs` | `aps.alert.title-loc-args` | `Array` | +| `action` | `aps.alert.action` | `String` | +| `actionLocKey` | `aps.alert.action-loc-key` | `String` | +| `launchImage` | `aps.launch-image` | `String` | +| `badge` | `aps.badge` | `Number` | +| `sound` | `aps.sound` | `String` | +| `contentAvailable` | `aps.content-available` | `1` | +| `mutableContent` | `aps.mutable-content` | `1` | +| `urlArgs` | `aps.url-args` | `Array` | +| `category` | `aps.category` | `String` | +| `mdm` | `mdm` | `String` | -### notification.setAlertTitle(alertTitle) +When the notification is transmitted these properties will be added to the output before encoding. -Set the `title` property of the `aps.alert` object - used with Safari Push Notifications +For each convenience setter there is also a chainable method which invokes the setter and returns `this`. These are predictably named: `propertyName -> setPropertyName()`. -### notification.setAlertAction(alertAction) +It is also possible to set properties directly on `aps` if the setters above do not meet your needs. -Set the `action` property of the `aps.alert` object - used with Safari Push Notifications +**Example:** +```javascript +let notification = new apn.Notification(); -### notification.setActionLocKey(key) +/// Convenience setter +notification.body = "Hello, world!"; +notification.title = "node-apn"; +notification.badge = 10; -Set the `action-loc-key` property of the `aps.alert` object. +/// Chainable setter +notification.setAction("npm install") + .setMutableContent(1); -### notification.setLocKey(key) +/// Direct `aps` property access +notification.aps.category = "nodejs"; +``` -Set the `loc-key` property of the `aps.alert` object. +**Output:** -### notification.setLocArgs(args) +```json +{ + "aps":{ + "alert":{ + "body":"Hello, world!", + "title":"node-apn", + "action":"npm install", + "mutable-content": 1 + }, + "badge":10, + "category":"nodejs" + } +} +``` -Set the `loc-args` property of the `aps.alert` object. +### Properties -### notification.setLaunchImage(image) +The properties below are sent alongside the notification as configuration and do not form part of the JSON payload. As such, they are not counted against the payload size limit. -Set the `launch-image` property of the `aps.alert` object. +#### notification.topic -### notification.setMDM(mdm) +_Required_: The destination topic for the notification. -Set the `mdm` property on the payload. +#### notification.id -### notification.setContentAvailable(available) +A UUID to identify the notification with APNS. If an `id` is not supplied, APNS will generate one automatically. If an error occurs the response will contain the `id`. This property populates the `apns-id` header. -Set the `content-available` property of the `aps` object. +#### notification.expiry -### notification.setUrlArgs(urlArgs) +A UNIX timestamp when the notification should expire. If the notification cannot be delivered to the device, APNS will retry until it expires. An expiry of `0` indicates that the notification expires immediately, therefore no retries will be attempted. -Set the `url-args` property of the `aps` object. +#### notification.priority -### notification.trim() +Provide one of the following values: -Attempt to automatically trim the notification alert text body to meet the payload size limit of 2048 bytes. + * `10` - The push notification is sent to the device immediately. (Default) + > The push notification must trigger an alert, sound, or badge on the device. It is an error use this priority for a push that contains only the `content-available` key. + * `5` - The push message is sent at a time that conserves power on the device receiving it. -[pl]:https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/ApplePushService.html#//apple_ref/doc/uid/TP40008194-CH100-SW1 "Local and Push Notification Programming Guide: Apple Push Notification Service" -[notificationFormat]:https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Appendixes/BinaryProviderAPI.html#//apple_ref/doc/uid/TP40008194-CH106-SW8 "The Binary Interface and Notification Format" -[webpayloaddocs]:https://developer.apple.com/library/prerelease/mac/documentation/NetworkingInternet/Conceptual/NotificationProgrammingGuideForWebsites/PushNotifications/PushNotifications.html#//apple_ref/doc/uid/TP40013225-CH3-SW12 "Configuring Safari Push Notifications" +[pl]:https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/TheNotificationPayload.html "Local and Push Notification Programming Guide: Apple Push Notification Service" diff --git a/index.js b/index.js index 871ce21f..ff64cde8 100644 --- a/index.js +++ b/index.js @@ -1,14 +1,60 @@ -exports.Connection = require("./lib/connection"); -exports.connection = exports.Connection; +const debug = require("debug")("apn"); -exports.Device = require("./lib/device"); -exports.device = exports.Device; +const parse = require("./lib/credentials/parse")({ + parsePkcs12: require("./lib/credentials/parsePkcs12"), + parsePemKey: require("./lib/credentials/parsePemKey"), + parsePemCert: require("./lib/credentials/parsePemCertificate"), +}); -exports.Errors = require("./lib/errors"); -exports.error = exports.Errors; +const prepareCredentials = require("./lib/credentials/prepare")({ + load: require("./lib/credentials/load"), + parse, + validate: require("./lib/credentials/validate"), + logger: debug, +}); -exports.Feedback = require("./lib/feedback"); -exports.feedback = exports.Feedback; +const config = require("./lib/config")({ + debug, + prepareCredentials, +}); -exports.Notification = require("./lib/notification"); -exports.notification = exports.Notification; \ No newline at end of file +const tls = require("tls"); + +const framer = require("http2/lib/protocol/framer"); +const compressor = require("http2/lib/protocol/compressor"); + +const protocol = { + Serializer: framer.Serializer, + Deserializer: framer.Deserializer, + Compressor: compressor.Compressor, + Decompressor: compressor.Decompressor, + Connection: require("http2/lib/protocol/connection").Connection, +}; + +const Endpoint = require("./lib/protocol/endpoint")({ + tls, + protocol, +}); + +const EndpointManager = require("./lib/protocol/endpointManager")({ + Endpoint, +}); + +const Client = require("./lib/client")({ + config, + EndpointManager, +}); + +const Provider = require("./lib/provider")({ + Client, +}); + +const Notification = require("./lib/notification"); + +const token = require("./lib/token"); + +module.exports = { + Provider, + Notification, + token, +}; diff --git a/lib/client.js b/lib/client.js new file mode 100644 index 00000000..f37321f5 --- /dev/null +++ b/lib/client.js @@ -0,0 +1,114 @@ +"use strict"; + +const VError = require("verror"); +const extend = require("./util/extend"); + +module.exports = function (dependencies) { + const config = dependencies.config; + const EndpointManager = dependencies.EndpointManager; + + function Client (options) { + this.config = config(options); + + this.endpointManager = new EndpointManager(this.config); + this.endpointManager.on("wakeup", () => { + while (this.queue.length > 0) { + const stream = this.endpointManager.getStream(); + if (!stream) { + return; + } + const resolve = this.queue.shift(); + resolve(stream); + } + + if (this.shutdownPending) { + this.endpointManager.shutdown(); + } + }); + + this.endpointManager.on("error", (err) => { + this.queue.forEach((resolve) => { + resolve(Promise.reject(err)); + }) + }); + + this.queue = []; + } + + Client.prototype.write = function write (notification, device) { + return this.getStream().then( stream => { + stream.setEncoding("utf8"); + + stream.headers(extend({ + ":scheme": "https", + ":method": "POST", + ":authority": this.config.address, + ":path": "/3/device/" + device, + }, notification.headers)); + + let status, responseData = ""; + stream.on("headers", headers => { + status = headers[":status"]; + }); + + stream.on("data", data => { + responseData = responseData + data; + }); + + stream.write(notification.body); + + return new Promise ( resolve => { + stream.on("end", () => { + if (status === "200") { + resolve({ device }); + } else if (responseData !== "") { + const response = JSON.parse(responseData); + resolve({ device, status, response }); + } else { + let error = new VError("stream ended unexpectedly"); + resolve({ device, error }); + } + }); + + stream.on("unprocessed", () => { + resolve(this.write(notification, device)); + }); + + stream.on("error", err => { + let error; + if (typeof err === "string") { + error = new VError("apn write failed: %s", err); + } else { + error = new VError(err, "apn write failed"); + } + resolve({ device, error }); + }); + + stream.end(); + }); + }).catch( error => { + return { device, error }; + }); + }; + + Client.prototype.getStream = function getStream() { + return new Promise( resolve => { + const stream = this.endpointManager.getStream(); + if (!stream) { + this.queue.push(resolve); + } else { + resolve(stream); + } + }); + } + + Client.prototype.shutdown = function shutdown() { + if (this.queue.length > 0) { + this.shutdownPending = true; + return; + } + this.endpointManager.shutdown(); + }; + + return Client; +} diff --git a/lib/config.js b/lib/config.js new file mode 100644 index 00000000..fad8c433 --- /dev/null +++ b/lib/config.js @@ -0,0 +1,79 @@ +"use strict"; + +const extend = require("./util/extend"); + +let EndpointAddress = { + production: "api.push.apple.com", + sandbox: "api.sandbox.push.apple.com" +}; + +module.exports = function(dependencies) { + const debug = dependencies.debug; + const prepareCredentials = dependencies.prepareCredentials; + + function config(options) { + let config = { + cert: "cert.pem", + key: "key.pem", + ca: null, + pfx: null, + passphrase: null, + production: (process.env.NODE_ENV === "production"), + address: null, + port: 443, + rejectUnauthorized: true, + connectTimeout: 10000, + connectionTimeout: 3600000, + connectionRetryLimit: 3, + maxConnections: 10, + }; + + validateOptions(options); + + extend(config, options); + configureAddress(config); + + if (config.pfx || config.pfxData) { + config.cert = options.cert; + config.key = options.key; + } + + extend(config, prepareCredentials(config)); + return config; + } + + function validateOptions(options) { + for (var key in options) { + if (options[key] === null || options[key] === undefined) { + debug("Option [" + key + "] is " + options[key] + ". This may cause unexpected behaviour."); + } + } + + if (options) { + if (options.passphrase && typeof options.passphrase !== "string") { + throw new Error("Passphrase must be a string"); + } + } + } + + return config; +}; + +function configureAddress(options) { + if (!options.address) { + if (options.production) { + options.address = EndpointAddress.production; + } + else { + options.address = EndpointAddress.sandbox; + } + } + else { + if (options.address === EndpointAddress.production) { + options.production = true; + } + else { + options.production = false; + } + } +}; diff --git a/lib/connection.js b/lib/connection.js deleted file mode 100644 index 93e19759..00000000 --- a/lib/connection.js +++ /dev/null @@ -1,767 +0,0 @@ -"use strict"; - -var Errors = require("./errors"); - -var q = require("q"); -var sysu = require("util"); -var util = require("./util"); -var events = require("events"); -var Device = require("./device"); -var loadCredentials = require("./credentials/load"); -var parseCredentials = require("./credentials/parse"); -var validateCredentials = require("./credentials/validate"); - -var createSocket = require("./socket"); -var debug = require("debug")("apn"); -var trace = require("debug")("apn:trace"); - -/** - * Create a new connection to the APN service. - * @constructor - * @param {Object} [options] - * @config {Buffer|String} [cert="cert.pem"] The filename of the connection certificate to load from disk, or a Buffer/String containing the certificate data. - * @config {Buffer|String} [key="key.pem"] The filename of the connection key to load from disk, or a Buffer/String containing the key data. - * @config {Buffer[]|String[]} [ca] An array of trusted certificates. Each element should contain either a filename to load, or a Buffer/String to be used directly. If this is omitted several well known "root" CAs will be used. - You may need to use this as some environments don't include the CA used by Apple (entrust_2048). - * @config {Buffer|String} [pfx] File path for private key, certificate and CA certs in PFX or PKCS12 format, or a Buffer/String containing the PFX data. If supplied will be used instead of certificate and key above. - * @config {String} [passphrase] The passphrase for the connection key, if required - * @config {Boolean} [production=(NODE_ENV=='production')] Specifies which environment to connect to: Production (if true) or Sandbox. (Defaults to false, unless NODE_ENV == "production") - * @config {Number} [port=2195] Gateway port - * @config {Boolean} [rejectUnauthorized=true] Reject Unauthorized property to be passed through to tls.connect() - * @config {Function} [errorCallback] A callback which accepts 2 parameters (err, notification). Use `transmissionError` event instead. - * @config {Number} [cacheLength=1000] Number of notifications to cache for error purposes (See doc/apn.markdown) - * @config {Boolean} [autoAdjustCache=false] Whether the cache should grow in response to messages being lost after errors. (Will still emit a 'cacheTooSmall' event) - * @config {Number} [maxConnections=1] The maximum number of connections to create for sending messages. - * @config {Number} [minConnections=1] The minimum number of connections to create for sending messages. - * @config {Number} [connectionTimeout=3600000] The duration the socket should stay alive with no activity in milliseconds. 0 = Disabled. - * @config {Boolean} [buffersNotifications=true] Whether to buffer notifications and resend them after failure. - * @config {Boolean} [fastMode=false] Whether to aggresively empty the notification buffer while connected. - */ -function Connection (options) { - if(false === (this instanceof Connection)) { - return new Connection(options); - } - this.options = { - cert: "cert.pem", - key: "key.pem", - ca: null, - pfx: null, - passphrase: null, - production: (process.env.NODE_ENV === "production"), - voip: false, - address: null, - port: 2195, - rejectUnauthorized: true, - cacheLength: 1000, - autoAdjustCache: true, - maxConnections: 1, - minConnections: 1, - connectTimeout: 10000, - connectionTimeout: 3600000, - connectionRetryLimit: 10, - buffersNotifications: true, - fastMode: false, - disableNagle: false, - disableEPIPEFix: false - }; - - for (var key in options) { - if (options[key] === null || options[key] === undefined) { - debug("Option [" + key + "] set to null. This may cause unexpected behaviour."); - } - } - - util.extend(this.options, options); - - this.configureAddress(); - - if (this.options.pfx || this.options.pfxData) { - this.options.cert = options.cert; - this.options.key = options.key; - } - - // Set cache length to 1 to ensure transmitted notifications can be sent. - this.options.cacheLength = Math.max(this.options.cacheLength, 1); - this.options.maxConnections = Math.max(this.options.maxConnections, 1); - this.options.minConnections = Math.max(this.options.minConnections, 1); - this.deferredConnection = null; - this.sockets = []; - this.notificationBuffer = []; - - this.socketId = 0; - - this.failureCount = 0; - this.currentConnectionRoundRobin = 0; - - // when true, we end all sockets after the pending notifications reach 0 - this.shutdownPending = false; - - // track when notifications are queued so transmitCompleted is only emitted one when - // notifications are transmitted rather than after socket timeouts - this.notificationsQueued = false; - - this.terminated = false; - - events.EventEmitter.call(this); -} - -sysu.inherits(Connection, events.EventEmitter); - - -/** - * - * @private - */ -Connection.prototype.maintainMinConnection = function() { - if (this.sockets.length < this.options.minConnections && !this.shutdownPending) { - this.createConnection(); - } -}; - -Connection.prototype.configureAddress = function() { - if (this.options.gateway) { - this.options.address = this.options.gateway; - } - - if (!this.options.address) { - if (this.options.production) { - this.options.address = "gateway.push.apple.com"; - } - else { - this.options.address = "gateway.sandbox.push.apple.com"; - } - } - else { - if (this.options.address === "gateway.push.apple.com") { - this.options.production = true; - } - else { - this.options.production = false; - } - } -}; - -/** - * You should never need to call this method, initialization and connection is handled by {@link Connection#sendNotification} - * @private - */ -Connection.prototype.loadCredentials = function () { - if (!this.credentialsPromise) { - debug("Loading Credentials"); - - var production = this.options.production; - this.credentialsPromise = loadCredentials(this.options) - .then(function(credentials) { - var parsed; - try { - parsed = parseCredentials(credentials); - } - catch (e) { - debug(e); - return credentials; - } - parsed.production = production; - validateCredentials(parsed); - return credentials; - }); - } - - return this.credentialsPromise; -}; - -/** - * You should never need to call this method, initialisation and connection is handled by {@link Connection#pushNotification} - * @private - */ -Connection.prototype.createSocket = function () { - if (this.deferredConnection) { - return this.deferredConnection.promise; - } - - debug("Initialising connection"); - this.deferredConnection = q.defer(); - this.loadCredentials().then(function(credentials) { - var socketOptions = {}; - - socketOptions.port = this.options.port; - socketOptions.host = this.options.address; - socketOptions.disableEPIPEFix = this.options.disableEPIPEFix; - - socketOptions.disableNagle = this.options.disableNagle; - socketOptions.connectionTimeout = this.options.connectionTimeout; - - socketOptions.pfx = credentials.pfx; - socketOptions.cert = credentials.cert; - socketOptions.key = credentials.key; - socketOptions.ca = credentials.ca; - socketOptions.passphrase = this.options.passphrase; - socketOptions.rejectUnauthorized = this.options.rejectUnauthorized; - - this.socket = createSocket(this, socketOptions, - function () { - debug("Connection established"); - this.emit("connected", this.sockets.length + 1); - if(this.deferredConnection) { - this.deferredConnection.resolve(); - } - }.bind(this)); - - this.socket.on("error", this.errorOccurred.bind(this, this.socket)); - this.socket.on("timeout", this.socketTimeout.bind(this, this.socket)); - this.socket.on("data", this.handleTransmissionError.bind(this, this.socket)); - this.socket.on("drain", this.socketDrained.bind(this, this.socket, true)); - this.socket.once("close", this.socketClosed.bind(this, this.socket)); - }.bind(this)).done(null, function (error) { - debug("Module initialisation error:", error); - - // This is a pretty fatal scenario, we don't have key/certificate to connect to APNS, there's not much we can do, so raise errors and clear the queue. - this.rejectBuffer(Errors.moduleInitialisationFailed); - this.emit("error", error); - this.deferredConnection.reject(error); - this.terminated = true; - }.bind(this)); - - if (this.options.connectTimeout > 0) { - var connectionTimer = setTimeout(function () { - if(this.deferredConnection) { - this.deferredConnection.reject(new Error("Connect timed out")); - } - if(this.socket) { - this.socket.end(); - } - }.bind(this), this.options.connectTimeout); - - return this.deferredConnection.promise.finally(function() { - clearTimeout(connectionTimer); - }); - } - - return this.deferredConnection.promise; -}; - -/** - * @private - */ -Connection.prototype.createConnection = function() { - if (this.initialisingConnection() || this.sockets.length >= this.options.maxConnections) { - return; - } - - // Delay here because Apple will successfully authenticate production certificates - // in sandbox, but will then immediately close the connection. Necessary to wait for a beat - // to see if the connection stays before sending anything because the connection will be closed - // without error and messages could be lost. - this.createSocket().delay(100).then(function () { - if (this.socket.apnRetired) { - throw new Error("Socket unusable after connection. Hint: You may be using a certificate for the wrong environment"); - } - this.failureCount = 0; - - this.socket.apnSocketId = this.socketId++; - this.socket.apnCurrentId = 0; - this.socket.apnCachedNotifications = []; - - this.sockets.push(this.socket); - trace("connection established", this.socketId); - }.bind(this)).fail(function (error) { - // Exponential backoff when connections fail. - var delay = Math.pow(2, this.failureCount++) * 1000; - - trace("connection failed", delay); - - this.raiseError(error); - this.emit("socketError", error); - - if (this.options.connectionRetryLimit > 0 - && this.failureCount > this.options.connectionRetryLimit - && this.sockets.length === 0) { - this.rejectBuffer(Errors.connectionRetryLimitExceeded); - this.emit("error", error); - this.shutdown(); - this.terminated = true; - return; - } - - return q.delay(delay); - }.bind(this)).finally(function () { - trace("create completed", this.sockets.length); - this.deferredConnection = null; - this.socket = undefined; - this.maintainMinConnection(); - this.serviceBuffer(); - }.bind(this)).done(null, function(error) { - this.emit("error", error); - }.bind(this)); -}; - -/** - * @private - */ -Connection.prototype.initialisingConnection = function() { - if(this.deferredConnection !== null) { - return true; - } - return false; -}; - -/** - * @private - */ - Connection.prototype.serviceBuffer = function() { - - var socket = null; - var repeat = false; - var socketsAvailable = 0; - if(this.options.fastMode) { - repeat = true; - } - do { - socketsAvailable = 0; - for (var i = 0; i < this.sockets.length; i++) { - var roundRobin = this.currentConnectionRoundRobin; - socket = this.sockets[roundRobin]; - if(!this.socketAvailable(socket)) { - continue; - } - - if (this.notificationBuffer.length === 0) { - socketsAvailable += 1; - break; - } - this.currentConnectionRoundRobin = (roundRobin + 1) % this.sockets.length; - - // If a socket is available then transmit. If true is returned then manually call socketDrained - if (this.transmitNotification(socket, this.notificationBuffer.shift())) { - // Only set socket available here because if transmit returns false then the socket - // is blocked so shouldn't be used in the next loop. - socketsAvailable += 1; - this.socketDrained(socket, !repeat); - } - } - } while(repeat && socketsAvailable > 0 && this.notificationBuffer.length > 0); - - if (this.notificationBuffer.length > 0 && socketsAvailable === 0) { - this.createConnection(); - } - - if (this.notificationBuffer.length === 0 && socketsAvailable === this.sockets.length){ - if (this.notificationsQueued) { - this.emit("completed"); - this.notificationsQueued = false; - } - if (this.shutdownPending) { - debug("closing connections"); - - for (var j = 0; j < this.sockets.length; j++) { - socket = this.sockets[j]; - // We delay before closing connections to ensure we don't miss any error packets from the service. - setTimeout(socket.end.bind(socket), 2500); - this.retireSocket(socket); - } - } - } - - debug("%d left to send", this.notificationBuffer.length); - }; - -/** - * @private - */ -Connection.prototype.errorOccurred = function(socket, err) { - debug("Socket error occurred", socket.apnSocketId, err); - - if(socket.transmissionErrorOccurred && err.code === "EPIPE") { - debug("EPIPE occurred after a transmission error which we can ignore"); - return; - } - - if(this.socket === socket && this.deferredConnection && this.deferredConnection.promise.isPending()) { - this.deferredConnection.reject(err); - } - else { - this.emit("socketError", err); - this.raiseError(err, null); - } - - if(socket.apnBusy && socket.apnCachedNotifications.length > 0) { - // A notification was in flight. It should be buffered for resending. - this.bufferNotification(socket.apnCachedNotifications[socket.apnCachedNotifications.length - 1]); - } - - this.destroyConnection(socket); -}; - -/** - * @private - */ -Connection.prototype.socketAvailable = function(socket) { - if (!socket || !socket.writable || socket.apnRetired || socket.apnBusy || socket.transmissionErrorOccurred) { - return false; - } - return true; -}; - -/** - * @private - */ -Connection.prototype.socketDrained = function(socket, serviceBuffer) { - debug("Socket drained", socket.apnSocketId); - socket.apnBusy = false; - if(socket.apnCachedNotifications.length > 0) { - var notification = socket.apnCachedNotifications[socket.apnCachedNotifications.length - 1]; - this.emit("transmitted", notification.notification, notification.recipient); - } - if(serviceBuffer === true && !this.runningOnNextTick) { - // There is a possibility that this could add multiple invocations to the - // call stack unnecessarily. It will be resolved within one event loop but - // should be mitigated if possible, this.nextTick aims to solve this, - // ensuring "serviceBuffer" is only called once per loop. - util.setImmediate(function() { - this.runningOnNextTick = false; - this.serviceBuffer(); - }.bind(this)); - this.runningOnNextTick = true; - } -}; - -/** - * @private - */ - Connection.prototype.socketTimeout = function(socket) { - debug("Socket timeout", socket.apnSocketId); - this.emit("timeout"); - this.destroyConnection(socket); - - this.serviceBuffer(); - }; - -/** - * @private - */ -Connection.prototype.destroyConnection = function(socket) { - debug("Destroying connection", socket.apnSocketId); - if (socket) { - this.retireSocket(socket); - socket.destroy(); - } -}; - -/** - * @private - */ -Connection.prototype.socketClosed = function(socket) { - debug("Socket closed", socket.apnSocketId); - - if (socket === this.socket && this.deferredConnection.promise.isPending()) { - debug("Connection error occurred before TLS Handshake"); - this.deferredConnection.reject(new Error("Unable to connect")); - } - else { - this.retireSocket(socket); - this.emit("disconnected", this.sockets.length); - } - - this.serviceBuffer(); -}; - -/** - * @private - */ - Connection.prototype.retireSocket = function(socket) { - debug("Removing socket from pool", socket.apnSocketId); - - socket.apnRetired = true; - var index = this.sockets.indexOf(socket); - if (index > -1) { - this.sockets.splice(index, 1); - } - this.maintainMinConnection(); - }; - -/** - * Use this method to modify the cache length after initialisation. - */ -Connection.prototype.setCacheLength = function(newLength) { - this.options.cacheLength = newLength; -}; - -/** - * @private - */ -Connection.prototype.bufferNotification = function (notification) { - if (notification.retryLimit === 0) { - this.raiseError(Errors.retryLimitExceeded, notification); - this.emit("transmissionError", Errors.retryLimitExceeded, notification.notification, notification.recipient); - return; - } - notification.retryLimit -= 1; - this.notificationBuffer.push(notification); - this.notificationsQueued = true; -}; - -/** - * @private - */ -Connection.prototype.rejectBuffer = function (errCode) { - while(this.notificationBuffer.length > 0) { - var notification = this.notificationBuffer.shift(); - this.raiseError(errCode, notification.notification, notification.recipient); - this.emit("transmissionError", errCode, notification.notification, notification.recipient); - } -}; - -/** - * @private - */ -Connection.prototype.prepareNotification = function (notification, device) { - var recipient = device; - // If a device token hasn't been given then we should raise an error. - if (recipient === undefined) { - util.setImmediate(function () { - this.raiseError(Errors.missingDeviceToken, notification); - this.emit("transmissionError", Errors.missingDeviceToken, notification); - }.bind(this)); - return; - } - - // If we have been passed a token instead of a `Device` then we should convert. - if (!(recipient instanceof Device)) { - try { - recipient = new Device(recipient); - } - catch (e) { - // If an exception has been thrown it's down to an invalid token. - util.setImmediate(function () { - this.raiseError(Errors.invalidToken, notification, device); - this.emit("transmissionError", Errors.invalidToken, notification, device); - }.bind(this)); - return; - } - } - - var retryLimit = (notification.retryLimit < 0) ? -1 : notification.retryLimit + 1; - this.bufferNotification( { "notification": notification, "recipient": recipient, "retryLimit": retryLimit } ); -}; - -/** - * @private - */ -Connection.prototype.cacheNotification = function (socket, notification) { - socket.apnCachedNotifications.push(notification); - if (socket.apnCachedNotifications.length > this.options.cacheLength) { - debug("Clearing notification %d from the cache", socket.apnCachedNotifications[0]._uid); - socket.apnCachedNotifications.splice(0, socket.apnCachedNotifications.length - this.options.cacheLength); - } -}; - -/** - * @private - */ -Connection.prototype.handleTransmissionError = function (socket, data) { - if (data[0] === 8) { - socket.transmissionErrorOccurred = true; - - var errorCode = data[1]; - var identifier = data.readUInt32BE(2); - var notification = null; - var foundNotification = false; - var temporaryCache = []; - - debug("Notification %d caused an error: %d", identifier, errorCode); - - while (socket.apnCachedNotifications.length) { - notification = socket.apnCachedNotifications.shift(); - if (notification._uid === identifier) { - foundNotification = true; - break; - } - temporaryCache.push(notification); - } - - if (foundNotification) { - while (temporaryCache.length) { - temporaryCache.shift(); - } - this.emit("transmissionError", errorCode, notification.notification, notification.recipient); - this.raiseError(errorCode, notification.notification, notification.recipient); - } - else { - socket.apnCachedNotifications = temporaryCache; - - if(socket.apnCachedNotifications.length > 0) { - var differentialSize = socket.apnCachedNotifications[0]._uid - identifier; - this.emit("cacheTooSmall", differentialSize); - if(this.options.autoAdjustCache) { - this.options.cacheLength += differentialSize * 2; - } - } - - this.emit("transmissionError", errorCode, null); - this.raiseError(errorCode, null); - } - - var count = socket.apnCachedNotifications.length; - if(this.options.buffersNotifications) { - debug("Buffering %d notifications for resending", count); - for (var i = 0; i < count; ++i) { - notification = socket.apnCachedNotifications.shift(); - this.bufferNotification(notification); - } - } - } - else { - debug("Unknown data received: ", data); - } -}; - -/** - * @private - */ -Connection.prototype.raiseError = function(errorCode, notification, recipient) { - debug("Raising error:", errorCode, notification, recipient); - - if(errorCode instanceof Error) { - debug("Error occurred with trace:", errorCode.stack); - } - - if (notification && typeof notification.errorCallback === "function" ) { - notification.errorCallback(errorCode, recipient); - } else if (typeof this.options.errorCallback === "function") { - this.options.errorCallback(errorCode, notification, recipient); - } -}; - -/** - * @private - * @return {Boolean} Write completed, returns true if socketDrained should be called by the caller of this method. - */ -Connection.prototype.transmitNotification = function(socket, notification) { - var token = notification.recipient.token; - var encoding = notification.notification.encoding || "utf8"; - var message = notification.notification.compile(); - var messageLength = Buffer.byteLength(message, encoding); - var position = 0; - var data; - - notification._uid = socket.apnCurrentId++; - if (socket.apnCurrentId > 0xffffffff) { - socket.apnCurrentId = 0; - } - - // New Protocol uses framed notifications consisting of multiple items - // 1: Device Token - // 2: Payload - // 3: Notification Identifier - // 4: Expiration Date - // 5: Priority - // Each item has a 3 byte header: Type (1), Length (2) followed by data - // The frame layout is hard coded for now as original dynamic system had a - // significant performance penalty - - var frameLength = 3 + token.length + 3 + messageLength + 3 + 4; - if(notification.notification.expiry > 0) { - frameLength += 3 + 4; - } - if(notification.notification.priority !== 10) { - frameLength += 3 + 1; - } - - // Frame has a 5 byte header: Type (1), Length (4) followed by items. - data = new Buffer(5 + frameLength); - data[position] = 2; position += 1; - - // Frame Length - data.writeUInt32BE(frameLength, position); position += 4; - - // Token Item - data[position] = 1; position += 1; - data.writeUInt16BE(token.length, position); position += 2; - position += token.copy(data, position, 0); - - // Payload Item - data[position] = 2; position += 1; - data.writeUInt16BE(messageLength, position); position += 2; - position += data.write(message, position, encoding); - - // Identifier Item - data[position] = 3; position += 1; - data.writeUInt16BE(4, position); position += 2; - data.writeUInt32BE(notification._uid, position); position += 4; - - if(notification.notification.expiry > 0) { - // Expiry Item - data[position] = 4; position += 1; - data.writeUInt16BE(4, position); position += 2; - data.writeUInt32BE(notification.notification.expiry, position); position += 4; - } - if(notification.notification.priority !== 10) { - // Priority Item - data[position] = 5; position += 1; - data.writeUInt16BE(1, position); position += 2; - data[position] = notification.notification.priority; position += 1; - } - - this.cacheNotification(socket, notification); - - socket.apnBusy = true; - return socket.write(data); -}; - -Connection.prototype.validNotification = function (notification, recipient) { - var messageLength = notification.length(); - var maxLength = (this.options.voip ? 4096 : 2048); - - if (messageLength > maxLength) { - util.setImmediate(function () { - this.raiseError(Errors.invalidPayloadSize, notification, recipient); - this.emit("transmissionError", Errors.invalidPayloadSize, notification, recipient); - }.bind(this)); - return false; - } - return true; -}; - -/** - * Queue a notification for delivery to recipients - * @param {Notification} notification The Notification object to be sent - * @param {Device|String|Buffer|Device[]|String[]|Buffer[]} recipient The token(s) for devices the notification should be delivered to. - * @since v1.3.0 - */ -Connection.prototype.pushNotification = function (notification, recipient) { - if (this.terminated) { - this.emit("transmissionError", Errors.connectionTerminated, notification, recipient); - return false; - } - if (!this.validNotification(notification, recipient)) { - return; - } - if (sysu.isArray(recipient)) { - for (var i = recipient.length - 1; i >= 0; i--) { - this.prepareNotification(notification, recipient[i]); - } - } - else { - this.prepareNotification(notification, recipient); - } - - this.shutdownPending = false; - this.serviceBuffer(); -}; - -/** - * Send a notification to the APN service - * @param {Notification} notification The notification object to be sent - * @deprecated Since v1.3.0, use pushNotification instead - */ -Connection.prototype.sendNotification = function (notification) { - return this.pushNotification(notification, notification.device); -}; - -/** - * End connections with APNS once we've finished sending all notifications - */ -Connection.prototype.shutdown = function () { - debug("Shutdown pending"); - this.shutdownPending = true; -}; - -module.exports = Connection; diff --git a/lib/credentials/index.js b/lib/credentials/index.js new file mode 100644 index 00000000..a0238a7a --- /dev/null +++ b/lib/credentials/index.js @@ -0,0 +1,5 @@ +module.exports = { + load: require("./load"), + parse: require("./parse"), + validate: require("./validate") +}; diff --git a/lib/credentials/load.js b/lib/credentials/load.js index c44733c0..f6cc67e4 100644 --- a/lib/credentials/load.js +++ b/lib/credentials/load.js @@ -1,42 +1,32 @@ "use strict"; -var q = require("q"); -var sysu = require("util"); - var resolve = require("./resolve"); function loadCredentials(credentials) { // Prepare PKCS#12 data if available - var pfxPromise = resolve(credentials.pfx || credentials.pfxData); + var pfx = resolve(credentials.pfx || credentials.pfxData); // Prepare Certificate data if available. - var certPromise = resolve(credentials.cert || credentials.certData); + var cert = resolve(credentials.cert || credentials.certData); // Prepare Key data if available - var keyPromise = resolve(credentials.key || credentials.keyData); + var key = resolve(credentials.key || credentials.keyData); // Prepare Certificate Authority data if available. - var caPromises = []; + var ca = []; + if (credentials.ca !== null) { - if(!sysu.isArray(credentials.ca)) { + if(!Array.isArray(credentials.ca)) { credentials.ca = [ credentials.ca ]; } - credentials.ca.forEach(function(ca) { - caPromises.push(resolve(ca)); - }); - } - if (caPromises.length === 0) { - caPromises = undefined; + ca = credentials.ca.map( resolve ); } - else { - caPromises = q.all(caPromises); + if (ca.length === 0) { + ca = undefined; } - return q.all([pfxPromise, certPromise, keyPromise, caPromises]) - .spread(function(pfx, cert, key, ca) { - return { pfx: pfx, cert: cert, key: key, ca: ca, passphrase: credentials.passphrase }; - }); + return { pfx: pfx, cert: cert, key: key, ca: ca, passphrase: credentials.passphrase }; } -module.exports = loadCredentials; \ No newline at end of file +module.exports = loadCredentials; diff --git a/lib/credentials/parse.js b/lib/credentials/parse.js index 5e7dc598..b09a835b 100644 --- a/lib/credentials/parse.js +++ b/lib/credentials/parse.js @@ -1,20 +1,21 @@ -var parsePkcs12 = require("./parsePkcs12"); -var parsePemKey = require("./parsePemKey"); -var parsePemCert = require("./parsePemCertificate"); +module.exports = function (dependencies) { + const parsePkcs12 = dependencies.parsePkcs12; + const parsePemKey = dependencies.parsePemKey; + const parsePemCert = dependencies.parsePemCert; + function parse(credentials) { + var parsed = {}; -function parse(credentials) { - var parsed = {}; + parsed.key = parsePemKey(credentials.key, credentials.passphrase); + parsed.certificates = parsePemCert(credentials.cert); - parsed.key = parsePemKey(credentials.key, credentials.passphrase); - parsed.certificates = parsePemCert(credentials.cert); + var pkcs12Parsed = parsePkcs12(credentials.pfx, credentials.passphrase); + if (pkcs12Parsed) { + parsed.key = pkcs12Parsed.key; + parsed.certificates = pkcs12Parsed.certificates; + } - var pkcs12Parsed = parsePkcs12(credentials.pfx, credentials.passphrase); - if (pkcs12Parsed) { - parsed.key = pkcs12Parsed.key; - parsed.certificates = pkcs12Parsed.certificates; + return parsed; } - return parsed; -} - -module.exports = parse; \ No newline at end of file + return parse; +}; \ No newline at end of file diff --git a/lib/credentials/prepare.js b/lib/credentials/prepare.js new file mode 100644 index 00000000..b06db4a5 --- /dev/null +++ b/lib/credentials/prepare.js @@ -0,0 +1,25 @@ +"use strict"; + +module.exports = function(dependencies) { + const load = dependencies.load; + const parse = dependencies.parse; + const validate = dependencies.validate; + + const logger = dependencies.logger; + + function loadAndValidate(credentials) { + const loaded = load(credentials); + let parsed; + try { + parsed = parse(loaded); + } catch(err) { + logger(err); + return loaded; + } + parsed.production = credentials.production; + validate(parsed); + return loaded; + } + + return loadAndValidate; +}; diff --git a/lib/credentials/resolve.js b/lib/credentials/resolve.js index a1e6619c..0ac7afdd 100644 --- a/lib/credentials/resolve.js +++ b/lib/credentials/resolve.js @@ -1,7 +1,6 @@ "use strict"; var fs = require("fs"); -var q = require("q"); function resolveCredential(value) { if (!value) { @@ -14,8 +13,8 @@ function resolveCredential(value) { return value; } else { - return q.nfbind(fs.readFile)(value); + return fs.readFileSync(value); } } -module.exports = resolveCredential; \ No newline at end of file +module.exports = resolveCredential; diff --git a/lib/device.js b/lib/device.js deleted file mode 100644 index 16644c63..00000000 --- a/lib/device.js +++ /dev/null @@ -1,33 +0,0 @@ -"use strict"; -/** - * Creates a Device. - * @constructor - * @param {String|Buffer} token Device token - */ -function Device(deviceToken) { - if (!(this instanceof Device)) { - return new Device(deviceToken); - } - - if(typeof deviceToken === "string") { - this.token = new Buffer(deviceToken.replace(/[^0-9a-f]/gi, ""), "hex"); - } - else if(Buffer.isBuffer(deviceToken)) { - this.token = new Buffer(deviceToken.length); - deviceToken.copy(this.token); - } - - if (!this.token || this.token.length === 0) { - throw new Error("Invalid Token Specified, must be a Buffer or valid hex String"); - } -} - -/** - * @returns {String} Device token in hex string representation - * @since v1.2.0 - */ -Device.prototype.toString = function() { - return this.token.toString("hex"); -}; - -module.exports = Device; \ No newline at end of file diff --git a/lib/errors.js b/lib/errors.js deleted file mode 100644 index f2518560..00000000 --- a/lib/errors.js +++ /dev/null @@ -1,25 +0,0 @@ -"use strict"; -/** - * Error codes used by Apple - * @see The Binary Interface and Notification Formats - */ - -var Errors = { - "noErrorsEncountered": 0, - "processingError": 1, - "missingDeviceToken": 2, - "missingTopic": 3, - "missingPayload": 4, - "invalidTokenSize": 5, - "invalidTopicSize": 6, - "invalidPayloadSize": 7, - "invalidToken": 8, - "apnsShutdown": 10, - "none": 255, - "retryLimitExceeded": 512, - "moduleInitialisationFailed": 513, - "connectionRetryLimitExceeded": 514, // When a connection is unable to be established. Usually because of a network / SSL error this will be emitted - "connectionTerminated": 515 -}; - -module.exports = Errors; \ No newline at end of file diff --git a/lib/feedback.js b/lib/feedback.js deleted file mode 100644 index ded27557..00000000 --- a/lib/feedback.js +++ /dev/null @@ -1,294 +0,0 @@ -"use strict"; - -var loadCredentials = require("./credentials/load"); -var parseCredentials = require("./credentials/parse"); -var validateCredentials = require("./credentials/validate"); -var Device = require("./device"); - -var createSocket = require("./socket"); - -var q = require("q"); -var sysu = require("util"); -var util = require("./util"); -var events = require("events"); -var debug = require("debug")("apnfb"); - -/** - * Create a new connection to the APN Feedback. - * @constructor - * @param {Object} [options] - * @config {Buffer|String} [cert="cert.pem"] The filename of the connection certificate to load from disk, or a Buffer/String containing the certificate data. - * @config {Buffer|String} [key="key.pem"] The filename of the connection key to load from disk, or a Buffer/String containing the key data. - * @config {Buffer[]|String[]} [ca] An array of trusted certificates. Each element should contain either a filename to load, or a Buffer/String to be used directly. If this is omitted several well known "root" CAs will be used. - You may need to use this as some environments don't include the CA used by Apple (entrust_2048). - * @config {Buffer|String} [pfx] File path for private key, certificate and CA certs in PFX or PKCS12 format, or a Buffer/String containing the PFX data. If supplied will be used instead of certificate and key above. - * @config {String} [passphrase] The passphrase for the connection key, if required - * @config {String} [address="feedback.push.apple.com"] The feedback server to connect to. - * @config {Number} [port=2196] Feedback server port - * @config {Function} [feedback] Deprecated ** A callback which accepts 2 parameters (timestamp, {@link Device}) or an array of (timestamp, {@link Device}) object tuples, depending on the value of batchFeedback option. See: {@link Communicating with APS. - * @config {Boolean} [batchFeedback=true] If true, the feedback callback should only be called after all tokens are received, with an array of timestamp and device token tuples. - * @config {Number} [batchSize=0] The maximum number of tokens to pass when emitting the event. After `batchSize` tokens are received the `feedback` event will be emitted. - * @config {Function} [errorCallback] Deprecated ** Callback which will capture connection errors - * @config {Number} [interval=3600] Interval to automatically connect to the Feedback service. - */ -function Feedback(options) { - if(false === (this instanceof Feedback)) { - return new Feedback(options); - } - this.options = { - cert: "cert.pem", /* Certificate file */ - key: "key.pem", /* Key file */ - ca: null, /* Certificate Authority */ - pfx: null, /* PFX File */ - passphrase: null, /* Passphrase for key */ - production: (process.env.NODE_ENV === "production"), - port: 2196, /* feedback port */ - rejectUnauthorized: true, /* Set this to false incase using a local proxy, reject otherwise */ - feedback: false, /* **Deprecated**: Use `feedback` event instead, enable feedback service, set to callback */ - batchFeedback: true, /* If the feedback callback should only be called after all tokens are received. */ - batchSize: 0, /* The maximum number of tokens to pass when emitting the `feedback` event, by default pass all tokens when connection closes. */ - errorCallback: false, /* error handler to catch connection exceptions */ - interval: 3600 /* interval in seconds to connect to feedback service */ - }; - - for (var key in options) { - if (options[key] === null) { - debug("Option [" + key + "] set to null. This may cause unexpected behaviour."); - } - } - - util.extend(this.options, options); - - this.configureAddress(); - - if (this.options.pfx || this.options.pfxData) { - if (!options.cert) { - this.options.cert = null; - } - if (!options.key) { - this.options.key = null; - } - } - - events.EventEmitter.call(this); - - if (typeof this.options.errorCallback === "function") { - this.on("error", this.options.errorCallback); - } - - if (typeof this.options.feedback === "function") { - this.on("feedback", this.options.feedback); - } - - process.nextTick(function() { - if(this.listeners("feedback").length === 0) { - debug("WARNING: A `feedback` listener has not been specified. Data may be lost."); - } - }.bind(this)); - - this.start(); -} - -sysu.inherits(Feedback, events.EventEmitter); - -Feedback.prototype.configureAddress = function() { - if (!this.options.address) { - if (this.options.production) { - this.options.address = "feedback.push.apple.com"; - } - else { - this.options.address = "feedback.sandbox.push.apple.com"; - } - } - else { - if (this.options.address === "feedback.push.apple.com") { - this.options.production = true; - } - else { - this.options.production = false; - } - } -}; - -/** - * You should never need to call this method, initialization and connection is handled by {@link Connection#sendNotification} - * @private - */ -Feedback.prototype.loadCredentials = function () { - if (!this.credentialsPromise) { - debug("Loading Credentials"); - - var production = this.options.production; - this.credentialsPromise = loadCredentials(this.options) - .then(function(credentials) { - var parsed; - try { - parsed = parseCredentials(credentials); - } - catch (e) { - debug(e); - return credentials; - } - parsed.production = production; - validateCredentials(parsed); - return credentials; - }); - } - - return this.credentialsPromise; -}; - -/** - * You should call {@link Feedback#start} instead of this method - * @private - */ -Feedback.prototype.createSocket = function () { - if(this.deferredConnection) { - return this.deferredConnection.promise; - } - - debug("Initialising connection"); - this.deferredConnection = q.defer(); - this.loadCredentials().then(function(credentials) { - var socketOptions = {}; - - socketOptions.port = this.options.port; - socketOptions.host = this.options.address; - socketOptions.pfx = credentials.pfx; - socketOptions.cert = credentials.cert; - socketOptions.key = credentials.key; - socketOptions.ca = credentials.ca; - socketOptions.passphrase = this.options.passphrase; - socketOptions.rejectUnauthorized = this.options.rejectUnauthorized; - - this.socket = createSocket(this, socketOptions, - function () { - debug("Connection established"); - this.deferredConnection.resolve(); - }.bind(this)); - - this.readBuffer = new Buffer(0); - this.feedbackData = []; - this.socket.on("data", this.receive.bind(this)); - this.socket.on("error", this.destroyConnection.bind(this)); - this.socket.once("close", this.resetConnection.bind(this)); - }.bind(this), function (error) { - debug("Module initialisation error:", error); - this.cancel(); - - throw error; - }.bind(this)).done(null, function(error) { - this.emit("error", error); - this.deferredConnection.reject(error); - this.deferredConnection = null; - }.bind(this)); - - return this.deferredConnection.promise; -}; - -/** - * @private - */ -Feedback.prototype.receive = function (data) { - var time = 0; - var tokenLength = 0; - var token = null; - - debug("Received packet of length: %d", data.length); - var newBuffer = new Buffer(this.readBuffer.length + data.length); - this.readBuffer.copy(newBuffer); - data.copy(newBuffer, this.readBuffer.length); - this.readBuffer = newBuffer; - while (this.readBuffer.length > 6) { - time = this.readBuffer.readUInt32BE(0); - tokenLength = this.readBuffer.readUInt16BE(4); - if ((this.readBuffer.length - 6) < tokenLength) { - return; - } - token = new Buffer(tokenLength); - this.readBuffer.copy(token, 0, 6, 6 + tokenLength); - - debug("Parsed device token: %s, timestamp: %d", token.toString("hex"), time); - var device = new Device(token); - if (!this.options.batchFeedback) { - debug("Emitting feedback event"); - this.emit("feedback", time, device); - } else { - this.feedbackData.push({ time: time, device: device }); - if (this.options.batchSize > 0 && this.options.batchSize <= this.feedbackData.length) { - this.emit("feedback", this.feedbackData); - this.feedbackData = []; - } - } - this.readBuffer = this.readBuffer.slice(6 + tokenLength); - } -}; - -/** - * @private - */ -Feedback.prototype.destroyConnection = function (err) { - debug("Destroying connection"); - if(err) { - this.emit("feedbackError", err); - } - if (this.socket) { - this.socket.destroySoon(); - } -}; - -/** - * @private - */ -Feedback.prototype.resetConnection = function () { - debug("Resetting connection"); - - if (this.options.batchFeedback) { - debug("Emitting " + this.feedbackData.length + " feedback tokens"); - this.emit("feedback", this.feedbackData); - this.feedbackData = []; - } - - if(this.deferredConnection.promise.isPending()) { - debug("Connection error occurred before TLS Handshake"); - this.deferredConnection.reject(new Error("Unable to connect")); - } - - this.socket = null; - this.deferredConnection = null; -}; - -/** - * Connect to the feedback service, also initialise the timer if an interval is specified. - */ -Feedback.prototype.start = function () { - debug("Starting feedback service"); - this.cancel(); - if (this.options.interval > 0) { - debug("Feedback service interval set at: %d", this.options.interval); - this.interval = setInterval(this.request.bind(this), this.options.interval * 1000); - } - this.request(); -}; - -/** - * @private - */ -Feedback.prototype.request = function () { - debug("Performing feedback request"); - this.createSocket().fail(function (error) { - this.emit("feedbackError", error); - }.bind(this)); -}; - -/** - * Cancel the timer to stop the Feedback service periodically connecting. - */ -Feedback.prototype.cancel = function () { - debug("Cancelling feedback interval"); - if (this.interval !== undefined) { - clearInterval(this.interval); - this.interval = undefined; - } -}; - -module.exports = Feedback; diff --git a/lib/notification.js b/lib/notification.js deleted file mode 100644 index 872463bb..00000000 --- a/lib/notification.js +++ /dev/null @@ -1,536 +0,0 @@ -"use strict"; -/** - * Create a notification - * @constructor - */ -function Notification (payload) { - this.encoding = "utf8"; - - this.payload = payload || {}; - this.expiry = 0; - this.priority = 10; - - this.retryLimit = -1; - - /** @deprecated since v1.3.0 used connection#pushNotification instead which accepts device token separately **/ - this.device = undefined; - - this.compiled = false; - - this.truncateAtWordEnd = false; -} - -Notification.prototype = { - get alert() { - return this._alert; - }, - set alert(value) { - var type = typeof value; - if (type === "string" || type === "object" || value === undefined) { - this._alert = value; - this.compiled = false; - } - }, - - get badge() { - return this._badge; - }, - set badge(value) { - if (typeof value === "number" || value === undefined) { - this._badge = value; - this.compiled = false; - } - }, - - get sound() { - return this._sound; - }, - set sound(value) { - if (typeof value === "string" || value === undefined) { - this._sound = value; - this.compiled = false; - } - }, - - get contentAvailable() { - return this._contentAvailable; - }, - set contentAvailable(value) { - this.compiled = false; - if (value === 1 || value === true) { - this._contentAvailable = 1; - return; - } - this._contentAvailable = undefined; - }, - - get mutableContent() { - return this._mutableContent; - }, - set mutableContent(value) { - this.compiled = false; - if (value === 1 || value === true) { - this._mutableContent = 1; - return; - } - this._mutableContent = undefined; - }, - - get newsstandAvailable() { - return this.contentAvailable; - }, - set newsstandAvailable(value) { - this.contentAvailable = value; - }, - - get mdm() { - return this._mdm; - }, - set mdm(value) { - this._mdm = value; - this.compiled = false; - }, - - get urlArgs() { - return this._urlArgs; - }, - set urlArgs(value) { - if(Array.isArray(value) || value === undefined) { - this._urlArgs = value; - this.compiled = false; - } - }, - - get category() { - return this._category; - }, - set category(value) { - if(typeof value === "string" || value === undefined) { - this._category = value; - this.compiled = false; - } - } -}; - -/** - * Clone a notification to send to multiple devices - * @param {Device} [device] Device the notification will be sent to - * @returns {Notification} A notification containing the same properties as the receiver - * @since v1.2.0 - * @deprecated Since v1.3.0, notifications are not tied to a device so do not need cloning. - */ -Notification.prototype.clone = function (device) { - var notification = new Notification(); - - notification.encoding = this.encoding; - notification.payload = this.payload; - notification.expiry = this.expiry; - notification.priority = this.priority; - notification.device = device; - - notification.alert = this.alert; - notification.badge = this.badge; - notification.sound = this.sound; - notification.newsstandAvailable = this.newsstandAvailable; - notification.contentAvailable = this.contentAvailable; - notification.mutableContent = this.mutableContent; - notification.mdm = this.mdm; - notification.truncateAtWordEnd = this.truncateAtWordEnd; - notification.urlArgs = this.urlArgs; - - notification.category = this.category; - - return notification; -}; - -/** - * Set the expiry value on the payload - * @param {Number} [expiry] Timestamp when the notification should expire. - * @since v1.3.5 - */ -Notification.prototype.setExpiry = function (expiry) { - this.expiry = expiry; - return this; -}; - -/** - * Set the priority - * @param {Number} [priority=10] Priority value for the notification. - * @since v1.3.9 - */ - Notification.prototype.setPriority = function (priority) { - this.priority = priority; - return this; - }; - -/** - * Set the "badge" value on the alert object - * @param {Number} [badge] Badge Value - * @since v1.3.5 - */ -Notification.prototype.setBadge = function (badge) { - this.badge = badge; - return this; -}; - -/** - * Set the "sound" value on the alert object - * @param {String} [sound] Sound file name - * @since v1.3.5 - */ -Notification.prototype.setSound = function (sound) { - this.sound = sound; - return this; -}; - -Notification.prototype.getAlertText = function () { - if(typeof this.alert === "object") { - return this.alert.body; - } - return this.alert; -}; - -/** - * Set the alert text for the notification - * @param {String} alertText The text of the alert message. - * @see The Payload Documentation - * @since v1.2.0 - */ -Notification.prototype.setAlertText = function (text) { - if(typeof this.alert !== "object") { - this.alert = text; - } - else { - this.prepareAlert(); - this.alert.body = text; - } - return this; -}; - -/** - * Set the alert title for the notification - used with Safari Push Notifications and iOS Push Notifications displayed on Apple Watch - * @param {String} alertTitle The title for the alert. - * @see The Pushing Notifications in the Notification Programming Guide for Websites - * @since v1.5.0 - */ -Notification.prototype.setAlertTitle = function(alertTitle) { - this.prepareAlert(); - this.alert.title = alertTitle; - return this; -}; - -/** - * Set the alert title-loc-key for the notification - used with iOS Push Notifications displayed on Apple Watch. Please note: The corresponding localization key must be in your host app's (i.e. iPhone app) Localizable.strings file and not inside your WatchKit extension or WatchKit app. - * @param {String} titleLocKey The localization key for the alert title. - * @see The Payload Documentation - * @since XXX - */ -Notification.prototype.setTitleLocKey = function(titleLocKey) { - this.prepareAlert(); - this.alert["title-loc-key"] = titleLocKey; - return this; -}; - -/** - * Set the alert title-loc-args for the notification - used with iOS Push Notifications displayed on Apple Watch - * @param {String[]} [titleLocArgs] Variable string values to appear in place of the format specifiers in title-loc-key. - * @see The Payload Documentation - * @since XXX - */ -Notification.prototype.setTitleLocArgs = function(titleLocArgs) { - this.prepareAlert(); - this.alert["title-loc-args"] = titleLocArgs; - return this; -}; - -/** - * Set the alert action label for the notification - used with Safari Push Notifications - * @param {String} alertLabel The label for the alert action button. - * @see The Pushing Notifications in the Notification Programming Guide for Websites - * @since v1.5.0 - */ -Notification.prototype.setAlertAction = function(alertAction) { - this.prepareAlert(); - this.alert.action = alertAction; - return this; -}; - -/** - * Set the action-loc-key property on the alert object - * @param {String} [key] If a string is specified, displays an alert with two buttons, whose behavior is described in Table 3-1. However, iOS uses the string as a key to get a localized string in the current localization to use for the right button’s title instead of “View”. If the value is null, the system displays an alert with a single OK button that simply dismisses the alert when tapped. - * @see The Payload Documentation - * @since v1.2.0 - */ -Notification.prototype.setActionLocKey = function (key) { - this.prepareAlert(); - this.alert["action-loc-key"] = key; - return this; -}; - -/** - * Set the loc-key parameter on the alert object - * @param {String} [key] A key to an alert-message string in a Localizable.strings file for the current localization (which is set by the user’s language preference). - * @see The Payload Documentation - * @since v1.2.0 - */ -Notification.prototype.setLocKey = function (key) { - this.prepareAlert(); - if(!key) { - delete this.alert["loc-key"]; - return; - } - this.alert["loc-key"] = key; - return this; -}; - -/** - * Set the loc-args parameter on the alert object - * @param {String[]} [args] Variable string values to appear in place of the format specifiers in loc-key. - * @see The Payload Documentation - * @since v1.2.0 - */ -Notification.prototype.setLocArgs = function (args) { - this.prepareAlert(); - if(!args) { - delete this.alert["loc-args"]; - return; - } - this.alert["loc-args"] = args; - return this; -}; - -/** - * Set the launch-image parameter on the alert object - * @param {String} [image] The filename of an image file in the application bundle; it may include the extension or omit it. - * @see The Payload Documentation - * @since v1.2.0 - */ -Notification.prototype.setLaunchImage = function (image) { - this.prepareAlert(); - if(!image) { - delete this.alert["launch-image"]; - return; - } - this.alert["launch-image"] = image; - return this; -}; - -/** - * Set the 'content-available' flag on the payload - * @param {Boolean} [contentAvailable] Whether the content-available flag should be set or not. - * @since v1.3.5 - */ -Notification.prototype.setContentAvailable = function (contentAvailable) { - this.contentAvailable = contentAvailable; - return this; -}; - -/** - * Set the 'content-available' flag on the payload - * @param {Boolean} [newsstandAvailable] Whether the content-available flag should be set or not. - * @since v1.3.5 - */ -Notification.prototype.setNewsstandAvailable = function (newsstandAvailable) { - this.newsstandAvailable = newsstandAvailable; - return this; -}; - -/** - * Set the 'mutable-content' flag on the payload - * @param {Boolean} [mutableContent] Whether the mutable-content flag should be set or not. - * @since v1.7.8 - */ -Notification.prototype.setMutableContent = function (mutableContent) { - this.mutableContent = mutableContent; - return this; -}; - -/** - * Set the 'mdm' flag on the payload - * @param {Object} [mdm] The mdm property for the payload. - * @since v1.3.5 - */ -Notification.prototype.setMDM = function (mdm) { - this.mdm = mdm; - return this; -}; - -/** - * Set the 'truncateAtWordEnd' flag for truncation logic - * @param {Boolean} [truncateAtWordEnd] Whether the truncateAtWordEnd flag should be set or not. - */ -Notification.prototype.setTruncateAtWordEnd = function (truncateAtWordEnd) { - this.truncateAtWordEnd = truncateAtWordEnd; - return this; -}; - -/** - * Set the urlArgs for the notification - * @param {Array} [urlArgs] The url args for the endpoint - * @see The Web Payload Documentation - * @since v1.4.1 - */ -Notification.prototype.setUrlArgs = function (urlArgs) { - this.urlArgs = urlArgs; - return this; -}; - -/** - * Set the category for the notification - * @param {String} [category] The category for the push notification action - */ -Notification.prototype.setCategory = function (category) { - this.category = category; - return this; -}; - -/** - * If an alert object doesn't already exist create it and transfer any existing message into the .body property - * @private - * @since v1.2.0 - */ -Notification.prototype.prepareAlert = function () { - var existingValue = this.alert; - if(typeof existingValue !== "object") { - this.alert = {}; - if(typeof existingValue === "string") { - this.alert.body = existingValue; - } - } -}; - -/** - * @returns {Number} Byte length of the notification payload - * @since v1.2.0 - */ -Notification.prototype.length = function () { - this.compiled = false; - return Buffer.byteLength(this.compile(), this.encoding || "utf8"); -}; - -/** - * If the notification payload is too long to send this method will attempt to trim the alert body text. - * @returns {Number} The number of characters removed from the body text. If a negative value is returned, the text is too short to be trimmed enough. - * @since v1.2.0 - */ -Notification.prototype.trim = function(length) { - var payloadLength = this.length(); - var tooLong = payloadLength - (length || 2048); - if(tooLong <= 0) { - return 0; - } - this.compiled = false; - var encoding = this.encoding || "utf8"; - var escaped = this.getAlertText(); - - if(!escaped) { - return -tooLong; - } - - escaped = JSON.stringify(escaped).slice(1, -1); // trim quotes - length = Buffer.byteLength(escaped, encoding); - if (length < tooLong) { - return length - tooLong; - } - escaped = this.truncateStringToLength(escaped, length - tooLong, encoding); - escaped = escaped.replace(/(\\u[0-9a-fA-F]{0,3})$/, ""); - escaped = escaped.replace(/\\+$/, function(a){ return a.length % 2 === 0 ? a : a.slice(0, -1); }); - - var trimmed = Buffer.byteLength(escaped, encoding); - escaped = JSON.parse("\"" + escaped + "\""); - - this.setAlertText(escaped); - return length - trimmed; -}; - -/** - * Compile a notification down to its JSON format. Compilation is final, changes made to the notification after this method is called will not be reflected in further calls. - * @returns {String} JSON payload for the notification. - * @since v1.3.0 - */ -Notification.prototype.compile = function () { - if(!this.compiled) { - this.compiled = JSON.stringify(this); - } - return this.compiled; -}; - -function hasValidUnicodeTail(string, encoding) { - var code = string.charCodeAt(string.length - 1); - if (code !== 0xFFFD && encoding === "utf8") { - return true; - } - else if ((code < 0xD800 || code > 0xDBFF) && (encoding === "utf16le" || encoding === "ucs2")) { - return true; - } - return false; -} - -/** - * @param {String} [string] Unicode string to be truncated - * @param {Number} [length] The maximum number of bytes permitted in the Unicode string - * @returns {String} Truncated String - * @private - */ -Notification.prototype.truncateStringToLength = function (string, length, encoding) { - // Convert to a buffer and back to a string with the correct encoding to truncate the unicode series correctly. - var result = new Buffer(string, encoding).toString(encoding, 0, length); - - if (this.truncateAtWordEnd === true) { - var lastSpaceIndexInResult = result.lastIndexOf(" "); - - if(lastSpaceIndexInResult !== -1 && string.charAt(result.length) !== " "){ - result = result.substr(0, lastSpaceIndexInResult); - } - } - - // since we might have chopped off the end of a multi-byte sequence, remove any - // invalid characters (represented as U+FFFD "REPLACEMENT CHARACTER") for UTF-8 - // or orphaned lead surrogates for UTF-16 (UCS-2) - where only the tail surrogate - // has been removed. - if (encoding === "utf8" || encoding === "utf16le" || encoding === "ucs2") { - while( result.length > 0 && !hasValidUnicodeTail(result, encoding) ) { - result = result.substr(0, result.length - 1); - } - } - - return result; -}; - -/** - * @private - */ -Notification.prototype.apsPayload = function() { - var aps = this.payload.aps || {}; - - aps.badge = typeof this.badge !== "undefined" ? this.badge : aps.badge; - aps.sound = this.sound || aps.sound; - aps.alert = this.alert || aps.alert; - if (this.contentAvailable) { - aps["content-available"] = 1; - } - - if (this.mutableContent) { - aps["mutable-content"] = 1; - } - - aps["url-args"] = this.urlArgs || aps["url-args"]; - aps.category = this.category || aps.category; - - return Object.keys(aps).reduce(function(populated, key) { - return populated || aps[key] !== undefined; - }, false) ? aps : undefined; -}; - -Notification.prototype.toJSON = function () { - if (typeof this.mdm === "string") { - this.payload.mdm = this.mdm; - return this.payload; - } - - this.payload.aps = this.apsPayload(); - - return this.payload; -}; - -module.exports = Notification; diff --git a/lib/notification/apsProperties.js b/lib/notification/apsProperties.js new file mode 100644 index 00000000..7ac498ff --- /dev/null +++ b/lib/notification/apsProperties.js @@ -0,0 +1,119 @@ +"use strict"; + +module.exports = { + set alert(value) { + this.aps.alert = value; + }, + + get body() { + if (this.aps.alert) { + return this.aps.alert.body || this.aps.alert; + } + return this.aps.alert; + }, + + set body(value) { + if(typeof this.aps.alert !== "object") { + this.aps.alert = value; + } + else { + this.prepareAlert(); + this.aps.alert.body = value; + } + }, + + set locKey(value) { + this.prepareAlert(); + this.aps.alert["loc-key"] = value; + }, + + set locArgs(value) { + this.prepareAlert(); + this.aps.alert["loc-args"] = value; + }, + + set title(value) { + this.prepareAlert(); + this.aps.alert.title = value; + }, + + set subtitle(value) { + this.prepareAlert(); + this.aps.alert.subtitle = value; + }, + + set titleLocKey(value) { + this.prepareAlert(); + this.aps.alert["title-loc-key"] = value; + }, + + set titleLocArgs(value) { + this.prepareAlert(); + this.aps.alert["title-loc-args"] = value; + }, + + set action(value) { + this.prepareAlert(); + this.aps.alert.action = value; + }, + + set actionLocKey(value) { + this.prepareAlert(); + this.aps.alert["action-loc-key"] = value; + }, + + set launchImage(value) { + this.prepareAlert(); + this.aps.alert["launch-image"] = value; + }, + + set badge(value) { + if (typeof value === "number" || value === undefined) { + this.aps.badge = value; + } + }, + + set sound(value) { + if (typeof value === "string" || value === undefined) { + this.aps.sound = value; + } + }, + + set contentAvailable(value) { + if (value === true || value === 1) { + this.aps["content-available"] = 1; + } else { + this.aps["content-available"] = undefined; + } + }, + + set mutableContent(value) { + if (value === true || value === 1) { + this.aps["mutable-content"] = 1; + } else { + this.aps["mutable-content"] = undefined; + } + }, + + set mdm(value) { + this._mdm = value; + }, + + set urlArgs(value) { + if(Array.isArray(value) || value === undefined) { + this.aps["url-args"] = value; + } + }, + + set category(value) { + if(typeof value === "string" || value === undefined) { + this.aps.category = value; + } + }, + + prepareAlert: function () { + if(typeof this.aps.alert !== "object") { + this.aps.alert = {"body": this.aps.alert}; + } + } +}; diff --git a/lib/notification/index.js b/lib/notification/index.js new file mode 100644 index 00000000..119c713d --- /dev/null +++ b/lib/notification/index.js @@ -0,0 +1,106 @@ +"use strict"; +/** + * Create a notification + * @constructor + */ +function Notification (payload) { + this.encoding = "utf8"; + this.payload = {}; + this.compiled = false; + + this.aps = {}; + this.expiry = 0; + this.priority = 10; + + if (payload) { + for(let key in payload) { + if (payload.hasOwnProperty(key)) { + this[key] = payload[key]; + } + } + } +} + +Notification.prototype = require("./apsProperties"); + +["payload", "expiry", "priority", "alert", "body", "locKey", +"locArgs", "title", "subtitle", "titleLocKey", "titleLocArgs", "action", +"actionLocKey", "launchImage", "badge", "sound", "contentAvailable", +"mutableContent", "mdm", "urlArgs", "category"].forEach( propName => { + const methodName = "set" + propName[0].toUpperCase() + propName.slice(1); + Notification.prototype[methodName] = function (value) { + this[propName] = value; + return this; + }; +}); + +Notification.prototype.headers = function headers() { + let headers = {}; + + if (this.priority !== 10) { + headers["apns-priority"] = this.priority; + } + + if (this.id) { + headers["apns-id"] = this.id; + } + + if (this.expiry > 0) { + headers["apns-expiration"] = this.expiry; + } + + if (this.topic) { + headers["apns-topic"] = this.topic; + } + + if (this.collapseId) { + headers["apns-collapse-id"] = this.collapseId; + } + + return headers; +}; + +/** + * Compile a notification down to its JSON format. Compilation is final, changes made to the notification after this method is called will not be reflected in further calls. + * @returns {String} JSON payload for the notification. + * @since v1.3.0 + */ +Notification.prototype.compile = function () { + if(!this.compiled) { + this.compiled = JSON.stringify(this); + } + return this.compiled; +}; + +/** + * @returns {Number} Byte length of the notification payload + * @since v1.2.0 + */ +Notification.prototype.length = function () { + return Buffer.byteLength(this.compile(), this.encoding || "utf8"); +}; + +/** + * @private + */ +Notification.prototype.apsPayload = function() { + var aps = this.aps; + + return Object.keys(aps).find( key => aps[key] !== undefined ) ? aps : undefined; +}; + +Notification.prototype.toJSON = function () { + if (this.rawPayload != null) { + return this.rawPayload; + } + + if (typeof this._mdm === "string") { + return { "mdm": this._mdm }; + } + + this.payload.aps = this.apsPayload(); + + return this.payload; +}; + +module.exports = Notification; diff --git a/lib/protocol/endpoint.js b/lib/protocol/endpoint.js new file mode 100644 index 00000000..13e20345 --- /dev/null +++ b/lib/protocol/endpoint.js @@ -0,0 +1,172 @@ +"use strict"; + +const EventEmitter = require("events"); + +const noop = () => {}; +const noopLogger = { + fatal: noop, + error: noop, + warn : noop, + info : noop, + debug: noop, + trace: noop, + + child: function() { return this; } +}; + +const CLIENT_PRELUDE = new Buffer("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"); + +module.exports = function(dependencies) { + const tls = dependencies.tls; + const protocol = dependencies.protocol; + + function Endpoint(options) { + EventEmitter.call(this); + + this.options = options; + options.host = options.host || options.address; + options.servername = options.address; + + this._acquiredStreamSlots = 0; + this._maximumStreamSlots = 0; + + options.ALPNProtocols = ["h2"]; + + this._connect(); + this._setupHTTP2Pipeline(); + } + + Endpoint.prototype = Object.create(EventEmitter.prototype, { + availableStreamSlots: { + get: function() { + return this._maximumStreamSlots - this._acquiredStreamSlots; + } + } + }); + + Endpoint.prototype._setupHTTP2Pipeline = function _setupHTTP2Pipeline() { + const serializer = new protocol.Serializer(noopLogger.child("serializer")); + const compressor = new protocol.Compressor(noopLogger.child("compressor"), "REQUEST"); + const deserializer = new protocol.Deserializer(noopLogger.child("deserializer")); + const decompressor = new protocol.Decompressor(noopLogger.child("decompressor"), "RESPONSE"); + + this._connection.pipe(compressor); + compressor.pipe(serializer); + serializer.pipe(this._socket); + + this._socket.pipe(deserializer); + deserializer.pipe(decompressor); + decompressor.pipe(this._connection); + + this._connection.on("RECEIVING_SETTINGS_HEADER_TABLE_SIZE", compressor.setTableSizeLimit.bind(compressor)); + this._connection.on("ACKNOWLEDGED_SETTINGS_HEADER_TABLE_SIZE", decompressor.setTableSizeLimit.bind(decompressor)); + + this._connection.on("RECEIVING_SETTINGS_MAX_CONCURRENT_STREAMS", maxStreams => { + this._maximumStreamSlots = maxStreams; + this.emit("wakeup"); + }); + + serializer.on("error", this._protocolError.bind(this, "serializer")); + compressor.on("error", this._protocolError.bind(this, "compressor")); + deserializer.on("error", this._protocolError.bind(this, "deserializer")); + decompressor.on("error", this._protocolError.bind(this, "decompressor")); + }; + + Endpoint.prototype._connect = function connect() { + this._socket = tls.connect(this.options); + this._socket.on("secureConnect", this._connected.bind(this)); + this._socket.on("error", this._error.bind(this)); + this._socket.on("close", this._close.bind(this)); + this._socket.on("end", this.emit.bind(this, "end")); + this._socket.write(CLIENT_PRELUDE); + + this._connection = new protocol.Connection(noopLogger, 1); + this._connection.on("error", this._protocolError.bind(this, "connection")); + this._connection.on("GOAWAY", this._goaway.bind(this)); + }; + + Endpoint.prototype._connected = function connected() { + this.emit("connect"); + }; + + Endpoint.prototype._protocolError = function protocolError(component, errCode) { + this._error(component + " error: " + errCode); + } + + Endpoint.prototype._error = function error(err) { + this.lastError = err; + + this.emit("error", err); + } + + Endpoint.prototype._goaway = function goaway(frame) { + // When we receive a goaway we must be prepared to + // signal streams which have not been processed by the + // server enabling them to be re-enqueued. We hold + // onto the last stream ID to process it in `close` + this.lastStream = frame.last_stream; + + if (frame.error === "NO_ERROR") { + return; + } + + let message = "GOAWAY: " + frame.error; + if(frame.debug_data) { + message += " " + frame.debug_data.toString(); + } + this._error(message); + } + + Endpoint.prototype._close = function close() { + // After the endpoint closes we loop through all + // dangling streams to handle their state. + this._connection._streamIds.forEach( (stream, id) => { + + // Ignore stream 0 (connection stream) + if (id === 0) return; + + // let stream = this._connection._streamIds[id]; + + // Is stream unprocessed? (last_stream < id) + if (this.lastStream < id) { + stream.emit("unprocessed"); + + } else if (this.lastError) { + // If it *has* been at least partially processed + // and an error has occurred + stream.emit("error", this.lastError); + } + }); + } + + Endpoint.prototype.createStream = function createStream() { + let stream = this._connection.createStream(); + this._connection._allocateId(stream); + + this._acquiredStreamSlots += 1; + stream.on("end", () => { + stream = null; + this._acquiredStreamSlots -= 1; + this.emit("wakeup"); + + if (this._closePending) { + this.close(); + } + }); + + return stream; + }; + + Endpoint.prototype.close = function close() { + if (this._acquiredStreamSlots === 0) { + this._connection.close(); + } + this._closePending = true; + }; + + Endpoint.prototype.destroy = function destroy() { + this._socket.destroy(); + }; + + return Endpoint; +}; diff --git a/lib/protocol/endpointManager.js b/lib/protocol/endpointManager.js new file mode 100644 index 00000000..4f803d05 --- /dev/null +++ b/lib/protocol/endpointManager.js @@ -0,0 +1,104 @@ +"use strict"; + +const EventEmitter = require("events"); +const VError = require("verror"); + +module.exports = function(dependencies) { + + const Endpoint = dependencies.Endpoint; + + function EndpointManager(config) { + EventEmitter.call(this); + + this._endpoints = []; + this._endpointIndex = 0; + this._config = config || {}; + this._connectionFailures = 0; + } + + EndpointManager.prototype = Object.create(EventEmitter.prototype); + + EndpointManager.prototype.getStream = function getStream() { + for (let i=0; i < this._endpoints.length; i++) { + this._endpointIndex += 1; + this._endpointIndex %= this._endpoints.length; + + if (this._endpoints[this._endpointIndex].availableStreamSlots > 0) { + return this._endpoints[this._endpointIndex].createStream(); + } + } + + if (!this.connectionRetryLimitReached()) { + this.createEndpoint(); + } + + return null; + }; + + EndpointManager.prototype.connectionRetryLimitReached = function connectionRetryLimitReached() { + if (!this._config.connectionRetryLimit) { + return false; + } + + return this._connectionFailures >= this._config.connectionRetryLimit; + } + + EndpointManager.prototype.createEndpoint = function createEndpoint() { + if (this._currentConnection || this._endpoints.length >= this._config.maxConnections) { + return; + } + + const endpoint = new Endpoint(this._config); + this._currentConnection = endpoint; + + endpoint.once("connect", () => { + this._endpoints.push(endpoint); + this._connectionFailures = 0; + delete this._currentConnection; + }); + + endpoint.on("error", err => { + endpoint.destroy(); + this.removeEndpoint(endpoint); + + if (this._currentConnection === endpoint) { + this._currentConnection = null; + if(this._endpoints.length === 0) { + this._connectionFailures += 1; + if (this.connectionRetryLimitReached()) { + this.emit("error", new VError(err, "endpoint error")); + } + } + } + + this.emit("wakeup"); + }); + + endpoint.on("end", () => { + this.removeEndpoint(endpoint); + this.emit("wakeup"); + }); + + endpoint.on("wakeup", this.emit.bind(this, "wakeup")); + }; + + EndpointManager.prototype.removeEndpoint = function removeEndpoint(endpoint) { + let index = this._endpoints.indexOf(endpoint); + if (index > -1) { + this._endpoints.splice(index, 1); + } + }; + + EndpointManager.prototype.shutdown = function shutdown() { + for(let endpoint of this._endpoints) { + endpoint.close(); + } + + if (this._currentConnection) { + this._currentConnection.close(); + delete this._currentConnection; + } + }; + + return EndpointManager; +}; diff --git a/lib/provider.js b/lib/provider.js new file mode 100644 index 00000000..85dcbdb7 --- /dev/null +++ b/lib/provider.js @@ -0,0 +1,50 @@ +"use strict"; +const EventEmitter = require("events"); + +module.exports = function(dependencies) { + const Client = dependencies.Client; + + function Provider (options) { + if(false === (this instanceof Provider)) { + return new Provider(options); + } + + this.client = new Client(options); + + EventEmitter.call(this); + } + + Provider.prototype = Object.create(EventEmitter.prototype); + + Provider.prototype.send = function send(notification, recipients) { + const builtNotification = { + headers: notification.headers(), + body: notification.compile(), + }; + + if (!Array.isArray(recipients)) { + recipients = [recipients]; + } + + return Promise.all( recipients.map(this.client.write.bind(this.client, builtNotification)) ) + .then( responses => { + let sent = []; + let failed = []; + + responses.forEach( response => { + if (response.status || response.error) { + failed.push(response); + } else { + sent.push(response); + } + }); + return {sent, failed}; + }); + }; + + Provider.prototype.shutdown = function shutdown() { + this.client.shutdown(); + } + + return Provider; +}; diff --git a/lib/socket.js b/lib/socket.js deleted file mode 100644 index ddaf47cc..00000000 --- a/lib/socket.js +++ /dev/null @@ -1,81 +0,0 @@ -"use strict"; -var tls = require("tls"); -var net = require("net"); - -var debug = require("debug")("apn:socket"); - -function DestroyEPIPEFix(e) { - // When a write error occurs we miss the opportunity to - // read error data from APNS. Delay the call to destroy - // to allow more data to be read. - var socket = this; - var args = arguments; - var call = function () { - socket._apnDestroy.apply(socket, args); - }; - - if (e && e.syscall === "write") { - setTimeout(call, 1000); - } - else { - call(); - } -} - -function apnSocketLegacy(connection, socketOptions, connected) { - // For node < 0.12. We pass in our own Stream to delay connection - // until we have attached the event listeners below. - socketOptions.socket = new net.Socket(); - - if (!socketOptions.disableEPIPEFix) { - socketOptions.socket._apnDestroy = socketOptions.socket._destroy; - socketOptions.socket._destroy = DestroyEPIPEFix; - socketOptions.socket.on("error", function () {}); - } - - var socket = tls.connect( socketOptions.port, socketOptions.host, - socketOptions, connected); - - debug("connecting to: ", socketOptions.host + ":" + socketOptions.port); - - socketOptions.socket.setNoDelay(socketOptions.disableNagle); - socketOptions.socket.setKeepAlive(true); - if (socketOptions.connectionTimeout > 0) { - socketOptions.socket.setTimeout(socketOptions.connectionTimeout); - } - - // The actual connection is delayed until after all the event listeners have - // been attached. - socketOptions.socket.connect(socketOptions.port, socketOptions.host); - - return socket; -} - -function apnSocket(connection, socketOptions, connected) { - - var socket = tls.connect( socketOptions, connected); - - if (!socketOptions.disableEPIPEFix) { - socket._apnDestroy = socket._destroy; - socket._destroy = DestroyEPIPEFix; - } - - socket.setNoDelay(socketOptions.disableNagle); - socket.setKeepAlive(true); - if (socketOptions.connectionTimeout > 0) { - socket.setTimeout(socketOptions.connectionTimeout); - } - - debug("connecting to: ", socketOptions.host + ":" + socketOptions.port); - - return socket; -} - -if (tls.TLSSocket) { - debug("Using 0.12 socket API"); - module.exports = apnSocket; -} -else { - debug("Using legacy socket API"); - module.exports = apnSocketLegacy; -} \ No newline at end of file diff --git a/lib/token.js b/lib/token.js new file mode 100644 index 00000000..b3e05d07 --- /dev/null +++ b/lib/token.js @@ -0,0 +1,25 @@ +"use strict"; +/** + * Validates a device token + * + * Will convert to string and removes invalid characters as required. + */ +function token(input) { + let token; + + if (typeof input === "string") { + token = input; + } else if (Buffer.isBuffer(input)) { + token = input.toString("hex"); + } + + token = token.replace(/[^0-9a-f]/gi, ""); + + if (token.length === 0) { + throw new Error("Token has invalid length"); + } + + return token; +} + +module.exports = token; diff --git a/lib/util.js b/lib/util.js deleted file mode 100644 index c386022f..00000000 --- a/lib/util.js +++ /dev/null @@ -1,23 +0,0 @@ -"use strict"; - -var extend = function(target) { - Array.prototype.slice.call(arguments, 1).forEach(function(source) { - for (var key in source) { - if (source[key] !== undefined) { - target[key] = source[key]; - } - } - }); -}; - -var apnSetImmediate = function (method) { - if("function" === typeof setImmediate) { - setImmediate(method); - } - else { - process.nextTick(method); - } -}; - -module.exports.extend = extend; -module.exports.setImmediate = apnSetImmediate; diff --git a/lib/util/extend.js b/lib/util/extend.js new file mode 100644 index 00000000..365be26a --- /dev/null +++ b/lib/util/extend.js @@ -0,0 +1,10 @@ +"use strict"; + +module.exports = function extend(target, source) { + for (var key in source) { + if (source[key] !== undefined) { + target[key] = source[key]; + } + } + return target; +}; diff --git a/mock/client.js b/mock/client.js new file mode 100644 index 00000000..6f9da218 --- /dev/null +++ b/mock/client.js @@ -0,0 +1,13 @@ +"use strict"; + +module.exports = function() { + + function Client() { + } + + Client.prototype.write = function mockWrite() { + return {}; + }; + + return Client; +}; diff --git a/mock/index.js b/mock/index.js new file mode 100644 index 00000000..0fdb3ce2 --- /dev/null +++ b/mock/index.js @@ -0,0 +1,17 @@ +"use strict"; + +const Client = require("./client")(); + +const Provider = require("../lib/provider")({ + Client, +}); + +const Notification = require("../lib/notification"); +const token = require("../lib/token"); + +module.exports = { + Provider, + Notification, + Client, + token, +}; diff --git a/package.json b/package.json index a49b3d05..091b79d4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "apn", "description": "An interface to the Apple Push Notification service for Node.js", - "version": "1.7.8", + "version": "2.0.0-alpha1", "author": "Andrew Naylor ", "contributors": [ { @@ -28,15 +28,14 @@ }, "dependencies": { "debug": "^2.2.0", + "http2": "https://github.com/argon/node-http2#apn", "node-forge": "^0.6.20", - "q": "^1.1.0" + "verror": "*" }, "devDependencies": { "chai": "3.x", "chai-as-promised": "*", - "lolex": "^1.2.1", "mocha": "*", - "rewire": "^2.3.0", "sinon": "^1.12.2", "sinon-chai": "^2.6.0" }, @@ -46,8 +45,15 @@ "jshintConfig": { "node": true }, + "eslintConfig": { + "ecmaVersion": 6, + "env": { + "es6": true, + "node": true + } + }, "engines": { - "node": ">= 0.8.0" + "node": ">= 4.0.0" }, "license": "MIT" } diff --git a/test/client.js b/test/client.js new file mode 100644 index 00000000..4f2c7b88 --- /dev/null +++ b/test/client.js @@ -0,0 +1,551 @@ +"use strict"; + +const sinon = require("sinon"); +const stream = require("stream"); +const EventEmitter = require("events"); + +describe("Client", function () { + let fakes, Client; + + beforeEach(function () { + 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", function () { + it("prepares the configuration with passed options", function () { + let options = { production: true }; + let client = new Client(options); + + expect(fakes.config).to.be.calledWith(options); + }); + + describe("EndpointManager instance", function() { + it("is created", function () { + let client = new Client(); + + expect(fakes.EndpointManager).to.be.calledOnce; + expect(fakes.EndpointManager).to.be.calledWithNew; + }); + + it("is passed the prepared configuration", function () { + const returnSentinel = { "configKey": "configValue"}; + fakes.config.returns(returnSentinel); + + let client = new Client({}); + expect(fakes.EndpointManager).to.be.calledWith(returnSentinel); + }); + }); + }); + + describe("write", function () { + beforeEach(function () { + fakes.config.returnsArg(0); + fakes.endpointManager.getStream = sinon.stub(); + + fakes.EndpointManager.returns(fakes.endpointManager); + }); + + context("a stream is available", function () { + let client; + + context("transmission succeeds", function () { + beforeEach( function () { + 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 () { + return client.write(builtNotification(), "abcd1234") + .then(function () { + expect(fakes.endpointManager.getStream).to.be.calledOnce; + }); + }); + + describe("headers", function () { + + it("sends the required HTTP/2 headers", function () { + return client.write(builtNotification(), "abcd1234") + .then(function () { + 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", function () { + return client.write(builtNotification(), "abcd1234") + .then(function () { + ["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 () { + 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(function () { + 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", + }); + }); + }); + }); + + it("writes the notification data to the pipe", function () { + const notification = builtNotification(); + return client.write(notification, "abcd1234") + .then(function () { + expect(fakes.stream._transform).to.be.calledWithMatch(actual => actual.equals(Buffer(notification.body))); + }); + }); + + it("ends the stream", function () { + sinon.spy(fakes.stream, "end"); + return client.write(builtNotification(), "abcd1234") + .then(function () { + expect(fakes.stream.end).to.be.calledOnce; + }); + }); + + it("resolves with the device token", function () { + return expect(client.write(builtNotification(), "abcd1234")) + .to.become({ device: "abcd1234" }); + }); + }); + + context("error occurs", function () { + let promise; + + beforeEach(function () { + 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", function () { + return expect(promise).to.eventually.deep.equal({ status: "400", device: "abcd1234", response: { reason: "BadDeviceToken" }}); + }); + }); + + context("stream ends without completing request", function () { + let promise; + + beforeEach(function () { + 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", function () { + return expect(promise).to.eventually.have.property("device", "abcd1234"); + }); + + it("resolves with an object containing an error", function () { + 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", function () { + let promise; + + beforeEach(function () { + 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", function () { + return expect(promise).to.eventually.deep.equal({ device: "abcd1234" }); + }); + }); + + context("stream error occurs", function () { + let promise; + + beforeEach(function () { + 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", function () { + beforeEach(function () { + fakes.stream.emit("error", new Error("stream error")); + }); + + it("resolves with an object containing the device token", function () { + return expect(promise).to.eventually.have.property("device", "abcd1234"); + }); + + it("resolves with an object containing a wrapped error", function () { + 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", function () { + it("resolves with the device token and an error", function () { + 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", function () { + let notification, promise; + + beforeEach( function () { + 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", function () { + 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", function () { + expect(fakes.stream._transform).to.be.calledWithMatch(actual => actual.equals(Buffer(notification.body))); + }); + }); + + context("when 5 successive notifications are sent", function () { + + beforeEach(function () { + 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", function () { + 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]); + 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", function () { + 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", function () { + fakes.streams.forEach( stream => { + expect(stream._transform).to.be.calledWithMatch(actual => actual.equals(Buffer(builtNotification().body))); + }); + }); + + it("resolves with the notification outcomes", function () { + 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", function () { + 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(function () { + 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 () { + 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; + }); + + it("sends the correct device ID for each stream", function () { + 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", function () { + fakes.streams.forEach( stream => { + expect(stream._transform).to.be.calledWithMatch(actual => actual.equals(Buffer(builtNotification().body))); + }); + }); + + it("resolves with the notification reponses", function () { + 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("connection fails", function () { + let promises; + + beforeEach( function() { + const client = new Client( { address: "testapi" } ); + + fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[0]); + + promises = Promise.all([ + client.write(builtNotification(), "abcd1234"), + client.write(builtNotification(), "adfe5969"), + client.write(builtNotification(), "abcd1335"), + ]); + + setTimeout(function () { + fakes.endpointManager.getStream.reset(); + fakes.endpointManager.emit("error", new Error("endpoint failed")); + }, 1); + + return promises; + }); + + it("resolves with 1 success", function () { + return promises.then( response => { + expect(response[0]).to.deep.equal({ device: "abcd1234" }); + }); + }); + + it("resolves with 2 errors", function () { + 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") }); + }) + }) + }); + }); + }); + + describe("shutdown", function () { + beforeEach(function () { + 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 () { + let client = new Client(); + client.shutdown(); + + expect(fakes.endpointManager.shutdown).to.be.calledOnce; + }); + }); + + context("with pending notifications", function () { + it("invokes shutdown on endpoint manager after queue drains", function () { + let client = new Client({ address: "none" }); + + 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" }), + ]; + + fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[0]); + fakes.endpointManager.getStream.onCall(1).returns(fakes.streams[1]); + + let promises = Promise.all([ + client.write(builtNotification(), "abcd1234"), + client.write(builtNotification(), "adfe5969"), + client.write(builtNotification(), "abcd1335"), + client.write(builtNotification(), "bcfe4433"), + client.write(builtNotification(), "aabbc788"), + ]); + + client.shutdown(); + + expect(fakes.endpointManager.shutdown).to.not.be.called; + + setTimeout(function () { + 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 () { + 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.then( () => { + expect(fakes.endpointManager.shutdown).to.have.been.called; + }); + }); + }); + }); +}); + +function builtNotification() { + return { + headers: sinon.stub().returns({}), + 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, new Buffer(JSON.stringify(response) || "")); + }) + }); + fakeStream.headers = sinon.stub(); + + return fakeStream; +} diff --git a/test/config.js b/test/config.js new file mode 100644 index 00000000..d233e21c --- /dev/null +++ b/test/config.js @@ -0,0 +1,181 @@ +"use strict"; + +const sinon = require("sinon"); + +describe("config", function () { + let config, fakes; + + beforeEach(function() { + fakes = { + debug: sinon.spy(), + prepareCredentials: sinon.stub(), + }; + + config = require("../lib/config")(fakes); + }); + + it("supplies sensible defaults", function () { + expect(config()).to.deep.equal({ + cert: "cert.pem", + key: "key.pem", + ca: null, + pfx: null, + passphrase: null, + production: false, + address: "api.sandbox.push.apple.com", + port: 443, + rejectUnauthorized: true, + connectTimeout: 10000, + connectionTimeout: 3600000, + connectionRetryLimit: 3, + maxConnections: 10, + }); + }); + + describe("address configuration", function () { + let originalEnv; + + before(function() { + originalEnv = process.env.NODE_ENV; + }); + + after(function() { + process.env.NODE_ENV = originalEnv; + }); + + beforeEach(function() { + process.env.NODE_ENV = ""; + }); + + it("should use api.sandbox.push.apple.com as the default connection address", function () { + expect(config()).to.have.property("address", "api.sandbox.push.apple.com"); + }); + + it("should use api.push.apple.com when NODE_ENV=production", function () { + process.env.NODE_ENV = "production"; + expect(config()).to.have.property("address", "api.push.apple.com"); + }); + + it("should give precedence to production flag over NODE_ENV=production", function () { + process.env.NODE_ENV = "production"; + expect(config({ production: false })).to.have.property("address", "api.sandbox.push.apple.com"); + }); + + it("should use api.push.apple.com when production:true", function () { + expect(config({production:true})).to.have.property("address", "api.push.apple.com"); + }); + + it("should use a custom address when passed", function () { + expect(config({address: "testaddress"})).to.have.property("address", "testaddress"); + }); + + describe("address is passed", function() { + it("sets production to true when using production address", function() { + expect(config({address: "api.push.apple.com"})).to.have.property("production", true); + }); + + it("sets production to false when using sandbox address", function() { + process.env.NODE_ENV = "production"; + expect(config({address: "api.sandbox.push.apple.com"})).to.have.property("production", false); + }); + }); + }); + + describe("credentials", function () { + + describe("passphrase", function () { + it("throws an error when supplied passphrase is not a string", function () { + expect(() => config({ passphrase: 123 }) ).to.throw("Passphrase must be a string"); + }); + + it("does not throw when passphrase is a string", function () { + expect(() => config({ passphrase: "seekrit" }) ).to.not.throw(); + }); + + it("does not throw when passphrase is not supplied", function () { + expect(() => config({ }) ).to.not.throw(); + }); + }); + + context("pfx value is supplied without cert and key", function () { + it("includes the value of `pfx`", function () { + expect(config( { pfx: "apn.pfx" } )).to.have.property("pfx", "apn.pfx"); + }); + + it("does not include a value for `cert`", function () { + expect(config( { pfx: "apn.pfx" }).cert).to.be.undefined; + }); + + it("does not include a value for `key`", function () { + expect(config( { pfx: "apn.pfx" }).key).to.be.undefined; + }); + }); + + context("pfx value is supplied along with a cert and key", function () { + it("includes the value of `pfx`", function () { + expect(config( { pfx: "apn.pfx", cert: "cert.pem", key: "key.pem" } )).to.have.property("pfx", "apn.pfx"); + }); + + it("does not include a value for `cert`", function () { + expect(config( { pfx: "apn.pfx", cert: "cert.pem", key: "key.pem" })).to.have.property("cert", "cert.pem"); + }); + + it("does not include a value for `key`", function () { + expect(config( { pfx: "apn.pfx", cert: "cert.pem", key: "key.pem" })).to.have.property("key", "key.pem"); + }); + }); + + context("pfxData value is supplied without cert and key", function () { + it("includes the value of `pfxData`", function () { + expect(config( { pfxData: "apnData" } )).to.have.property("pfxData", "apnData"); + }); + + it("does not include a value for `cert`", function () { + expect(config( { pfxData: "apnData" } ).cert).to.be.undefined; + }); + + it("does not include a value for `key`", function () { + expect(config( { pfxData: "apnData" }).key).to.be.undefined; + }); + }); + + context("pfxData value is supplied along with a cert and key", function () { + it("includes the value of `pfxData`", function () { + expect(config( { pfxData: "apnData", cert: "cert.pem", key: "key.pem" } )).to.have.property("pfxData", "apnData"); + }); + + it("does not include a value for `cert`", function () { + expect(config( { pfxData: "apnData", cert: "cert.pem", key: "key.pem" })).to.have.property("cert", "cert.pem"); + }); + + it("does not include a value for `key`", function () { + expect(config( { pfxData: "apnData", cert: "cert.pem", key: "key.pem" })).to.have.property("key", "key.pem"); + }); + }); + + it("loads and validates the credentials", function () { + fakes.prepareCredentials.returns({"cert": "certData", "key": "keyData", "pfx": "pfxData"}); + + let configuration = config({}); + expect(configuration).to.have.property("cert", "certData"); + expect(configuration).to.have.property("key", "keyData"); + expect(configuration).to.have.property("pfx", "pfxData"); + }); + }); + + context("a null config value is passed", function () { + it("should log a message with `debug`", function () { + config( { address: null } ); + + expect(fakes.debug).to.be.calledWith("Option [address] is null. This may cause unexpected behaviour."); + }); + }); + + context("a config value is undefined", function () { + it("should log a message with `debug`", function () { + config( { anOption: undefined } ); + + expect(fakes.debug).to.be.calledWith("Option [anOption] is undefined. This may cause unexpected behaviour."); + }); + }); +}); diff --git a/test/connection.js b/test/connection.js deleted file mode 100644 index c0667e7f..00000000 --- a/test/connection.js +++ /dev/null @@ -1,491 +0,0 @@ -var rewire = require("rewire"); -var Connection = rewire("../lib/connection"); - -var events = require("events"); -var sinon = require("sinon"); -var lolex = require("lolex"); -var Q = require("q"); - -describe("Connection", function() { - describe("constructor", function () { - var originalEnv; - - before(function() { - originalEnv = process.env.NODE_ENV; - }); - - after(function() { - process.env.NODE_ENV = originalEnv; - }); - - beforeEach(function() { - process.env.NODE_ENV = ""; - }); - - // Issue #50 - it("should use gateway.sandbox.push.apple.com as the default connection address", function () { - expect(Connection().options.address).to.equal("gateway.sandbox.push.apple.com"); - }); - - it("should use gateway.push.apple.com when NODE_ENV=production", function () { - process.env.NODE_ENV = "production"; - expect(Connection().options.address).to.equal("gateway.push.apple.com"); - }); - - it("should give precedence to production flag over NODE_ENV=production", function () { - process.env.NODE_ENV = "production"; - expect(Connection({ production: false }).options.address).to.equal("gateway.sandbox.push.apple.com"); - }); - - it("should use gateway.push.apple.com when production:true", function () { - expect(Connection({production:true}).options.address).to.equal("gateway.push.apple.com"); - }); - - it("should use a custom address when passed", function () { - expect(Connection({address: "testaddress"}).options.address).to.equal("testaddress"); - }); - - describe("gateway option", function() { - it("uses the legacy gateway option when supplied", function() { - expect(Connection({gateway: "testaddress"}).options.address).to.equal("testaddress"); - }); - }); - - describe("address is passed", function() { - it("sets production to true when using production address", function() { - expect(Connection({address: "gateway.push.apple.com"}).options.production).to.be.true; - }); - - it("sets production to false when using sandbox address", function() { - process.env.NODE_ENV = "production"; - expect(Connection({address: "gateway.sandbox.push.apple.com"}).options.production).to.be.false; - }); - }); - }); - - describe("#loadCredentials", function () { - var loadStub, parseStub, validateStub, removeStubs; - beforeEach(function() { - loadStub = sinon.stub(); - loadStub.displayName = "loadCredentials"; - - parseStub = sinon.stub(); - parseStub.displayName = "parseCredentials"; - - validateStub = sinon.stub(); - validateStub.displayName = "validateCredentials"; - - removeStubs = Connection.__set__({ - "loadCredentials": loadStub, - "parseCredentials": parseStub, - "validateCredentials": validateStub, - }); - }); - - afterEach(function() { - removeStubs(); - }); - - it("only loads credentials once", function() { - loadStub.returns(Q({})); - - var connection = Connection(); - connection.loadCredentials(); - connection.loadCredentials(); - expect(loadStub).to.be.calledOnce; - }); - - describe("with valid credentials", function() { - var credentials; - var testOptions = { - pfx: "myCredentials.pfx", cert: "myCert.pem", key: "myKey.pem", ca: "myCa.pem", - passphrase: "apntest", production: true - }; - - beforeEach(function() { - loadStub.withArgs(sinon.match(function(v) { - return v.pfx === "myCredentials.pfx" && v.cert === "myCert.pem" && v.key === "myKey.pem" && - v.ca === "myCa.pem" && v.passphrase === "apntest"; - })).returns(Q({ pfx: "myPfxData", cert: "myCertData", key: "myKeyData", ca: ["myCaData"], passphrase: "apntest" })); - - parseStub.returnsArg(0); - - credentials = Connection(testOptions).loadCredentials(); - }); - - it("should be fulfilled", function () { - return expect(credentials).to.be.fulfilled; - }); - - describe("the validation stage", function() { - it("is called once", function() { - return credentials.finally(function() { - expect(validateStub).to.be.calledOnce; - }); - }); - - it("is passed the production flag", function() { - return credentials.finally(function() { - expect(validateStub.getCall(0).args[0]).to.have.property("production", true); - }); - }); - - describe("passed credentials", function() { - it("contains the PFX data", function() { - return credentials.finally(function() { - expect(validateStub.getCall(0).args[0]).to.have.property("pfx", "myPfxData"); - }); - }); - - it("contains the key data", function() { - return credentials.finally(function() { - expect(validateStub.getCall(0).args[0]).to.have.property("key", "myKeyData"); - }); - }); - - it("contains the certificate data", function() { - return credentials.finally(function() { - expect(validateStub.getCall(0).args[0]).to.have.property("cert", "myCertData"); - }); - }); - - it("includes passphrase", function() { - return credentials.finally(function() { - expect(validateStub.getCall(0).args[0]).to.have.property("passphrase", "apntest"); - }); - }); - }); - }); - - describe("resolution value", function() { - it("contains the PFX data", function() { - return expect(credentials).to.eventually.have.property("pfx", "myPfxData"); - }); - - it("contains the key data", function() { - return expect(credentials).to.eventually.have.property("key", "myKeyData"); - }); - - it("contains the certificate data", function() { - return expect(credentials).to.eventually.have.property("cert", "myCertData"); - }); - - it("contains the CA data", function() { - return expect(credentials).to.eventually.have.deep.property("ca[0]", "myCaData"); - }); - - it("includes passphrase", function() { - return expect(credentials).to.eventually.have.property("passphrase", "apntest"); - }); - }); - }); - - describe("credential file cannot be parsed", function() { - beforeEach(function() { - loadStub.returns(Q({ cert: "myCertData", key: "myKeyData" })); - parseStub.throws(new Error("unable to parse key")); - }); - - it("should resolve with the credentials", function() { - var credentials = Connection({ cert: "myUnparseableCert.pem", key: "myUnparseableKey.pem" }).loadCredentials(); - return expect(credentials).to.become({ cert: "myCertData", key: "myKeyData" }); - }); - - it("should log an error", function() { - var debug = sinon.spy(); - var reset = Connection.__set__("debug", debug); - var credentials = Connection({ cert: "myUnparseableCert.pem", key: "myUnparseableKey.pem" }).loadCredentials(); - - return credentials.finally(function() { - reset(); - expect(debug).to.be.calledWith(sinon.match(function(err) { - return err.message ? err.message.match(/unable to parse key/) : false; - }, "\"unable to parse key\"")); - }); - }); - - it("should not attempt to validate", function() { - var credentials = Connection({ cert: "myUnparseableCert.pem", key: "myUnparseableKey.pem" }).loadCredentials(); - return credentials.finally(function() { - expect(validateStub).to.not.be.called; - }); - }); - }); - - describe("credential validation fails", function() { - it("should be rejected", function() { - loadStub.returns(Q({ cert: "myCertData", key: "myMismatchedKeyData" })); - parseStub.returnsArg(0); - validateStub.throws(new Error("certificate and key do not match")); - - var credentials = Connection({ cert: "myCert.pem", key: "myMistmatchedKey.pem" }).loadCredentials(); - return expect(credentials).to.eventually.be.rejectedWith(/certificate and key do not match/); - }); - }); - - describe("credential file cannot be loaded", function() { - it("should be rejected", function() { - loadStub.returns(Q.reject(new Error("ENOENT, no such file or directory"))); - - var credentials = Connection({ cert: "noSuchFile.pem", key: "myKey.pem" }).loadCredentials(); - return expect(credentials).to.eventually.be.rejectedWith("ENOENT, no such file or directory"); - }); - }); - }); - - describe("createSocket", function() { - var socketDouble, socketStub, removeSocketStub; - - before(function() { - var loadCredentialsStub = sinon.stub(Connection.prototype, "loadCredentials"); - loadCredentialsStub.returns(Q({ - pfx: "pfxData", - key: "keyData", - cert: "certData", - ca: ["caData1", "caData2"], - passphrase: "apntest" })); - }); - - after(function() { - Connection.prototype.loadCredentials.restore(); - }); - - beforeEach(function() { - socketDouble = new events.EventEmitter(); - socketDouble.end = sinon.spy(); - - socketStub = sinon.stub(); - socketStub.callsArg(2); - socketStub.returns(socketDouble); - - removeSocketStub = Connection.__set__("createSocket", socketStub); - }); - - afterEach(function() { - socketDouble.removeAllListeners(); - removeSocketStub(); - }); - - it("loadCredentialss the module", function(done) { - var connection = Connection({ pfx: "myCredentials.pfx" }); - connection.createSocket().finally(function() { - expect(connection.loadCredentials).to.have.been.calledOnce; - done(); - }); - }); - - describe("with valid credentials", function() { - it("resolves", function() { - var connection = Connection({ - cert: "myCert.pem", - key: "myKey.pem" - }); - return expect(connection.createSocket()).to.be.fulfilled; - }); - - describe("the call to create socket", function() { - var connect; - - it("passes PFX data", function() { - connect = Connection({ - pfx: "myCredentials.pfx", - passphrase: "apntest" - }).createSocket(); - return connect.then(function() { - var socketOptions = socketStub.args[0][1]; - expect(socketOptions.pfx).to.equal("pfxData"); - }); - }); - - it("passes the passphrase", function() { - connect = Connection({ - passphrase: "apntest", - cert: "myCert.pem", - key: "myKey.pem" - }).createSocket(); - return connect.then(function() { - var socketOptions = socketStub.args[0][1]; - expect(socketOptions.passphrase).to.equal("apntest"); - }); - }); - - it("passes the cert", function() { - connect = Connection({ - cert: "myCert.pem", - key: "myKey.pem" - }).createSocket(); - return connect.then(function() { - var socketOptions = socketStub.args[0][1]; - expect(socketOptions.cert).to.equal("certData"); - }); - }); - - it("passes the key", function() { - connect = Connection({ - cert: "test/credentials/support/cert.pem", - key: "test/credentials/support/key.pem" - }).createSocket(); - return connect.then(function() { - var socketOptions = socketStub.args[0][1]; - expect(socketOptions.key).to.equal("keyData"); - }); - }); - - it("passes the ca certificates", function() { - connect = Connection({ - cert: "test/credentials/support/cert.pem", - key: "test/credentials/support/key.pem", - ca: [ "test/credentials/support/issuerCert.pem" ] - }).createSocket(); - return connect.then(function() { - var socketOptions = socketStub.args[0][1]; - expect(socketOptions.ca[0]).to.equal("caData1"); - }); - }); - }); - }); - - describe("intialization failure", function() { - it("is rejected", function() { - var connection = Connection({ pfx: "a-non-existant-file-which-really-shouldnt-exist.pfx" }); - connection.on("error", function() {}); - connection.loadCredentials = sinon.stub(); - - connection.loadCredentials.returns(Q.reject(new Error("loadCredentials failed"))); - - return expect(connection.createSocket()).to.be.rejectedWith("loadCredentials failed"); - }); - }); - - describe("timeout option", function() { - var clock, timeoutRestore; - beforeEach(function() { - clock = lolex.createClock(); - timeoutRestore = Connection.__set__({ - "setTimeout": clock.setTimeout, - "clearTimeout": clock.clearTimeout - }); - }); - - afterEach(function() { - timeoutRestore(); - }); - - it("ends the socket when connection takes too long", function() { - var connection = Connection({connectTimeout: 3000}).createSocket(); - socketStub.onCall(0).returns(socketDouble); - - process.nextTick(function(){ - clock.tick(5000); - }); - - return connection.then(function() { - throw "connection did not time out"; - }, function() { - expect(socketDouble.end).to.have.been.called; - }); - }); - - it("does not end the socket when the connnection succeeds", function() { - var connection = Connection({connectTimeout: 3000}).createSocket(); - - return connection.then(function() { - clock.tick(5000); - expect(socketDouble.end).to.not.have.been.called; - }); - }); - - it("does not end the socket when the connection fails", function() { - var connection = Connection({connectTimeout: 3000}).createSocket(); - socketStub.onCall(0).returns(socketDouble); - - process.nextTick(function() { - socketDouble.emit("close"); - }); - - return connection.then(function() { - throw "connection should have failed"; - }, function() { - clock.tick(5000); - expect(socketDouble.end).to.not.have.been.called; - }); - }); - - it("does not fire when disabled", function() { - var connection = Connection({connectTimeout: 0}).createSocket(); - socketStub.onCall(0).returns(socketDouble); - - process.nextTick(function() { - clock.tick(100000); - socketDouble.emit("close"); - }); - - return connection.then(function() { - throw "connection should have failed"; - }, function() { - expect(socketDouble.end).to.not.have.been.called; - }); - }); - - context("timeout fires before socket is created", function() { - it("does not throw", function() { - var connection = Connection({connectTimeout: 100}); - connection.credentialsPromise = Q.defer(); - - connection.createSocket(); - expect(function() { clock.tick(500); }).to.not.throw(TypeError); - }); - }); - - context("after timeout fires", function() { - it("does not throw if socket connects", function() { - var connection = Connection({connectTimeout: 100}); - socketStub.onCall(0).returns(socketDouble); - - connection.loadCredentials().then(function() { - clock.tick(500); - }); - - return connection.createSocket().then(null, function() { - connection.deferredConnection = null; - expect(socketStub.getCall(0).args[2]).to.not.throw(TypeError); - }); - }); - }); - }); - }); - - describe("validNotification", function() { - describe("notification is shorter than max allowed", function() { - it("returns true", function() { - var connection = Connection(); - var notification = { length: function() { return 128; }}; - expect(connection.validNotification(notification)).to.equal(true); - }); - }); - - describe("notification is the maximum length", function() { - it("returns true", function() { - var connection = Connection(); - var notification = { length: function() { return 2048; }}; - expect(connection.validNotification(notification)).to.equal(true); - }); - }); - - describe("notification too long", function() { - it("returns false", function() { - var connection = Connection(); - var notification = { length: function() { return 2176; }}; - expect(connection.validNotification(notification)).to.equal(false); - }); - }); - - describe("VoIP flag set", function() { - it("allows longer payload", function() { - var connection = Connection({"voip": true}); - var notification = { length: function() { return 4096; }}; - expect(connection.validNotification(notification)).to.equal(true); - }); - }); - }); -}); \ No newline at end of file diff --git a/test/credentials/load.js b/test/credentials/load.js index ea95b731..d50a9b3e 100644 --- a/test/credentials/load.js +++ b/test/credentials/load.js @@ -9,109 +9,91 @@ describe("loadCredentials", function() { key = fs.readFileSync("test/support/initializeTest.key"); }); - it("should eventually load a pfx file from disk", function () { + it("should load a pfx file from disk", function () { return expect(loadCredentials({ pfx: "test/support/initializeTest.pfx" }) - .get("pfx").post("toString")) - .to.eventually.equal(pfx.toString()); + .pfx.toString()).to.equal(pfx.toString()); }); - it("should eventually provide pfx data from memory", function () { - return expect(loadCredentials({ pfx: pfx }).get("pfx").post("toString")) - .to.eventually.equal(pfx.toString()); + it("should provide pfx data from memory", function () { + return expect(loadCredentials({ pfx: pfx }).pfx.toString()) + .to.equal(pfx.toString()); }); - it("should eventually provide pfx data explicitly passed in pfxData parameter", function () { - return expect(loadCredentials({ pfxData: pfx }).get("pfx").post("toString")) - .to.eventually.equal(pfx.toString()); + it("should provide pfx data explicitly passed in pfxData parameter", function () { + return expect(loadCredentials({ pfxData: pfx }).pfx.toString()) + .to.equal(pfx.toString()); }); - it("should eventually load a certificate from disk", function () { + it("should load a certificate from disk", function () { return expect(loadCredentials({ cert: "test/support/initializeTest.crt", key: null}) - .get("cert").post("toString")) - .to.eventually.equal(cert.toString()); + .cert.toString()).to.equal(cert.toString()); }); - it("should eventually provide a certificate from a Buffer", function () { - return expect(loadCredentials({ cert: cert, key: null}) - .get("cert").post("toString")) - .to.eventually.equal(cert.toString()); + it("should provide a certificate from a Buffer", function () { + return expect(loadCredentials({ cert: cert, key: null}).cert.toString()) + .to.equal(cert.toString()); }); - it("should eventually provide a certificate from a String", function () { - return expect(loadCredentials({ cert: cert.toString(), key: null}) - .get("cert")) - .to.eventually.equal(cert.toString()); + it("should provide a certificate from a String", function () { + return expect(loadCredentials({ cert: cert.toString(), key: null}).cert) + .to.equal(cert.toString()); }); - it("should eventually provide certificate data explicitly passed in the certData parameter", function () { - return expect(loadCredentials({ certData: cert, key: null}) - .get("cert").post("toString")) - .to.eventually.equal(cert.toString()); + it("should provide certificate data explicitly passed in the certData parameter", function () { + return expect(loadCredentials({ certData: cert, key: null}).cert.toString()) + .to.equal(cert.toString()); }); - it("should eventually load a key from disk", function () { + it("should load a key from disk", function () { return expect(loadCredentials({ cert: null, key: "test/support/initializeTest.key"}) - .get("key").post("toString")) - .to.eventually.equal(key.toString()); + .key.toString()).to.equal(key.toString()); }); - it("should eventually provide a key from a Buffer", function () { - return expect(loadCredentials({ cert: null, key: key}) - .get("key").post("toString")) - .to.eventually.equal(key.toString()); + it("should provide a key from a Buffer", function () { + return expect(loadCredentials({ cert: null, key: key}).key.toString()) + .to.equal(key.toString()); }); - it("should eventually provide a key from a String", function () { - return expect(loadCredentials({ cert: null, key: key.toString()}) - .get("key")) - .to.eventually.equal(key.toString()); + it("should provide a key from a String", function () { + return expect(loadCredentials({ cert: null, key: key.toString()}).key) + .to.equal(key.toString()); }); - it("should eventually provide key data explicitly passed in the keyData parameter", function () { - return expect(loadCredentials({ cert: null, keyData: key}) - .get("key").post("toString")) - .to.eventually.equal(key.toString()); + it("should provide key data explicitly passed in the keyData parameter", function () { + return expect(loadCredentials({ cert: null, keyData: key}).key.toString()) + .to.equal(key.toString()); }); - it("should eventually load a single CA certificate from disk", function () { + it("should load a single CA certificate from disk", function () { return expect(loadCredentials({ cert: null, key: null, ca: "test/support/initializeTest.crt" }) - .get("ca").get(0).post("toString")) - .to.eventually.equal(cert.toString()); + .ca[0].toString()).to.equal(cert.toString()); }); - it("should eventually provide a single CA certificate from a Buffer", function () { - return expect(loadCredentials({ cert: null, key: null, ca: cert }) - .get("ca").get(0).post("toString")) - .to.eventually.equal(cert.toString()); + it("should provide a single CA certificate from a Buffer", function () { + return expect(loadCredentials({ cert: null, key: null, ca: cert }).ca[0].toString()) + .to.equal(cert.toString()); }); - it("should eventually provide a single CA certificate from a String", function () { - return expect(loadCredentials({ cert: null, key: null, ca: cert.toString() }) - .get("ca").get(0)) - .to.eventually.equal(cert.toString()); + it("should provide a single CA certificate from a String", function () { + return expect(loadCredentials({ cert: null, key: null, ca: cert.toString() }).ca[0]) + .to.equal(cert.toString()); }); - it("should eventually load an array of CA certificates", function (done) { - loadCredentials({ cert: null, key: null, ca: ["test/support/initializeTest.crt", cert, cert.toString()] }) - .get("ca").spread(function(cert1, cert2, cert3) { - var certString = cert.toString(); - if (cert1.toString() === certString && - cert2.toString() === certString && - cert3.toString() === certString) { - done(); - } - else { - done(new Error("provided certificates did not match")); - } - }, done); + it("should load an array of CA certificates", function () { + const certString = cert.toString(); + return expect(loadCredentials({ cert: null, key: null, + ca: ["test/support/initializeTest.crt", cert, certString] + }).ca.map( cert => cert.toString() )) + .to.deep.equal([certString, certString, certString]); }); it("returns undefined if no CA values are specified", function() { - return expect(loadCredentials({ cert: null, key: null, ca: null}).get("ca")).to.eventually.be.undefined; + return expect(loadCredentials({ cert: null, key: null, ca: null}).ca) + .to.be.undefined; }); it("should inclue the passphrase in the resolved value", function() { - return expect(loadCredentials({ passphrase: "apntest" }).get("passphrase")) - .to.eventually.equal("apntest"); + return expect(loadCredentials({ passphrase: "apntest" }).passphrase) + .to.equal("apntest"); }); -}); \ No newline at end of file +}); diff --git a/test/credentials/parse.js b/test/credentials/parse.js index f1a684cf..46926e37 100644 --- a/test/credentials/parse.js +++ b/test/credentials/parse.js @@ -1,63 +1,56 @@ -var sinon = require("sinon"); -var rewire = require("rewire"); -var parseCredentials = rewire("../../lib/credentials/parse"); +"use strict"; -var APNCertificate = require("../../lib/credentials/APNCertificate"); -var APNKey = require("../../lib/credentials/APNKey"); +const sinon = require("sinon"); + +const APNCertificate = require("../../lib/credentials/APNCertificate"); +const APNKey = require("../../lib/credentials/APNKey"); describe("parseCredentials", function() { - var reset; - var pkcs12Spy, pemKeySpy, pemCertSpy; + let fakes, parseCredentials; - var pfxKey = new APNKey({n: 1, e: 1 }); - var pfxCert = new APNCertificate({publicKey: {}, validity: {}, subject: {} }); + const pfxKey = new APNKey({n: 1, e: 1 }); + const pfxCert = new APNCertificate({publicKey: {}, validity: {}, subject: {} }); - var pemKey = new APNKey({n: 2, e: 1 }); - var pemCert = new APNCertificate({publicKey: {}, validity: {}, subject: {} }); + const pemKey = new APNKey({n: 2, e: 1 }); + const pemCert = new APNCertificate({publicKey: {}, validity: {}, subject: {} }); beforeEach(function() { - pkcs12Spy = sinon.stub(); - - pemKeySpy = sinon.stub(); - pemKeySpy.withArgs("pemkey").returns(pemKey); + fakes = { + parsePkcs12: sinon.stub(), + parsePemKey: sinon.stub(), + parsePemCert: sinon.stub(), + }; - pemCertSpy = sinon.stub(); - pemCertSpy.withArgs("pemcert").returns(pemCert); + fakes.parsePemKey.withArgs("pemkey").returns(pemKey); - reset = parseCredentials.__set__({ - "parsePkcs12": pkcs12Spy, - "parsePemKey": pemKeySpy, - "parsePemCert": pemCertSpy, - }); - }); + fakes.parsePemKey.withArgs("pemcert").returns(pemCert); - afterEach(function() { - reset(); + parseCredentials = require("../../lib/credentials/parse")(fakes); }); describe("with PFX file", function() { it("returns the parsed key", function() { - pkcs12Spy.withArgs("pfxData").returns({ key: pfxKey, certificates: [pfxCert] }); + fakes.parsePkcs12.withArgs("pfxData").returns({ key: pfxKey, certificates: [pfxCert] }); - var parsed = parseCredentials({ pfx: "pfxData" }); + const parsed = parseCredentials({ pfx: "pfxData" }); expect(parsed.key).to.be.an.instanceof(APNKey); }); it("returns the parsed certificates", function() { - pkcs12Spy.withArgs("pfxData").returns({ key: pfxKey, certificates: [pfxCert] }); + fakes.parsePkcs12.withArgs("pfxData").returns({ key: pfxKey, certificates: [pfxCert] }); - var parsed = parseCredentials({ pfx: "pfxData" }); + const parsed = parseCredentials({ pfx: "pfxData" }); expect(parsed.certificates[0]).to.be.an.instanceof(APNCertificate); }); describe("having passphrase", function() { beforeEach(function() { - pkcs12Spy.withArgs("encryptedPfxData", "apntest").returns({ key: pfxKey, certificates: [pfxCert] }); - pkcs12Spy.withArgs("encryptedPfxData", sinon.match.any).throws(new Error("unable to read credentials, incorrect passphrase")); + fakes.parsePkcs12.withArgs("encryptedPfxData", "apntest").returns({ key: pfxKey, certificates: [pfxCert] }); + fakes.parsePkcs12.withArgs("encryptedPfxData", sinon.match.any).throws(new Error("unable to read credentials, incorrect passphrase")); }); it("returns the parsed key", function() { - var parsed = parseCredentials({ pfx: "encryptedPfxData", passphrase: "apntest" }); + const parsed = parseCredentials({ pfx: "encryptedPfxData", passphrase: "apntest" }); expect(parsed.key).to.be.an.instanceof(APNKey); }); @@ -77,20 +70,20 @@ describe("parseCredentials", function() { describe("with PEM key", function() { it("returns the parsed key", function() { - pemKeySpy.withArgs("pemKeyData").returns(pemKey); + fakes.parsePemKey.withArgs("pemKeyData").returns(pemKey); - var parsed = parseCredentials({ key: "pemKeyData" }); + const parsed = parseCredentials({ key: "pemKeyData" }); expect(parsed.key).to.be.an.instanceof(APNKey); }); describe("having passphrase", function() { beforeEach(function() { - pemKeySpy.withArgs("encryptedPemKeyData", "apntest").returns(pemKey); - pemKeySpy.withArgs("encryptedPemKeyData", sinon.match.any).throws(new Error("unable to load key, incorrect passphrase")); + fakes.parsePemKey.withArgs("encryptedPemKeyData", "apntest").returns(pemKey); + fakes.parsePemKey.withArgs("encryptedPemKeyData", sinon.match.any).throws(new Error("unable to load key, incorrect passphrase")); }); it("returns the parsed key", function() { - var parsed = parseCredentials({ key: "encryptedPemKeyData", passphrase: "apntest" }); + const parsed = parseCredentials({ key: "encryptedPemKeyData", passphrase: "apntest" }); expect(parsed.key).to.be.an.instanceof(APNKey); }); @@ -110,20 +103,20 @@ describe("parseCredentials", function() { describe("with PEM certificate", function() { it("returns the parsed certificate", function() { - pemCertSpy.withArgs("pemCertData").returns([pemCert]); + fakes.parsePemCert.withArgs("pemCertData").returns([pemCert]); - var parsed = parseCredentials({ cert: "pemCertData" }); + const parsed = parseCredentials({ cert: "pemCertData" }); expect(parsed.certificates[0]).to.be.an.instanceof(APNCertificate); }); }); describe("both PEM and PFX data is supplied", function() { it("it prefers PFX to PEM", function() { - pkcs12Spy.withArgs("pfxData").returns({ key: pfxKey, certificates: [pfxCert] }); - pemKeySpy.withArgs("pemKeyData").returns(pemKey); - pemCertSpy.withArgs("pemCertData").returns([pemCert]); + fakes.parsePkcs12.withArgs("pfxData").returns({ key: pfxKey, certificates: [pfxCert] }); + fakes.parsePemKey.withArgs("pemKeyData").returns(pemKey); + fakes.parsePemCert.withArgs("pemCertData").returns([pemCert]); - var parsed = parseCredentials({ pfx: "pfxData", key: "pemKeyData", cert: "pemCertData"}); + const parsed = parseCredentials({ pfx: "pfxData", key: "pemKeyData", cert: "pemCertData"}); expect(parsed.key).to.equal(pfxKey); expect(parsed.certificates[0]).to.equal(pfxCert); }); diff --git a/test/credentials/prepare.js b/test/credentials/prepare.js new file mode 100644 index 00000000..d6196bdd --- /dev/null +++ b/test/credentials/prepare.js @@ -0,0 +1,139 @@ +"use strict"; + +const sinon = require("sinon"); + +describe("prepare", function () { + let fakes, prepare; + + beforeEach(function () { + fakes = { + load: sinon.stub(), + parse: sinon.stub(), + validate: sinon.stub(), + logger: sinon.stub(), + }; + + prepare = require("../../lib/credentials/prepare")(fakes); + }); + + describe("with valid credentials", function() { + let credentials; + const testOptions = { + pfx: "myCredentials.pfx", + cert: "myCert.pem", + key: "myKey.pem", + ca: "myCa.pem", + passphrase: "apntest", + production: true, + }; + + beforeEach(function() { + fakes.load.withArgs(sinon.match(testOptions)).returns( + { + pfx: "myPfxData", + cert: "myCertData", + key: "myKeyData", + ca: ["myCaData"], + passphrase: "apntest", + } + ); + + fakes.parse.returnsArg(0); + credentials = prepare(testOptions); + }); + + describe("the validation stage", function() { + it("is called once", function() { + expect(fakes.validate).to.be.calledOnce; + }); + + it("is passed the production flag", function() { + expect(fakes.validate.getCall(0).args[0]).to.have.property("production", true); + }); + + describe("passed credentials", function() { + it("contains the PFX data", function() { + expect(fakes.validate.getCall(0).args[0]).to.have.property("pfx", "myPfxData"); + }); + + it("contains the key data", function() { + expect(fakes.validate.getCall(0).args[0]).to.have.property("key", "myKeyData"); + }); + + it("contains the certificate data", function() { + expect(fakes.validate.getCall(0).args[0]).to.have.property("cert", "myCertData"); + }); + + it("includes passphrase", function() { + expect(fakes.validate.getCall(0).args[0]).to.have.property("passphrase", "apntest"); + }); + }); + }); + + describe("resolution value", function() { + + it("contains the PFX data", function() { + return expect(credentials).to.have.property("pfx", "myPfxData"); + }); + + it("contains the key data", function() { + return expect(credentials).to.have.property("key", "myKeyData"); + }); + + it("contains the certificate data", function() { + return expect(credentials).to.have.property("cert", "myCertData"); + }); + + it("contains the CA data", function() { + return expect(credentials).to.have.deep.property("ca[0]", "myCaData"); + }); + + it("includes passphrase", function() { + return expect(credentials).to.have.property("passphrase", "apntest"); + }); + }); + }); + + describe("credential file cannot be parsed", function() { + beforeEach(function() { + fakes.load.returns({ cert: "myCertData", key: "myKeyData" }); + fakes.parse.throws(new Error("unable to parse key")); + }); + + it("should resolve with the credentials", function() { + let credentials = prepare({ cert: "myUnparseableCert.pem", key: "myUnparseableKey.pem", production: true }); + return expect(credentials).to.deep.equal({ cert: "myCertData", key: "myKeyData" }); + }); + + it("should log an error", function() { + prepare({ cert: "myUnparseableCert.pem", key: "myUnparseableKey.pem" }); + + expect(fakes.logger).to.be.calledWith(sinon.match(function(err) { + return err.message ? err.message.match(/unable to parse key/) : false; + }, "\"unable to parse key\"")); + }); + + it("should not attempt to validate", function() { + prepare({ cert: "myUnparseableCert.pem", key: "myUnparseableKey.pem" }); + expect(fakes.validate).to.not.be.called; + }); + }); + + describe("credential validation fails", function() { + it("should throw", function() { + fakes.load.returns(Promise.resolve({ cert: "myCertData", key: "myMismatchedKeyData" })); + fakes.parse.returnsArg(0); + fakes.validate.throws(new Error("certificate and key do not match")); + + return expect(() => prepare({ cert: "myCert.pem", key: "myMistmatchedKey.pem" })).to.throw(/certificate and key do not match/); + }); + }); + + describe("credential file cannot be loaded", function() { + it("should throw", function() { + fakes.load.throws(new Error("ENOENT, no such file or directory")); + + return expect(() => prepare({ cert: "noSuchFile.pem", key: "myKey.pem" })).to.throw("ENOENT, no such file or directory"); + }); + }); +}); diff --git a/test/credentials/resolve.js b/test/credentials/resolve.js index 845a53a9..0f64725e 100644 --- a/test/credentials/resolve.js +++ b/test/credentials/resolve.js @@ -22,19 +22,19 @@ describe("resolve", function() { }); describe("with file path", function() { - it("eventually returns a Buffer for valid path", function() { + it("returns a Buffer for valid path", function() { return expect(resolve("test/support/initializeTest.key")) - .to.eventually.satisfy(Buffer.isBuffer); + .to.satisfy(Buffer.isBuffer); }); - it("eventually returns contents for value path", function () { + it("returns contents for value path", function () { return expect(resolve("test/support/initializeTest.key") - .post("toString")).to.eventually.equal(key.toString()); + .toString()).to.equal(key.toString()); }); - it("is eventually rejected for invalid path", function() { - return expect(resolve("test/support/fail/initializeTest.key")) - .to.eventually.be.rejected; + it("throws for invalid path", function() { + return expect(() => { resolve("test/support/fail/initializeTest.key") }) + .to.throw; }); }); @@ -42,4 +42,4 @@ describe("resolve", function() { expect(resolve(null)).to.be.null; expect(resolve()).to.be.undefined; }); -}); \ No newline at end of file +}); diff --git a/test/device.js b/test/device.js deleted file mode 100644 index 5018c1f2..00000000 --- a/test/device.js +++ /dev/null @@ -1,28 +0,0 @@ -var apn = require("../"); - -describe("Device", function() { - describe("constructor", function () { - - // Issue #149 - it("should error when given a device string which contains no hex characters and results in 0 length token", function () { - expect(function () { - apn.Device("som string without hx lttrs"); - }).to.throw(); - }); - - it("should error when given a device string which contains an odd number of hex characters", function () { - expect(function () { - apn.Device("01234"); - }).to.throw(); - }); - - it("should return a Device object containing the correct token when given a hex string", function () { - expect(apn.Device("<0123 4567 89AB CDEF>").toString()).to.equal("0123456789abcdef"); - }); - - it("should return a Device object containing the correct token when given a Buffer", function () { - var buf = new Buffer([1, 35, 69, 103, 137, 171, 205, 239]); - expect(apn.Device(buf).toString()).to.equal("0123456789abcdef"); - }); - }); -}); \ No newline at end of file diff --git a/test/feedback.js b/test/feedback.js deleted file mode 100644 index 143efc97..00000000 --- a/test/feedback.js +++ /dev/null @@ -1,362 +0,0 @@ -var rewire = require("rewire"); -var Feedback = rewire("../lib/feedback"); - -var sinon = require("sinon"); -var Q = require("q"); - -describe("Feedback", function() { - var startMethod; - before(function() { - // Constructor has side effects :-( - startMethod = Feedback.prototype.start; - Feedback.prototype.start = function() { }; - }); - - after(function() { - Feedback.prototype.start = startMethod; - }); - - describe("constructor", function () { - var originalEnv; - - before(function() { - originalEnv = process.env.NODE_ENV; - }); - - after(function() { - process.env.NODE_ENV = originalEnv; - }); - - beforeEach(function() { - process.env.NODE_ENV = ""; - }); - - // Issue #50 - it("should use feedback.sandbox.push.apple.com as the default Feedback address", function () { - expect(Feedback().options.address).to.equal("feedback.sandbox.push.apple.com"); - }); - - it("should use feedback.push.apple.com when NODE_ENV=production", function () { - process.env.NODE_ENV = "production"; - expect(Feedback().options.address).to.equal("feedback.push.apple.com"); - }); - - it("should use feedback.push.apple.com when production:true", function () { - expect(Feedback({production:true}).options.address).to.equal("feedback.push.apple.com"); - }); - - it("should give precedence to production flag over NODE_ENV=production", function () { - process.env.NODE_ENV = "production"; - expect(Feedback({ production: false }).options.address).to.equal("feedback.sandbox.push.apple.com"); - }); - - it("should use a custom address when passed", function () { - expect(Feedback({address: "testaddress"}).options.address).to.equal("testaddress"); - }); - - describe("address is passed", function() { - it("sets production to true when using production address", function() { - expect(Feedback({address: "feedback.push.apple.com"}).options.production).to.be.true; - }); - - it("sets production to false when using sandbox address", function() { - process.env.NODE_ENV = "production"; - expect(Feedback({address: "feedback.sandbox.push.apple.com"}).options.production).to.be.false; - }); - }); - }); - - describe("#loadCredentials", function () { - var loadStub, parseStub, validateStub, removeStubs; - beforeEach(function() { - loadStub = sinon.stub(); - loadStub.displayName = "loadCredentials"; - - parseStub = sinon.stub(); - parseStub.displayName = "parseCredentials"; - - validateStub = sinon.stub(); - validateStub.displayName = "validateCredentials"; - - removeStubs = Feedback.__set__({ - "loadCredentials": loadStub, - "parseCredentials": parseStub, - "validateCredentials": validateStub, - }); - }); - - afterEach(function() { - removeStubs(); - }); - - it("only loads credentials once", function() { - loadStub.returns(Q({})); - - var feedback = Feedback(); - feedback.loadCredentials(); - feedback.loadCredentials(); - expect(loadStub).to.be.calledOnce; - }); - - describe("with valid credentials", function() { - var initialization; - var testOptions = { - pfx: "myCredentials.pfx", cert: "myCert.pem", key: "myKey.pem", ca: "myCa.pem", - passphrase: "apntest", production: true - }; - - beforeEach(function() { - loadStub.withArgs(sinon.match(function(v) { - return v.pfx === "myCredentials.pfx" && v.cert === "myCert.pem" && v.key === "myKey.pem" && - v.ca === "myCa.pem" && v.passphrase === "apntest"; - })).returns(Q({ pfx: "myPfxData", cert: "myCertData", key: "myKeyData", ca: ["myCaData"], passphrase: "apntest" })); - - parseStub.returnsArg(0); - - initialization = Feedback(testOptions).loadCredentials(); - }); - - it("should be fulfilled", function () { - return expect(initialization).to.be.fulfilled; - }); - - describe("the validation stage", function() { - it("is called once", function() { - return initialization.finally(function() { - expect(validateStub).to.be.calledOnce; - }); - }); - - it("is passed the production flag", function() { - return initialization.finally(function() { - expect(validateStub.getCall(0).args[0]).to.have.property("production", true); - }); - }); - - describe("passed credentials", function() { - it("contains the PFX data", function() { - return initialization.finally(function() { - expect(validateStub.getCall(0).args[0]).to.have.property("pfx", "myPfxData"); - }); - }); - - it("contains the key data", function() { - return initialization.finally(function() { - expect(validateStub.getCall(0).args[0]).to.have.property("key", "myKeyData"); - }); - }); - - it("contains the certificate data", function() { - return initialization.finally(function() { - expect(validateStub.getCall(0).args[0]).to.have.property("cert", "myCertData"); - }); - }); - - it("includes passphrase", function() { - return initialization.finally(function() { - expect(validateStub.getCall(0).args[0]).to.have.property("passphrase", "apntest"); - }); - }); - }); - }); - - describe("resolution value", function() { - it("contains the PFX data", function() { - return expect(initialization).to.eventually.have.property("pfx", "myPfxData"); - }); - - it("contains the key data", function() { - return expect(initialization).to.eventually.have.property("key", "myKeyData"); - }); - - it("contains the certificate data", function() { - return expect(initialization).to.eventually.have.property("cert", "myCertData"); - }); - - it("contains the CA data", function() { - return expect(initialization).to.eventually.have.deep.property("ca[0]", "myCaData"); - }); - - it("includes passphrase", function() { - return expect(initialization).to.eventually.have.property("passphrase", "apntest"); - }); - }); - }); - - describe("credential file cannot be parsed", function() { - beforeEach(function() { - loadStub.returns(Q({ cert: "myCertData", key: "myKeyData" })); - parseStub.throws(new Error("unable to parse key")); - }); - - it("should resolve with the credentials", function() { - var initialization = Feedback({ cert: "myUnparseableCert.pem", key: "myUnparseableKey.pem" }).loadCredentials(); - return expect(initialization).to.become({ cert: "myCertData", key: "myKeyData" }); - }); - - it("should log an error", function() { - var debug = sinon.spy(); - var reset = Feedback.__set__("debug", debug); - var initialization = Feedback({ cert: "myUnparseableCert.pem", key: "myUnparseableKey.pem" }).loadCredentials(); - - return initialization.finally(function() { - reset(); - expect(debug).to.be.calledWith(sinon.match(function(err) { - return err.message ? err.message.match(/unable to parse key/) : false; - }, "\"unable to parse key\"")); - }); - }); - - it("should not attempt to validate", function() { - var initialization = Feedback({ cert: "myUnparseableCert.pem", key: "myUnparseableKey.pem" }).loadCredentials(); - return initialization.finally(function() { - expect(validateStub).to.not.be.called; - }); - }); - }); - - describe("credential validation fails", function() { - it("should be rejected", function() { - loadStub.returns(Q({ cert: "myCertData", key: "myMismatchedKeyData" })); - parseStub.returnsArg(0); - validateStub.throws(new Error("certificate and key do not match")); - - var initialization = Feedback({ cert: "myCert.pem", key: "myMistmatchedKey.pem" }).loadCredentials(); - return expect(initialization).to.eventually.be.rejectedWith(/certificate and key do not match/); - }); - }); - - describe("credential file cannot be loaded", function() { - it("should be rejected", function() { - loadStub.returns(Q.reject(new Error("ENOENT, no such file or directory"))); - - var initialization = Feedback({ cert: "noSuchFile.pem", key: "myKey.pem" }).loadCredentials(); - return expect(initialization).to.eventually.be.rejectedWith("ENOENT, no such file or directory"); - }); - }); - }); - - describe("createSocket", function() { - var socketStub, removeSocketStub; - - before(function() { - var loadCredentialsStub = sinon.stub(Feedback.prototype, "loadCredentials"); - loadCredentialsStub.returns(Q({ - pfx: "pfxData", - key: "keyData", - cert: "certData", - ca: ["caData1", "caData2"], - passphrase: "apntest" })); - }); - - beforeEach(function() { - socketStub = sinon.stub(); - socketStub.callsArg(2); - socketStub.returns({ on: function() {}, once: function() {}, end: function() {} }); - - removeSocketStub = Feedback.__set__("createSocket", socketStub); - }); - - afterEach(function() { - removeSocketStub(); - }); - - it("loads credentials", function(done) { - var feedback = Feedback({ pfx: "myCredentials.pfx" }); - feedback.createSocket().finally(function() { - expect(feedback.loadCredentials).to.have.been.calledOnce; - done(); - }); - }); - - describe("with valid credentials", function() { - it("resolves", function() { - var feedback = Feedback({ - cert: "myCert.pem", - key: "myKey.pem" - }); - return expect(feedback.createSocket()).to.be.fulfilled; - }); - - describe("the call to create socket", function() { - var createSocket; - - it("passes PFX data", function() { - createSocket = Feedback({ - pfx: "myCredentials.pfx", - passphrase: "apntest" - }).createSocket(); - return createSocket.then(function() { - var socketOptions = socketStub.args[0][1]; - expect(socketOptions.pfx).to.equal("pfxData"); - }); - }); - - it("passes the passphrase", function() { - createSocket = Feedback({ - passphrase: "apntest", - cert: "myCert.pem", - key: "myKey.pem" - }).createSocket(); - return createSocket.then(function() { - var socketOptions = socketStub.args[0][1]; - expect(socketOptions.passphrase).to.equal("apntest"); - }); - }); - - it("passes the cert", function() { - createSocket = Feedback({ - cert: "myCert.pem", - key: "myKey.pem" - }).createSocket(); - return createSocket.then(function() { - var socketOptions = socketStub.args[0][1]; - expect(socketOptions.cert).to.equal("certData"); - }); - }); - - it("passes the key", function() { - createSocket = Feedback({ - cert: "test/credentials/support/cert.pem", - key: "test/credentials/support/key.pem" - }).createSocket(); - return createSocket.then(function() { - var socketOptions = socketStub.args[0][1]; - expect(socketOptions.key).to.equal("keyData"); - }); - }); - - it("passes the ca certificates", function() { - createSocket = Feedback({ - cert: "test/credentials/support/cert.pem", - key: "test/credentials/support/key.pem", - ca: [ "test/credentials/support/issuerCert.pem" ] - }).createSocket(); - return createSocket.then(function() { - var socketOptions = socketStub.args[0][1]; - expect(socketOptions.ca[0]).to.equal("caData1"); - }); - }); - }); - }); - - describe("intialization failure", function() { - it("is rejected", function() { - var feedback = Feedback({ pfx: "a-non-existant-file-which-really-shouldnt-exist.pfx" }); - feedback.on("error", function() {}); - feedback.loadCredentials.returns(Q.reject(new Error("loadCredentials failed"))); - - return expect(feedback.createSocket()).to.be.rejectedWith("loadCredentials failed"); - }); - }); - }); - - describe("cancel", function() { - it("should clear interval after cancel", function() { - var feedback = new Feedback(); - feedback.interval = 1; - feedback.cancel(); - expect(feedback.interval).to.be.undefined; - }); - }); -}); \ No newline at end of file diff --git a/test/notification.js b/test/notification.js deleted file mode 100644 index f10b0ff0..00000000 --- a/test/notification.js +++ /dev/null @@ -1,728 +0,0 @@ -var apn = require("../"); -var sinon = require("sinon"); - -describe("Notification", function() { - - var note; - beforeEach(function() { - note = new apn.Notification(); - }); - - describe("aps payload", function() { - describe("alert property", function() { - it("defaults to undefined", function() { - expect(note.alert).to.be.undefined; - }); - - it("can be set to a string", function() { - note.alert = "hello"; - expect(note.alert).to.equal("hello"); - }); - - it("resets the `compiled` flag", function() { - note.compiled = true; - note.alert = "hello"; - expect(note.compiled).to.be.false; - }); - - it("can be set to an object", function() { - note.alert = {"body": "hello"}; - expect(note.alert).to.eql({"body": "hello"}); - }); - - it("can be set to undefined", function() { - note.alert = {"body": "hello"}; - note.alert = undefined; - expect(note.alert).to.be.undefined; - }); - - it("cannot be set to a number", function() { - note.alert = 5; - expect(note.alert).to.be.undefined; - }); - - describe("getAlertText", function() { - describe("plain alert string", function() { - it("gets the alert text", function() { - note.alert = "hello"; - - expect(note.getAlertText()).to.equal("hello"); - }); - }); - - describe("alert object", function() { - it("gets the alert text", function() { - note.alert = { "body": "hello" }; - - expect(note.getAlertText()).to.equal("hello"); - }); - }); - }); - }); - - describe("badge property", function() { - it("defaults to undefined", function() { - expect(note.badge).to.be.undefined; - }); - - it("can be set to a number", function() { - note.badge = 5; - expect(typeof note.badge).to.equal("number"); - }); - - it("resets the `compiled` flag", function() { - note.compiled = true; - note.badge = 5; - expect(note.compiled).to.be.false; - }); - - it("can be set to undefined", function() { - note.badge = 5; - note.badge = undefined; - expect(note.badge).to.be.undefined; - }); - - it("cannot be set to a string", function() { - note.badge = "hello"; - expect(note.badge).to.be.undefined; - }); - }); - - describe("sound property", function() { - it("defaults to undefined", function() { - expect(note.sound).to.be.undefined; - }); - - it("can be set to a string", function() { - note.sound = "sound.caf"; - expect(typeof note.sound).to.equal("string"); - }); - - it("resets the `compiled` flag", function() { - note.compiled = true; - note.sound = "sound.caf"; - expect(note.compiled).to.be.false; - }); - - it("can be set to undefined", function() { - note.sound = "sound.caf"; - note.sound = undefined; - expect(note.sound).to.be.undefined; - }); - - it("cannot be set to a number", function() { - note.sound = 5; - expect(note.sound).to.be.undefined; - }); - }); - - describe("content-available property", function() { - it("defaults to undefined", function() { - expect(note.contentAvailable).to.be.undefined; - }); - - it("can be set to `1` with a boolean value", function() { - note.contentAvailable = true; - expect(note.contentAvailable).to.equal(1); - }); - - it("resets the `compiled` flag when enabled", function() { - note.compiled = true; - note.contentAvailable = true; - expect(note.compiled).to.be.false; - }); - - it("resets the `compiled` flag when disabled", function() { - note.compiled = true; - note.contentAvailable = false; - expect(note.compiled).to.be.false; - }); - - it("can be set to undefined", function() { - note.contentAvailable = true; - note.contentAvailable = undefined; - expect(note.contentAvailable).to.be.undefined; - }); - - it("can be set to `1`", function() { - note.contentAvailable = 1; - expect(typeof note.contentAvailable).to.equal("number"); - }); - - it("cannot be set to a string", function() { - note.contentAvailable = "true"; - expect(note.contentAvailable).to.be.undefined; - }); - - it("can be disabled", function() { - note.contentAvailable = false; - expect(note.contentAvailable).to.be.undefined; - }); - - describe("newsstand-available property", function() { - it("sets the content available flag", function() { - note.newsstandAvailable = true; - expect(note.contentAvailable).to.equal(1); - }); - - it("returns the content-available flag", function() { - note.contentAvailable = false; - expect(note.newsstandAvailable).to.be.undefined; - }); - }); - }); - - describe("mutable-content property", function() { - it("defaults to undefined", function() { - expect(note.mutableContent).to.be.undefined; - }); - - it("can be set to `1` with a boolean value", function() { - note.mutableContent = true; - expect(note.mutableContent).to.equal(1); - }); - - it("resets the `compiled` flag when enabled", function() { - note.compiled = true; - note.mutableContent = true; - expect(note.compiled).to.be.false; - }); - - it("resets the `compiled` flag when disabled", function() { - note.compiled = true; - note.mutableContent = false; - expect(note.compiled).to.be.false; - }); - - it("can be set to undefined", function() { - note.mutableContent = true; - note.mutableContent = undefined; - expect(note.mutableContent).to.be.undefined; - }); - - it("can be set to `1`", function() { - note.mutableContent = 1; - expect(typeof note.mutableContent).to.equal("number"); - }); - - it("cannot be set to a string", function() { - note.mutableContent = "true"; - expect(note.mutableContent).to.be.undefined; - }); - - it("can be disabled", function() { - note.mutableContent = false; - expect(note.mutableContent).to.be.undefined; - }); - - }); - - describe("mdm property", function() { - it("defaults to undefined", function() { - expect(note.mdm).to.be.undefined; - }); - - it("can be set to a string", function() { - note.mdm = "mdm payload"; - expect(typeof note.mdm).to.equal("string"); - }); - - it("resets the `compiled` flag", function() { - note.compiled = true; - note.mdm = "mdm payload"; - expect(note.compiled).to.be.false; - }); - - it("can be set to undefined", function() { - note.mdm = "mdm payload"; - note.mdm = undefined; - expect(note.mdm).to.be.undefined; - }); - }); - - describe("urlArgs property", function() { - it("defaults to undefined", function() { - expect(note.urlArgs).to.be.undefined; - }); - - it("can be set to an array", function() { - note.urlArgs = ["arg1", "arg2"]; - expect(note.urlArgs).to.eql(["arg1", "arg2"]); - }); - - it("resets the `compiled` flag", function() { - note.compiled = true; - note.urlArgs = ["arg1"]; - expect(note.compiled).to.be.false; - }); - - it("can be set to undefined", function() { - note.urlArgs = ["arg1", "arg2"]; - note.urlArgs = undefined; - expect(note.urlArgs).to.be.undefined; - }); - - it("cannot be set to an object", function() { - note.urlArgs = {}; - expect(note.urlArgs).to.be.undefined; - }); - - it("cannot be set to a string", function() { - note.urlArgs = "arg1"; - expect(note.urlArgs).to.be.undefined; - }); - }); - - describe("category property", function() { - it("defaults to undefined", function() { - expect(note.category).to.be.undefined; - }); - - it("can be set to a string", function() { - note.category = "the-category"; - expect(note.category).to.eql("the-category"); - }); - - it("resets the `compiled` flag", function() { - note.compiled = true; - note.category = "the-category"; - expect(note.compiled).to.be.false; - }); - - it("cannot be set to an object", function() { - note.category = {}; - expect(note.category).to.be.undefined; - }); - - it("can be set to undefined", function() { - note.category = "the-category"; - note.category = undefined; - expect(note.category).to.be.undefined; - }); - }); - }); - - describe("length", function() { - it("returns the correct payload length", function() { - note.alert = "length"; - expect(note.length()).to.equal(26); - }); - - describe("payload changes after first calculation", function() { - beforeEach(function() { - note.alert = "short"; - note.length(); - }); - - it("returns the correct payload length", function() { - note.alert = "longer"; - expect(note.length()).to.equal(26); - }); - }); - }); - - describe("compile", function() { - var stub; - beforeEach(function() { - stub = sinon.stub(note, "toJSON"); - }); - - it("compiles the JSON payload", function() { - stub.returns("payload"); - - expect(note.compile()).to.equal("\"payload\""); - }); - - it("returns the JSON payload", function() { - stub.returns({}); - - expect(note.compile()).to.equal("{}"); - }); - - it("memoizes the JSON payload", function() { - stub.returns("payload1"); - note.compile(); - - stub.returns("payload2"); - - expect(note.compile()).to.equal("\"payload1\""); - }); - - it("re-compiles the JSON payload when `note.compiled` = false", function() { - stub.returns("payload1"); - note.compile(); - - stub.returns("payload2"); - note.compiled = false; - - expect(note.compile()).to.equal("\"payload2\""); - }); - }); - - describe("trim", function() { - describe("when notification payload is below the maximum length", function() { - it("returns zero",function() { - note.alert = "test message"; - expect(note.trim()).to.equal(0); - }); - - it("does not change the alert text", function() { - var shortAlert = "This does not need trimming"; - note.alert = shortAlert; - note.trim(); - - expect(note.alert).to.equal(shortAlert); - }); - }); - - describe("when notification payload is greater than the maximum", function() { - var longText = 'ㅂㅈ ㅐ: LONDON (AP) — E\\\veryone says there areare lots of hidden costs to owning a home. If you own a palace, the costs are royal.\n\nThat became evident when the Buckingham Palace released its accounts Thursday, which showed the monarchy cost British taxpayers 35.7 million pounds ($60.8 million) last year — 56 pence (just under $1) for everyone in the country.\n\nThat is 7.2 percent, or 2.4 million pounds, more than the year before and the increase is mainly explained by the British royal family\'s repair bills.\n\nTheir properties are hardly typical. Buckingham Palace, for example, has 240 bedrooms and 78 bathrooms. That\'s a lot of plumbing to fix when things go wrong.\n\nSo it\'s no surprise that more than a third of the money British taxpayers paid for the monarchy, led by Queen Elizabeth II, was spent on repairs, improvements and maintenance of aging but still opulent palaces.\n\n"We continue to focus on value for money," said Keeper of the Privy Purse Alan Reid, asserting that careful spending habits had allowed for more money to be used for important maintenance work.\n\n\nFILE - In this Saturday, June 14, 2014 file photo, Britain\'s Queen Elizabeth II, foreground, sur …\nA big part of the fixer-upper budget in the 12 months that ended on March 31 went to creating a suitable home for the young family of Prince William, his wife Kate and their toddler Prince George.\n\nSome 3.4 million pounds of taxpayer funds were used to refurbish part of London\'s Kensington Palace for the couple. The extensive work included removing asbestos, installing new heating and redecorating.\n\nThe couple, who have considerable personal financial resources in part because of the estate left by Princess Diana, paid for the carpets, curtains and furniture out of personal funds, the palace said.\n\nIn addition, Prince Charles\' private secretary, William Nye, suggested that Charles and his wife Camilla — who are supported by profits from the extensive Duchy of Cornwall estate — may have helped William and Kate set up their new home.\n\nThe palace accounts also showed the high cost of entertaining on a royal scale: 2 million pounds were spent on "housekeeping and hospitality" in the 12 months that ended on March 31.\n---ending ---\n\n'; - describe("with default length", function() { - it("trims notification text to reduce payload to maximum length", function () { - note.alert = longText - note.trim(); - expect(note.length()).to.equal(2048); - }); - - it("trims notification alert body to reduce payload to maximum length", function () { - note.alert = { - body: longText - }; - note.trim(); - expect(note.length()).to.equal(2048); - }); - }); - - describe("with custom length", function() { - it("trims to a shorter length than default", function() { - note.alert = "12345"; - var trimLength = note.length() - 2; - note.trim(trimLength); - expect(note.length()).to.equal(trimLength); - }); - - it("trims to a longer length than default", function() { - note.alert = longText + longText; - var trimLength = 4096; - note.trim(trimLength); - expect(note.length()).to.equal(4096); - }); - }); - - describe("with truncateAtWordEnd flag", function() { - it("removes partially trimmed words", function() { - note.alert = "this is a test payload"; - note.truncateAtWordEnd = true; - - note.trim(note.length() - 3); - expect(note.alert).to.equal("this is a test"); - }); - - it("does not truncate when boundary is at end of word", function() { - note.alert = "this is a test payload"; - note.truncateAtWordEnd = true; - - note.trim(note.length() - 8); - expect(note.alert).to.equal("this is a test"); - }); - - it("leaves alert intact when there are no other spaces in the string", function() { - note.alert = "this_is_a_test_payload"; - note.truncateAtWordEnd = true; - - note.trim(note.length() - 8); - expect(note.alert).to.equal("this_is_a_test"); - }); - }); - - describe("alert contains escape sequences at trim point", function() { - it("strips them", function () { - note.alert = "\n\n\n"; - var trimLength = note.length() - 2; - note.trim(trimLength); - expect(note.alert).to.equal("\n\n"); - }); - - it("leaves escaped escape character intact", function() { - note.alert = "test\\ message"; - note.trim(26); - expect(note.alert).to.equal("test\\"); - }); - - it("strips orphaned escape character", function () { - note.alert = "test\\ message"; - note.trim(25); - expect(note.alert).to.equal("test"); - }); - - it("leaves an even number of escape characters", function() { - note.alert = "test\\\\\n"; - note.trim(29); - expect(note.alert).to.equal("test\\\\"); - }); - }); - - it("returns the number of bytes removed from the alert text", function() { - note.alert = "Test\ud83d\udca3"; - expect(note.trim(25)).to.equal(4); - }); - - describe("with no alert text", function() { - it("returns the number of bytes too long", function() { - note.payload.largePayload = "this is a very long payload"; - expect(note.trim(40)).to.equal(-6); - }); - }); - - describe("when alert text is shorter than the length that needs to be removed", function() { - it("returns the number of bytes too long", function() { - note.payload.largePayload = "this is a very long payload"; - note.alert = "alert"; - expect(note.trim(40)).to.equal(-25); - }); - }); - }); - - describe("unicode text", function() { - it("trims to maximum byte length", function () { - note.alert = "ㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐㅂㅈ ㅐ"; - note.trim(256); - expect(note.length()).to.be.at.most(256); - }); - - describe("with UTF-8 encoding", function() { - it("removes trailing `REPLACEMENT CHARACTER` 0xFFFD", function() { - note.alert = Buffer([0xF0, 0x9F, 0x98, 0x83, 0xF0, 0x9F, 0x98, 0x9E]).toString("utf8"); - var trimLength = note.length() - 1; - note.trim(trimLength); - - var length = note.alert.length; - expect(note.alert.charCodeAt(length - 1)).to.not.equal(0xFFFD); - }); - }); - - describe("with UTF-16LE encoding", function() { - beforeEach(function() { - note.encoding = "utf16le"; - }); - - it("trims to correct byte length", function() { - note.alert = "test message"; - note.trim(48); - - expect(note.length()).to.equal(48); - }); - - it("correctly empties the string", function() { - note.alert = Buffer([0x3D, 0xD8, 0x03, 0xDE, 0x3D, 0xD8, 0x1E, 0xDE]).toString(note.encoding); - var trimLength = note.length() - 8; - note.trim(trimLength); - - expect(note.alert.length).to.equal(0); - }); - - it("removes orphaned lead surrogates", function() { - note.alert = Buffer([0x3D, 0xD8, 0x03, 0xDE, 0x3D, 0xD8, 0x1E, 0xDE]).toString(note.encoding); - var trimLength = note.length() - 2; - note.trim(trimLength); - - var length = note.alert.length; - expect(note.alert.charCodeAt(length - 1)).to.not.be.within(0xD800, 0xD8FF); - }); - }); - - describe("escape sequences", function() { - it("removes sequence without digits", function() { - note.alert = "\u0006\u0007"; - var trimLength = note.length() - 4; - note.trim(trimLength); - - expect(note.alert.length).to.equal(1); - }); - - it("removes sequence with fewer than 4 digits", function() { - note.alert = "\u0006\u0007"; - var trimLength = note.length() - 3; - note.trim(trimLength); - - expect(note.alert.length).to.equal(1); - }); - - it("does not remove a complete sequence", function() { - note.alert = "\u0006\u0007 "; - var trimLength = note.length() - 1; - note.trim(trimLength); - - expect(note.alert.charCodeAt(1)).to.equal(7); - }); - }); - }); - }); - - describe("toJSON", function() { - it("returns an Object", function() { - expect(note.toJSON()).to.be.an("object"); - }); - - describe("payload", function() { - describe("when no aps properties are set", function() { - it("contains all original payload properties", function() { - note.payload = {"foo": "bar", "baz": 1}; - expect(note.toJSON()).to.eql(note.payload); - }); - }); - - describe("when aps payload is present", function() { - beforeEach(function() { - note.payload = {"foo": "bar", "baz": 1, "aps": { "badge": 1, "alert": "Hi there!" }}; - }); - - it("contains all original payload properties", function() { - expect(note.toJSON()).to.have.property("foo", "bar"); - expect(note.toJSON()).to.have.property("baz", 1); - }); - - it("contains the correct aps properties", function() { - expect(note.toJSON()).to.have.deep.property("aps.badge", 1); - expect(note.toJSON()).to.have.deep.property("aps.alert", "Hi there!"); - }); - }); - - context("when passed in the notification constructor", function() { - beforeEach(function() { - note = new apn.Notification({"foo": "bar", "baz": 1, "aps": { "badge": 1, "alert": "Hi there!" }}); - }); - - it("contains all original payload properties", function() { - expect(note.toJSON()).to.have.property("foo", "bar"); - expect(note.toJSON()).to.have.property("baz", 1); - }); - - it("contains the correct aps properties", function() { - expect(note.toJSON()).to.have.deep.property("aps.badge", 1); - expect(note.toJSON()).to.have.deep.property("aps.alert", "Hi there!"); - }); - }); - }); - - describe("mdm payload", function() { - it("is included in the notification", function() { - note.mdm = "mdm payload"; - expect(note.toJSON().mdm).to.equal("mdm payload"); - }); - - it("does not include the aps payload", function() { - note.mdm = "mdm payload"; - note.badge = 5; - - expect(note.toJSON()).to.not.have.any.keys("aps"); - }); - }); - - describe("aps payload", function() { - describe("when no aps properties are set", function() { - it("is not present", function() { - expect(note.toJSON().aps).to.be.undefined; - }); - }); - - describe("when manual `aps` properties are set on `payload`", function() { - it("retains them", function() { - note.payload.aps = {}; - note.payload.aps.custom = "custom property"; - - expect(note.toJSON().aps.custom).to.equal("custom property"); - }); - - it("adds the alert property", function() { - note.payload.aps = {}; - note.payload.aps.custom = "custom property"; - note.alert = "test alert"; - - expect(note.toJSON().aps.custom).to.equal("custom property"); - expect(note.toJSON().aps.alert).to.equal("test alert"); - }); - }); - - it("includes alert text", function() { - note.alert = "Test Message"; - expect(note.toJSON().aps.alert).to.equal("Test Message"); - }); - - it("includes alert object", function() { - var alert = { - body: "Test Message" - }; - note.alert = alert; - - expect(note.toJSON().aps.alert).to.eql(alert); - }); - - it("includes badge value", function() { - note.badge = 3; - - expect(note.toJSON().aps.badge).to.eql(3); - }); - - it("includes sound value", function() { - note.sound = "awesome.caf"; - - expect(note.toJSON().aps.sound).to.eql("awesome.caf"); - }); - - describe("with contentAvailable property", function() { - it("sets the 'content-available' flag", function() { - note.contentAvailable = true; - - expect(note.toJSON().aps["content-available"]).to.eql(1); - }); - }); - - describe("with contentAvailable property disabled", function() { - it("does not set the 'content-available' flag", function() { - note.alert = "message"; - note.contentAvailable = false; - - expect(note.toJSON().aps["content-available"]).to.be.undefined; - }); - }); - - describe("with mutableContent property", function() { - it("sets the 'mutable-content' flag", function() { - note.mutableContent = true; - - expect(note.toJSON().aps["mutable-content"]).to.eql(1); - }); - }); - - describe("with mutableContent property disabled", function() { - it("does not set the 'mutable-content' flag", function() { - note.alert = "message"; - note.mutableContent = false; - - expect(note.toJSON().aps["mutable-content"]).to.be.undefined; - }); - }); - - describe("with newsstandAvailable property", function() { - it("sets the 'content-available' flag", function() { - note.contentAvailable = true; - - expect(note.toJSON().aps["content-available"]).to.eql(1); - }); - }); - - it("includes the urlArgs property", function() { - note.urlArgs = ["arguments", "for", "url"]; - - expect(note.toJSON().aps["url-args"]).to.eql(["arguments", "for", "url"]); - }); - - it("includes the category value", function() { - note.category = "mouse"; - - expect(note.toJSON().aps.category).to.eql("mouse"); - }); - }); - }); -}); diff --git a/test/notification/apsProperties.js b/test/notification/apsProperties.js new file mode 100644 index 00000000..2fd8b0d3 --- /dev/null +++ b/test/notification/apsProperties.js @@ -0,0 +1,686 @@ +"use strict"; + +const Notification = require("../../lib/notification"); + +describe("Notification", function() { + + let note; + beforeEach(function() { + note = new Notification(); + }); + + describe("aps convenience properties", function() { + describe("alert", function() { + it("defaults to undefined", function() { + expect(compiledOutput()).to.not.have.deep.property("aps.alert"); + }); + + it("can be set to a string", function() { + note.alert = "hello"; + expect(compiledOutput()).to.have.deep.property("aps.alert", "hello"); + }); + + it("can be set to an object", function() { + note.alert = {"body": "hello"}; + expect(compiledOutput()).to.have.deep.property("aps.alert") + .that.deep.equals({"body": "hello"}); + }); + + it("can be set to undefined", function() { + note.alert = {"body": "hello"}; + note.alert = undefined; + expect(compiledOutput()).to.not.have.deep.property("aps.alert"); + }); + + describe("setAlert", function () { + it("is chainable", function () { + expect(note.setAlert("hello")).to.equal(note); + expect(compiledOutput()).to.have.deep.property("aps.alert", "hello"); + }); + }); + }); + + describe("body", function() { + it("defaults to undefined", function() { + expect(note.body).to.be.undefined; + }); + + it("can be set to a string", function() { + note.body = "Hello, world"; + expect(typeof compiledOutput().aps.alert).to.equal("string"); + }); + + it("sets alert as a string by default", function () { + note.body = "Hello, world"; + expect(compiledOutput()).to.have.deep.property("aps.alert", "Hello, world"); + }); + + context("alert is already an Object", function () { + beforeEach(function () { + note.alert = {"body": "Existing Body"}; + }); + + it("reads the value from alert body", function () { + expect(note.body).to.equal("Existing Body"); + }); + + it("sets the value correctly", function () { + note.body = "Hello, world"; + expect(compiledOutput()).to.have.deep.property("aps.alert.body", "Hello, world"); + }); + }); + + describe("setBody", function () { + it("is chainable", function () { + expect(note.setBody("hello")).to.equal(note); + expect(compiledOutput()).to.have.deep.property("aps.alert", "hello"); + }); + }); + }); + + describe("locKey", function () { + it("sets the aps.alert.loc-key property", function () { + note.locKey = "hello_world"; + expect(compiledOutput()).to.have.deep.property("aps.alert.loc\-key", "hello_world"); + }); + + context("alert is already an object", function () { + beforeEach(function () { + note.alert = {body: "Test", "launch-image": "test.png"}; + note.locKey = "hello_world"; + }); + + it("contains all expected properties", function () { + expect(compiledOutput()).to.have.deep.property("aps.alert") + .that.deep.equals({body: "Test", "launch-image": "test.png", "loc-key": "hello_world"}); + }); + }); + + context("alert is already a string", function () { + beforeEach(function () { + note.alert = "Good Morning"; + note.locKey = "good_morning"; + }); + + it("retains the alert body correctly", function () { + expect(compiledOutput()).to.have.deep.property("aps.alert.body", "Good Morning"); + }); + + it("sets the aps.alert.loc-key property", function () { + expect(compiledOutput()).to.have.deep.property("aps.alert.loc\-key", "good_morning"); + }); + }); + + describe("setLocKey", function () { + it("is chainable", function () { + expect(note.setLocKey("good_morning")).to.equal(note); + expect(compiledOutput()).to.have.deep.property("aps.alert.loc\-key", "good_morning"); + }); + }); + }); + + describe("locArgs", function () { + it("sets the aps.alert.loc-args property", function () { + note.locArgs = ["arg1", "arg2"]; + expect(compiledOutput()).to.have.deep.property("aps.alert.loc\-args") + .that.deep.equals(["arg1", "arg2"]); + }); + + context("alert is already an object", function () { + beforeEach(function () { + note.alert = {body: "Test", "launch-image": "test.png"}; + note.locArgs = ["Hi there"]; + }); + + it("contains all expected properties", function () { + expect(compiledOutput()).to.have.deep.property("aps.alert") + .that.deep.equals({body: "Test", "launch-image": "test.png", "loc-args": ["Hi there"]}); + }); + }); + + context("alert is already a string", function () { + beforeEach(function () { + note.alert = "Hello, world"; + note.locArgs = ["Hi there"]; + }); + + it("retains the alert body", function () { + expect(compiledOutput()).to.have.deep.property("aps.alert.body", "Hello, world"); + }); + + it("sets the aps.alert.loc-args property", function () { + expect(compiledOutput()).to.have.deep.property("aps.alert.loc\-args") + .that.deep.equals(["Hi there"]); + }); + }); + + describe("setLocArgs", function () { + it("is chainable", function () { + expect(note.setLocArgs(["Robert"])).to.equal(note); + expect(compiledOutput()).to.have.deep.property("aps.alert.loc\-args") + .that.deep.equals(["Robert"]); + }); + }); + }); + + describe("title", function () { + it("sets the aps.alert.title property", function () { + note.title = "node-apn"; + expect(compiledOutput()).to.have.deep.property("aps.alert.title", "node-apn"); + }); + + context("alert is already an object", function () { + beforeEach(function () { + note.alert = {body: "Test", "launch-image": "test.png"}; + note.title = "node-apn"; + }); + + it("contains all expected properties", function () { + expect(compiledOutput()).to.have.deep.property("aps.alert") + .that.deep.equals({body: "Test", "launch-image": "test.png", "title": "node-apn"}); + }); + }); + + context("alert is already a string", function () { + beforeEach(function () { + note.alert = "Hello, world"; + note.title = "Welcome"; + }); + + it("retains the alert body", function () { + expect(compiledOutput()).to.have.deep.property("aps.alert.body", "Hello, world"); + }); + + it("sets the aps.alert.title property", function () { + expect(compiledOutput()).to.have.deep.property("aps.alert.title", "Welcome"); + }); + }); + + describe("setTitle", function () { + it("is chainable", function () { + expect(note.setTitle("Bienvenue")).to.equal(note); + expect(compiledOutput()).to.have.deep.property("aps.alert.title", "Bienvenue"); + }); + }); + }); + + describe("subtitle", function () { + it("sets the aps.alert.subtitle property", function () { + note.subtitle = "node-apn"; + expect(compiledOutput()).to.have.deep.property("aps.alert.subtitle", "node-apn"); + }); + + context("alert is already an object", function () { + beforeEach(function () { + note.alert = {body: "Test", "launch-image": "test.png"}; + note.subtitle = "node-apn"; + }); + + it("contains all expected properties", function () { + expect(compiledOutput()).to.have.deep.property("aps.alert") + .that.deep.equals({body: "Test", "launch-image": "test.png", "subtitle": "node-apn"}); + }); + }); + + context("alert is already a string", function () { + beforeEach(function () { + note.alert = "Hello, world"; + note.subtitle = "Welcome"; + }); + + it("retains the alert body", function () { + expect(compiledOutput()).to.have.deep.property("aps.alert.body", "Hello, world"); + }); + + it("sets the aps.alert.subtitle property", function () { + expect(compiledOutput()).to.have.deep.property("aps.alert.subtitle", "Welcome"); + }); + }); + + describe("setSubtitle", function () { + it("is chainable", function () { + expect(note.setSubtitle("Bienvenue")).to.equal(note); + expect(compiledOutput()).to.have.deep.property("aps.alert.subtitle", "Bienvenue"); + }); + }); + }); + describe("titleLocKey", function () { + it("sets the aps.alert.title-loc-key property", function () { + note.titleLocKey = "Warning"; + expect(compiledOutput()).to.have.deep.property("aps.alert.title\-loc\-key", "Warning"); + }); + + context("alert is already an object", function () { + beforeEach(function () { + note.alert = {body: "Test", "launch-image": "test.png"}; + note.titleLocKey = "Warning"; + }); + + it("contains all expected properties", function () { + expect(compiledOutput()).to.have.deep.property("aps.alert") + .that.deep.equals({body: "Test", "launch-image": "test.png", "title-loc-key": "Warning"}); + }); + }); + + context("alert is already a string", function () { + beforeEach(function () { + note.alert = "Hello, world"; + note.titleLocKey = "Warning"; + }); + + it("retains the alert body correctly", function () { + expect(compiledOutput()).to.have.deep.property("aps.alert.body", "Hello, world"); + }); + + it("sets the aps.alert.title-loc-key property", function () { + expect(compiledOutput()).to.have.deep.property("aps.alert.title\-loc\-key", "Warning"); + }); + }); + + describe("setAlert", function () { + it("is chainable", function () { + expect(note.setTitleLocKey("greeting")).to.equal(note); + expect(compiledOutput()).to.have.deep.property("aps.alert.title\-loc\-key", "greeting"); + }); + }); + }); + + describe("titleLocArgs", function () { + it("sets the aps.alert.title-loc-args property", function () { + note.titleLocArgs = ["arg1", "arg2"]; + expect(compiledOutput()).to.have.deep.property("aps.alert.title\-loc\-args") + .that.deep.equals(["arg1", "arg2"]); + }); + + context("alert is already an object", function () { + beforeEach(function () { + note.alert = {body: "Test", "launch-image": "test.png"}; + note.titleLocArgs = ["Hi there"]; + }); + + it("contains all expected properties", function () { + expect(compiledOutput()).to.have.deep.property("aps.alert") + .that.deep.equals({body: "Test", "launch-image": "test.png", "title-loc-args": ["Hi there"]}); + }); + }); + + context("alert is already a string", function () { + beforeEach(function () { + note.alert = "Hello, world"; + note.titleLocArgs = ["Hi there"]; + }); + + it("retains the alert body", function () { + expect(compiledOutput()).to.have.deep.property("aps.alert.body", "Hello, world"); + }); + + it("sets the aps.alert.title-loc-args property", function () { + expect(compiledOutput()).to.have.deep.property("aps.alert.title\-loc\-args") + .that.deep.equals(["Hi there"]); + }); + }); + + describe("setTitleLocArgs", function () { + it("is chainable", function () { + expect(note.setTitleLocArgs(["iPhone 6s"])).to.equal(note); + expect(compiledOutput()).to.have.deep.property("aps.alert.title\-loc\-args") + .that.deep.equals(["iPhone 6s"]); + }); + }); + }); + + describe("action", function () { + it("sets the aps.alert.action property", function () { + note.action = "View"; + expect(compiledOutput()).to.have.deep.property("aps.alert.action", "View"); + }); + + context("alert is already an object", function () { + beforeEach(function () { + note.alert = {body: "Test", "launch-image": "test.png"}; + note.action = "View"; + }); + + it("contains all expected properties", function () { + expect(compiledOutput()).to.have.deep.property("aps.alert") + .that.deep.equals({body: "Test", "launch-image": "test.png", "action": "View"}); + }); + }); + + context("alert is already a string", function () { + beforeEach(function () { + note.alert = "Alert"; + note.action = "Investigate"; + }); + + it("retains the alert body", function () { + expect(compiledOutput()).to.have.deep.property("aps.alert.body", "Alert"); + }); + + it("sets the aps.alert.action property", function () { + expect(compiledOutput()).to.have.deep.property("aps.alert.action", "Investigate"); + }) + }); + + describe("setAction", function () { + it("is chainable", function () { + expect(note.setAction("Reply")).to.equal(note); + expect(compiledOutput()).to.have.deep.property("aps.alert.action", "Reply"); + }); + }); + }); + + describe("actionLocKey", function () { + it("sets the aps.alert.action-loc-key property", function () { + note.actionLocKey = "reply_title"; + expect(compiledOutput()).to.have.deep.property("aps.alert.action\-loc\-key", "reply_title"); + }); + + context("alert is already an object", function () { + beforeEach(function () { + note.alert = {body: "Test", "launch-image": "test.png"}; + note.actionLocKey = "reply_title"; + }); + + it("contains all expected properties", function () { + expect(compiledOutput()).to.have.deep.property("aps.alert") + .that.deep.equals({body: "Test", "launch-image": "test.png", "action-loc-key": "reply_title"}); + }); + }); + + context("alert is already a string", function () { + beforeEach(function () { + note.alert = "Hello, world"; + note.actionLocKey = "ignore_title"; + }); + + it("retains the alert body correctly", function () { + expect(compiledOutput()).to.have.deep.property("aps.alert.body", "Hello, world"); + }); + + it("sets the aps.alert.action-loc-key property", function () { + expect(compiledOutput()).to.have.deep.property("aps.alert.action\-loc\-key", "ignore_title"); + }); + }); + + describe("setActionLocKey", function () { + it("is chainable", function () { + expect(note.setActionLocKey("ignore_title")).to.equal(note); + expect(compiledOutput()).to.have.deep.property("aps.alert.action\-loc\-key", "ignore_title"); + }); + }); + }); + + describe("launchImage", function () { + it("sets the aps.alert.launch-image property", function () { + note.launchImage = "testLaunch.png"; + expect(compiledOutput()).to.have.deep.property("aps.alert.launch\-image") + .that.deep.equals("testLaunch.png"); + }); + + context("alert is already an object", function () { + beforeEach(function () { + note.alert = {body: "Test", "title-loc-key": "node-apn"}; + note.launchImage = "apnLaunch.png"; + }); + + it("contains all expected properties", function () { + expect(compiledOutput()).to.have.deep.property("aps.alert") + .that.deep.equals({body: "Test", "title-loc-key": "node-apn", "launch-image": "apnLaunch.png"}); + }); + }); + + context("alert is already a string", function () { + beforeEach(function () { + note.alert = "Hello, world"; + note.launchImage = "apnLaunch.png"; + }); + + it("retains the alert body", function () { + expect(compiledOutput()).to.have.deep.property("aps.alert.body", "Hello, world"); + }); + + it("sets the aps.alert.launch-image property", function () { + expect(compiledOutput()).to.have.deep.property("aps.alert.launch\-image") + .that.deep.equals("apnLaunch.png"); + }) + }); + + describe("setLaunchImage", function () { + it("is chainable", function () { + expect(note.setLaunchImage("remoteLaunch.png")).to.equal(note); + expect(compiledOutput()).to.have.deep.property("aps.alert.launch\-image", "remoteLaunch.png"); + }); + }); + }); + + describe("badge", function() { + it("defaults to undefined", function() { + expect(compiledOutput()).to.not.have.deep.property("aps.badge"); + }); + + it("can be set to a number", function() { + note.badge = 5; + + expect(compiledOutput()).to.have.deep.property("aps.badge", 5); + }); + + it("can be set to undefined", function() { + note.badge = 5; + note.badge = undefined; + + expect(compiledOutput()).to.not.have.deep.property("aps.badge"); + }); + + it("can be set to zero", function() { + note.badge = 0; + + expect(compiledOutput()).to.have.deep.property("aps.badge", 0); + }); + + it("cannot be set to a string", function() { + note.badge = "hello"; + + expect(compiledOutput()).to.not.have.deep.property("aps.badge"); + }); + + describe("setBadge", function () { + it("is chainable", function () { + expect(note.setBadge(7)).to.equal(note); + expect(compiledOutput()).to.have.deep.property("aps.badge", 7); + }); + }); + }); + + describe("sound", function() { + it("defaults to undefined", function() { + expect(compiledOutput()).to.not.have.deep.property("aps.sound"); + }); + + it("can be set to a string", function() { + note.sound = "sound.caf"; + + expect(compiledOutput()).to.have.deep.property("aps.sound", "sound.caf"); + }); + + it("can be set to undefined", function() { + note.sound = "sound.caf"; + note.sound = undefined; + + expect(compiledOutput()).to.not.have.deep.property("aps.sound"); + }); + + it("cannot be set to a number", function() { + note.sound = 5; + + expect(compiledOutput()).to.not.have.deep.property("aps.sound"); + }); + + describe("setSound", function () { + it("is chainable", function () { + expect(note.setSound("bee.caf")).to.equal(note); + expect(compiledOutput()).to.have.deep.property("aps.sound", "bee.caf"); + }); + }); + }); + + describe("content-available", function() { + it("defaults to undefined", function() { + expect(compiledOutput()).to.not.have.deep.property("aps.content\-available"); + }); + + it("can be set to a boolean value", function() { + note.contentAvailable = true; + + expect(compiledOutput()).to.have.deep.property("aps.content\-available", 1); + }); + + it("can be set to `1`", function () { + note.contentAvailable = 1; + + expect(compiledOutput()).to.have.deep.property("aps.content\-available", 1); + }); + + it("can be set to undefined", function() { + note.contentAvailable = true; + note.contentAvailable = undefined; + + expect(compiledOutput()).to.not.have.deep.property("aps.content\-available"); + }); + + describe("setContentAvailable", function () { + it("is chainable", function () { + expect(note.setContentAvailable(true)).to.equal(note); + expect(compiledOutput()).to.have.deep.property("aps.content\-available", 1); + }); + }); + }); + + describe("mutable-content", function() { + it("defaults to undefined", function() { + expect(compiledOutput()).to.not.have.deep.property("aps.mutable\-content"); + }); + + it("can be set to a boolean value", function() { + note.mutableContent = true; + + expect(compiledOutput()).to.have.deep.property("aps.mutable\-content", 1); + }); + + it("can be set to `1`", function () { + note.mutableContent = 1; + + expect(compiledOutput()).to.have.deep.property("aps.mutable\-content", 1); + }); + + it("can be set to undefined", function() { + note.mutableContent = true; + note.mutableContent = undefined; + + expect(compiledOutput()).to.not.have.deep.property("aps.mutable\-content"); + }); + + describe("setMutableContent", function () { + it("is chainable", function () { + expect(note.setMutableContent(true)).to.equal(note); + expect(compiledOutput()).to.have.deep.property("aps.mutable\-content", 1); + }); + }); + }); + + describe("mdm", function() { + it("defaults to undefined", function() { + expect(compiledOutput()).to.not.have.deep.property("mdm"); + }); + + it("can be set to a string", function() { + note.mdm = "mdm payload"; + + expect(compiledOutput()).to.deep.equal({"mdm": "mdm payload"}); + }); + + it("can be set to undefined", function() { + note.mdm = "mdm payload"; + note.mdm = undefined; + + expect(compiledOutput()).to.not.have.deep.property("mdm"); + }); + + it("does not include the aps payload", function() { + note.mdm = "mdm payload"; + note.badge = 5; + + expect(compiledOutput()).to.not.have.any.keys("aps"); + }); + + describe("setMdm", function () { + it("is chainable", function () { + expect(note.setMdm("hello")).to.equal(note); + expect(compiledOutput()).to.have.deep.property("mdm", "hello"); + }); + }); + }); + + describe("urlArgs", function() { + it("defaults to undefined", function() { + expect(compiledOutput()).to.not.have.deep.property("aps.url\-args"); + }); + + it("can be set to an array", function() { + note.urlArgs = ["arg1", "arg2"]; + + expect(compiledOutput()).to.have.deep.property("aps.url\-args") + .that.deep.equals(["arg1", "arg2"]); + }); + + it("can be set to undefined", function() { + note.urlArgs = ["arg1", "arg2"]; + note.urlArgs = undefined; + + expect(compiledOutput()).to.not.have.deep.property("aps.url\-args"); + }); + + describe("setUrlArgs", function () { + it("is chainable", function () { + expect(note.setUrlArgs(["A318", "BA001"])).to.equal(note); + expect(compiledOutput()).to.have.deep.property("aps.url\-args") + .that.deep.equals(["A318", "BA001"]); + }); + }); + }); + + describe("category", function() { + it("defaults to undefined", function() { + expect(compiledOutput()).to.not.have.deep.property("aps.category"); + }); + + it("can be set to a string", function() { + note.category = "the-category"; + expect(compiledOutput()).to.have.deep.property("aps.category", "the-category"); + }); + + it("can be set to undefined", function() { + note.category = "the-category"; + note.category = undefined; + expect(compiledOutput()).to.not.have.deep.property("aps.category"); + }); + + describe("setCategory", function () { + it("is chainable", function () { + expect(note.setCategory("reminder")).to.equal(note); + expect(compiledOutput()).to.have.deep.property("aps.category", "reminder"); + }); + }); + }); + + context("when no aps properties are set", function() { + it("is not present", function() { + expect(compiledOutput().aps).to.be.undefined; + }); + }); + }); + + function compiledOutput() { + return JSON.parse(note.compile()); + } +}); diff --git a/test/notification/index.js b/test/notification/index.js new file mode 100644 index 00000000..9d30eaba --- /dev/null +++ b/test/notification/index.js @@ -0,0 +1,189 @@ +"use strict"; + +var Notification = require("../../lib/notification"); +var sinon = require("sinon"); + +describe("Notification", function() { + + var note; + beforeEach(function() { + note = new Notification(); + }); + + describe("constructor", function () { + it("accepts initialization values", function () { + let properties = { "priority": 5, "topic": "io.apn.node", "payload": { "foo": "bar" }, "badge": 5}; + note = new Notification(properties); + + expect(note.payload).to.deep.equal({"foo": "bar"}); + expect(note.priority).to.equal(5); + expect(note.topic).to.equal("io.apn.node"); + expect(compiledOutput()).to.have.deep.property("aps.badge", 5); + }); + }); + + describe("rawPayload", function () { + + it("is used as the JSON output", function () { + let payload = { "some": "payload" }; + note = new Notification({ "rawPayload": payload }); + + expect(note.rawPayload).to.deep.equal({ "some": "payload" }); + expect(compiledOutput()).to.deep.equal({ "some": "payload" }); + }); + + it("does not get clobbered by aps accessors", function () { + let payload = { "some": "payload", "aps": {"alert": "Foo"}}; + + note = new Notification({ "rawPayload": payload }); + note.alertBody = "Bar"; + + expect(note.rawPayload).to.deep.equal({ "some": "payload", "aps": {"alert": "Foo"}}); + expect(compiledOutput()).to.deep.equal({ "some": "payload", "aps": {"alert": "Foo"}}); + }); + + it("takes precedence over the `mdm` property", function () { + let payload = { "some": "payload" }; + + note = new Notification({ "rawPayload": payload }); + note.mdm = "abcd"; + + expect(note.rawPayload).to.deep.equal({ "some": "payload" }); + expect(compiledOutput()).to.deep.equal({ "some": "payload" }); + }); + + context("when passed in the notification constructor", function() { + beforeEach(function() { + note = new Notification({"rawPayload": {"foo": "bar", "baz": 1, "aps": { "badge": 1, "alert": "Hi there!" }}}); + }); + + it("contains all original payload properties", function() { + expect(compiledOutput()).to.have.property("foo", "bar"); + expect(compiledOutput()).to.have.property("baz", 1); + }); + + it("contains the correct aps properties", function() { + expect(compiledOutput()).to.have.deep.property("aps.badge", 1); + expect(compiledOutput()).to.have.deep.property("aps.alert", "Hi there!"); + }); + }); + }); + + describe("payload", function() { + describe("when no aps properties are set", function() { + it("contains all original payload properties", function() { + note.payload = {"foo": "bar", "baz": 1}; + expect(compiledOutput()).to.eql({"foo": "bar", "baz": 1}); + }); + }); + + describe("when aps payload is present", function() { + beforeEach(function() { + note.payload = {"foo": "bar", "baz": 1, "aps": { "badge": 1, "alert": "Hi there!" }}; + }); + + it("contains all original payload properties", function() { + expect(compiledOutput()).to.have.property("foo", "bar"); + expect(compiledOutput()).to.have.property("baz", 1); + }); + + it("does not contain the aps properties", function() { + expect(compiledOutput()).to.not.have.property("aps"); + }); + }); + }); + + describe("length", function() { + it("returns the correct payload length", function() { + note.alert = "length"; + expect(note.length()).to.equal(26); + }); + }); + + describe("headers", function() { + it("contains no properties by default", function() { + expect(note.headers()).to.deep.equal({}); + }); + + context("priority is non-default", function() { + it("contains the apns-priority header", function() { + note.priority = 5; + expect(note.headers()).to.have.property("apns-priority", 5); + }); + }); + + context("id is set", function() { + it("contains the apns-id header", function() { + note.id = "123e4567-e89b-12d3-a456-42665544000"; + + expect(note.headers()).to.have.property("apns-id", "123e4567-e89b-12d3-a456-42665544000"); + }); + }); + + context("expiry is non-zero", function() { + it("contains the apns-expiration header", function() { + note.expiry = 1000; + + expect(note.headers()).to.have.property("apns-expiration", 1000); + }); + }); + + context("topic is set", function() { + it("contains the apns-topic header", function() { + note.topic = "io.apn.node"; + + expect(note.headers()).to.have.property("apns-topic", "io.apn.node"); + }); + }); + + context("collapseId is set", function () { + it("contains the apns-collapse-id header", function () { + note.collapseId = "io.apn.collapse"; + + expect(note.headers()).to.have.property("apns-collapse-id", "io.apn.collapse"); + }); + }); + }); + + describe("compile", function() { + var stub; + beforeEach(function() { + stub = sinon.stub(note, "toJSON"); + }); + + it("compiles the JSON payload", function() { + stub.returns("payload"); + + expect(note.compile()).to.equal("\"payload\""); + }); + + it("returns the JSON payload", function() { + stub.returns({}); + + expect(note.compile()).to.equal("{}"); + }); + + it("memoizes the JSON payload", function() { + stub.returns("payload1"); + note.compile(); + + stub.returns("payload2"); + + expect(note.compile()).to.equal("\"payload1\""); + }); + + it("re-compiles the JSON payload when `note.compiled` = false", function() { + stub.returns("payload1"); + note.compile(); + + stub.returns("payload2"); + note.compiled = false; + + expect(note.compile()).to.equal("\"payload2\""); + }); + }); + + function compiledOutput() { + return JSON.parse(note.compile()); + } +}); diff --git a/test/protocol/endpoint.js b/test/protocol/endpoint.js new file mode 100644 index 00000000..6cca5739 --- /dev/null +++ b/test/protocol/endpoint.js @@ -0,0 +1,728 @@ +"use strict"; + +const sinon = require("sinon"); +const stream = require("stream"); + +const bunyanLogger = sinon.match({ + fatal: sinon.match.func, + warn: sinon.match.func, + info: sinon.match.func, + debug: sinon.match.func, + trace: sinon.match.func, + child: sinon.match.func +}); + +describe("Endpoint", function () { + let fakes, streams, Endpoint; + + beforeEach(function () { + fakes = { + tls: { + connect: sinon.stub(), + }, + protocol: { + Connection: sinon.stub(), + Serializer: sinon.stub(), + Deserializer: sinon.stub(), + Compressor: sinon.stub(), + Decompressor: sinon.stub(), + }, + }; + + streams = { + socket: new stream.PassThrough(), + connection: new stream.PassThrough(), + serializer: new stream.PassThrough(), + deserializer: new stream.PassThrough(), + compressor: new stream.PassThrough(), + decompressor: new stream.PassThrough(), + }; + + // These streams should never actually pass writable -> readable + // otherwise the tests create an infinite loop. The real streams terminate. + // PassThrough is just an easy way to inspect the stream behaviour. + sinon.stub(streams.socket, "pipe"); + sinon.stub(streams.connection, "pipe"); + + streams.connection._allocateId = sinon.stub(); + + streams.compressor.setTableSizeLimit = sinon.spy(); + streams.decompressor.setTableSizeLimit = sinon.spy(); + + fakes.tls.connect.returns(streams.socket); + fakes.protocol.Connection.returns(streams.connection); + fakes.protocol.Serializer.returns(streams.serializer); + fakes.protocol.Deserializer.returns(streams.deserializer); + fakes.protocol.Compressor.returns(streams.compressor); + fakes.protocol.Decompressor.returns(streams.decompressor); + + Endpoint = require("../../lib/protocol/endpoint")(fakes); + }); + + describe("connect", function () { + describe("tls socket", function () { + + it("is created", function () { + new Endpoint({}); + + expect(fakes.tls.connect).to.be.calledOnce; + }); + + describe("connection parameters", function () { + + context("all supplied", function () { + + beforeEach(function () { + new Endpoint({ + address: "localtest", host: "127.0.0.1", port: 443, + pfx: "pfxData", cert: "certData", + key: "keyData", passphrase: "p4ssphr4s3" + }); + }); + + it("includes the host and port and servername", function () { + expect(fakes.tls.connect).to.be.calledWith(sinon.match({ + host: "127.0.0.1", + port: 443, + servername: "localtest" + })); + }); + + it("includes the ALPNProtocols", function () { + expect(fakes.tls.connect).to.be.calledWith(sinon.match({ + ALPNProtocols: ["h2"] + })); + }); + + it("includes the credentials", function () { + expect(fakes.tls.connect).to.be.calledWith(sinon.match({ + pfx: "pfxData", + cert: "certData", + key: "keyData", + passphrase: "p4ssphr4s3" + })); + }); + }); + + context("host is not omitted", function () { + it("falls back on 'address'", function () { + new Endpoint({ + address: "localtest", port: 443 + }); + + expect(fakes.tls.connect).to.be.calledWith(sinon.match({ + host: "localtest", + port: 443, + servername: "localtest" + })); + }); + }); + }); + + context("connection established", function () { + it("writes the HTTP/2 prelude", function () { + sinon.spy(streams.socket, "write"); + + new Endpoint({}); + + streams.socket.emit("secureConnect"); + + const HTTP2_PRELUDE = new Buffer("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"); + + expect(streams.socket.write.firstCall).to.be.calledWith(HTTP2_PRELUDE); + }); + + it("emits 'connect' event", function () { + const endpoint = new Endpoint({}); + let connect = sinon.spy(); + + endpoint.on("connect", connect); + streams.socket.emit("secureConnect"); + expect(connect).to.be.calledOnce; + }); + }); + + it("bubbles error events", function () { + const endpoint = new Endpoint({}); + const errorSpy = sinon.spy(); + endpoint.on("error", errorSpy); + + streams.socket.emit("error", "this should be bubbled"); + + expect(errorSpy).to.have.been.calledWith("this should be bubbled"); + }); + + it("bubbles end events", function () { + const endpoint = new Endpoint({}); + const endSpy = sinon.spy(); + endpoint.on("end", endSpy); + + streams.socket.emit("end"); + + expect(endSpy).to.have.been.calledOnce; + }); + }); + + describe("HTTP/2 layer", function () { + let endpoint; + + beforeEach(function () { + endpoint = new Endpoint({}); + }); + + describe("connection", function () { + it("is created", function () { + expect(fakes.protocol.Connection).to.have.been.calledWithNew; + expect(fakes.protocol.Connection).to.have.been.calledOnce; + }); + + it("is passed the correct parameters", function () { + + // Empty bunyan logger + expect(fakes.protocol.Connection).to.have.been.calledWith(bunyanLogger); + + // First stream ID + expect(fakes.protocol.Connection).to.have.been.calledWith(sinon.match.any, 1); + }); + + it("bubbles error events with label", function () { + const errorSpy = sinon.spy(); + endpoint.on("error", errorSpy); + + streams.connection.emit("error", "this should be bubbled"); + + expect(errorSpy).to.have.been.calledWith("connection error: this should be bubbled"); + }); + }); + + describe("serializer", function () { + it("is created", function () { + expect(fakes.protocol.Serializer).to.have.been.calledWithNew; + expect(fakes.protocol.Serializer).to.have.been.calledOnce; + }); + + it("is passed the logger", function () { + expect(fakes.protocol.Serializer).to.have.been.calledWith(bunyanLogger); + }); + + it("bubbles error events with label", function () { + const errorSpy = sinon.spy(); + endpoint.on("error", errorSpy); + + streams.serializer.emit("error", "this should be bubbled"); + + expect(errorSpy).to.have.been.calledWith("serializer error: this should be bubbled"); + }); + }); + + describe("deserializer", function () { + it("is created", function () { + expect(fakes.protocol.Deserializer).to.have.been.calledWithNew; + expect(fakes.protocol.Deserializer).to.have.been.calledOnce; + }); + + it("is passed the logger", function () { + expect(fakes.protocol.Deserializer).to.have.been.calledWith(bunyanLogger); + }); + + it("bubbles error events with label", function () { + const errorSpy = sinon.spy(); + endpoint.on("error", errorSpy); + + streams.deserializer.emit("error", "this should be bubbled"); + + expect(errorSpy).to.have.been.calledWith("deserializer error: this should be bubbled"); + }); + }); + + describe("compressor", function () { + it("is created", function () { + expect(fakes.protocol.Compressor).to.have.been.calledWithNew; + expect(fakes.protocol.Compressor).to.have.been.calledOnce; + }); + + it("is passed the correct parameters", function () { + expect(fakes.protocol.Compressor).to.have.been.calledWith(bunyanLogger); + expect(fakes.protocol.Compressor).to.have.been.calledWith(sinon.match.any, "REQUEST"); + }); + + it("handles HEADER_TABLE_SIZE settings update", function () { + streams.connection.emit("RECEIVING_SETTINGS_HEADER_TABLE_SIZE", 1000); + expect(streams.compressor.setTableSizeLimit).to.have.been.calledWith(1000); + }); + + it("bubbles error events", function () { + const errorSpy = sinon.spy(); + endpoint.on("error", errorSpy); + + streams.compressor.emit("error", "this should be bubbled"); + + expect(errorSpy).to.have.been.calledWith("compressor error: this should be bubbled"); + }); + }); + + describe("decompressor", function () { + it("is created", function () { + expect(fakes.protocol.Decompressor).to.have.been.calledWithNew; + expect(fakes.protocol.Decompressor).to.have.been.calledOnce; + }); + + it("is passed the correct parameters", function () { + expect(fakes.protocol.Decompressor).to.have.been.calledWith(bunyanLogger); + expect(fakes.protocol.Decompressor).to.have.been.calledWith(sinon.match.any, "RESPONSE"); + }); + + it("handles HEADER_TABLE_SIZE settings acknowledgement", function () { + streams.connection.emit("ACKNOWLEDGED_SETTINGS_HEADER_TABLE_SIZE", 1000); + expect(streams.decompressor.setTableSizeLimit).to.have.been.calledWith(1000); + }); + + it("bubbles error events", function () { + const errorSpy = sinon.spy(); + endpoint.on("error", errorSpy); + + streams.decompressor.emit("error", "this should be bubbled"); + + expect(errorSpy).to.have.been.calledWith("decompressor error: this should be bubbled"); + }); + }); + }); + }); + + describe("stream behaviour", function () { + + beforeEach(function () { + sinon.stub(streams.serializer, "pipe"); + sinon.stub(streams.deserializer, "pipe"); + sinon.stub(streams.compressor, "pipe"); + sinon.stub(streams.decompressor, "pipe"); + + sinon.spy(streams.socket, "write"); + + new Endpoint({}); + }); + + it("pipes the tls socket to the deserializer", function () { + expect(streams.socket.pipe).to.be.calledWith(streams.deserializer); + expect(streams.socket.pipe).to.be.calledAfter(streams.socket.write); + }); + + it("pipes the serializer to the tls socket", function () { + expect(streams.serializer.pipe).to.be.calledWith(streams.socket); + expect(streams.socket.pipe).to.be.calledAfter(streams.socket.write); + }); + + it("pipes the connection to the compressor", function () { + expect(streams.connection.pipe).to.be.calledWith(streams.compressor); + }); + + it("pipes the compressor to the serializer", function () { + expect(streams.compressor.pipe).to.be.calledWith(streams.serializer); + }); + + it("pipes the deserializer to the decompressor", function () { + expect(streams.deserializer.pipe).to.be.calledWith(streams.decompressor); + }); + + it("pipes the decompressor to the connection", function () { + expect(streams.decompressor.pipe).to.be.calledWith(streams.connection); + }); + }); + + describe("available stream slots", function () { + let endpoint; + + beforeEach(function () { + endpoint = new Endpoint({}); + streams.connection.createStream = sinon.stub().returns(new stream.PassThrough()); + + expect(endpoint.availableStreamSlots).to.equal(0); + streams.connection.emit("RECEIVING_SETTINGS_MAX_CONCURRENT_STREAMS", 5); + expect(endpoint.availableStreamSlots).to.equal(5); + }); + + it("reflects the received settings value", function () { + streams.connection.emit("RECEIVING_SETTINGS_MAX_CONCURRENT_STREAMS", 1024); + expect(endpoint.availableStreamSlots).to.equal(1024); + }); + + it("reduces when a stream is created", function () { + endpoint.createStream(); + expect(endpoint.availableStreamSlots).to.equal(4); + }); + + it("increases when a stream ends", function () { + const stream = endpoint.createStream(); + + stream.emit("end"); + expect(endpoint.availableStreamSlots).to.equal(5); + }); + }); + + describe("error occurrence", function () { + let promises; + + beforeEach(function () { + let endpoint = new Endpoint({}); + endpoint.on("error", () => {}) + + streams.connection._streamIds = []; + + // Stream 0 is an exception and should not triggered + streams.connection._streamIds[0] = new stream.PassThrough(); + + promises = []; + function erroringStream() { + let s = new stream.PassThrough(); + promises.push(new Promise( resolve => { + s.on("error", function(err) { + resolve(err); + }); + })); + + return s; + } + + streams.connection._streamIds[5] = erroringStream(); + streams.connection._streamIds[7] = erroringStream(); + streams.connection._streamIds[9] = erroringStream(); + }); + + context("socket error", function () { + it("emits the error from all active streams after close", function () { + let error = new Error("socket failed"); + streams.socket.emit("error", error); + streams.socket.emit("close", true); + + return expect(Promise.all(promises)).to.eventually.deep.equal([ + error, error, error, + ]); + }); + + it("does not emit `unprocessed` on any streams", function () { + let unprocessedSpy = sinon.spy(); + + streams.connection._streamIds[5].on("unprocessed", unprocessedSpy); + streams.connection._streamIds[7].on("unprocessed", unprocessedSpy); + streams.connection._streamIds[9].on("unprocessed", unprocessedSpy); + + let error = new Error("socket failed"); + streams.socket.emit("error", error); + streams.socket.emit("close", true); + + expect(unprocessedSpy).to.not.be.called; + }); + }); + + context("connection error", function () { + it("emits an error with the code from all active streams", function () { + streams.connection.emit("error", "PROTOCOL_ERROR"); + streams.socket.emit("close", false); + + return Promise.all(promises).then( responses => { + expect(responses[0]).to.match(/connection error: PROTOCOL_ERROR/); + expect(responses[1]).to.match(/connection error: PROTOCOL_ERROR/); + expect(responses[2]).to.match(/connection error: PROTOCOL_ERROR/); + }); + }); + }); + + context("serializer error", function () { + it("emits an error with the code from all active streams", function () { + streams.serializer.emit("error", "PROTOCOL_ERROR"); + streams.socket.emit("close", false); + + return Promise.all(promises).then( responses => { + expect(responses[0]).to.match(/serializer error: PROTOCOL_ERROR/); + expect(responses[1]).to.match(/serializer error: PROTOCOL_ERROR/); + expect(responses[2]).to.match(/serializer error: PROTOCOL_ERROR/); + }); + }); + }); + + context("compressor error", function () { + it("emits an error with the code from all active streams", function () { + streams.compressor.emit("error", "PROTOCOL_ERROR"); + streams.socket.emit("close", false); + + return Promise.all(promises).then( responses => { + expect(responses[0]).to.match(/compressor error: PROTOCOL_ERROR/); + expect(responses[1]).to.match(/compressor error: PROTOCOL_ERROR/); + expect(responses[2]).to.match(/compressor error: PROTOCOL_ERROR/); + }); + }); + }); + + context("deserializer error", function () { + it("emits an error with the code from all active streams", function () { + streams.deserializer.emit("error", "PROTOCOL_ERROR"); + streams.socket.emit("close", false); + + return Promise.all(promises).then( responses => { + expect(responses[0]).to.match(/deserializer error: PROTOCOL_ERROR/); + expect(responses[1]).to.match(/deserializer error: PROTOCOL_ERROR/); + expect(responses[2]).to.match(/deserializer error: PROTOCOL_ERROR/); + }); + }); + }); + + context("decompressor error", function () { + it("emits an error with the code from all active streams", function () { + streams.decompressor.emit("error", "PROTOCOL_ERROR"); + streams.socket.emit("close", false); + + return Promise.all(promises).then( responses => { + expect(responses[0]).to.match(/decompressor error: PROTOCOL_ERROR/); + expect(responses[1]).to.match(/decompressor error: PROTOCOL_ERROR/); + expect(responses[2]).to.match(/decompressor error: PROTOCOL_ERROR/); + }); + }); + }); + }); + + describe("`GOAWAY` received", function () { + let frame, errorSpy; + + beforeEach(function () { + let endpoint = new Endpoint({}); + + errorSpy = sinon.spy(); + endpoint.on("error", errorSpy); + }); + + context("no error", function () { + it("does not emit an error", function () { + streams.connection.emit("GOAWAY", { error: "NO_ERROR" }); + + expect(errorSpy).to.not.be.called; + }); + + context("some streams are unprocessed", function () { + beforeEach(function () { + streams.connection._streamIds = []; + + // Stream 0 is an exception and should not triggered + streams.connection._streamIds[0] = new stream.PassThrough(); + + streams.connection._streamIds[5] = new stream.PassThrough(); + streams.connection._streamIds[7] = new stream.PassThrough(); + streams.connection._streamIds[9] = new stream.PassThrough(); + }); + + it("does not emit `unprocessed` on streams below `last_stream`", function () { + let spy = sinon.spy(); + streams.connection._streamIds[5].on("unprocessed", spy); + + streams.connection.emit("GOAWAY", { error: "NO_ERROR", last_stream: 5 }); + streams.socket.emit("close"); + + expect(spy).to.not.be.called; + }); + + it("emits `unprocessed` on streams above `last_stream`", function () { + let spy7 = sinon.spy(); + streams.connection._streamIds[7].on("unprocessed", spy7); + + let spy9 = sinon.spy(); + streams.connection._streamIds[9].on("unprocessed", spy9); + + streams.connection.emit("GOAWAY", { error: "NO_ERROR", last_stream: 5 }); + streams.socket.emit("close"); + + expect(spy7).to.be.calledOnce; + expect(spy9).to.be.calledOnce; + }); + + it("does not emit any errors on streams below `last_stream`", function () { + let errorSpy = sinon.spy(); + streams.connection._streamIds[5].on("error", errorSpy); + streams.connection._streamIds[7].on("error", errorSpy); + + streams.connection.emit("GOAWAY", { error: "NO_ERROR", last_stream: 7 }); + streams.socket.emit("close"); + + expect(errorSpy).to.not.be.called; + }); + }); + }); + + context("with error", function () { + const debug_data = new Buffer(6); + debug_data.write("error!"); + + const formattedError = "GOAWAY: PROTOCOL_ERROR error!"; + + beforeEach(function () { + frame = { error: "PROTOCOL_ERROR", debug_data: debug_data }; + }); + + it("emits an error with the type and debug data", function () { + streams.connection.emit("GOAWAY", frame); + + expect(errorSpy).to.be.calledWith(formattedError); + }); + + context("some streams are unprocessed", function () { + + beforeEach(function () { + streams.connection._streamIds = []; + + // Stream 0 is an exception and should not triggered + streams.connection._streamIds[0] = new stream.PassThrough(); + + function erroringStream() { + let s = new stream.PassThrough(); + s.on("error", () => {}); + + return s; + } + + streams.connection._streamIds[5] = erroringStream(); + streams.connection._streamIds[7] = erroringStream(); + streams.connection._streamIds[9] = erroringStream(); + }); + + it("does not emit `unprocessed` on streams below `last_stream`", function () { + let spy = sinon.spy(); + streams.connection._streamIds[5].on("unprocessed", spy); + + frame.last_stream = 5; + streams.connection.emit("GOAWAY", frame); + streams.socket.emit("close"); + + expect(spy).to.not.be.called; + }); + + it("emits `unprocessed` on streams above `last_stream`", function () { + let spy7 = sinon.spy(); + streams.connection._streamIds[7].on("unprocessed", spy7); + + let spy9 = sinon.spy(); + streams.connection._streamIds[9].on("unprocessed", spy9); + + frame.last_stream = 5; + streams.connection.emit("GOAWAY", frame); + streams.socket.emit("close"); + + expect(spy7).to.be.calledOnce; + expect(spy9).to.be.calledOnce; + }); + + it("emits the formatted error on streams below `last_stream`", function () { + let errorSpy = sinon.spy(); + streams.connection._streamIds[5].on("error", errorSpy); + streams.connection._streamIds[7].on("error", errorSpy); + + frame.last_stream = 7; + streams.connection.emit("GOAWAY", frame); + streams.socket.emit("close"); + + expect(errorSpy).to.be.calledTwice.and.calledWith(formattedError); + }); + }); + }) + }); + + describe("`wakeup` event", function () { + + context("when max concurrent streams limit updates", function () { + it("emits", function () { + const endpoint = new Endpoint({}); + const wakeupSpy = sinon.spy(); + endpoint.on("wakeup", wakeupSpy); + + streams.connection.emit("RECEIVING_SETTINGS_MAX_CONCURRENT_STREAMS", 5); + + expect(wakeupSpy).to.have.been.calledOnce; + }); + }); + + context("when stream ends", function () { + it("emits", function () { + const endpoint = new Endpoint({}); + const wakeupSpy = sinon.spy(); + endpoint.on("wakeup", wakeupSpy); + + streams.connection.createStream = sinon.stub().returns(new stream.PassThrough()); + endpoint.createStream().emit("end"); + + expect(wakeupSpy).to.have.been.calledOnce; + }); + }); + }); + + describe("createStream", function () { + let endpoint; + + beforeEach(function () { + streams.connection.createStream = sinon.stub().returns(new stream.PassThrough()); + endpoint = new Endpoint({}); + }); + + it("calls createStream on the connection", function () { + endpoint.createStream(); + + expect(streams.connection.createStream).to.have.been.calledOnce; + }); + + it("allocates a stream ID", function () { + let stream = endpoint.createStream(); + + expect(streams.connection._allocateId).to.be.calledWith(stream); + }); + + it("passes the return value from the connection", function () { + let stream = endpoint.createStream(); + let connectionStream = streams.connection.createStream.firstCall.returnValue; + + expect(stream).to.deep.equal(connectionStream); + }); + }); + + describe("close", function () { + context("when there are no acquired streams", function () { + it("calls close on the connection", function () { + const endpoint = new Endpoint({}); + + streams.connection.emit("RECEIVING_SETTINGS_MAX_CONCURRENT_STREAMS", 5); + streams.connection.close = sinon.stub(); + + endpoint.close(); + expect(streams.connection.close).to.have.been.calledOnce; + }); + }); + + context("when there is an acquired stream", function () { + it("waits until all streams are closed to call close on the connection", function () { + const endpoint = new Endpoint({}); + + streams.connection.createStream = sinon.stub().returns(new stream.PassThrough()); + streams.connection.emit("RECEIVING_SETTINGS_MAX_CONCURRENT_STREAMS", 5); + + const createdStream = endpoint.createStream(); + streams.connection.close = sinon.stub(); + + endpoint.close(); + expect(streams.connection.close).to.have.not.been.called; + + createdStream.emit("end"); + expect(streams.connection.close).to.have.been.calledOnce; + }); + }); + }); + + describe("destroy", function () { + let endpoint; + + beforeEach(function () { + endpoint = new Endpoint({}); + streams.socket.destroy = sinon.stub(); + }); + + it("destroys the underlying socket", function () { + endpoint.destroy(); + + expect(streams.socket.destroy).to.be.called.once; + }); + }); +}); diff --git a/test/protocol/endpointManager.js b/test/protocol/endpointManager.js new file mode 100644 index 00000000..5ce3d36f --- /dev/null +++ b/test/protocol/endpointManager.js @@ -0,0 +1,520 @@ +"use strict"; + +const sinon = require("sinon"); +const EventEmitter = require("events"); + +describe("Endpoint Manager", function () { + let fakes, EndpointManager; + + beforeEach(function () { + fakes = { + Endpoint: sinon.spy(function() { + const endpoint = new EventEmitter(); + endpoint.destroy = sinon.spy(); + endpoint.createStream = sinon.stub().returns({"kind": "stream"}); + return endpoint; + }), + }; + + EndpointManager = require("../../lib/protocol/endpointManager")(fakes); + }); + + describe("get stream", function () { + let manager; + + beforeEach(function () { + fakes.Endpoint.reset(); + manager = new EndpointManager({ + "connectionRetryLimit": 3, + "maxConnections": 2, + }); + }); + + context("with no established endpoints", function () { + it("creates an endpoint connection", function () { + manager.getStream(); + + expect(fakes.Endpoint).to.be.calledOnce; + expect(fakes.Endpoint).to.be.calledWithNew; + }); + + it("passes configuration through to endpoint initialiser", function () { + const fakeConfig = { "sentinel": "config", "maxConnections": 3 }; + const manager = new EndpointManager(fakeConfig); + + manager.getStream(); + + expect(fakes.Endpoint).to.be.calledWith(fakeConfig); + }); + + describe("created endpoint", function () { + context("error occurs before connect", function () { + beforeEach(function () { + manager.getStream(); + fakes.Endpoint.firstCall.returnValue.emit("error", new Error("this should be handled")); + }); + + it("is destroyed", function () { + const endpoint = fakes.Endpoint.firstCall.returnValue; + expect(endpoint.destroy).to.be.called.once; + }); + + it("emits wakeup", function (){ + let endpoint = establishEndpoint(manager, true); + + let wakeupSpy = sinon.spy(); + manager.on("wakeup", wakeupSpy); + + endpoint.emit("error", new Error("this should be handled")); + expect(wakeupSpy).to.be.calledOnce; + }); + + it("allows immediate reconnect in the wakeup event", function (done) { + let endpoint = establishEndpoint(manager, true); + + manager.on("wakeup", function() { + let endpoint = establishEndpoint(manager, true); + + expect(endpoint).to.not.be.null; + + done(); + }); + + endpoint.emit("error", new Error("this should be handled")); + }); + }); + }); + + it("returns null", function () { + expect(manager.getStream()).to.be.null; + }); + + context("with an endpoint already connecting", function () { + it("does not create a new Endpoint", function () { + manager.getStream(); + + fakes.Endpoint.reset(); + manager.getStream(); + + expect(fakes.Endpoint).to.not.be.called; + }); + + it("returns null", function () { + manager.getStream(); + + expect(manager.getStream()).to.be.null; + }); + }); + }); + + context("with an established endpoint", function () { + let endpoint; + + beforeEach(function () { + endpoint = establishEndpoint(manager); + }); + + context("when there are available slots", function () { + beforeEach(function () { + endpoint.availableStreamSlots = 5; + }); + + it("calls createStream on the endpoint", function () { + manager.getStream(); + + expect(endpoint.createStream).to.have.been.calledOnce; + }); + + it("returns the endpoints created stream", function () { + const sentinel = new Object; + endpoint.createStream.returns(sentinel); + + expect(manager.getStream()).to.equal(sentinel); + }); + }); + + context("when there are no available stream slots", function () { + beforeEach(function () { + endpoint.availableStreamSlots = 0; + }); + + it("returns null", function () { + expect(manager.getStream()).to.be.null; + }); + + context("when there are fewer than `maxConnections` connections", function () { + it("creates an endpoint connection", function () { + manager.getStream(); + + expect(fakes.Endpoint).to.be.calledTwice; + }); + }); + + context("when there are already `maxConnections` connections", function () { + it("does not attempt to create a further endpoint connection", function () { + establishEndpoint(manager); + + manager.getStream(); + expect(fakes.Endpoint).to.be.calledTwice; + }); + }); + }); + }); + + context("with multiple endpoints", function () { + let firstEndpoint, secondEndpoint; + + beforeEach(function () { + firstEndpoint = establishEndpoint(manager); + secondEndpoint = establishEndpoint(manager); + }); + + it("reserves streams by round-robin", function () { + firstEndpoint.availableStreamSlots = 1; + secondEndpoint.availableStreamSlots = 1; + + expect(manager.getStream()).to.not.be.null; + expect(manager.getStream()).to.not.be.null; + expect(firstEndpoint.createStream).to.be.calledOnce; + expect(secondEndpoint.createStream).to.be.calledOnce; + }); + + context("where next endpoint has no available slots", function () { + it("skips to endpoint with availablility", function () { + firstEndpoint.availableStreamSlots = 0; + secondEndpoint.availableStreamSlots = 1; + + expect(manager.getStream()).to.not.be.null; + expect(firstEndpoint.createStream).to.not.be.called; + expect(secondEndpoint.createStream).to.be.calledOnce; + }); + }); + + context("when one endpoint has one available slot", function () { + it("returns one stream", function () { + firstEndpoint.availableStreamSlots = 0; + + secondEndpoint.availableStreamSlots = 1; + expect(manager.getStream()).to.not.be.null; + + secondEndpoint.availableStreamSlots = 0; + expect(manager.getStream()).to.be.null; + + expect(firstEndpoint.createStream).to.not.be.called; + expect(secondEndpoint.createStream).to.be.calledOnce; + }); + }); + + context("where no endpoints have available slots", function () { + it("returns null without reserving a stream", function () { + firstEndpoint.availableStreamSlots = 0; + secondEndpoint.availableStreamSlots = 0; + + expect(manager.getStream()).to.be.null; + expect(firstEndpoint.createStream).to.not.be.called; + expect(secondEndpoint.createStream).to.not.be.called; + }); + }); + }); + }); + + context("with one established endpoint", function () { + let endpoint, manager; + + beforeEach(function () { + manager = new EndpointManager({ "maxConnections": 3 }); + endpoint = establishEndpoint(manager); + endpoint.availableStreamSlots = 5; + }); + + context("when an error occurs", function () { + let wakeupSpy; + + beforeEach(function () { + wakeupSpy = sinon.spy(); + manager.on("wakeup", wakeupSpy); + + endpoint.emit("error", new Error("this should be handled")); + }); + + it("is destroyed", function () { + expect(endpoint.destroy).to.be.called.once; + }); + + it("is no longer used for streams", function () { + manager.getStream(); + + expect(endpoint.createStream).to.not.be.called; + }); + + it("emits an wakeup event", function (){ + expect(wakeupSpy).to.be.calledOnce; + }); + + it("does not affect a 'connecting' endpoint", function () { + fakes.Endpoint.reset(); + manager = new EndpointManager({ "maxConnections": 3 }); + endpoint = establishEndpoint(manager); + + // Trigger creation of a second endpoint + manager.getStream(); + let connectingEndpoint = fakes.Endpoint.secondCall.returnValue; + expect(connectingEndpoint).to.not.be.null; + expect(fakes.Endpoint).to.be.calledTwice; + + // Error-out the first endpoint + endpoint.emit("error", new Error("this should be handled")); + + // Ensure a third endpoint isn't created as the second is still connecting + manager.getStream(); + + expect(fakes.Endpoint).to.be.calledTwice; + }); + }); + + context("when it ends", function () { + beforeEach(function () { + endpoint.emit("end"); + }); + + it("is no longer used for streams", function () { + manager.getStream(); + + expect(endpoint.createStream).to.not.be.called; + }); + + it("triggers a wakeup", function (done) { + fakes.Endpoint.reset(); + manager = new EndpointManager({ "maxConnections": 3 }); + endpoint = establishEndpoint(manager); + + manager.on("wakeup", function() { + done(); + }); + + endpoint.emit("end"); + }); + }); + }); + + describe("`connectionRetryLimit` option", function () { + let connectionRetryLimit, manager; + + beforeEach(function () { + connectionRetryLimit = (Math.floor(Math.random() * 3) % 3) + 2; + manager = new EndpointManager({ + "connectionRetryLimit": connectionRetryLimit, + "maxConnections": 2, + }); + }); + + context("with no established endpoints", function () { + context("when the configured number of connections fail", function () { + let error; + + beforeEach(function () { + for (let i = 0; i < connectionRetryLimit - 1; i++) { + manager.getStream(); + fakes.Endpoint.lastCall.returnValue.emit("error", new Error("this should be handled")); + } + + error = null; + // Only allow an error after the limit is reached + manager.on("error", err => { + error = err; + }); + + manager.getStream(); + fakes.Endpoint.lastCall.returnValue.emit("error", new Error("this should be handled")); + }); + + it("emits an error", function() { + expect(error).to.match(/endpoint error/i); + expect(error.cause()).to.match(/this should be handled/i); + }); + + describe("getStream", function () { + it("will not attempt to create any further connections", function () { + fakes.Endpoint.reset(); + expect(manager.getStream()).to.be.null; + + expect(fakes.Endpoint).to.not.be.called; + }); + }); + }); + }); + + context("with an established endpoint", function() { + let establishedEndpoint; + + beforeEach(function () { + establishedEndpoint = establishEndpoint(manager); + }) + + context("when the configured number of connections fail", function () { + it("does not emit an error", function() { + manager.on("error", function() { + throw err; + }); + + for (let i = 0; i < connectionRetryLimit; i++) { + manager.getStream(); + fakes.Endpoint.lastCall.returnValue.emit("error", new Error("this should be handled")); + } + }); + }); + + context("when the endpoint ends after some failed connections", function () { + beforeEach(function () { + for (let i = 0; i < connectionRetryLimit - 1; i++) { + manager.getStream(); + fakes.Endpoint.lastCall.returnValue.emit("error", new Error("this should be handled")); + } + establishedEndpoint.emit("end"); + }); + + context("when the configured number of connections fail", function () { + let error; + + beforeEach(function () { + for (let i = 0; i < connectionRetryLimit - 1; i++) { + manager.getStream(); + fakes.Endpoint.lastCall.returnValue.emit("error", new Error("this should be handled")); + } + + error = null; + // Only allow an error after the limit is reached + manager.on("error", err => { + error = err; + }); + + manager.getStream(); + fakes.Endpoint.lastCall.returnValue.emit("error", new Error("this should be handled")); + }); + + it("emits an error", function() { + expect(error).to.match(/endpoint error/i); + expect(error.cause()).to.match(/this should be handled/i); + }); + }); + }); + }); + + context("when a connection is successful between the failed connections", function () { + it("does not emit an error", function () { + const manager = new EndpointManager({ + "maxConnections": 2, + "connectionRetryLimit": 2, + }); + + manager.getStream(); + fakes.Endpoint.lastCall.returnValue.emit("error", new Error("this should be handled")); + + manager.getStream(); + fakes.Endpoint.lastCall.returnValue.emit("connect"); + + manager.getStream(); + fakes.Endpoint.lastCall.returnValue.emit("error", new Error("this should be handled")); + + // Should not trigger an unhandled 'error' event + }); + }); + + context("when an error happens on a connected endpoint", function () { + it("does not contribute to reaching the limit", function (done) { + const manager = new EndpointManager({ + "maxConnections": 2, + "connectionRetryLimit": 2, + }); + + manager.getStream(); + fakes.Endpoint.lastCall.returnValue.emit("connect"); + fakes.Endpoint.lastCall.returnValue.emit("error", new Error("this should be handled")); + + manager.getStream(); + fakes.Endpoint.lastCall.returnValue.emit("error", new Error("this should be handled")); + + manager.on("error", err => { + expect(err).to.match(/endpoint error/i); + expect(err.cause()).to.match(/this should be emitted/i); + done(); + }); + + manager.getStream(); + fakes.Endpoint.lastCall.returnValue.emit("error", new Error("this should be emitted")); + + expect(fakes.Endpoint).to.be.calledThrice; + }); + }); + }); + + describe("wakeup event", function () { + + context("when an endpoint wakes up", function () { + let wakeupSpy, endpoint; + + beforeEach(function () { + const manager = new EndpointManager({ "maxConnections": 3 }); + endpoint = establishEndpoint(manager); + + wakeupSpy = sinon.spy(); + manager.on("wakeup", wakeupSpy); + }); + + it("is emitted", function () { + endpoint.availableStreamSlots = 5; + + endpoint.emit("wakeup"); + + expect(wakeupSpy).to.be.called; + }); + }); + }); + + describe("shutdown", function () { + + it("calls `close` on all established endpoints", function () { + const manager = new EndpointManager({ maxConnections: 3 }); + + let firstEndpoint = establishEndpoint(manager); + let secondEndpoint = establishEndpoint(manager); + + firstEndpoint.close = sinon.stub(); + secondEndpoint.close = sinon.stub(); + + manager.shutdown(); + + expect(firstEndpoint.close).to.have.been.calledOnce; + expect(firstEndpoint.close).to.have.been.calledOnce; + }); + + it("aborts pending endpoint connects", function () { + const manager = new EndpointManager({ maxConnections: 3 }); + + const connectingEndpoint = establishEndpoint(manager, true); + + connectingEndpoint.close = sinon.stub(); + + manager.shutdown(); + + expect(connectingEndpoint.close).to.have.been.calledOnce; + }); + }); + + function establishEndpoint(manager, skipConnect) { + let callCount = fakes.Endpoint.callCount; + manager.getStream(); + + if(fakes.Endpoint.callCount !== callCount + 1) { + return null + } + + let endpoint = fakes.Endpoint.lastCall.returnValue; + endpoint.availableStreamSlots = 0; + + if (!skipConnect) { + endpoint.emit("connect"); + } + return endpoint; + } +}); diff --git a/test/provider.js b/test/provider.js new file mode 100644 index 00000000..c8b36b70 --- /dev/null +++ b/test/provider.js @@ -0,0 +1,165 @@ +"use strict"; + +const sinon = require("sinon"); +const EventEmitter = require("events"); + +describe("Provider", function() { + let fakes, Provider; + + beforeEach(function () { + fakes = { + Client: sinon.stub(), + client: new EventEmitter(), + }; + + fakes.Client.returns(fakes.client); + fakes.client.write = sinon.stub(); + fakes.client.shutdown = sinon.stub(); + + Provider = require("../lib/provider")(fakes); + }); + + describe("constructor", function () { + + context("called without `new`", function () { + it("returns a new instance", function () { + expect(Provider()).to.be.an.instanceof(Provider); + }); + }); + + describe("Client instance", function() { + it("is created", function () { + Provider(); + + expect(fakes.Client).to.be.calledOnce; + expect(fakes.Client).to.be.calledWithNew; + }); + + it("is passed the options", function () { + const options = { "configKey": "configValue"}; + + Provider(options); + expect(fakes.Client).to.be.calledWith(options); + }); + }); + }); + + describe("send", function () { + + describe("single notification behaviour", function () { + let provider; + + context("transmission succeeds", function () { + beforeEach( function () { + provider = new Provider( { address: "testapi" } ); + + fakes.client.write.onCall(0).returns(Promise.resolve({ device: "abcd1234" })); + }); + + it("invokes the writer withe correct `this`", function () { + return provider.send(notificationDouble(), "abcd1234") + .then(function () { + expect(fakes.client.write).to.be.calledOn(fakes.client); + }); + }); + + it("writes the notification to the client once", function () { + return provider.send(notificationDouble(), "abcd1234") + .then(function () { + const notification = notificationDouble(); + const builtNotification = { + headers: notification.headers(), + body: notification.compile(), + }; + expect(fakes.client.write).to.be.calledOnce; + expect(fakes.client.write).to.be.calledWith(builtNotification, "abcd1234"); + }); + }); + + it("resolves with the device token in the sent array", function () { + return expect(provider.send(notificationDouble(), "abcd1234")) + .to.become({ sent: [{"device": "abcd1234"}], failed: []}); + }); + }); + + context("error occurs", function () { + let promise; + + beforeEach(function () { + const provider = new Provider( { address: "testapi" } ); + + fakes.client.write.onCall(0).returns(Promise.resolve({ device: "abcd1234", status: "400", response: { reason: "BadDeviceToken" }})); + promise = provider.send(notificationDouble(), "abcd1234"); + }); + + it("resolves with the device token, status code and response in the failed array", function () { + return expect(promise).to.eventually.deep.equal({ sent: [], failed: [{"device": "abcd1234", "status": "400", "response": { "reason" : "BadDeviceToken" }}]}); + }); + }); + }); + + context("when multiple tokens are passed", function () { + + beforeEach(function () { + fakes.resolutions = [ + { 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" }}, + { device: "fbcde238", error: new Error("connection failed") }, + ]; + }); + + context("streams are always returned", function () { + let promise; + + beforeEach( function () { + const provider = new Provider( { address: "testapi" } ); + + for(let i=0; i < fakes.resolutions.length; i++) { + fakes.client.write.onCall(i).returns(Promise.resolve(fakes.resolutions[i])); + } + + promise = provider.send(notificationDouble(), fakes.resolutions.map( res => res.device )); + + return promise; + }); + + it("resolves with the sent notifications", function () { + return promise.then( (response) => { + expect(response.sent).to.deep.equal([{device: "abcd1234"}, {device: "bcfe4433"}]); + }); + }); + + it("resolves with the device token, status code and response or error of the unsent notifications", function () { + return promise.then( (response) => { + expect(response.failed).to.deep.equal([ + { device: "adfe5969", status: "400", response: { reason: "MissingTopic" }}, + { device: "abcd1335", status: "410", response: { reason: "BadDeviceToken", timestamp: 123456789 }}, + { device: "aabbc788", status: "413", response: { reason: "PayloadTooLarge" }}, + { device: "fbcde238", error: new Error("connection failed") }, + ]); + }); + }); + }); + }); + }); + + describe("shutdown", function () { + it("invokes shutdown on the client", function () { + let provider = new Provider({}); + provider.shutdown(); + + expect(fakes.client.shutdown).to.be.calledOnce; + }); + }); +}); + +function notificationDouble() { + return { + headers: sinon.stub().returns({}), + payload: { aps: { badge: 1 } }, + compile: function() { return JSON.stringify(this.payload); } + }; +} diff --git a/test/token.js b/test/token.js new file mode 100644 index 00000000..52290638 --- /dev/null +++ b/test/token.js @@ -0,0 +1,40 @@ +const token = require("../lib/token"); + +describe("token", function () { + + context("string input", function () { + context("contains valid token", function () { + it("returns token as string", function () { + expect(token("a9d0ed10e9cfd022a61cb08753f49c5a0b0dfb784697bf9f9d750a1003da19c7")) + .to.equal("a9d0ed10e9cfd022a61cb08753f49c5a0b0dfb784697bf9f9d750a1003da19c7"); + }); + + it("strips invalid characters", function () { + expect(token("")) + .to.equal("a9d0ed10e9cfd022a61cb08753f49c5a0b0dfb784697bf9f9d750a1003da19c7"); + }); + + it("supports uppercase input", function () { + expect(token("a9d0ed10e9cfd022a61cb08753f49c5a0b0dfb784697bf9f9d750a1003da19c7")) + .to.equal("a9d0ed10e9cfd022a61cb08753f49c5a0b0dfb784697bf9f9d750a1003da19c7"); + }); + }); + + it("throws when input is empty", function () { + expect(function () { token(""); }).to.throw(/invalid length/); + }); + }); + + context("Buffer input", function() { + context("contains valid token", function () { + it("returns token as string", function () { + expect(token(new Buffer("a9d0ed10e9cfd022a61cb08753f49c5a0b0dfb784697bf9f9d750a1003da19c7", "hex"))) + .to.equal("a9d0ed10e9cfd022a61cb08753f49c5a0b0dfb784697bf9f9d750a1003da19c7"); + }); + }); + + it("throws when input is empty", function () { + expect(function () { token(new Buffer([])); }).to.throw(/invalid length/); + }); + }); +});