diff --git a/README.md b/README.md index 4fac2e22..ed4b0f82 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ A Node.js module for interfacing with the Apple Push Notification service. - [Connecting through an HTTP proxy](#connecting-through-an-http-proxy) - [Using a pool of http/2 connections](#using-a-pool-of-http2-connections) - [Sending a notification](#sending-a-notification) + - [Managing channels](#manage-channels) + - [Sending a broadcast notification](#sending-a-broadcast-notification) # Features @@ -36,7 +38,7 @@ $ npm install @parse/node-apn --save # Quick Start -This readme is a brief introduction, please refer to the full [documentation](doc/apn.markdown) in `doc/` for more details. +This readme is a brief introduction; please refer to the full [documentation](doc/apn.markdown) in `doc/` for more details. If you have previously used v1.x and wish to learn more about what's changed in v2.0, please see [What's New](doc/whats-new.markdown) @@ -59,15 +61,19 @@ var options = { production: false }; -var apnProvider = new apn.Provider(options); +const apnProvider = new apn.Provider(options); ``` 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 [provider documentation](doc/provider.markdown). +For more information about configuration options, consult the [provider documentation](doc/provider.markdown). Help with preparing the key and certificate files for connection can be found in the [wiki][certificateWiki] +⚠️ 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, there is no need for more than one `Provider`. + +If you are constantly creating `Provider` instances in your app, make sure to call `Provider.shutdown()` when you are done with each provider to release its resources and memory. + ### Connecting through an HTTP proxy If you need to connect through an HTTP proxy, you simply need to provide the `proxy: {host, port}` option when creating the provider. For example: @@ -86,7 +92,7 @@ var options = { production: false }; -var apnProvider = new apn.Provider(options); +const apnProvider = new apn.Provider(options); ``` The provider will first send an HTTP CONNECT request to the specified proxy in order to establish an HTTP tunnel. Once established, it will create a new secure connection to the Apple Push Notification provider API through the tunnel. @@ -111,11 +117,11 @@ var options = { production: false }; -var apnProvider = new apn.MultiProvider(options); +const apnProvider = new apn.MultiProvider(options); ``` ## Sending a notification -To send a notification you will first need a device token from your app as a string +To send a notification, you will first need a device token from your app as a string. ```javascript let deviceToken = "a9d0ed10e9cfd022a61cb08753f49c5a0b0dfb383697bf9f9d750a1003da19c7" @@ -124,7 +130,7 @@ let deviceToken = "a9d0ed10e9cfd022a61cb08753f49c5a0b0dfb383697bf9f9d750a1003da1 Create a notification object, configuring it with the relevant parameters (See the [notification documentation](doc/notification.markdown) for more details.) ```javascript -var note = new apn.Notification(); +let note = new apn.Notification(); note.expiry = Math.floor(Date.now() / 1000) + 3600; // Expires 1 hour from now. note.badge = 3; @@ -137,29 +143,32 @@ note.topic = ""; Send the notification to the API with `send`, which returns a promise. ```javascript -apnProvider.send(note, deviceToken).then( (result) => { +try { + const result = apnProvider.send(note, deviceToken); // see documentation for an explanation of result -}); +} catch(error) { + // Handle error... +} ``` -This will result in the the following notification payload being sent to the device +This will result in the following notification payload being sent to the device. ```json {"messageFrom":"John Appelseed","aps":{"badge":3,"sound":"ping.aiff","alert":"\uD83D\uDCE7 \u2709 You have a new message"}} ``` -Create a Live Activity notification object, configuring it with the relevant parameters (See the [notification documentation](doc/notification.markdown) for more details.) +Create a Live Activity notification object and configure it with the relevant parameters (See the [notification documentation](doc/notification.markdown) for more details.) ```javascript -var note = new apn.Notification(); +let note = new apn.Notification(); +note.topic = ".push-type.liveactivity"; note.expiry = Math.floor(Date.now() / 1000) + 3600; // Expires 1 hour from now. +note.pushType = "liveactivity", note.badge = 3; note.sound = "ping.aiff"; note.alert = "\uD83D\uDCE7 \u2709 You have a new message"; note.payload = {'messageFrom': 'John Appleseed'}; -note.topic = ""; -note.pushType = "liveactivity", note.relevanceScore = 75, note.timestamp = Math.floor(Date.now() / 1000); // Current time note.staleDate = Math.floor(Date.now() / 1000) + (8 * 3600); // Expires 8 hour from now. @@ -170,18 +179,107 @@ note.contentState = {} Send the notification to the API with `send`, which returns a promise. ```javascript -apnProvider.send(note, deviceToken).then( (result) => { - // see documentation for an explanation of result -}); +try { + const result = await apnProvider.send(note, deviceToken); + // see the documentation for an explanation of the result +} catch (error) { + // Handle error... +} ``` -This will result in the the following notification payload being sent to the device +This will result in the following notification payload being sent to the device. ```json {"messageFrom":"John Appleseed","aps":{"badge":3,"sound":"ping.aiff","alert":"\uD83D\uDCE7 \u2709 You have a new message", "relevance-score":75,"timestamp":1683129662,"stale-date":1683216062,"event":"update","content-state":{}}} ``` -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`. +## Manage Channels +Starting in iOS 18 and iPadOS 18 Live Activities can be used to broadcast push notifications over channels. To do so, you will need your apps' `bundleId`. -If you are constantly creating `Provider` instances in your app, make sure to call `Provider.shutdown()` when you are done with each provider to release its resources and memory. +```javascript +let bundleId = "com.node.apn"; +``` + +Create a notification object, configuring it with the relevant parameters (See the [notification documentation](doc/notification.markdown) for more details.) + +```javascript +let note = new apn.Notification(); + +note.requestId = "0309F412-AA57-46A8-9AC6-B5AECA8C4594"; // Optional +note.payload = {'message-storage-policy': '1', 'push-type': 'liveactivity'}; // Required +``` + +Create a channel with `manageChannels` and the `create` action, which returns a promise. + +```javascript +try { + const result = await apnProvider.manageChannels(note, bundleId, 'create'); + // see the documentation for an explanation of the result +} catch (error) { + // Handle error... +} +``` + +If the channel is created successfully, the result will look like the following: +```javascript +{ + apns-request-id: '0309F412-AA57-46A8-9AC6-B5AECA8C4594', + apns-channel-id: 'dHN0LXNyY2gtY2hubA==' // The new channel +} +``` + +Similarly, `manageChannels` has additional `action`s that allow you to `read`, `readAll`, and `delete` channels. The `read` and `delete` actions require similar information to the `create` example above, with the exception that they require `note.channelId` to be populated. To request all active channel id's, you can use the `readAll` action: + +```javascript +try { + const result = await apnProvider.manageChannels(note, bundleId, 'readAll'); + // see the documentation for an explanation of the result +} catch (error) { + // Handle error... +} +``` + +After the promise is fulfilled, `result` will look like the following: + +```javascript +{ + apns-request-id: 'some id value', + channels: ['dHN0LXNyY2gtY2hubA==', 'eCN0LXNyY2gtY2hubA==' ...] // A list of active channels +} +``` + +Further information about managing channels can be found in [Apple's documentation](https://developer.apple.com/documentation/usernotifications/sending-channel-management-requests-to-apns). + +## Sending A Broadcast Notification +Starting in iOS 18 and iPadOS 18, after a channel is created using `manageChannels`, broadcast push notifications can be sent to any device subscribed to the respective `channelId` created for a `bundleId`. A broadcast notification looks similar to a standard Live Activity notification mentioned above but requires `note.channelId` to be populated. An example is below: + +```javascript +let note = new apn.Notification(); + +note.channelId = "dHN0LXNyY2gtY2hubA=="; // Required +note.expiry = Math.floor(Date.now() / 1000) + 3600; // Expires 1 hour from now. +note.pushType = "liveactivity", +note.badge = 3; +note.sound = "ping.aiff"; +note.alert = "\uD83D\uDCE7 \u2709 You have a new message"; +note.payload = {'messageFrom': 'John Appleseed'}; +note.relevanceScore = 75, +note.timestamp = Math.floor(Date.now() / 1000); // Current time +note.staleDate = Math.floor(Date.now() / 1000) + (8 * 3600); // Expires 8 hour from now. +note.event = "update" +note.contentState = {} +``` + +Send the broadcast notification to the API with `broadcast`, which returns a promise. + +```javascript +try { + const result = await apnProvider.broadcast(note, bundleId); + // see documentation for an explanation of result +} catch (error) { + // Handle error... +} +``` + +Further information about broadcast notifications can be found in [Apple's documentation](https://developer.apple.com/documentation/usernotifications/sending-broadcast-push-notification-requests-to-apns). diff --git a/doc/provider.markdown b/doc/provider.markdown index f56cc7e0..f2c9a209 100644 --- a/doc/provider.markdown +++ b/doc/provider.markdown @@ -13,9 +13,9 @@ Options: - `key` {Buffer|String} The filename of the connection key to load from disk, or a Buffer/String containing the key data. (Defaults to: `key.pem`) - - `ca` An array of trusted certificates. Each element should contain either a filename to load, or a Buffer/String (in PEM format) 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). + - `ca` An array of trusted certificates. Each element should contain either a filename to load, or a Buffer/String (in PEM format) 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) - - `pfx` {Buffer|String} File path for private key, certificate and CA certs in PFX or PKCS12 format, or a Buffer containing the PFX data. If supplied will always be used instead of certificate and key above. + - `pfx` {Buffer|String} File path for private key, certificate and CA certs in PFX or PKCS12 format, or a Buffer containing the PFX data. If supplied will always be used instead of certificate and key above - `passphrase` {String} The passphrase for the connection key, if required @@ -23,8 +23,24 @@ Options: - `rejectUnauthorized` {Boolean} Reject Unauthorized property to be passed through to tls.connect() (Defaults to `true`) + - `address` {String} The address of the APNs server to send notifications to. If not provided, will connect to standard APNs server + +- `port` {Number} The port of the APNs server to send notifications to. (Defaults to 443) + + - `manageChannelsAddress` {String} The address of the APNs channel management server to send notifications to. If not provided, will connect to standard APNs channel management server + + - `manageChannelsPort` {Number} The port of the APNs channel management server to send notifications to. If not provided, will connect to standard APNs channel management port + + - `proxy` {host: String, port: Number|String} Connect through an HTTP proxy when sending notifications + + - `manageChannelsProxy` {host: String, port: Number|String} Connect through an HTTP proxy when managing channels + + - `rejectUnauthorized` {Boolean} Reject Unauthorized property to be passed through to tls.connect() (Defaults to `true`) + - `connectionRetryLimit` {Number} The maximum number of connection failures that will be tolerated before `apn.Provider` will "give up". [See below.](#connection-retry-limit) (Defaults to: 3) + - `heartBeat` {Number} The delay interval in ms that apn will ping APNs servers. (Defaults to: 60000) + - `requestTimeout` {Number} The maximum time in ms that apn will wait for a response to a request. (Defaults to: 5000) #### Provider Certificates vs. Authentication Tokens @@ -47,31 +63,54 @@ The `Provider` can continue to be used for sending notifications and the counter ## Class: apn.Provider +`apn.Provider` provides a number of methods for sending notifications, broadcasting notifications, and managing channels. Calling any of the methods will return a `Promise` for each notification, which is discussed more in [Results from APN Provider Methods](#results-from-apnprovider-methods). + ### connection.send(notification, recipients) -This is main interface for sending notifications. Create a `Notification` object and pass it in, along with a single recipient or an array of them and node-apn will take care of the rest, delivering a copy of the notification to each recipient. +This is the main interface for sending notifications. Create a `Notification` object and pass it in, along with a single recipient or an array of them, and node-apn will take care of the rest, delivering a copy of the notification to each recipient. > A "recipient" is a `String` containing the hex-encoded device token. -Calling `send` will return a `Promise`. The Promise will resolve after each notification (per token) has reached a final state. Each notification can end in one of three possible states: +Calling `send` will return a `Promise`. The Promise will resolve after each notification (per token) has reached a final state. + +### connection.manageChannels(notification, bundleId, action) +This is the interface for managing broadcast channels. Create a single `Notification` object or an array of them and pass the notification(s) in, along with a bundleId, and an action (`create`, `read`, `readAll`, `delete`) and node-apn will take care of the rest, asking the APNs to perform the action using the criteria specified in each notification. + +> A "bundleId" is a `String` containing the bundle identifier for the application. + +> An "action" is a `String` containing: `create`, `read`, `readAll`, or `delete` and represents what action to perform with a channel (See more in [Apple Documentation](https://developer.apple.com/documentation/usernotifications/sending-channel-management-requests-to-apns)). + +Calling `manageChannels` will return a `Promise`. The Promise will resolve after each notification has reached a final state. + +### connection.broadcast(notification, bundleId) + +This is the interface for broadcasting Live Activity notifications. Create a single `Notification` object or an array of them and pass the notification(s) in, along with a bundleId and node-apn will take care of the rest, asking the APNs to broadcast using the criteria specified in each notification. + +> A "bundleId" is a `String` containing the bundle identifier for the application. + +Calling `broadcast` will return a `Promise`. The Promise will resolve after each notification has reached a final state. + +### Results from apn.Provider methods + + Each notification can end in one of three possible states: - `sent` - the notification was accepted by Apple for delivery to the given recipient - - `failed` (rejected) - the notification was rejected by Apple. A rejection has an associated `status` and `reason` which is included. - - `failed` (error) - a connection-level error occurred which prevented successful communication with Apple. In very rare cases it's possible that the notification was still delivered. However, this state usually results from a network problem. + - `failed` (rejected) - the notification was rejected by Apple. A rejection has an associated `status` and `reason` which are included. + - `failed` (error) - a connection-level error occurred, which prevented successful communication with Apple. In very rare cases, it's possible that the notification was still delivered. However, this state usually results from a network problem. When the returned `Promise` resolves, its value will be an Object containing two properties #### sent -An array of device tokens to which the notification was successfully sent and accepted by Apple. +An array of device tokens or bundle identifiers to which the notification was successfully sent and accepted by Apple. -Being `sent` does **not** guaranteed the notification will be _delivered_, other unpredictable factors - including whether the device is reachable - can ultimately prevent delivery. +Being `sent` does **not** guarantee the notification will be _delivered_, other unpredictable factors - including whether the device is reachable - can ultimately prevent delivery. #### failed -An array of objects for each failed token. Each object will contain the device token which failed and details of the failure which will differ between rejected and errored notifications. +An array of objects for each failed token or bundle identifier. Each object will contain the device token or bundle identifier that failed and details of the failure, which will differ between rejected and errored notifications. -For **rejected** notifications the object will take the following form +For **rejected** notifications using `send()`, the object will take the following form ```javascript { @@ -85,7 +124,7 @@ For **rejected** notifications the object will take the following form More details about the response and associated status codes can be found in the [HTTP/2 Response from APN documentation][http2-response]. -If a failed notification was instead caused by an **error** then it will have an `error` property instead of `response` and `status`: +If a failed notification using `send()` was instead caused by an **error** then it will have an `error` property instead of `response` and `status`: ```javascript { @@ -103,7 +142,7 @@ If you wish to send notifications containing emoji or other multi-byte character Indicate to node-apn that it should close all open connections when the queue of pending notifications is fully drained. This will allow your application to terminate. -**Note:** If notifications are pushed after the connection has started, an error will be thrown. +**Note:** If notifications are pushed after the shutdown has started, an error will be thrown. [provider-api]: https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingwithAPNs.html [provider-auth-tokens]: https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingwithAPNs.html#//apple_ref/doc/uid/TP40008194-CH11-SW1 diff --git a/index.d.ts b/index.d.ts index b073ef55..ff8ba216 100644 --- a/index.d.ts +++ b/index.d.ts @@ -31,11 +31,11 @@ export interface ProviderOptions { */ key?: string|Buffer; /** - * An array of trusted certificates. Each element should contain either a filename to load, or a Buffer/String (in PEM format) 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). + * An array of trusted certificates. Each element should contain either a filename to load, or a Buffer/String (in PEM format) 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) */ ca?: (string|Buffer)[]; /** - * File path for private key, certificate and CA certs in PFX or PKCS12 format, or a Buffer containing the PFX data. If supplied will always be used instead of certificate and key above. + * File path for private key, certificate and CA certs in PFX or PKCS12 format, or a Buffer containing the PFX data. If supplied will always be used instead of certificate and key above */ pfx?: string|Buffer; /** @@ -46,6 +46,30 @@ export interface ProviderOptions { * 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) */ production?: boolean; + /** + * The address of the APNs server to send notifications to. If not provided, will connect to standard APNs server + */ + address?: string; + /** + * The port of the APNs server to send notifications to. (Defaults to 443) + */ + port?: number; + /** + * The address of the APNs channel management server to send notifications to. If not provided, will connect to standard APNs channel management server + */ + manageChannelsAddress?: string; + /** + * The port of the APNs channel management server to send notifications to. If not provided, will connect to standard APNs channel management port + */ + manageChannelsPort?: number; + /** + * Connect through an HTTP proxy when sending notifications + */ + proxy?: { host: string, port: number|string } + /** + * Connect through an HTTP proxy when managing channels + */ + manageChannelsProxy?: { host: string, port: number|string } /** * Reject Unauthorized property to be passed through to tls.connect() (Defaults to `true`) */ @@ -55,13 +79,13 @@ export interface ProviderOptions { */ connectionRetryLimit?: number; /** - * The maximum time in ms that apn will wait for a response to a request. (Defaults to: 5000) + * The delay interval in ms that apn will ping APNs servers. (Defaults to: 60000) */ - requestTimeout?: number; + heartBeat?: number; /** - * Connect through an HTTP proxy + * The maximum time in ms that apn will wait for a response to a request. (Defaults to: 5000) */ - proxy?: { host: string, port: number|string } + requestTimeout?: number; } export interface MultiProviderOptions extends ProviderOptions { @@ -99,11 +123,35 @@ interface Aps { "mutable-content"?: undefined | 1 "url-args"?: string[] category?: string + "thread-id"?: string + "target-content-id"?: string + "interruption-level"?: string | ApsNotificationInterruptionLevel + "relevance-score"?: number + "filter-criteria"?: string + "stale-date"?: number + "content-state"?: Object + timestamp?: number + event?: string + "dismissal-date"?: number + "attributes-type"?: string + attributes?: Object } export interface ResponseSent { device: string; } + +export interface BroadcastResponse { + bundleId: string; + "apns-request-id"?: string; + "apns-channel-id"?: string; + "message-storage-policy"?: number; + "push-type"?: string; + "channels"?: string[]; +} + +export interface LoggerResponse extends Partial, Partial {} + export interface ResponseFailure { device: string; error?: Error; @@ -114,60 +162,114 @@ export interface ResponseFailure { }; } -export interface Responses { - sent: ResponseSent[]; - failed: ResponseFailure[]; +export interface BroadcastResponseFailure extends Omit { + bundleId: string; +} + +export interface LoggerResponseFailure extends Partial, Partial {} + +export interface Responses { + sent: R[]; + failed: F[]; } export class Provider extends EventEmitter { constructor(options: ProviderOptions); /** - * This is main interface for sending notifications. Create a Notification object and pass it in, along with a single recipient or an array of them and node-apn will take care of the rest, delivering a copy of the notification to each recipient. + * This is main interface for sending notifications. + * + * @remarks + * Create a Notification object and pass it in, along with a single recipient or an array of them and node-apn will take care of the rest, delivering a copy of the notification to each recipient. + * + * @param notification - The notification to send. + * @param recipients - A String or an Array of Strings containing the hex-encoded device token. + */ + send(notification: Notification, recipients: string|string[]): Promise>; + + /** + * Manage channels using a specific action. * - * A "recipient" is a String containing the hex-encoded device token. + * @param notifications - A Notification or an Array of Notifications to send. Each notification should specify the respective channelId it's directed to. + * @param bundleId - The bundleId for your application. + * @param action - Specifies the action to perform on the channel(s). */ - send(notification: Notification, recipients: string|string[]): Promise; + manageChannels(notifications: Notification|Notification[], bundleId: string, action: ChannelAction): Promise>; + + /** + * Broadcast notificaitons to channel(s). + * + * @param notifications - A Notification or an Array of Notifications to send. Each notification should specify the respective channelId it's directed to. + * @param bundleId: The bundleId for your application. + */ + broadcast(notifications: Notification|Notification[], bundleId: string): Promise>; /** * Set an info logger, and optionally an errorLogger to separately log errors. * + * @remarks * In order to log, these functions must have a property '.enabled' that is true. * (The default logger uses the npm 'debug' module which sets '.enabled' * based on the DEBUG environment variable) */ - setLogger(logger: (msg: string) => void, errorLogger?: (msg: string) => void): Promise; + setLogger(logger: (msg: string) => void, errorLogger?: (msg: string) => void): Promise>; /** * Indicate to node-apn that it should close all open connections when the queue of pending notifications is fully drained. This will allow your application to terminate. */ - shutdown(callback?: () => void): void; + shutdown(callback?: () => void): Promise; } export class MultiProvider extends EventEmitter { constructor(options: MultiProviderOptions); /** - * This is main interface for sending notifications. Create a Notification object and pass it in, along with a single recipient or an array of them and node-apn will take care of the rest, delivering a copy of the notification to each recipient. + * This is main interface for sending notifications. + * + * @remarks + * Create a Notification object and pass it in, along with a single recipient or an array of them and node-apn will take care of the rest, delivering a copy of the notification to each recipient. + * + * @param notification - The notification to send. + * @param recipients - A String or an Array of Strings containing the hex-encoded device token. + */ + send(notification: Notification, recipients: string|string[]): Promise>; + + /** + * Manage channels using a specific action. * - * A "recipient" is a String containing the hex-encoded device token. + * @param notifications - A Notification or an Array of Notifications to send. Each notification should specify the respective channelId it's directed to. + * @param bundleId - The bundleId for your application. + * @param action - Specifies the action to perform on the channel(s). */ - send(notification: Notification, recipients: string|string[]): Promise; + manageChannels(notifications: Notification|Notification[], bundleId: string, action: ChannelAction): Promise>; + + /** + * Broadcast notificaitons to channel(s). + * + * @param notifications - A Notification or an Array of Notifications to send. Each notification should specify the respective channelId it's directed to. + * @param bundleId: The bundleId for your application. + */ + broadcast(notifications: Notification|Notification[], bundleId: string): Promise>; /** * Set an info logger, and optionally an errorLogger to separately log errors. * + * @remarks * In order to log, these functions must have a property '.enabled' that is true. - * (The default logger uses the npm 'debug' module which sets '.enabled' + * (The default logger uses the npm 'debug' module which sets '.enabled' * based on the DEBUG environment variable) */ - setLogger(logger: (msg: string) => void, errorLogger?: (msg: string) => void): Promise; + setLogger(logger: (msg: string) => void, errorLogger?: (msg: string) => void): Promise>; /** * Indicate to node-apn that it should close all open connections when the queue of pending notifications is fully drained. This will allow your application to terminate. */ - shutdown(callback?: () => void): void; + shutdown(callback?: () => void): Promise; } -export type NotificationPushType = 'background' | 'alert' | 'voip' | 'pushtotalk' | 'liveactivity'; +export type NotificationPushType = 'background' | 'alert' | 'voip' | 'pushtotalk' | 'liveactivity' | 'location' | 'complication' | 'fileprovider' | 'mdm'; + +export type ChannelAction = 'create' | 'read' | 'readAll' | 'delete'; + +export type ApsNotificationInterruptionLevel = 'passive' | 'active' | 'time-sensitive' | 'critical'; export interface NotificationAlertOptions { title?: string; @@ -198,21 +300,22 @@ export class Notification { */ public id: string; /** - * 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. + * A UUID to identify this request. */ - public expiry: number; + public requestId: string; /** - * Multiple notifications with same collapse identifier are displayed to the user as a single notification. The value should not exceed 64 bytes. + * A base64-encoded string that identifies the channel to publish the payload. + The channel ID is generated by sending channel creation request to APNs. */ - public collapseId: string; + public channelId: string; /** * Multiple notifications with same collapse identifier are displayed to the user as a single notification. The value should not exceed 64 bytes. */ - public requestId: string; + public collapseId: string; /** - * An optional custom request identifier that’s returned back in the response. The request identifier must be encoded as a UUID string. + * 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. */ - public channelId: string; + public expiry: number; /** * Provide one of the following values: * @@ -225,7 +328,6 @@ export class Notification { * The type of the notification. */ public pushType: NotificationPushType; - /** * An app-specific identifier for grouping related notifications. */ diff --git a/lib/client.js b/lib/client.js index d62cbb5e..f7e98134 100644 --- a/lib/client.js +++ b/lib/client.js @@ -18,79 +18,326 @@ module.exports = function (dependencies) { HTTP2_HEADER_AUTHORITY, HTTP2_HEADER_PATH, HTTP2_METHOD_POST, + HTTP2_METHOD_GET, + HTTP2_METHOD_DELETE, NGHTTP2_CANCEL, } = http2.constants; + const HTTPMethod = { + post: HTTP2_METHOD_POST, + get: HTTP2_METHOD_GET, + delete: HTTP2_METHOD_DELETE, + }; + const TIMEOUT_STATUS = '(timeout)'; const ABORTED_STATUS = '(aborted)'; const ERROR_STATUS = '(error)'; function Client(options) { + this.isDestroyed = false; this.config = config(options); this.logger = defaultLogger; this.errorLogger = defaultErrorLogger; this.healthCheckInterval = setInterval(() => { if (this.session && !this.session.closed && !this.session.destroyed && !this.isDestroyed) { this.session.ping((error, duration) => { - if (error) { + if (error && this.errorLogger.enabled) { this.errorLogger( 'No Ping response after ' + duration + ' ms with error:' + error.message ); - return; + } else if (this.logger.enabled) { + this.logger('Ping response after ' + duration + ' ms'); + } + }); + } + }, this.config.heartBeat).unref(); + this.manageChannelsHealthCheckInterval = setInterval(() => { + if ( + this.manageChannelsSession && + !this.manageChannelsSession.closed && + !this.manageChannelsSession.destroyed && + !this.isDestroyed + ) { + this.manageChannelsSession.ping((error, duration) => { + if (error && this.errorLogger.enabled) { + this.errorLogger( + 'ManageChannelsSession No Ping response after ' + + duration + + ' ms with error:' + + error.message + ); + } else if (this.logger.enabled) { + this.logger('ManageChannelsSession Ping response after ' + duration + ' ms'); } - this.logger('Ping response after ' + duration + ' ms'); }); } }, this.config.heartBeat).unref(); } - // Session should be passed except when destroying the client - Client.prototype.destroySession = function (session, callback) { + // The respective session should always be passed. + Client.prototype.destroySession = function (session) { if (!session) { - session = this.session; - } - if (session) { - if (this.session === session) { - this.session = null; - } - if (!session.destroyed) { - session.destroy(); - } + return; } - if (callback) { - callback(); + if (!session.destroyed) { + session.destroy(); } + session = null; }; - // Session should be passed except when destroying the client - Client.prototype.closeAndDestroySession = function (session, callback) { + // The respective session should always be passed. + Client.prototype.closeAndDestroySession = async function (session) { if (!session) { - session = this.session; + return; } - if (session) { - if (this.session === session) { - this.session = null; + if (!session.closed) { + await new Promise(resolve => { + session.close(() => { + resolve(); + }); + }); + } + this.destroySession(session); + }; + + Client.prototype.makePath = function makePath(type, subDirectory) { + switch (type) { + case 'channels': + return `/1/apps/${subDirectory}/channels`; + case 'allChannels': + return `/1/apps/${subDirectory}/all-channels`; + case 'device': + return `/3/device/${subDirectory}`; + case 'broadcasts': + return `/4/broadcasts/apps/${subDirectory}`; + default: + return null; + } + }; + + Client.prototype.subDirectoryLabel = function subDirectoryLabel(type) { + switch (type) { + case 'device': + return 'device'; + case 'channels': + case 'allChannels': + case 'broadcasts': + return 'bundleId'; + default: + return null; + } + }; + + Client.prototype.makeSubDirectoryTypeObject = function makeSubDirectoryTypeObject( + label, + subDirectory + ) { + const subDirectoryObject = {}; + subDirectoryObject[label] = subDirectory; + + return subDirectoryObject; + }; + + Client.prototype.write = async function write(notification, subDirectory, type, method, count) { + const retryStatusCodes = [408, 429, 500, 502, 503, 504]; + const retryCount = count || 0; + const subDirectoryLabel = this.subDirectoryLabel(type) ?? type; + const subDirectoryInformation = this.makeSubDirectoryTypeObject( + subDirectoryLabel, + subDirectory + ); + const path = this.makePath(type, subDirectory); + if (path == null) { + const error = { + ...subDirectoryInformation, + error: new VError(`could not make a path for ${type} and ${subDirectory}`), + }; + throw error; + } + + const httpMethod = HTTPMethod[method]; + if (httpMethod == null) { + const error = { + ...subDirectoryInformation, + error: new VError(`invalid httpMethod "${method}"`), + }; + throw error; + } + + if (this.isDestroyed) { + const error = { ...subDirectoryInformation, error: new VError('client is destroyed') }; + throw error; + } + + if (path.includes('/1/apps/')) { + // Connect manageChannelsSession. + if ( + !this.manageChannelsSession || + this.manageChannelsSession.closed || + this.manageChannelsSession.destroyed + ) { + try { + await this.manageChannelsConnect(); + } catch (error) { + if (this.errorLogger.enabled) { + // Proxy server that returned error doesn't have access to logger. + this.errorLogger(error.message); + } + const updatedError = { ...subDirectoryInformation, error }; + throw updatedError; + } } - if (!session.closed) { - session.close(() => this.destroySession(session, callback)); - } else { - this.destroySession(session, callback); + + try { + const sentRequest = await this.request( + this.manageChannelsSession, + this.config.manageChannelsAddress, + notification, + path, + httpMethod + ); + return { ...subDirectoryInformation, ...sentRequest }; + } catch (error) { + // Determine if this is a retryable request. + if ( + retryStatusCodes.includes(error.status) || + (typeof error.error !== 'undefined' && + error.status == 403 && + error.error.message === 'ExpiredProviderToken') + ) { + try { + const resentRequest = await this.retryRequest( + error, + this.manageChannelsSession, + this.config.manageChannelsAddress, + notification, + path, + httpMethod, + retryCount + ); + return { ...subDirectoryInformation, ...resentRequest }; + } catch (error) { + if (error.status == 500) { + await this.closeAndDestroySession(this.manageChannelsSession); + } + delete error.retryAfter; // Never propagate retryAfter outside of client. + const updatedError = { ...subDirectoryInformation, ...error }; + throw updatedError; + } + } else { + delete error.retryAfter; // Never propagate retryAfter outside of client. + throw { ...subDirectoryInformation, ...error }; + } + } + } else { + // Connect to standard session. + if (!this.session || this.session.closed || this.session.destroyed) { + try { + await this.connect(); + } catch (error) { + if (this.errorLogger.enabled) { + // Proxy server that returned error doesn't have access to logger. + this.errorLogger(error.message); + } + delete error.retryAfter; // Never propagate retryAfter outside of client. + const updatedError = { ...subDirectoryInformation, error }; + throw updatedError; + } + } + + try { + const sentRequest = await this.request( + this.session, + this.config.address, + notification, + path, + httpMethod + ); + return { ...subDirectoryInformation, ...sentRequest }; + } catch (error) { + // Determine if this is a retryable request. + if ( + retryStatusCodes.includes(error.status) || + (typeof error.error !== 'undefined' && + error.status == 403 && + error.error.message === 'ExpiredProviderToken') + ) { + try { + const resentRequest = await this.retryRequest( + error, + this.session, + this.config.address, + notification, + path, + httpMethod, + retryCount + ); + return { ...subDirectoryInformation, ...resentRequest }; + } catch (error) { + if (error.status == 500) { + await this.closeAndDestroySession(this.session); + } + delete error.retryAfter; // Never propagate retryAfter outside of client. + const updatedError = { ...subDirectoryInformation, ...error }; + throw updatedError; + } + } else { + delete error.retryAfter; // Never propagate retryAfter outside of client. + throw { ...subDirectoryInformation, ...error }; + } } - } else if (callback) { - callback(); } }; - Client.prototype.write = function write(notification, device, count) { - if (this.isDestroyed) { - return Promise.resolve({ device, error: new VError('client is destroyed') }); + Client.prototype.retryRequest = async function retryRequest( + error, + session, + address, + notification, + path, + httpMethod, + count + ) { + if (this.isDestroyed || session.closed) { + const error = { error: new VError('client session is either closed or destroyed') }; + throw error; + } + + const retryCount = count + 1; + + if (retryCount > this.config.connectionRetryLimit) { + throw error; } - // Connect session - if (!this.session || this.session.closed || this.session.destroyed) { - return this.connect().then(() => this.request(notification, device, count)); + const delayInSeconds = parseInt(error.retryAfter || 0); + // Obey servers request to try after a specific time in ms. + const delayPromise = new Promise(resolve => setTimeout(resolve, delayInSeconds * 1000)); + await delayPromise; + + try { + const sentRequest = await this.request( + session, + address, + notification, + path, + httpMethod, + retryCount + ); + return sentRequest; + } catch (error) { + // Recursivelly call self until retryCount is exhausted + // or error is thrown. + const sentRequest = await this.retryRequest( + error, + session, + address, + notification, + path, + httpMethod, + retryCount + ); + return sentRequest; } - return this.request(notification, device, count); }; Client.prototype.connect = function connect() { @@ -105,6 +352,7 @@ module.exports = function (dependencies) { this.sessionPromise = proxySocketPromise.then(socket => { this.sessionPromise = null; + if (socket) { this.config.createConnection = authority => authority.protocol === 'http:' @@ -123,6 +371,12 @@ module.exports = function (dependencies) { this.config )); + if (this.logger.enabled) { + this.session.on('connect', () => { + this.logger('Session connected'); + }); + } + this.session.on('close', () => { if (this.errorLogger.enabled) { this.errorLogger('Session closed'); @@ -130,60 +384,161 @@ module.exports = function (dependencies) { this.destroySession(session); }); - this.session.on('socketError', error => { + this.session.on('error', error => { if (this.errorLogger.enabled) { - this.errorLogger(`Socket error: ${error}`); + this.errorLogger(`Session error: ${error}`); } this.closeAndDestroySession(session); }); - this.session.on('error', error => { + this.session.on('goaway', (errorCode, lastStreamId, opaqueData) => { if (this.errorLogger.enabled) { - this.errorLogger(`Session error: ${error}`); + this.errorLogger( + `GOAWAY received: (errorCode ${errorCode}, lastStreamId: ${lastStreamId}, opaqueData: ${opaqueData})` + ); } this.closeAndDestroySession(session); }); - this.session.on('goaway', (errorCode, lastStreamId, opaqueData) => { + this.session.on('frameError', (frameType, errorCode, streamId) => { + // This is a frame error not associate with any request(stream). if (this.errorLogger.enabled) { this.errorLogger( - `GOAWAY received: (errorCode ${errorCode}, lastStreamId: ${lastStreamId}, opaqueData: ${opaqueData})` + `Frame error: (frameType: ${frameType}, errorCode ${errorCode}, streamId: ${streamId})` ); } this.closeAndDestroySession(session); }); + }); + + return this.sessionPromise; + }; + + Client.prototype.manageChannelsConnect = async function manageChannelsConnect() { + if (this.manageChannelsSessionPromise) return this.manageChannelsSessionPromise; + + const proxySocketPromise = this.config.manageChannelsProxy + ? createProxySocket(this.config.manageChannelsProxy, { + host: this.config.manageChannelsAddress, + port: this.config.manageChannelsPort, + }) + : Promise.resolve(); + + this.manageChannelsSessionPromise = proxySocketPromise.then(socket => { + this.manageChannelsSessionPromise = null; + + if (socket) { + this.config.createConnection = authority => + authority.protocol === 'http:' + ? socket + : authority.protocol === 'https:' + ? tls.connect(+authority.port || this.config.manageChannelsPort, authority.hostname, { + socket, + servername: authority.hostname, + ALPNProtocols: ['h2'], + }) + : null; + } + + const config = { ...this.config }; // Only need a shallow copy. + // http2 will use this address and port. + config.address = config.manageChannelsAddress; + config.port = config.manageChannelsPort; + + const session = (this.manageChannelsSession = http2.connect( + this._mockOverrideUrl || `https://${config.address}`, + config + )); if (this.logger.enabled) { - this.session.on('connect', () => { - this.logger('Session connected'); + this.manageChannelsSession.on('connect', () => { + this.logger('ManageChannelsSession connected'); }); } - this.session.on('frameError', (frameType, errorCode, streamId) => { + + this.manageChannelsSession.on('close', () => { + if (this.errorLogger.enabled) { + this.errorLogger('ManageChannelsSession closed'); + } + this.destroySession(session); + }); + + this.manageChannelsSession.on('socketError', error => { + if (this.errorLogger.enabled) { + this.errorLogger(`ManageChannelsSession Socket error: ${error}`); + } + this.closeAndDestroySession(session); + }); + + this.manageChannelsSession.on('error', error => { + if (this.errorLogger.enabled) { + this.errorLogger(`ManageChannelsSession error: ${error}`); + } + this.closeAndDestroySession(session); + }); + + this.manageChannelsSession.on('goaway', (errorCode, lastStreamId, opaqueData) => { + if (this.errorLogger.enabled) { + this.errorLogger( + `ManageChannelsSession GOAWAY received: (errorCode ${errorCode}, lastStreamId: ${lastStreamId}, opaqueData: ${opaqueData})` + ); + } + this.closeAndDestroySession(session); + }); + + this.manageChannelsSession.on('frameError', (frameType, errorCode, streamId) => { // This is a frame error not associate with any request(stream). if (this.errorLogger.enabled) { this.errorLogger( - `Frame error: (frameType: ${frameType}, errorCode ${errorCode}, streamId: ${streamId})` + `ManageChannelsSession Frame error: (frameType: ${frameType}, errorCode ${errorCode}, streamId: ${streamId})` ); } this.closeAndDestroySession(session); }); }); - return this.sessionPromise; + return this.manageChannelsSessionPromise; + }; + + Client.prototype.createHeaderObject = function createHeaderObject( + uniqueId, + requestId, + channelId + ) { + const header = {}; + if (uniqueId) { + header['apns-unique-id'] = uniqueId; + } + if (requestId) { + header['apns-request-id'] = requestId; + } + if (channelId) { + header['apns-channel-id'] = channelId; + } + return header; }; - Client.prototype.request = function request(notification, device, count) { + Client.prototype.request = async function request( + session, + address, + notification, + path, + httpMethod + ) { let tokenGeneration = null; let status = null; + let retryAfter = null; + let uniqueId = null; + let requestId = null; + let channelId = null; let responseData = ''; - const retryCount = count || 0; const headers = extend( { [HTTP2_HEADER_SCHEME]: 'https', - [HTTP2_HEADER_METHOD]: HTTP2_METHOD_POST, - [HTTP2_HEADER_AUTHORITY]: this.config.address, - [HTTP2_HEADER_PATH]: `/3/device/${device}`, + [HTTP2_HEADER_METHOD]: httpMethod, + [HTTP2_HEADER_AUTHORITY]: address, + [HTTP2_HEADER_PATH]: path, }, notification.headers ); @@ -196,59 +551,80 @@ module.exports = function (dependencies) { tokenGeneration = this.config.token.generation; } - const request = this.session.request(headers); + const request = session.request(headers); request.setEncoding('utf8'); request.on('response', headers => { status = headers[HTTP2_HEADER_STATUS]; + retryAfter = headers['Retry-After']; + uniqueId = headers['apns-unique-id']; + requestId = headers['apns-request-id']; + channelId = headers['apns-channel-id']; }); request.on('data', data => { responseData += data; }); - request.write(notification.body); + if (Object.keys(notification.body).length > 0) { + request.write(notification.body); + } - return new Promise(resolve => { + return new Promise((resolve, reject) => { request.on('end', () => { try { if (this.logger.enabled) { this.logger(`Request ended with status ${status} and responseData: ${responseData}`); } + const headerObject = this.createHeaderObject(uniqueId, requestId, channelId); - if (status === 200) { - resolve({ device }); + if (status === 200 || status === 201 || status === 204) { + const body = responseData !== '' ? JSON.parse(responseData) : {}; + resolve({ ...headerObject, ...body }); + return; } else if ([TIMEOUT_STATUS, ABORTED_STATUS, ERROR_STATUS].includes(status)) { + const error = { + status, + retryAfter, + error: new VError('Timeout, aborted, or other unknown error'), + }; + reject({ ...headerObject, ...error }); return; } else if (responseData !== '') { const response = JSON.parse(responseData); - if (status === 403 && response.reason === 'ExpiredProviderToken' && retryCount < 2) { + if (status === 403 && response.reason === 'ExpiredProviderToken') { this.config.token.regenerate(tokenGeneration); - resolve(this.write(notification, device, retryCount + 1)); + const error = { + status, + retryAfter, + error: new VError(response.reason), + }; + reject({ ...headerObject, ...error }); return; } else if (status === 500 && response.reason === 'InternalServerError') { - this.closeAndDestroySession(); - const error = new VError('Error 500, stream ended unexpectedly'); - resolve({ device, error }); + const error = { + status, + retryAfter, + error: new VError('Error 500, stream ended unexpectedly'), + }; + reject({ ...headerObject, ...error }); return; } - - resolve({ device, status, response }); + reject({ ...headerObject, status, retryAfter, response }); } else { - this.closeAndDestroySession(); - const error = new VError( - `stream ended unexpectedly with status ${status} and empty body` - ); - resolve({ device, error }); + const error = { + error: new VError(`stream ended unexpectedly with status ${status} and empty body`), + }; + reject({ ...headerObject, ...error }); } } catch (e) { const error = new VError(e, 'Unexpected error processing APNs response'); if (this.errorLogger.enabled) { this.errorLogger(`Unexpected error processing APNs response: ${e.message}`); } - resolve({ device, error }); + reject({ error }); } }); @@ -261,7 +637,8 @@ module.exports = function (dependencies) { request.close(NGHTTP2_CANCEL); - resolve({ device, error: new VError('apn write timeout') }); + const error = { error: new VError('apn write timeout') }; + reject(error); }); request.on('aborted', () => { @@ -271,7 +648,8 @@ module.exports = function (dependencies) { status = ABORTED_STATUS; - resolve({ device, error: new VError('apn write aborted') }); + const error = { error: new VError('apn write aborted') }; + reject(error); }); request.on('error', error => { @@ -287,22 +665,23 @@ module.exports = function (dependencies) { error = new VError(error, 'apn write failed'); } - resolve({ device, error }); + reject({ error }); }); - if (this.errorLogger.enabled) { - request.on('frameError', (frameType, errorCode, streamId) => { - this.errorLogger( - `Request frame error: (frameType: ${frameType}, errorCode ${errorCode}, streamId: ${streamId})` - ); - }); - } + request.on('frameError', (frameType, errorCode, streamId) => { + const errorMessage = `Request frame error: (frameType: ${frameType}, errorCode ${errorCode}, streamId: ${streamId})`; + if (this.errorLogger.enabled) { + this.errorLogger(errorMessage); + } + const error = new VError(errorMessage); + reject({ error }); + }); request.end(); }); }; - Client.prototype.shutdown = function shutdown(callback) { + Client.prototype.shutdown = async function shutdown(callback) { if (this.isDestroyed) { if (callback) { callback(); @@ -317,7 +696,16 @@ module.exports = function (dependencies) { clearInterval(this.healthCheckInterval); this.healthCheckInterval = null; } - this.closeAndDestroySession(undefined, callback); + if (this.manageChannelsHealthCheckInterval) { + clearInterval(this.manageChannelsHealthCheckInterval); + this.manageChannelsHealthCheckInterval = null; + } + await this.closeAndDestroySession(this.session); + await this.closeAndDestroySession(this.manageChannelsSession); + + if (callback) { + callback(); + } }; Client.prototype.setLogger = function (newLogger, newErrorLogger = null) { diff --git a/lib/config.js b/lib/config.js index f653eb68..63f19861 100644 --- a/lib/config.js +++ b/lib/config.js @@ -5,6 +5,11 @@ const EndpointAddress = { development: 'api.sandbox.push.apple.com', }; +const ManageChannelsEndpointAddress = { + production: 'api-manage-broadcast.push.apple.com', + development: 'api-manage-broadcast.sandbox.push.apple.com', +}; + module.exports = function (dependencies) { const logger = dependencies.logger; const prepareCertificate = dependencies.prepareCertificate; @@ -22,9 +27,12 @@ module.exports = function (dependencies) { production: process.env.NODE_ENV === 'production', address: null, port: 443, + manageChannelsAddress: null, + manageChannelsPort: null, proxy: null, + manageChannelsProxy: null, rejectUnauthorized: true, - connectionRetryLimit: 10, + connectionRetryLimit: 3, heartBeat: 60000, requestTimeout: 5000, }; @@ -33,6 +41,7 @@ module.exports = function (dependencies) { extend(config, options); configureAddress(config); + configureManageChannelsAddress(config); if (config.token) { delete config.cert; @@ -105,3 +114,24 @@ function configureAddress(options) { } } } + +function configureManageChannelsAddress(options) { + if (!options.manageChannelsAddress) { + if (options.production) { + options.manageChannelsAddress = ManageChannelsEndpointAddress.production; + } else { + options.manageChannelsAddress = ManageChannelsEndpointAddress.development; + } + } + configureManageChannelsPort(options); +} + +function configureManageChannelsPort(options) { + if (!options.manageChannelsPort) { + if (options.production) { + options.manageChannelsPort = 2196; + } else { + options.manageChannelsPort = 2195; + } + } +} diff --git a/lib/multiclient.js b/lib/multiclient.js index faf39ed7..df64a6d6 100644 --- a/lib/multiclient.js +++ b/lib/multiclient.js @@ -30,19 +30,29 @@ module.exports = function (dependencies) { return client; }; - MultiClient.prototype.write = function write(notification, device, count) { - return this.chooseSingleClient().write(notification, device, count); + MultiClient.prototype.write = async function write( + notification, + subDirectory, + type, + method, + count + ) { + return await this.chooseSingleClient().write(notification, subDirectory, type, method, count); }; - MultiClient.prototype.shutdown = function shutdown(callback) { + MultiClient.prototype.shutdown = async function shutdown(callback) { let callCount = 0; const multiCallback = () => { callCount++; if (callCount === this.clients.length) { - callback(); + if (callback) { + callback(); + } } }; - this.clients.forEach(client => client.shutdown(multiCallback)); + for (const client of this.clients) { + await client.shutdown(multiCallback); + } }; MultiClient.prototype.setLogger = function (newLogger, newErrorLogger = null) { diff --git a/lib/notification/index.js b/lib/notification/index.js index 94f9700c..d4e60432 100644 --- a/lib/notification/index.js +++ b/lib/notification/index.js @@ -103,6 +103,26 @@ Notification.prototype.headers = function headers() { return headers; }; +Notification.prototype.removeNonChannelRelatedProperties = function () { + this.priority = 10; + this.id = undefined; + this.collapseId = undefined; + this.topic = undefined; + this.pushType = undefined; +}; + +/** + * Add live activity push type if it's not already provided. + * + * @remarks + * LiveActivity is the only current type supported. + */ +Notification.prototype.addPushTypeToPayloadIfNeeded = function () { + if (this.rawPayload == undefined && this.payload['push-type'] == undefined) { + this.payload['push-type'] = 'liveactivity'; + } +}; + /** * 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. diff --git a/lib/provider.js b/lib/provider.js index 60458d61..fe3e5a8b 100644 --- a/lib/provider.js +++ b/lib/provider.js @@ -1,4 +1,5 @@ const EventEmitter = require('events'); +const VError = require('verror'); module.exports = function (dependencies) { const Client = dependencies.Client; @@ -15,7 +16,7 @@ module.exports = function (dependencies) { Provider.prototype = Object.create(EventEmitter.prototype); - Provider.prototype.send = function send(notification, recipients) { + Provider.prototype.send = async function send(notification, recipients) { const builtNotification = { headers: notification.headers(), body: notification.compile(), @@ -25,21 +26,133 @@ module.exports = function (dependencies) { recipients = [recipients]; } - return Promise.all(recipients.map(token => this.client.write(builtNotification, token))).then( - responses => { - const sent = []; - const failed = []; - - responses.forEach(response => { - if (response.status || response.error) { - failed.push(response); - } else { - sent.push(response); - } - }); - return { sent, failed }; + const sentNotifications = await Promise.allSettled( + recipients.map( + async token => await this.client.write(builtNotification, token, 'device', 'post') + ) + ); + const sent = []; + const failed = []; + + sentNotifications.forEach(sentNotification => { + if (sentNotification.status == 'fulfilled') { + const sentNotificationValue = sentNotification.value; + if (sentNotificationValue.status || sentNotificationValue.error) { + failed.push(sentNotificationValue); + } else { + sent.push(sentNotificationValue); + } + } else { + failed.push(sentNotification.reason); + } + }); + + return { sent, failed }; + }; + + Provider.prototype.manageChannels = async function manageChannels( + notifications, + bundleId, + action + ) { + let type = 'channels'; + let method = 'post'; + + if (!Array.isArray(notifications)) { + notifications = [notifications]; + } + + switch (action) { + case 'create': + type = 'channels'; + method = 'post'; + break; + case 'read': + type = 'channels'; + method = 'get'; + break; + case 'readAll': + type = 'allChannels'; + method = 'get'; + break; + case 'delete': + type = 'channels'; + method = 'delete'; + break; + default: { + const error = { + bundleId, + error: new VError(`the action "${action}" is not supported`), + }; + throw error; + } + } + + const sentNotifications = await Promise.allSettled( + notifications.map(async notification => { + if (action == 'create') { + notification.addPushTypeToPayloadIfNeeded(); + } + notification.removeNonChannelRelatedProperties(); + const builtNotification = { + headers: notification.headers(), + body: notification.compile(), + }; + + return await this.client.write(builtNotification, bundleId, type, method); + }) + ); + const sent = []; + const failed = []; + + sentNotifications.forEach(sentNotification => { + if (sentNotification.status == 'fulfilled') { + const sentNotificationValue = sentNotification.value; + if (sentNotificationValue.status || sentNotificationValue.error) { + failed.push(sentNotificationValue); + } else { + sent.push(sentNotificationValue); + } + } else { + failed.push(sentNotification.reason); } + }); + + return { sent, failed }; + }; + + Provider.prototype.broadcast = async function broadcast(notifications, bundleId) { + if (!Array.isArray(notifications)) { + notifications = [notifications]; + } + + const sentNotifications = await Promise.allSettled( + notifications.map(async notification => { + const builtNotification = { + headers: notification.headers(), + body: notification.compile(), + }; + + return await this.client.write(builtNotification, bundleId, 'broadcasts', 'post'); + }) ); + const sent = []; + const failed = []; + + sentNotifications.forEach(sentNotification => { + if (sentNotification.status == 'fulfilled') { + const sentNotificationValue = sentNotification.value; + if (sentNotificationValue.status || sentNotificationValue.error) { + failed.push(sentNotificationValue); + } else { + sent.push(sentNotificationValue); + } + } else { + failed.push(sentNotification.reason); + } + }); + + return { sent, failed }; }; Provider.prototype.shutdown = function shutdown(callback) { diff --git a/lib/util/proxy.js b/lib/util/proxy.js index f91e3b0a..f93c8e22 100644 --- a/lib/util/proxy.js +++ b/lib/util/proxy.js @@ -1,4 +1,5 @@ const http = require('http'); +const VError = require('verror'); module.exports = function createProxySocket(proxy, target) { return new Promise((resolve, reject) => { @@ -9,7 +10,11 @@ module.exports = function createProxySocket(proxy, target) { path: target.host + ':' + target.port, headers: { Connection: 'Keep-Alive' }, }); - req.on('error', reject); + req.on('error', error => { + const connectionError = new VError(`cannot connect to proxy server: ${error}`); + const returnedError = { error: connectionError }; + reject(returnedError); + }); req.on('connect', (res, socket, head) => { resolve(socket); }); diff --git a/mock/client.js b/mock/client.js index 4540ad2e..910813d5 100644 --- a/mock/client.js +++ b/mock/client.js @@ -2,7 +2,7 @@ module.exports = function () { // Mocks of public API methods function Client() {} - Client.prototype.write = function mockWrite(notification, device) { + Client.prototype.write = function mockWrite(notification, device, type, method = 'post') { return { device }; }; diff --git a/test/client.js b/test/client.js index c23ffc08..6be0c1c3 100644 --- a/test/client.js +++ b/test/client.js @@ -2,12 +2,15 @@ const VError = require('verror'); const net = require('net'); const http2 = require('http2'); +const { HTTP2_METHOD_POST, HTTP2_METHOD_GET, HTTP2_METHOD_DELETE } = http2.constants; + const debug = require('debug')('apn'); const credentials = require('../lib/credentials')({ logger: debug, }); const TEST_PORT = 30939; +const CLIENT_TEST_PORT = TEST_PORT + 1; const LOAD_TEST_BATCH_SIZE = 2000; const config = require('../lib/config')({ @@ -59,20 +62,22 @@ describe('Client', () => { let client; const MOCK_BODY = '{"mock-key":"mock-value"}'; const MOCK_DEVICE_TOKEN = 'abcf0123abcf0123abcf0123abcf0123abcf0123abcf0123abcf0123abcf0123'; + const BUNDLE_ID = 'com.node.apn'; + const PATH_DEVICE = `/3/device/${MOCK_DEVICE_TOKEN}`; + const PATH_BROADCASTS = `/4/broadcasts/apps/${BUNDLE_ID}`; // Create an insecure http2 client for unit testing. // (APNS would use https://, not http://) // (It's probably possible to allow accepting invalid certificates instead, // but that's not the most important point of these tests) - const createClient = (port, timeout = 500) => { + const createClient = (port, timeout = 500, heartBeat = 6000) => { const c = new Client({ port: TEST_PORT, address: '127.0.0.1', + heartBeat: heartBeat, + requestTimeout: timeout, }); c._mockOverrideUrl = `http://127.0.0.1:${port}`; - c.config.port = port; - c.config.address = '127.0.0.1'; - c.config.requestTimeout = timeout; return c; }; // Create an insecure server for unit testing. @@ -105,31 +110,90 @@ describe('Client', () => { return server; }; - afterEach(done => { - const closeServer = () => { + afterEach(async () => { + const closeServer = async () => { if (server) { - server.close(); + await new Promise(resolve => { + server.close(() => { + resolve(); + }); + }); server = null; } - done(); }; if (client) { - client.shutdown(closeServer); + await client.shutdown(); client = null; - } else { - closeServer(); } + await closeServer(); + }); + + it('Treats HTTP 200 responses as successful for device', async () => { + let didRequest = false; + let establishedConnections = 0; + let requestsServed = 0; + const method = HTTP2_METHOD_POST; + const path = PATH_DEVICE; + server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { + expect(req.headers).to.deep.equal({ + ':authority': '127.0.0.1', + ':method': method, + ':path': path, + ':scheme': 'https', + 'apns-someheader': 'somevalue', + }); + expect(requestBody).to.equal(MOCK_BODY); + // res.setHeader('X-Foo', 'bar'); + // res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.writeHead(200); + res.end(''); + requestsServed += 1; + didRequest = true; + }); + server.on('connection', () => (establishedConnections += 1)); + await new Promise(resolve => server.on('listening', resolve)); + + client = createClient(CLIENT_TEST_PORT); + + const runSuccessfulRequest = async () => { + const mockHeaders = { 'apns-someheader': 'somevalue' }; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const device = MOCK_DEVICE_TOKEN; + const result = await client.write(mockNotification, device, 'device', 'post'); + expect(result).to.deep.equal({ device }); + expect(didRequest).to.be.true; + }; + expect(establishedConnections).to.equal(0); // should not establish a connection until it's needed + // Validate that when multiple valid requests arrive concurrently, + // only one HTTP/2 connection gets established + await Promise.all([ + runSuccessfulRequest(), + runSuccessfulRequest(), + runSuccessfulRequest(), + runSuccessfulRequest(), + runSuccessfulRequest(), + ]); + didRequest = false; + client.destroySession(); // Don't pass in session to destroy, should not force a disconnection. + await runSuccessfulRequest(); + expect(establishedConnections).to.equal(1); // should establish a connection to the server and reuse it + expect(requestsServed).to.equal(6); }); - it('Treats HTTP 200 responses as successful', async () => { + it('Treats HTTP 200 responses as successful for broadcasts', async () => { let didRequest = false; let establishedConnections = 0; let requestsServed = 0; + const method = HTTP2_METHOD_POST; + const path = PATH_BROADCASTS; server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { expect(req.headers).to.deep.equal({ ':authority': '127.0.0.1', - ':method': 'POST', - ':path': `/3/device/${MOCK_DEVICE_TOKEN}`, + ':method': method, + ':path': path, ':scheme': 'https', 'apns-someheader': 'somevalue', }); @@ -144,7 +208,7 @@ describe('Client', () => { server.on('connection', () => (establishedConnections += 1)); await new Promise(resolve => server.on('listening', resolve)); - client = createClient(TEST_PORT); + client = createClient(CLIENT_TEST_PORT); const runSuccessfulRequest = async () => { const mockHeaders = { 'apns-someheader': 'somevalue' }; @@ -152,9 +216,9 @@ describe('Client', () => { headers: mockHeaders, body: MOCK_BODY, }; - const mockDevice = MOCK_DEVICE_TOKEN; - const result = await client.write(mockNotification, mockDevice); - expect(result).to.deep.equal({ device: MOCK_DEVICE_TOKEN }); + const bundleId = BUNDLE_ID; + const result = await client.write(mockNotification, bundleId, 'broadcasts', 'post'); + expect(result).to.deep.equal({ bundleId }); expect(didRequest).to.be.true; }; expect(establishedConnections).to.equal(0); // should not establish a connection until it's needed @@ -178,11 +242,13 @@ describe('Client', () => { this.timeout(10000); let establishedConnections = 0; let requestsServed = 0; + const method = HTTP2_METHOD_POST; + const path = PATH_DEVICE; server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { expect(req.headers).to.deep.equal({ ':authority': '127.0.0.1', - ':method': 'POST', - ':path': `/3/device/${MOCK_DEVICE_TOKEN}`, + ':method': method, + ':path': path, ':scheme': 'https', 'apns-someheader': 'somevalue', }); @@ -197,7 +263,7 @@ describe('Client', () => { server.on('connection', () => (establishedConnections += 1)); await new Promise(resolve => server.on('listening', resolve)); - client = createClient(TEST_PORT, 1500); + client = createClient(CLIENT_TEST_PORT, 1500); const runSuccessfulRequest = async () => { const mockHeaders = { 'apns-someheader': 'somevalue' }; @@ -205,9 +271,9 @@ describe('Client', () => { headers: mockHeaders, body: MOCK_BODY, }; - const mockDevice = MOCK_DEVICE_TOKEN; - const result = await client.write(mockNotification, mockDevice); - expect(result).to.deep.equal({ device: MOCK_DEVICE_TOKEN }); + const device = MOCK_DEVICE_TOKEN; + const result = await client.write(mockNotification, device, 'device', 'post'); + expect(result).to.deep.equal({ device }); }; expect(establishedConnections).to.equal(0); // should not establish a connection until it's needed // Validate that when multiple valid requests arrive concurrently, @@ -222,6 +288,74 @@ describe('Client', () => { expect(requestsServed).to.equal(LOAD_TEST_BATCH_SIZE); }); + it('Log pings for session', async () => { + let establishedConnections = 0; + let requestsServed = 0; + const method = HTTP2_METHOD_POST; + const path = PATH_DEVICE; + const pingDelay = 50; + const responseDelay = pingDelay * 2; + server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { + expect(req.headers).to.deep.equal({ + ':authority': '127.0.0.1', + ':method': method, + ':path': path, + ':scheme': 'https', + 'apns-someheader': 'somevalue', + }); + expect(requestBody).to.equal(MOCK_BODY); + // Set a timeout of responseDelay to simulate latency to a remote server. + setTimeout(() => { + res.writeHead(200); + res.end(''); + requestsServed += 1; + }, responseDelay); + }); + server.on('connection', () => (establishedConnections += 1)); + await new Promise(resolve => server.on('listening', resolve)); + + client = createClient(CLIENT_TEST_PORT, 500, pingDelay); + + // Setup logger. + const infoMessages = []; + const errorMessages = []; + const mockInfoLogger = message => { + infoMessages.push(message); + }; + const mockErrorLogger = message => { + errorMessages.push(message); + }; + mockInfoLogger.enabled = true; + mockErrorLogger.enabled = true; + client.setLogger(mockInfoLogger, mockErrorLogger); + + const runSuccessfulRequest = async () => { + const mockHeaders = { 'apns-someheader': 'somevalue' }; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const device = MOCK_DEVICE_TOKEN; + const result = await client.write(mockNotification, device, 'device', 'post'); + expect(result).to.deep.equal({ device }); + }; + expect(establishedConnections).to.equal(0); // should not establish a connection until it's needed + await runSuccessfulRequest(); + expect(establishedConnections).to.equal(1); // should establish a connection to the server and reuse it + expect(requestsServed).to.equal(1); + expect(infoMessages).to.not.be.empty; + let infoMessagesContainsPing = false; + // Search for message, in older node, may be in random order. + for (const message of infoMessages) { + if (message.includes('Ping response')) { + infoMessagesContainsPing = true; + break; + } + } + expect(infoMessagesContainsPing).to.be.true; + expect(errorMessages).to.be.empty; + }); + // https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/handling_notification_responses_from_apns it('JSON decodes HTTP 400 responses', async () => { let didRequest = false; @@ -237,7 +371,7 @@ describe('Client', () => { server.on('connection', () => (establishedConnections += 1)); await new Promise(resolve => server.on('listening', resolve)); - client = createClient(TEST_PORT); + client = createClient(CLIENT_TEST_PORT); const infoMessages = []; const errorMessages = []; const mockInfoLogger = message => { @@ -256,10 +390,16 @@ describe('Client', () => { headers: mockHeaders, body: MOCK_BODY, }; - const mockDevice = MOCK_DEVICE_TOKEN; - const result = await client.write(mockNotification, mockDevice); - expect(result).to.deep.equal({ - device: MOCK_DEVICE_TOKEN, + const device = MOCK_DEVICE_TOKEN; + let receivedError; + try { + await client.write(mockNotification, device, 'device', 'post'); + } catch (e) { + receivedError = e; + } + expect(receivedError).to.exist; + expect(receivedError).to.deep.equal({ + device, response: { reason: 'BadDeviceToken', }, @@ -276,7 +416,88 @@ describe('Client', () => { 'Request ended with status 400 and responseData: {"reason": "BadDeviceToken"}', 'Request ended with status 400 and responseData: {"reason": "BadDeviceToken"}', ]); - expect(errorMessages).to.deep.equal([]); + expect(errorMessages).to.be.empty; + }); + + it('Attempts to regenerate token when HTTP 403 responses are received', async () => { + let establishedConnections = 0; + const responseDelay = 50; + server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { + // Wait 50ms before sending the responses in parallel + setTimeout(() => { + expect(requestBody).to.equal(MOCK_BODY); + res.writeHead(403); + res.end('{"reason": "ExpiredProviderToken"}'); + }, responseDelay); + }); + server.on('connection', () => (establishedConnections += 1)); + await new Promise(resolve => server.on('listening', resolve)); + + client = createClient(CLIENT_TEST_PORT); + + // Setup logger. + const infoMessages = []; + const errorMessages = []; + const mockInfoLogger = message => { + infoMessages.push(message); + }; + const mockErrorLogger = message => { + errorMessages.push(message); + }; + mockInfoLogger.enabled = true; + mockErrorLogger.enabled = true; + client.setLogger(mockInfoLogger, mockErrorLogger); + + const runRequestWithExpiredProviderToken = async () => { + const mockHeaders = { 'apns-someheader': 'somevalue' }; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const device = MOCK_DEVICE_TOKEN; + let receivedError; + try { + await client.write(mockNotification, device, 'device', 'post'); + } catch (e) { + receivedError = e; + } + expect(receivedError).to.exist; + expect(receivedError.device).to.equal(device); + expect(receivedError.error).to.be.an.instanceof(VError); + expect(receivedError.error.message).to.have.string('APNs response'); + }; + await runRequestWithExpiredProviderToken(); + await runRequestWithExpiredProviderToken(); + await runRequestWithExpiredProviderToken(); + expect(establishedConnections).to.equal(1); + + await Promise.allSettled([ + runRequestWithExpiredProviderToken(), + runRequestWithExpiredProviderToken(), + runRequestWithExpiredProviderToken(), + runRequestWithExpiredProviderToken(), + ]); + expect(establishedConnections).to.equal(1); // should close and establish new connections on http 500 + expect(errorMessages).to.not.be.empty; + let errorMessagesContainsAPN = false; + // Search for message, in older node, may be in random order. + for (const message of errorMessages) { + if (message.includes('APNs response')) { + errorMessagesContainsAPN = true; + break; + } + } + expect(errorMessagesContainsAPN).to.be.true; + expect(infoMessages).to.not.be.empty; + let infoMessagesContainsStatus = false; + // Search for message, in older node, may be in random order. + for (const message of infoMessages) { + if (message.includes('status 403')) { + infoMessagesContainsStatus = true; + break; + } + } + expect(infoMessagesContainsStatus).to.be.true; }); // node-apn started closing connections in response to a bug report where HTTP 500 responses @@ -295,7 +516,7 @@ describe('Client', () => { server.on('connection', () => (establishedConnections += 1)); await new Promise(resolve => server.on('listening', resolve)); - client = createClient(TEST_PORT); + client = createClient(CLIENT_TEST_PORT); const runRequestWithInternalServerError = async () => { const mockHeaders = { 'apns-someheader': 'somevalue' }; @@ -303,12 +524,17 @@ describe('Client', () => { headers: mockHeaders, body: MOCK_BODY, }; - const mockDevice = MOCK_DEVICE_TOKEN; - const result = await client.write(mockNotification, mockDevice); - expect(result).to.exist; - expect(result.device).to.equal(MOCK_DEVICE_TOKEN); - expect(result.error).to.be.an.instanceof(VError); - expect(result.error.message).to.have.string('stream ended unexpectedly'); + const device = MOCK_DEVICE_TOKEN; + let receivedError; + try { + await client.write(mockNotification, device, 'device', 'post'); + } catch (e) { + receivedError = e; + } + expect(receivedError).to.exist; + expect(receivedError.device).to.equal(device); + expect(receivedError.error).to.be.an.instanceof(VError); + expect(receivedError.error.message).to.have.string('stream ended unexpectedly'); }; await runRequestWithInternalServerError(); await runRequestWithInternalServerError(); @@ -317,7 +543,7 @@ describe('Client', () => { // Validate that nothing wrong happens when multiple HTTP 500s are received simultaneously. // (no segfaults, all promises get resolved, etc.) responseDelay = 50; - await Promise.all([ + await Promise.allSettled([ runRequestWithInternalServerError(), runRequestWithInternalServerError(), runRequestWithInternalServerError(), @@ -340,7 +566,7 @@ describe('Client', () => { server.on('connection', () => (establishedConnections += 1)); await new Promise(resolve => server.on('listening', resolve)); - client = createClient(TEST_PORT); + client = createClient(CLIENT_TEST_PORT); const runRequestWithInternalServerError = async () => { const mockHeaders = { 'apns-someheader': 'somevalue' }; @@ -348,12 +574,18 @@ describe('Client', () => { headers: mockHeaders, body: MOCK_BODY, }; - const mockDevice = MOCK_DEVICE_TOKEN; - const result = await client.write(mockNotification, mockDevice); + const device = MOCK_DEVICE_TOKEN; + let receivedError; + try { + await client.write(mockNotification, device, 'device', 'post'); + } catch (e) { + receivedError = e; + } // Should not happen, but if it does, the promise should resolve with an error - expect(result.device).to.equal(MOCK_DEVICE_TOKEN); + expect(receivedError).to.exist; + expect(receivedError.device).to.equal(device); expect( - result.error.message.startsWith( + receivedError.error.message.startsWith( 'Unexpected error processing APNs response: Unexpected token' ) ).to.equal(true); @@ -374,7 +606,7 @@ describe('Client', () => { didGetResponse = true; }, 1900); }); - client = createClient(TEST_PORT); + client = createClient(CLIENT_TEST_PORT); const onListeningPromise = new Promise(resolve => server.on('listening', resolve)); await onListeningPromise; @@ -384,11 +616,17 @@ describe('Client', () => { headers: mockHeaders, body: MOCK_BODY, }; - const mockDevice = MOCK_DEVICE_TOKEN; const performRequestExpectingTimeout = async () => { - const result = await client.write(mockNotification, mockDevice); - expect(result).to.deep.equal({ - device: MOCK_DEVICE_TOKEN, + const device = MOCK_DEVICE_TOKEN; + let receivedError; + try { + await client.write(mockNotification, device, 'device', 'post'); + } catch (e) { + receivedError = e; + } + expect(receivedError).to.exist; + expect(receivedError).to.deep.equal({ + device, error: new VError('apn write timeout'), }); expect(didGetRequest).to.be.true; @@ -416,7 +654,20 @@ describe('Client', () => { session.goaway(errorCode); }); server.on('connection', () => (establishedConnections += 1)); - client = createClient(TEST_PORT); + client = createClient(CLIENT_TEST_PORT); + + // Setup logger. + const infoMessages = []; + const errorMessages = []; + const mockInfoLogger = message => { + infoMessages.push(message); + }; + const mockErrorLogger = message => { + errorMessages.push(message); + }; + mockInfoLogger.enabled = true; + mockErrorLogger.enabled = true; + client.setLogger(mockInfoLogger, mockErrorLogger); const onListeningPromise = new Promise(resolve => server.on('listening', resolve)); await onListeningPromise; @@ -426,17 +677,34 @@ describe('Client', () => { headers: mockHeaders, body: MOCK_BODY, }; - const mockDevice = MOCK_DEVICE_TOKEN; const performRequestExpectingGoAway = async () => { - const result = await client.write(mockNotification, mockDevice); - expect(result.device).to.equal(MOCK_DEVICE_TOKEN); - expect(result.error).to.be.an.instanceof(VError); + const device = MOCK_DEVICE_TOKEN; + let receivedError; + try { + await client.write(mockNotification, device, 'device', 'post'); + } catch (e) { + receivedError = e; + } + expect(receivedError).to.exist; + expect(receivedError.device).to.equal(device); + expect(receivedError.error).to.be.an.instanceof(VError); expect(didGetRequest).to.be.true; didGetRequest = false; }; await performRequestExpectingGoAway(); await performRequestExpectingGoAway(); expect(establishedConnections).to.equal(2); + expect(errorMessages).to.not.be.empty; + let errorMessagesContainsGoAway = false; + // Search for message, in older node, may be in random order. + for (const message of errorMessages) { + if (message.includes('GOAWAY')) { + errorMessagesContainsGoAway = true; + break; + } + } + expect(errorMessagesContainsGoAway).to.be.true; + expect(infoMessages).to.not.be.empty; }); it('Handles unexpected protocol errors (no response sent)', async () => { @@ -453,7 +721,20 @@ describe('Client', () => { }, responseTimeout); }); server.on('connection', () => (establishedConnections += 1)); - client = createClient(TEST_PORT); + client = createClient(CLIENT_TEST_PORT); + + // Setup logger. + const infoMessages = []; + const errorMessages = []; + const mockInfoLogger = message => { + infoMessages.push(message); + }; + const mockErrorLogger = message => { + errorMessages.push(message); + }; + mockInfoLogger.enabled = true; + mockErrorLogger.enabled = true; + client.setLogger(mockInfoLogger, mockErrorLogger); const onListeningPromise = new Promise(resolve => server.on('listening', resolve)); await onListeningPromise; @@ -463,11 +744,17 @@ describe('Client', () => { headers: mockHeaders, body: MOCK_BODY, }; - const mockDevice = MOCK_DEVICE_TOKEN; const performRequestExpectingDisconnect = async () => { - const result = await client.write(mockNotification, mockDevice); - expect(result).to.deep.equal({ - device: MOCK_DEVICE_TOKEN, + const device = MOCK_DEVICE_TOKEN; + let receivedError; + try { + await client.write(mockNotification, device, 'device', 'post'); + } catch (e) { + receivedError = e; + } + expect(receivedError).to.exist; + expect(receivedError).to.deep.equal({ + device, error: new VError('stream ended unexpectedly with status null and empty body'), }); expect(didGetRequest).to.be.true; @@ -485,17 +772,41 @@ describe('Client', () => { performRequestExpectingDisconnect(), ]); expect(establishedConnections).to.equal(3); + expect(errorMessages).to.not.be.empty; + let errorMessagesContainsGoAway = false; + // Search for message, in older node, may be in random order. + for (const message of errorMessages) { + if (message.includes('GOAWAY')) { + errorMessagesContainsGoAway = true; + break; + } + } + expect(errorMessagesContainsGoAway).to.be.true; + expect(infoMessages).to.not.be.empty; + let infoMessagesContainsStatus = false; + // Search for message, in older node, may be in random order. + for (const message of infoMessages) { + if (message.includes('status null')) { + infoMessagesContainsStatus = true; + break; + } + } + expect(infoMessagesContainsStatus).to.be.true; }); it('Establishes a connection through a proxy server', async () => { let didRequest = false; let establishedConnections = 0; let requestsServed = 0; + const method = HTTP2_METHOD_POST; + const path = PATH_DEVICE; + const proxyPort = TEST_PORT - 1; + server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { expect(req.headers).to.deep.equal({ ':authority': '127.0.0.1', - ':method': 'POST', - ':path': `/3/device/${MOCK_DEVICE_TOKEN}`, + ':method': method, + ':path': path, ':scheme': 'https', 'apns-someheader': 'somevalue', }); @@ -510,8 +821,9 @@ describe('Client', () => { server.on('connection', () => (establishedConnections += 1)); await new Promise(resolve => server.once('listening', resolve)); - // Proxy forwards all connections to TEST_PORT - const proxy = net.createServer(clientSocket => { + // Proxy forwards all connections to TEST_PORT. + const sockets = []; + let proxy = net.createServer(clientSocket => { clientSocket.once('data', () => { const serverSocket = net.createConnection(TEST_PORT, () => { clientSocket.write('HTTP/1.1 200 OK\r\n\r\n'); @@ -520,29 +832,32 @@ describe('Client', () => { serverSocket.pipe(clientSocket); }, 1); }); + sockets.push(clientSocket, serverSocket); }); clientSocket.on('error', () => {}); }); - await new Promise(resolve => proxy.listen(3128, resolve)); + await new Promise(resolve => proxy.listen(proxyPort, resolve)); + // Don't block the tests if this server doesn't shut down properly. + proxy.unref(); - // Client configured with a port that the server is not listening on - client = createClient(TEST_PORT + 1); - // So without adding a proxy config request will fail with a network error - client.config.proxy = { host: '127.0.0.1', port: 3128 }; + // Client configured with a port that the server is not listening on. + client = createClient(CLIENT_TEST_PORT); + // Not adding a proxy config will cause a failure with a network error. + client.config.proxy = { host: '127.0.0.1', port: proxyPort }; const runSuccessfulRequest = async () => { const mockHeaders = { 'apns-someheader': 'somevalue' }; const mockNotification = { headers: mockHeaders, body: MOCK_BODY, }; - const mockDevice = MOCK_DEVICE_TOKEN; - const result = await client.write(mockNotification, mockDevice); - expect(result).to.deep.equal({ device: MOCK_DEVICE_TOKEN }); + const device = MOCK_DEVICE_TOKEN; + const result = await client.write(mockNotification, device, 'device', 'post'); + expect(result).to.deep.equal({ device }); expect(didRequest).to.be.true; }; expect(establishedConnections).to.equal(0); // should not establish a connection until it's needed // Validate that when multiple valid requests arrive concurrently, - // only one HTTP/2 connection gets established + // only one HTTP/2 connection gets established. await Promise.all([ runSuccessfulRequest(), runSuccessfulRequest(), @@ -555,7 +870,65 @@ describe('Client', () => { expect(establishedConnections).to.equal(1); // should establish a connection to the server and reuse it expect(requestsServed).to.equal(6); - proxy.close(); + // Shut down proxy server properly. + await new Promise(resolve => { + sockets.forEach(socket => socket.end('')); + proxy.close(() => { + resolve(); + }); + }); + proxy = null; + }); + + it('Throws an error when there is a bad proxy server', async () => { + // Client configured with a port that the server is not listening on. + client = createClient(CLIENT_TEST_PORT); + // Not adding a proxy config will cause a failure with a network error. + client.config.proxy = { host: '127.0.0.1', port: 'NOT_A_PORT' }; + + // Setup logger. + const infoMessages = []; + const errorMessages = []; + const mockInfoLogger = message => { + infoMessages.push(message); + }; + const mockErrorLogger = message => { + errorMessages.push(message); + }; + mockInfoLogger.enabled = true; + mockErrorLogger.enabled = true; + client.setLogger(mockInfoLogger, mockErrorLogger); + + const runUnsuccessfulRequest = async () => { + const mockHeaders = { 'apns-someheader': 'somevalue' }; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const device = MOCK_DEVICE_TOKEN; + let receivedError; + try { + await client.write(mockNotification, device, 'device', 'post'); + } catch (e) { + receivedError = e; + } + expect(receivedError).to.exist; + expect(receivedError.device).to.equal(device); + expect(receivedError.error.code).to.equal('ERR_SOCKET_BAD_PORT'); + }; + await runUnsuccessfulRequest(); + + expect(errorMessages).to.not.be.empty; + let errorMessagesContainsStatus = false; + // Search for message, in older node, may be in random order. + for (const message of errorMessages) { + if (message.includes('NOT_A_PORT')) { + errorMessagesContainsStatus = true; + break; + } + } + expect(errorMessagesContainsStatus).to.be.true; + expect(infoMessages).to.be.empty; }); // let fakes, Client; @@ -1163,3 +1536,1284 @@ describe('Client', () => { // }); }); }); + +describe('ManageChannelsClient', () => { + let server; + let client; + const MOCK_BODY = '{"mock-key":"mock-value"}'; + const BUNDLE_ID = 'com.node.apn'; + const PATH_CHANNELS = `/1/apps/${BUNDLE_ID}/channels`; + const PATH_ALL_CHANNELS = `/1/apps/${BUNDLE_ID}/all-channels`; + + // Create an insecure http2 client for unit testing. + // (APNS would use https://, not http://) + // (It's probably possible to allow accepting invalid certificates instead, + // but that's not the most important point of these tests) + const createClient = (port, timeout = 500, heartBeat = 6000) => { + const c = new Client({ + manageChannelsAddress: '127.0.0.1', + manageChannelsPort: TEST_PORT, + heartBeat: heartBeat, + requestTimeout: timeout, + }); + c._mockOverrideUrl = `http://127.0.0.1:${port}`; + return c; + }; + // Create an insecure server for unit testing. + const createAndStartMockServer = (port, cb) => { + server = http2.createServer((req, res) => { + const buffers = []; + req.on('data', data => buffers.push(data)); + req.on('end', () => { + const requestBody = Buffer.concat(buffers).toString('utf-8'); + cb(req, res, requestBody); + }); + }); + server.listen(port); + server.on('error', err => { + expect.fail(`unexpected error ${err}`); + }); + // Don't block the tests if this server doesn't shut down properly + server.unref(); + return server; + }; + const createAndStartMockLowLevelServer = (port, cb) => { + server = http2.createServer(); + server.on('stream', cb); + server.listen(port); + server.on('error', err => { + expect.fail(`unexpected error ${err}`); + }); + // Don't block the tests if this server doesn't shut down properly + server.unref(); + return server; + }; + + afterEach(async () => { + const closeServer = async () => { + if (server) { + await new Promise(resolve => { + server.close(() => { + resolve(); + }); + }); + server = null; + } + }; + if (client) { + await client.shutdown(); + client = null; + } + await closeServer(); + }); + + it('Treats HTTP 200 responses as successful for channels', async () => { + let didRequest = false; + let establishedConnections = 0; + let requestsServed = 0; + const method = HTTP2_METHOD_POST; + const path = PATH_CHANNELS; + server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { + expect(req.headers).to.deep.equal({ + ':authority': '127.0.0.1', + ':method': method, + ':path': path, + ':scheme': 'https', + 'apns-someheader': 'somevalue', + }); + expect(requestBody).to.equal(MOCK_BODY); + // res.setHeader('X-Foo', 'bar'); + // res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.writeHead(200); + res.end(''); + requestsServed += 1; + didRequest = true; + }); + server.on('connection', () => (establishedConnections += 1)); + await new Promise(resolve => server.on('listening', resolve)); + + client = createClient(CLIENT_TEST_PORT); + + const runSuccessfulRequest = async () => { + const mockHeaders = { 'apns-someheader': 'somevalue' }; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const bundleId = BUNDLE_ID; + const result = await client.write(mockNotification, bundleId, 'channels', 'post'); + expect(result).to.deep.equal({ bundleId }); + expect(didRequest).to.be.true; + }; + expect(establishedConnections).to.equal(0); // should not establish a connection until it's needed + // Validate that when multiple valid requests arrive concurrently, + // only one HTTP/2 connection gets established + await Promise.all([ + runSuccessfulRequest(), + runSuccessfulRequest(), + runSuccessfulRequest(), + runSuccessfulRequest(), + runSuccessfulRequest(), + ]); + didRequest = false; + client.destroySession(); // Don't pass in session to destroy, should not force a disconnection. + await runSuccessfulRequest(); + expect(establishedConnections).to.equal(1); // should establish a connection to the server and reuse it + expect(requestsServed).to.equal(6); + }); + + it('Treats HTTP 201 responses as successful for channels', async () => { + let didRequest = false; + let establishedConnections = 0; + let requestsServed = 0; + const method = HTTP2_METHOD_POST; + const path = PATH_CHANNELS; + const channel = 'dHN0LXNyY2gtY2hubA=='; + const requestId = '0309F412-AA57-46A8-9AC6-B5AECA8C4594'; + const uniqueId = '4C106C5F-2013-40B9-8193-EAA270B8F2C5'; + const additionalHeaderInfo = { + 'apns-channel-id': channel, + 'apns-request-id': requestId, + 'apns-unique-id': uniqueId, + }; + + server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { + expect(req.headers).to.deep.equal({ + ':authority': '127.0.0.1', + ':method': method, + ':path': path, + ':scheme': 'https', + 'apns-someheader': 'somevalue', + }); + expect(requestBody).to.equal(MOCK_BODY); + // res.setHeader('X-Foo', 'bar'); + // res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.writeHead(201, additionalHeaderInfo); + res.end(''); + requestsServed += 1; + didRequest = true; + }); + server.on('connection', () => (establishedConnections += 1)); + await new Promise(resolve => server.on('listening', resolve)); + + client = createClient(CLIENT_TEST_PORT); + + const runSuccessfulRequest = async () => { + const mockHeaders = { 'apns-someheader': 'somevalue' }; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const bundleId = BUNDLE_ID; + const result = await client.write(mockNotification, bundleId, 'channels', 'post'); + expect(result).to.deep.equal({ ...additionalHeaderInfo, bundleId }); + expect(didRequest).to.be.true; + }; + expect(establishedConnections).to.equal(0); // should not establish a connection until it's needed + // Validate that when multiple valid requests arrive concurrently, + // only one HTTP/2 connection gets established + await Promise.all([ + runSuccessfulRequest(), + runSuccessfulRequest(), + runSuccessfulRequest(), + runSuccessfulRequest(), + runSuccessfulRequest(), + ]); + didRequest = false; + await runSuccessfulRequest(); + expect(establishedConnections).to.equal(1); // should establish a connection to the server and reuse it + expect(requestsServed).to.equal(6); + }); + + it('Treats HTTP 204 responses as successful for channels', async () => { + let didRequest = false; + let establishedConnections = 0; + let requestsServed = 0; + const method = HTTP2_METHOD_DELETE; + const path = PATH_CHANNELS; + const channel = 'dHN0LXNyY2gtY2hubA=='; + const requestId = '0309F412-AA57-46A8-9AC6-B5AECA8C4594'; + const additionalHeaderInfo = { 'apns-request-id': requestId }; + + server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { + expect(req.headers).to.deep.equal({ + ':authority': '127.0.0.1', + ':method': method, + ':path': path, + ':scheme': 'https', + 'apns-channel-id': channel, + ...additionalHeaderInfo, + }); + expect(requestBody).to.be.empty; + // res.setHeader('X-Foo', 'bar'); + // res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.writeHead(204, additionalHeaderInfo); + res.end(''); + requestsServed += 1; + didRequest = true; + }); + server.on('connection', () => (establishedConnections += 1)); + await new Promise(resolve => server.on('listening', resolve)); + + client = createClient(CLIENT_TEST_PORT); + + const runSuccessfulRequest = async () => { + const mockHeaders = { 'apns-channel-id': channel, ...additionalHeaderInfo }; + const mockNotification = { + headers: mockHeaders, + body: {}, + }; + const bundleId = BUNDLE_ID; + const result = await client.write(mockNotification, bundleId, 'channels', 'delete'); + expect(result).to.deep.equal({ ...additionalHeaderInfo, bundleId }); + expect(didRequest).to.be.true; + }; + expect(establishedConnections).to.equal(0); // should not establish a connection until it's needed + // Validate that when multiple valid requests arrive concurrently, + // only one HTTP/2 connection gets established + await Promise.all([ + runSuccessfulRequest(), + runSuccessfulRequest(), + runSuccessfulRequest(), + runSuccessfulRequest(), + runSuccessfulRequest(), + ]); + didRequest = false; + await runSuccessfulRequest(); + expect(establishedConnections).to.equal(1); // should establish a connection to the server and reuse it + expect(requestsServed).to.equal(6); + }); + + it('Treats HTTP 200 responses as successful for allChannels', async () => { + let didRequest = false; + let establishedConnections = 0; + let requestsServed = 0; + const method = HTTP2_METHOD_GET; + const path = PATH_ALL_CHANNELS; + const channels = { channels: ['dHN0LXNyY2gtY2hubA=='] }; + const requestId = '0309F412-AA57-46A8-9AC6-B5AECA8C4594'; + const uniqueId = '4C106C5F-2013-40B9-8193-EAA270B8F2C5'; + const additionalHeaderInfo = { 'apns-request-id': requestId, 'apns-unique-id': uniqueId }; + + server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { + expect(req.headers).to.deep.equal({ + ':authority': '127.0.0.1', + ':method': method, + ':path': path, + ':scheme': 'https', + 'apns-request-id': requestId, + }); + + expect(requestBody).to.be.empty; + + const data = JSON.stringify(channels); + res.writeHead(200, additionalHeaderInfo); + res.write(data); + res.end(); + requestsServed += 1; + didRequest = true; + }); + server.on('connection', () => (establishedConnections += 1)); + await new Promise(resolve => server.on('listening', resolve)); + + client = createClient(CLIENT_TEST_PORT); + + const runSuccessfulRequest = async () => { + const mockHeaders = { 'apns-request-id': requestId }; + const mockNotification = { + headers: mockHeaders, + body: {}, + }; + const bundleId = BUNDLE_ID; + const result = await client.write(mockNotification, bundleId, 'allChannels', 'get'); + expect(result).to.deep.equal({ ...additionalHeaderInfo, bundleId, ...channels }); + expect(didRequest).to.be.true; + }; + expect(establishedConnections).to.equal(0); // should not establish a connection until it's needed + // Validate that when multiple valid requests arrive concurrently, + // only one HTTP/2 connection gets established + await Promise.all([ + runSuccessfulRequest(), + runSuccessfulRequest(), + runSuccessfulRequest(), + runSuccessfulRequest(), + runSuccessfulRequest(), + ]); + didRequest = false; + await runSuccessfulRequest(); + expect(establishedConnections).to.equal(1); // should establish a connection to the server and reuse it + expect(requestsServed).to.equal(6); + }); + + // Assert that this doesn't crash when a large batch of requests are requested simultaneously + it('Treats HTTP 200 responses as successful (load test for a batch of requests)', async function () { + this.timeout(10000); + let establishedConnections = 0; + let requestsServed = 0; + const method = HTTP2_METHOD_POST; + const path = PATH_CHANNELS; + server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { + expect(req.headers).to.deep.equal({ + ':authority': '127.0.0.1', + ':method': method, + ':path': path, + ':scheme': 'https', + 'apns-someheader': 'somevalue', + }); + expect(requestBody).to.equal(MOCK_BODY); + // Set a timeout of 100 to simulate latency to a remote server. + setTimeout(() => { + res.writeHead(200); + res.end(''); + requestsServed += 1; + }, 100); + }); + server.on('connection', () => (establishedConnections += 1)); + await new Promise(resolve => server.on('listening', resolve)); + + client = createClient(CLIENT_TEST_PORT, 1500); + + const runSuccessfulRequest = async () => { + const mockHeaders = { 'apns-someheader': 'somevalue' }; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const bundleId = BUNDLE_ID; + const result = await client.write(mockNotification, bundleId, 'channels', 'post'); + expect(result).to.deep.equal({ bundleId }); + }; + expect(establishedConnections).to.equal(0); // should not establish a connection until it's needed + // Validate that when multiple valid requests arrive concurrently, + // only one HTTP/2 connection gets established + const promises = []; + for (let i = 0; i < LOAD_TEST_BATCH_SIZE; i++) { + promises.push(runSuccessfulRequest()); + } + + await Promise.all(promises); + expect(establishedConnections).to.equal(1); // should establish a connection to the server and reuse it + expect(requestsServed).to.equal(LOAD_TEST_BATCH_SIZE); + }); + + it('Log pings for session', async () => { + let establishedConnections = 0; + let requestsServed = 0; + const method = HTTP2_METHOD_POST; + const path = PATH_CHANNELS; + const pingDelay = 50; + const responseDelay = pingDelay * 2; + server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { + expect(req.headers).to.deep.equal({ + ':authority': '127.0.0.1', + ':method': method, + ':path': path, + ':scheme': 'https', + 'apns-someheader': 'somevalue', + }); + expect(requestBody).to.equal(MOCK_BODY); + // Set a timeout of responseDelay to simulate latency to a remote server. + setTimeout(() => { + res.writeHead(200); + res.end(''); + requestsServed += 1; + }, responseDelay); + }); + server.on('connection', () => (establishedConnections += 1)); + await new Promise(resolve => server.on('listening', resolve)); + + client = createClient(CLIENT_TEST_PORT, 500, pingDelay); + + // Setup logger. + const infoMessages = []; + const errorMessages = []; + const mockInfoLogger = message => { + infoMessages.push(message); + }; + const mockErrorLogger = message => { + errorMessages.push(message); + }; + mockInfoLogger.enabled = true; + mockErrorLogger.enabled = true; + client.setLogger(mockInfoLogger, mockErrorLogger); + + const runSuccessfulRequest = async () => { + const mockHeaders = { 'apns-someheader': 'somevalue' }; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const bundleId = BUNDLE_ID; + const result = await client.write(mockNotification, bundleId, 'channels', 'post'); + expect(result).to.deep.equal({ bundleId }); + }; + expect(establishedConnections).to.equal(0); // should not establish a connection until it's needed + await runSuccessfulRequest(); + expect(establishedConnections).to.equal(1); // should establish a connection to the server and reuse it + expect(requestsServed).to.equal(1); + expect(infoMessages).to.not.be.empty; + let infoMessagesContainsPing = false; + // Search for message, in older node, may be in random order. + for (const message of infoMessages) { + if (message.includes('ManageChannelsSession Ping response')) { + infoMessagesContainsPing = true; + break; + } + } + expect(infoMessagesContainsPing).to.be.true; + expect(errorMessages).to.be.empty; + }); + + // https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/handling_notification_responses_from_apns + it('JSON decodes HTTP 400 responses', async () => { + let didRequest = false; + let establishedConnections = 0; + server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { + expect(requestBody).to.equal(MOCK_BODY); + // res.setHeader('X-Foo', 'bar'); + // res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.writeHead(400); + res.end('{"reason": "BadDeviceToken"}'); + didRequest = true; + }); + server.on('connection', () => (establishedConnections += 1)); + await new Promise(resolve => server.on('listening', resolve)); + + client = createClient(CLIENT_TEST_PORT); + const infoMessages = []; + const errorMessages = []; + const mockInfoLogger = message => { + infoMessages.push(message); + }; + const mockErrorLogger = message => { + errorMessages.push(message); + }; + mockInfoLogger.enabled = true; + mockErrorLogger.enabled = true; + client.setLogger(mockInfoLogger, mockErrorLogger); + + const runRequestWithBadDeviceToken = async () => { + const mockHeaders = { 'apns-someheader': 'somevalue' }; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const bundleId = BUNDLE_ID; + let receivedError; + try { + await client.write(mockNotification, bundleId, 'channels', 'post'); + } catch (e) { + receivedError = e; + } + expect(receivedError).to.exist; + expect(receivedError).to.deep.equal({ + bundleId, + response: { + reason: 'BadDeviceToken', + }, + status: 400, + }); + expect(didRequest).to.be.true; + didRequest = false; + }; + await runRequestWithBadDeviceToken(); + await runRequestWithBadDeviceToken(); + expect(establishedConnections).to.equal(1); // should establish a connection to the server and reuse it + expect(infoMessages).to.deep.equal([ + 'ManageChannelsSession connected', + 'Request ended with status 400 and responseData: {"reason": "BadDeviceToken"}', + 'Request ended with status 400 and responseData: {"reason": "BadDeviceToken"}', + ]); + expect(errorMessages).to.deep.equal([]); + }); + + it('Attempts to regenerate token when HTTP 403 responses are received', async () => { + let establishedConnections = 0; + const responseDelay = 50; + server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { + // Wait 50ms before sending the responses in parallel + setTimeout(() => { + expect(requestBody).to.equal(MOCK_BODY); + res.writeHead(403); + res.end('{"reason": "ExpiredProviderToken"}'); + }, responseDelay); + }); + server.on('connection', () => (establishedConnections += 1)); + await new Promise(resolve => server.on('listening', resolve)); + + client = createClient(CLIENT_TEST_PORT); + + // Setup logger. + const infoMessages = []; + const errorMessages = []; + const mockInfoLogger = message => { + infoMessages.push(message); + }; + const mockErrorLogger = message => { + errorMessages.push(message); + }; + mockInfoLogger.enabled = true; + mockErrorLogger.enabled = true; + client.setLogger(mockInfoLogger, mockErrorLogger); + + const runRequestWithExpiredProviderToken = async () => { + const mockHeaders = { 'apns-someheader': 'somevalue' }; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const bundleId = BUNDLE_ID; + let receivedError; + try { + await client.write(mockNotification, bundleId, 'channels', 'post'); + } catch (e) { + receivedError = e; + } + expect(receivedError).to.exist; + expect(receivedError.bundleId).to.equal(bundleId); + expect(receivedError.error).to.be.an.instanceof(VError); + expect(receivedError.error.message).to.have.string('APNs response'); + }; + await runRequestWithExpiredProviderToken(); + await runRequestWithExpiredProviderToken(); + await runRequestWithExpiredProviderToken(); + expect(establishedConnections).to.equal(1); + + await Promise.allSettled([ + runRequestWithExpiredProviderToken(), + runRequestWithExpiredProviderToken(), + runRequestWithExpiredProviderToken(), + runRequestWithExpiredProviderToken(), + ]); + expect(establishedConnections).to.equal(1); // should close and establish new connections on http 500 + expect(errorMessages).to.not.be.empty; + let errorMessagesContainsAPN = false; + // Search for message, in older node, may be in random order. + for (const message of errorMessages) { + if (message.includes('APNs response')) { + errorMessagesContainsAPN = true; + break; + } + } + expect(errorMessagesContainsAPN).to.be.true; + expect(infoMessages).to.not.be.empty; + let infoMessagesContainsStatus = false; + // Search for message, in older node, may be in random order. + for (const message of infoMessages) { + if (message.includes('status 403')) { + infoMessagesContainsStatus = true; + break; + } + } + expect(infoMessagesContainsStatus).to.be.true; + }); + + it('Closes connections when HTTP 500 responses are received', async () => { + let establishedConnections = 0; + const responseDelay = 50; + server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { + // Wait 50ms before sending the responses in parallel + setTimeout(() => { + expect(requestBody).to.equal(MOCK_BODY); + res.writeHead(500); + res.end('{"reason": "InternalServerError"}'); + }, responseDelay); + }); + server.on('connection', () => (establishedConnections += 1)); + await new Promise(resolve => server.on('listening', resolve)); + + client = createClient(CLIENT_TEST_PORT); + + // Setup logger. + const infoMessages = []; + const errorMessages = []; + const mockInfoLogger = message => { + infoMessages.push(message); + }; + const mockErrorLogger = message => { + errorMessages.push(message); + }; + mockInfoLogger.enabled = true; + mockErrorLogger.enabled = true; + client.setLogger(mockInfoLogger, mockErrorLogger); + + const runRequestWithInternalServerError = async () => { + const mockHeaders = { 'apns-someheader': 'somevalue' }; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const bundleId = BUNDLE_ID; + let receivedError; + try { + await client.write(mockNotification, bundleId, 'channels', 'post'); + } catch (e) { + receivedError = e; + } + expect(receivedError).to.exist; + expect(receivedError.bundleId).to.equal(bundleId); + expect(receivedError.error).to.be.an.instanceof(VError); + expect(receivedError.error.message).to.have.string('stream ended unexpectedly'); + }; + await runRequestWithInternalServerError(); + await runRequestWithInternalServerError(); + await runRequestWithInternalServerError(); + expect(establishedConnections).to.equal(3); // should close and establish new connections on http 500 + // Validate that nothing wrong happens when multiple HTTP 500s are received simultaneously. + // (no segfaults, all promises get resolved, etc.) + await Promise.allSettled([ + runRequestWithInternalServerError(), + runRequestWithInternalServerError(), + runRequestWithInternalServerError(), + runRequestWithInternalServerError(), + ]); + expect(establishedConnections).to.equal(4); // should close and establish new connections on http 500 + expect(errorMessages).to.not.be.empty; + let errorMessagesContainsClose = false; + // Search for message, in older node, may be in random order. + for (const message of errorMessages) { + if (message.includes('Session closed')) { + errorMessagesContainsClose = true; + break; + } + } + expect(errorMessagesContainsClose).to.be.true; + expect(infoMessages).to.not.be.empty; + let infoMessagesContainsStatus = false; + // Search for message, in older node, may be in random order. + for (const message of infoMessages) { + if (message.includes('status 500')) { + infoMessagesContainsStatus = true; + break; + } + } + expect(infoMessagesContainsStatus).to.be.true; + }); + + it('Handles unexpected invalid JSON responses', async () => { + let establishedConnections = 0; + const responseDelay = 0; + server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { + // Wait 50ms before sending the responses in parallel + setTimeout(() => { + expect(requestBody).to.equal(MOCK_BODY); + res.writeHead(500); + res.end('PC LOAD LETTER'); + }, responseDelay); + }); + server.on('connection', () => (establishedConnections += 1)); + await new Promise(resolve => server.on('listening', resolve)); + + client = createClient(CLIENT_TEST_PORT); + + // Setup logger. + const infoMessages = []; + const errorMessages = []; + const mockInfoLogger = message => { + infoMessages.push(message); + }; + const mockErrorLogger = message => { + errorMessages.push(message); + }; + mockInfoLogger.enabled = true; + mockErrorLogger.enabled = true; + client.setLogger(mockInfoLogger, mockErrorLogger); + + const runRequestWithInternalServerError = async () => { + const mockHeaders = { 'apns-someheader': 'somevalue' }; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const bundleId = BUNDLE_ID; + let receivedError; + try { + await client.write(mockNotification, bundleId, 'channels', 'post'); + } catch (e) { + receivedError = e; + } + // Should not happen, but if it does, the promise should resolve with an error + expect(receivedError).to.exist; + expect(receivedError.bundleId).to.equal(bundleId); + expect( + receivedError.error.message.startsWith( + 'Unexpected error processing APNs response: Unexpected token' + ) + ).to.equal(true); + }; + await runRequestWithInternalServerError(); + await runRequestWithInternalServerError(); + expect(establishedConnections).to.equal(1); // Currently reuses the connection. + expect(errorMessages).to.not.be.empty; + let errorMessagesContainsAPNs = false; + // Search for message, in older node, may be in random order. + for (const message of errorMessages) { + if (message.includes('processing APNs')) { + errorMessagesContainsAPNs = true; + break; + } + } + expect(errorMessagesContainsAPNs).to.be.true; + expect(infoMessages).to.not.be.empty; + let infoMessagesContainsStatus = false; + // Search for message, in older node, may be in random order. + for (const message of infoMessages) { + if (message.includes('status 500')) { + infoMessagesContainsStatus = true; + break; + } + } + expect(infoMessagesContainsStatus).to.be.true; + }); + + it('Handles APNs timeouts', async () => { + let didGetRequest = false; + let didGetResponse = false; + server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { + didGetRequest = true; + setTimeout(() => { + res.writeHead(200); + res.end(''); + didGetResponse = true; + }, 1900); + }); + client = createClient(CLIENT_TEST_PORT); + + // Setup logger. + const infoMessages = []; + const errorMessages = []; + const mockInfoLogger = message => { + infoMessages.push(message); + }; + const mockErrorLogger = message => { + errorMessages.push(message); + }; + mockInfoLogger.enabled = true; + mockErrorLogger.enabled = true; + client.setLogger(mockInfoLogger, mockErrorLogger); + + const onListeningPromise = new Promise(resolve => server.on('listening', resolve)); + await onListeningPromise; + + const mockHeaders = { 'apns-someheader': 'somevalue' }; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const performRequestExpectingTimeout = async () => { + const bundleId = BUNDLE_ID; + let receivedError; + try { + await client.write(mockNotification, bundleId, 'channels', 'post'); + } catch (e) { + receivedError = e; + } + expect(receivedError).to.exist; + expect(receivedError).to.deep.equal({ + bundleId, + error: new VError('apn write timeout'), + }); + expect(didGetRequest).to.be.true; + expect(didGetResponse).to.be.false; + }; + await performRequestExpectingTimeout(); + didGetResponse = false; + didGetRequest = false; + // Should be able to have multiple in flight requests all get notified that the server is shutting down + await Promise.all([ + performRequestExpectingTimeout(), + performRequestExpectingTimeout(), + performRequestExpectingTimeout(), + performRequestExpectingTimeout(), + ]); + expect(errorMessages).to.not.be.empty; + let errorMessagesContainsTimeout = false; + // Search for message, in older node, may be in random order. + for (const message of errorMessages) { + if (message.includes('Request timeout')) { + errorMessagesContainsTimeout = true; + break; + } + } + expect(errorMessagesContainsTimeout).to.be.true; + expect(infoMessages).to.not.be.empty; + let infoMessagesContainsTimeout = false; + // Search for message, in older node, may be in random order. + for (const message of infoMessages) { + if (message.includes('timeout')) { + infoMessagesContainsTimeout = true; + break; + } + } + expect(infoMessagesContainsTimeout).to.be.true; + }); + + it('Handles goaway frames', async () => { + let didGetRequest = false; + let establishedConnections = 0; + server = createAndStartMockLowLevelServer(TEST_PORT, stream => { + const { session } = stream; + const errorCode = 1; + didGetRequest = true; + session.goaway(errorCode); + }); + server.on('connection', () => (establishedConnections += 1)); + client = createClient(CLIENT_TEST_PORT); + + // Setup logger. + const infoMessages = []; + const errorMessages = []; + const mockInfoLogger = message => { + infoMessages.push(message); + }; + const mockErrorLogger = message => { + errorMessages.push(message); + }; + mockInfoLogger.enabled = true; + mockErrorLogger.enabled = true; + client.setLogger(mockInfoLogger, mockErrorLogger); + + const onListeningPromise = new Promise(resolve => server.on('listening', resolve)); + await onListeningPromise; + + const mockHeaders = { 'apns-someheader': 'somevalue' }; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const performRequestExpectingGoAway = async () => { + const bundleId = BUNDLE_ID; + let receivedError; + try { + await client.write(mockNotification, bundleId, 'channels', 'post'); + } catch (e) { + receivedError = e; + } + expect(receivedError).to.exist; + expect(receivedError.bundleId).to.equal(bundleId); + expect(receivedError.error).to.be.an.instanceof(VError); + expect(didGetRequest).to.be.true; + didGetRequest = false; + }; + await performRequestExpectingGoAway(); + await performRequestExpectingGoAway(); + expect(establishedConnections).to.equal(2); + expect(errorMessages).to.not.be.empty; + let errorMessagesContainsGoAway = false; + // Search for message, in older node, may be in random order. + for (const message of errorMessages) { + if (message.includes('ManageChannelsSession GOAWAY')) { + errorMessagesContainsGoAway = true; + break; + } + } + expect(errorMessagesContainsGoAway).to.be.true; + expect(infoMessages).to.not.be.empty; + }); + + it('Handles unexpected protocol errors (no response sent)', async () => { + let didGetRequest = false; + let establishedConnections = 0; + let responseTimeout = 0; + server = createAndStartMockLowLevelServer(TEST_PORT, stream => { + setTimeout(() => { + const { session } = stream; + didGetRequest = true; + if (session) { + session.destroy(); + } + }, responseTimeout); + }); + server.on('connection', () => (establishedConnections += 1)); + client = createClient(CLIENT_TEST_PORT); + + // Setup logger. + const infoMessages = []; + const errorMessages = []; + const mockInfoLogger = message => { + infoMessages.push(message); + }; + const mockErrorLogger = message => { + errorMessages.push(message); + }; + mockInfoLogger.enabled = true; + mockErrorLogger.enabled = true; + client.setLogger(mockInfoLogger, mockErrorLogger); + + const onListeningPromise = new Promise(resolve => server.on('listening', resolve)); + await onListeningPromise; + + const mockHeaders = { 'apns-someheader': 'somevalue' }; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const performRequestExpectingDisconnect = async () => { + const bundleId = BUNDLE_ID; + let receivedError; + try { + await client.write(mockNotification, bundleId, 'channels', 'post'); + } catch (e) { + receivedError = e; + } + expect(receivedError).to.exist; + expect(receivedError).to.deep.equal({ + bundleId, + error: new VError('stream ended unexpectedly with status null and empty body'), + }); + expect(didGetRequest).to.be.true; + }; + await performRequestExpectingDisconnect(); + didGetRequest = false; + await performRequestExpectingDisconnect(); + didGetRequest = false; + expect(establishedConnections).to.equal(2); + responseTimeout = 10; + await Promise.all([ + performRequestExpectingDisconnect(), + performRequestExpectingDisconnect(), + performRequestExpectingDisconnect(), + performRequestExpectingDisconnect(), + ]); + expect(establishedConnections).to.equal(3); + expect(errorMessages).to.not.be.empty; + let errorMessagesContainsGoAway = false; + // Search for message, in older node, may be in random order. + for (const message of errorMessages) { + if (message.includes('ManageChannelsSession GOAWAY')) { + errorMessagesContainsGoAway = true; + break; + } + } + expect(errorMessagesContainsGoAway).to.be.true; + expect(infoMessages).to.not.be.empty; + let infoMessagesContainsStatus = false; + // Search for message, in older node, may be in random order. + for (const message of infoMessages) { + if (message.includes('status null')) { + infoMessagesContainsStatus = true; + break; + } + } + expect(infoMessagesContainsStatus).to.be.true; + }); + + it('Throws error if a path cannot be generated from type', async () => { + let didGetRequest = false; + let establishedConnections = 0; + const responseTimeout = 0; + server = createAndStartMockLowLevelServer(TEST_PORT, stream => { + setTimeout(() => { + const { session } = stream; + didGetRequest = true; + if (session) { + session.destroy(); + } + }, responseTimeout); + }); + server.on('connection', () => (establishedConnections += 1)); + client = createClient(CLIENT_TEST_PORT); + + const onListeningPromise = new Promise(resolve => server.on('listening', resolve)); + await onListeningPromise; + + const mockHeaders = { 'apns-someheader': 'somevalue' }; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const performRequestExpectingDisconnect = async () => { + const bundleId = BUNDLE_ID; + const type = 'hello'; + let receivedError; + try { + await client.write(mockNotification, bundleId, type, 'post'); + } catch (e) { + receivedError = e; + } + expect(receivedError).to.exist; + expect(receivedError.error).to.be.an.instanceof(VError); + expect(receivedError.error.message).to.have.string('could not make a path'); + }; + await performRequestExpectingDisconnect(); + expect(didGetRequest).to.be.false; + expect(establishedConnections).to.equal(0); + }); + + it('Throws error on unknown method', async () => { + let didGetRequest = false; + let establishedConnections = 0; + const responseTimeout = 0; + server = createAndStartMockLowLevelServer(TEST_PORT, stream => { + setTimeout(() => { + const { session } = stream; + didGetRequest = true; + if (session) { + session.destroy(); + } + }, responseTimeout); + }); + server.on('connection', () => (establishedConnections += 1)); + client = createClient(CLIENT_TEST_PORT); + + const onListeningPromise = new Promise(resolve => server.on('listening', resolve)); + await onListeningPromise; + + const mockHeaders = { 'apns-someheader': 'somevalue' }; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const performRequestExpectingDisconnect = async () => { + const bundleId = BUNDLE_ID; + const method = 'hello'; + let receivedError; + try { + await client.write(mockNotification, bundleId, 'channels', method); + } catch (e) { + receivedError = e; + } + expect(receivedError).to.exist; + expect(receivedError.bundleId).to.equal(bundleId); + expect(receivedError.error).to.be.an.instanceof(VError); + expect(receivedError.error.message).to.have.string('invalid httpMethod'); + }; + await performRequestExpectingDisconnect(); + expect(didGetRequest).to.be.false; + expect(establishedConnections).to.equal(0); + }); + + it('Throws error if attempted to write after shutdown', async () => { + let didGetRequest = false; + let establishedConnections = 0; + const responseTimeout = 0; + server = createAndStartMockLowLevelServer(TEST_PORT, stream => { + setTimeout(() => { + const { session } = stream; + didGetRequest = true; + if (session) { + session.destroy(); + } + }, responseTimeout); + }); + server.on('connection', () => (establishedConnections += 1)); + client = createClient(CLIENT_TEST_PORT); + + const onListeningPromise = new Promise(resolve => server.on('listening', resolve)); + await onListeningPromise; + + const mockHeaders = { 'apns-someheader': 'somevalue' }; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const performRequestExpectingDisconnect = async () => { + const bundleId = BUNDLE_ID; + const method = 'post'; + let receivedError; + await client.shutdown(); + try { + await client.write(mockNotification, bundleId, 'channels', method); + } catch (e) { + receivedError = e; + } + expect(receivedError).to.exist; + expect(receivedError.bundleId).to.equal(bundleId); + expect(receivedError.error).to.be.an.instanceof(VError); + expect(receivedError.error.message).to.have.string('destroyed'); + }; + await performRequestExpectingDisconnect(); + expect(didGetRequest).to.be.false; + expect(establishedConnections).to.equal(0); + let calledCallBack = false; + await client.shutdown(() => { + calledCallBack = true; + }); + expect(calledCallBack).to.be.true; + }); + + it('Can connect and write successfully after destroy', async () => { + let didRequest = false; + let establishedConnections = 0; + let requestsServed = 0; + const method = HTTP2_METHOD_POST; + const path = PATH_CHANNELS; + server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { + expect(req.headers).to.deep.equal({ + ':authority': '127.0.0.1', + ':method': method, + ':path': path, + ':scheme': 'https', + 'apns-someheader': 'somevalue', + }); + expect(requestBody).to.equal(MOCK_BODY); + // res.setHeader('X-Foo', 'bar'); + // res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.writeHead(200); + res.end(''); + requestsServed += 1; + didRequest = true; + }); + server.on('connection', () => (establishedConnections += 1)); + await new Promise(resolve => server.on('listening', resolve)); + client = createClient(CLIENT_TEST_PORT); + + const mockHeaders = { 'apns-someheader': 'somevalue' }; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const performRequestExpectingDisconnect = async () => { + const bundleId = BUNDLE_ID; + const method = 'post'; + + await client.write(mockNotification, bundleId, 'channels', method); + expect(didRequest).to.be.true; + expect(establishedConnections).to.equal(1); + expect(requestsServed).to.equal(1); + + await client.destroySession(client.manageChannelsSession); + await client.write(mockNotification, bundleId, 'channels', method); + expect(establishedConnections).to.equal(2); + expect(requestsServed).to.equal(2); + }; + await performRequestExpectingDisconnect(); + }); + + it('Establishes a connection through a proxy server', async () => { + let didRequest = false; + let establishedConnections = 0; + let requestsServed = 0; + const method = HTTP2_METHOD_POST; + const path = PATH_CHANNELS; + const proxyPort = TEST_PORT - 2; + + server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { + expect(req.headers).to.deep.equal({ + ':authority': '127.0.0.1', + ':method': method, + ':path': path, + ':scheme': 'https', + 'apns-someheader': 'somevalue', + }); + expect(requestBody).to.equal(MOCK_BODY); + // res.setHeader('X-Foo', 'bar'); + // res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.writeHead(200); + res.end(''); + requestsServed += 1; + didRequest = true; + }); + server.on('connection', () => (establishedConnections += 1)); + await new Promise(resolve => server.once('listening', resolve)); + + // Proxy forwards all connections to TEST_PORT. + const sockets = []; + let proxy = net.createServer(clientSocket => { + clientSocket.once('data', () => { + const serverSocket = net.createConnection(TEST_PORT, () => { + clientSocket.write('HTTP/1.1 200 OK\r\n\r\n'); + clientSocket.pipe(serverSocket); + setTimeout(() => { + serverSocket.pipe(clientSocket); + }, 1); + }); + sockets.push(clientSocket, serverSocket); + }); + clientSocket.on('error', () => {}); + }); + await new Promise(resolve => proxy.listen(proxyPort, resolve)); + // Don't block the tests if this server doesn't shut down properly. + proxy.unref(); + + // Client configured with a port that the server is not listening on. + client = createClient(CLIENT_TEST_PORT); + // Not adding a proxy config will cause a failure with a network error. + client.config.manageChannelsProxy = { host: '127.0.0.1', port: proxyPort }; + const runSuccessfulRequest = async () => { + const mockHeaders = { 'apns-someheader': 'somevalue' }; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const bundleId = BUNDLE_ID; + const result = await client.write(mockNotification, bundleId, 'channels', 'post'); + expect(result).to.deep.equal({ bundleId }); + expect(didRequest).to.be.true; + }; + expect(establishedConnections).to.equal(0); // should not establish a connection until it's needed + // Validate that when multiple valid requests arrive concurrently, + // only one HTTP/2 connection gets established. + await Promise.all([ + runSuccessfulRequest(), + runSuccessfulRequest(), + runSuccessfulRequest(), + runSuccessfulRequest(), + runSuccessfulRequest(), + ]); + didRequest = false; + await runSuccessfulRequest(); + expect(establishedConnections).to.equal(1); // should establish a connection to the server and reuse it + expect(requestsServed).to.equal(6); + + // Shut down proxy server properly. + await new Promise(resolve => { + sockets.forEach(socket => socket.end('')); + proxy.close(() => { + resolve(); + }); + }); + proxy = null; + }); + + it('Throws an error when there is a bad proxy server', async () => { + // Client configured with a port that the server is not listening on. + client = createClient(CLIENT_TEST_PORT); + // Not adding a proxy config will cause a failure with a network error. + client.config.manageChannelsProxy = { host: '127.0.0.1', port: 'NOT_A_PORT' }; + + // Setup logger. + const infoMessages = []; + const errorMessages = []; + const mockInfoLogger = message => { + infoMessages.push(message); + }; + const mockErrorLogger = message => { + errorMessages.push(message); + }; + mockInfoLogger.enabled = true; + mockErrorLogger.enabled = true; + client.setLogger(mockInfoLogger, mockErrorLogger); + + const runUnsuccessfulRequest = async () => { + const mockHeaders = { 'apns-someheader': 'somevalue' }; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const bundleId = BUNDLE_ID; + let receivedError; + try { + await client.write(mockNotification, bundleId, 'channels', 'post'); + } catch (e) { + receivedError = e; + } + expect(receivedError).to.exist; + expect(receivedError.bundleId).to.equal(bundleId); + expect(receivedError.error.code).to.equal('ERR_SOCKET_BAD_PORT'); + }; + await runUnsuccessfulRequest(); + + expect(errorMessages).to.not.be.empty; + let errorMessagesContainsStatus = false; + // Search for message, in older node, may be in random order. + for (const message of errorMessages) { + if (message.includes('NOT_A_PORT')) { + errorMessagesContainsStatus = true; + break; + } + } + expect(errorMessagesContainsStatus).to.be.true; + expect(infoMessages).to.be.empty; + }); +}); diff --git a/test/config.js b/test/config.js index 122feab5..6e4db23b 100644 --- a/test/config.js +++ b/test/config.js @@ -25,9 +25,12 @@ describe('config', function () { production: false, address: 'api.sandbox.push.apple.com', port: 443, + manageChannelsAddress: 'api-manage-broadcast.sandbox.push.apple.com', + manageChannelsPort: 2195, proxy: null, + manageChannelsProxy: null, rejectUnauthorized: true, - connectionRetryLimit: 10, + connectionRetryLimit: 3, heartBeat: 60000, requestTimeout: 5000, }); @@ -88,6 +91,79 @@ describe('config', function () { }); }); + describe('manageChannelsAddress 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-manage-broadcast.sandbox.push.apple.com as the default connection address', function () { + const testConfig = config(); + expect(testConfig).to.have.property( + 'manageChannelsAddress', + 'api-manage-broadcast.sandbox.push.apple.com' + ); + expect(testConfig).to.have.property('manageChannelsPort', 2195); + }); + + it('should use api-manage-broadcast.push.apple.com when NODE_ENV=production', function () { + process.env.NODE_ENV = 'production'; + const testConfig = config(); + expect(testConfig).to.have.property( + 'manageChannelsAddress', + 'api-manage-broadcast.push.apple.com' + ); + expect(testConfig).to.have.property('manageChannelsPort', 2196); + }); + + it('should give precedence to production flag over NODE_ENV=production', function () { + process.env.NODE_ENV = 'production'; + const testConfig = config({ production: false }); + expect(testConfig).to.have.property( + 'manageChannelsAddress', + 'api-manage-broadcast.sandbox.push.apple.com' + ); + expect(testConfig).to.have.property('manageChannelsPort', 2195); + }); + + it('should use api-manage-broadcast.push.apple.com when production:true', function () { + const testConfig = config({ production: true }); + expect(testConfig).to.have.property( + 'manageChannelsAddress', + 'api-manage-broadcast.push.apple.com' + ); + expect(testConfig).to.have.property('manageChannelsPort', 2196); + }); + + it('should use a custom address and default port when passed', function () { + const testAddress = 'testaddress'; + const testPort = 2195; + const testConfig = config({ manageChannelsAddress: testAddress }); + expect(testConfig).to.have.property('manageChannelsAddress', testAddress); + expect(testConfig).to.have.property('manageChannelsPort', testPort); + }); + + it('should use a custom address and port when passed', function () { + const testAddress = 'testaddress'; + const testPort = 445; + const testConfig = config({ + manageChannelsAddress: testAddress, + manageChannelsPort: testPort, + }); + expect(testConfig).to.have.property('manageChannelsAddress', testAddress); + expect(testConfig).to.have.property('manageChannelsPort', testPort); + }); + }); + describe('credentials', function () { context('`token` not supplied, use certificate', function () { describe('passphrase', function () { diff --git a/test/multiclient.js b/test/multiclient.js index 670ae76d..80d46a18 100644 --- a/test/multiclient.js +++ b/test/multiclient.js @@ -4,12 +4,15 @@ const VError = require('verror'); const http2 = require('http2'); +const { HTTP2_METHOD_POST } = http2.constants; + const debug = require('debug')('apn'); const credentials = require('../lib/credentials')({ logger: debug, }); -const TEST_PORT = 30939; +const TEST_PORT = 30950; +const CLIENT_TEST_PORT = TEST_PORT; const LOAD_TEST_BATCH_SIZE = 2000; const config = require('../lib/config')({ @@ -63,6 +66,7 @@ describe('MultiClient', () => { let client; const MOCK_BODY = '{"mock-key":"mock-value"}'; const MOCK_DEVICE_TOKEN = 'abcf0123abcf0123abcf0123abcf0123abcf0123abcf0123abcf0123abcf0123'; + const PATH_DEVICE = `/3/device/${MOCK_DEVICE_TOKEN}`; // Create an insecure http2 client for unit testing. // (APNS would use https://, not http://) @@ -74,11 +78,13 @@ describe('MultiClient', () => { address: '127.0.0.1', clientCount: 2, }); + let count = 1; mc.clients.forEach(c => { - c._mockOverrideUrl = `http://127.0.0.1:${port}`; + c._mockOverrideUrl = `http://127.0.0.1:${port + count}`; c.config.port = port; c.config.address = '127.0.0.1'; c.config.requestTimeout = timeout; + count += 1; }); return mc; }; @@ -112,20 +118,23 @@ describe('MultiClient', () => { return server; }; - afterEach(done => { - const closeServer = () => { + afterEach(async () => { + const closeServer = async () => { if (server) { - server.close(); + await new Promise(resolve => { + server.close(() => { + resolve(); + }); + }); server = null; } - done(); }; if (client) { - client.shutdown(closeServer); - client = null; - } else { - closeServer(); + await client.shutdown(() => { + client = null; + }); } + await closeServer(); }); it('rejects invalid clientCount', () => { @@ -133,7 +142,7 @@ describe('MultiClient', () => { expect( () => new MultiClient({ - port: TEST_PORT, + port: CLIENT_TEST_PORT, address: '127.0.0.1', clientCount, }) @@ -145,11 +154,13 @@ describe('MultiClient', () => { let didRequest = false; let establishedConnections = 0; let requestsServed = 0; + const method = HTTP2_METHOD_POST; + const path = PATH_DEVICE; server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { expect(req.headers).to.deep.equal({ ':authority': '127.0.0.1', - ':method': 'POST', - ':path': `/3/device/${MOCK_DEVICE_TOKEN}`, + ':method': method, + ':path': path, ':scheme': 'https', 'apns-someheader': 'somevalue', }); @@ -164,7 +175,7 @@ describe('MultiClient', () => { server.on('connection', () => (establishedConnections += 1)); await new Promise(resolve => server.on('listening', resolve)); - client = createClient(TEST_PORT); + client = createClient(CLIENT_TEST_PORT); const runSuccessfulRequest = async () => { const mockHeaders = { 'apns-someheader': 'somevalue' }; @@ -172,9 +183,9 @@ describe('MultiClient', () => { headers: mockHeaders, body: MOCK_BODY, }; - const mockDevice = MOCK_DEVICE_TOKEN; - const result = await client.write(mockNotification, mockDevice); - expect(result).to.deep.equal({ device: MOCK_DEVICE_TOKEN }); + const device = MOCK_DEVICE_TOKEN; + const result = await client.write(mockNotification, device, 'device', 'post'); + expect(result).to.deep.equal({ device }); expect(didRequest).to.be.true; }; expect(establishedConnections).to.equal(0); // should not establish a connection until it's needed @@ -198,11 +209,13 @@ describe('MultiClient', () => { this.timeout(10000); let establishedConnections = 0; let requestsServed = 0; + const method = HTTP2_METHOD_POST; + const path = PATH_DEVICE; server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { expect(req.headers).to.deep.equal({ ':authority': '127.0.0.1', - ':method': 'POST', - ':path': `/3/device/${MOCK_DEVICE_TOKEN}`, + ':method': method, + ':path': path, ':scheme': 'https', 'apns-someheader': 'somevalue', }); @@ -217,7 +230,7 @@ describe('MultiClient', () => { server.on('connection', () => (establishedConnections += 1)); await new Promise(resolve => server.on('listening', resolve)); - client = createClient(TEST_PORT, 1500); + client = createClient(CLIENT_TEST_PORT, 1500); const runSuccessfulRequest = async () => { const mockHeaders = { 'apns-someheader': 'somevalue' }; @@ -225,9 +238,9 @@ describe('MultiClient', () => { headers: mockHeaders, body: MOCK_BODY, }; - const mockDevice = MOCK_DEVICE_TOKEN; - const result = await client.write(mockNotification, mockDevice); - expect(result).to.deep.equal({ device: MOCK_DEVICE_TOKEN }); + const device = MOCK_DEVICE_TOKEN; + const result = await client.write(mockNotification, device, 'device', 'post'); + expect(result).to.deep.equal({ device }); }; expect(establishedConnections).to.equal(0); // should not establish a connection until it's needed // Validate that when multiple valid requests arrive concurrently, @@ -257,7 +270,7 @@ describe('MultiClient', () => { server.on('connection', () => (establishedConnections += 1)); await new Promise(resolve => server.on('listening', resolve)); - client = createClient(TEST_PORT); + client = createClient(CLIENT_TEST_PORT); const infoMessages = []; const errorMessages = []; const mockInfoLogger = message => { @@ -276,10 +289,16 @@ describe('MultiClient', () => { headers: mockHeaders, body: MOCK_BODY, }; - const mockDevice = MOCK_DEVICE_TOKEN; - const result = await client.write(mockNotification, mockDevice); - expect(result).to.deep.equal({ - device: MOCK_DEVICE_TOKEN, + const device = MOCK_DEVICE_TOKEN; + let receivedError; + try { + await client.write(mockNotification, device, 'device', 'post'); + } catch (e) { + receivedError = e; + } + expect(receivedError).to.exist; + expect(receivedError).to.deep.equal({ + device, response: { reason: 'BadDeviceToken', }, @@ -316,7 +335,7 @@ describe('MultiClient', () => { server.on('connection', () => (establishedConnections += 1)); await new Promise(resolve => server.on('listening', resolve)); - client = createClient(TEST_PORT); + client = createClient(CLIENT_TEST_PORT); const runRequestWithInternalServerError = async () => { const mockHeaders = { 'apns-someheader': 'somevalue' }; @@ -324,12 +343,17 @@ describe('MultiClient', () => { headers: mockHeaders, body: MOCK_BODY, }; - const mockDevice = MOCK_DEVICE_TOKEN; - const result = await client.write(mockNotification, mockDevice); - expect(result).to.exist; - expect(result.device).to.equal(MOCK_DEVICE_TOKEN); - expect(result.error).to.be.an.instanceof(VError); - expect(result.error.message).to.have.string('stream ended unexpectedly'); + const device = MOCK_DEVICE_TOKEN; + let receivedError; + try { + await client.write(mockNotification, device, 'device', 'post'); + } catch (e) { + receivedError = e; + } + expect(receivedError).to.exist; + expect(receivedError.device).to.equal(device); + expect(receivedError.error).to.be.an.instanceof(VError); + expect(receivedError.error.message).to.have.string('stream ended unexpectedly'); }; await runRequestWithInternalServerError(); await runRequestWithInternalServerError(); @@ -338,7 +362,7 @@ describe('MultiClient', () => { // Validate that nothing wrong happens when multiple HTTP 500s are received simultaneously. // (no segfaults, all promises get resolved, etc.) responseDelay = 50; - await Promise.all([ + await Promise.allSettled([ runRequestWithInternalServerError(), runRequestWithInternalServerError(), runRequestWithInternalServerError(), @@ -361,7 +385,7 @@ describe('MultiClient', () => { server.on('connection', () => (establishedConnections += 1)); await new Promise(resolve => server.on('listening', resolve)); - client = createClient(TEST_PORT); + client = createClient(CLIENT_TEST_PORT); const runRequestWithInternalServerError = async () => { const mockHeaders = { 'apns-someheader': 'somevalue' }; @@ -369,12 +393,18 @@ describe('MultiClient', () => { headers: mockHeaders, body: MOCK_BODY, }; - const mockDevice = MOCK_DEVICE_TOKEN; - const result = await client.write(mockNotification, mockDevice); + const device = MOCK_DEVICE_TOKEN; + let receivedError; + try { + await client.write(mockNotification, device, 'device', 'post'); + } catch (e) { + receivedError = e; + } // Should not happen, but if it does, the promise should resolve with an error - expect(result.device).to.equal(MOCK_DEVICE_TOKEN); + expect(receivedError).to.exist; + expect(receivedError.device).to.equal(device); expect( - result.error.message.startsWith( + receivedError.error.message.startsWith( 'Unexpected error processing APNs response: Unexpected token' ) ).to.equal(true); @@ -395,7 +425,7 @@ describe('MultiClient', () => { didGetResponse = true; }, 1900); }); - client = createClient(TEST_PORT); + client = createClient(CLIENT_TEST_PORT); const onListeningPromise = new Promise(resolve => server.on('listening', resolve)); await onListeningPromise; @@ -405,11 +435,17 @@ describe('MultiClient', () => { headers: mockHeaders, body: MOCK_BODY, }; - const mockDevice = MOCK_DEVICE_TOKEN; const performRequestExpectingTimeout = async () => { - const result = await client.write(mockNotification, mockDevice); - expect(result).to.deep.equal({ - device: MOCK_DEVICE_TOKEN, + const device = MOCK_DEVICE_TOKEN; + let receivedError; + try { + await client.write(mockNotification, device, 'device', 'post'); + } catch (e) { + receivedError = e; + } + expect(receivedError).to.exist; + expect(receivedError).to.deep.equal({ + device, error: new VError('apn write timeout'), }); expect(didGetRequest).to.be.true; @@ -437,7 +473,7 @@ describe('MultiClient', () => { session.goaway(errorCode); }); server.on('connection', () => (establishedConnections += 1)); - client = createClient(TEST_PORT); + client = createClient(CLIENT_TEST_PORT); const onListeningPromise = new Promise(resolve => server.on('listening', resolve)); await onListeningPromise; @@ -447,11 +483,17 @@ describe('MultiClient', () => { headers: mockHeaders, body: MOCK_BODY, }; - const mockDevice = MOCK_DEVICE_TOKEN; const performRequestExpectingGoAway = async () => { - const result = await client.write(mockNotification, mockDevice); - expect(result.device).to.equal(MOCK_DEVICE_TOKEN); - expect(result.error).to.be.an.instanceof(VError); + const device = MOCK_DEVICE_TOKEN; + let receivedError; + try { + await client.write(mockNotification, device, 'device', 'post'); + } catch (e) { + receivedError = e; + } + expect(receivedError).to.exist; + expect(receivedError.device).to.equal(device); + expect(receivedError.error).to.be.an.instanceof(VError); expect(didGetRequest).to.be.true; didGetRequest = false; }; @@ -474,7 +516,7 @@ describe('MultiClient', () => { }, responseTimeout); }); server.on('connection', () => (establishedConnections += 1)); - client = createClient(TEST_PORT); + client = createClient(CLIENT_TEST_PORT); const onListeningPromise = new Promise(resolve => server.on('listening', resolve)); await onListeningPromise; @@ -484,11 +526,17 @@ describe('MultiClient', () => { headers: mockHeaders, body: MOCK_BODY, }; - const mockDevice = MOCK_DEVICE_TOKEN; const performRequestExpectingDisconnect = async () => { - const result = await client.write(mockNotification, mockDevice); - expect(result).to.deep.equal({ - device: MOCK_DEVICE_TOKEN, + const device = MOCK_DEVICE_TOKEN; + let receivedError; + try { + await client.write(mockNotification, device, 'device', 'post'); + } catch (e) { + receivedError = e; + } + expect(receivedError).to.exist; + expect(receivedError).to.deep.equal({ + device, error: new VError('stream ended unexpectedly with status null and empty body'), }); expect(didGetRequest).to.be.true; @@ -1113,3 +1161,501 @@ describe('MultiClient', () => { // }); }); }); + +describe('ManageChannelsMultiClient', () => { + let server; + let client; + const MOCK_BODY = '{"mock-key":"mock-value"}'; + const BUNDLE_ID = 'com.node.apn'; + const PATH_CHANNELS = `/1/apps/${BUNDLE_ID}/channels`; + + // Create an insecure http2 client for unit testing. + // (APNS would use https://, not http://) + // (It's probably possible to allow accepting invalid certificates instead, + // but that's not the most important point of these tests) + const createClient = (port, timeout = 500) => { + const mc = new MultiClient({ + manageChannelsAddress: '127.0.0.1', + manageChannelsPort: TEST_PORT, + clientCount: 2, + }); + let count = 1; + mc.clients.forEach(c => { + c._mockOverrideUrl = `http://127.0.0.1:${port + count}`; + c.config.manageChannelsPort = TEST_PORT; + c.config.manageChannelsAddress = '127.0.0.1'; + c.config.requestTimeout = timeout; + count += 1; + }); + return mc; + }; + // Create an insecure server for unit testing. + const createAndStartMockServer = (port, cb) => { + server = http2.createServer((req, res) => { + const buffers = []; + req.on('data', data => buffers.push(data)); + req.on('end', () => { + const requestBody = Buffer.concat(buffers).toString('utf-8'); + cb(req, res, requestBody); + }); + }); + server.listen(port); + server.on('error', err => { + expect.fail(`unexpected error ${err}`); + }); + // Don't block the tests if this server doesn't shut down properly + server.unref(); + return server; + }; + const createAndStartMockLowLevelServer = (port, cb) => { + server = http2.createServer(); + server.on('stream', cb); + server.listen(port); + server.on('error', err => { + expect.fail(`unexpected error ${err}`); + }); + // Don't block the tests if this server doesn't shut down properly + server.unref(); + return server; + }; + + afterEach(async () => { + const closeServer = async () => { + if (server) { + await new Promise(resolve => { + server.close(() => { + resolve(); + }); + }); + server = null; + } + }; + if (client) { + await client.shutdown(() => { + client = null; + }); + } + await closeServer(); + }); + + it('rejects invalid clientCount', () => { + [-1, 'invalid'].forEach(clientCount => { + expect( + () => + new MultiClient({ + port: CLIENT_TEST_PORT, + address: '127.0.0.1', + clientCount, + }) + ).to.throw(`Expected positive client count but got ${clientCount}`); + }); + }); + + it('Treats HTTP 200 responses as successful', async () => { + let didRequest = false; + let establishedConnections = 0; + let requestsServed = 0; + const method = HTTP2_METHOD_POST; + const path = PATH_CHANNELS; + server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { + expect(req.headers).to.deep.equal({ + ':authority': '127.0.0.1', + ':method': method, + ':path': path, + ':scheme': 'https', + 'apns-someheader': 'somevalue', + }); + expect(requestBody).to.equal(MOCK_BODY); + // res.setHeader('X-Foo', 'bar'); + // res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.writeHead(200); + res.end(''); + requestsServed += 1; + didRequest = true; + }); + server.on('connection', () => (establishedConnections += 1)); + await new Promise(resolve => server.on('listening', resolve)); + + client = createClient(CLIENT_TEST_PORT); + + const runSuccessfulRequest = async () => { + const mockHeaders = { 'apns-someheader': 'somevalue' }; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const bundleId = BUNDLE_ID; + const result = await client.write(mockNotification, bundleId, 'channels', 'post'); + expect(result).to.deep.equal({ bundleId }); + expect(didRequest).to.be.true; + }; + expect(establishedConnections).to.equal(0); // should not establish a connection until it's needed + // Validate that when multiple valid requests arrive concurrently, + // only one HTTP/2 connection gets established + await Promise.all([ + runSuccessfulRequest(), + runSuccessfulRequest(), + runSuccessfulRequest(), + runSuccessfulRequest(), + runSuccessfulRequest(), + ]); + didRequest = false; + await runSuccessfulRequest(); + expect(establishedConnections).to.equal(2); // should establish a connection to the server and reuse it + expect(requestsServed).to.equal(6); + }); + + // Assert that this doesn't crash when a large batch of requests are requested simultaneously + it('Treats HTTP 200 responses as successful (load test for a batch of requests)', async function () { + this.timeout(10000); + let establishedConnections = 0; + let requestsServed = 0; + const method = HTTP2_METHOD_POST; + const path = PATH_CHANNELS; + server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { + expect(req.headers).to.deep.equal({ + ':authority': '127.0.0.1', + ':method': method, + ':path': path, + ':scheme': 'https', + 'apns-someheader': 'somevalue', + }); + expect(requestBody).to.equal(MOCK_BODY); + // Set a timeout of 100 to simulate latency to a remote server. + setTimeout(() => { + res.writeHead(200); + res.end(''); + requestsServed += 1; + }, 100); + }); + server.on('connection', () => (establishedConnections += 1)); + await new Promise(resolve => server.on('listening', resolve)); + + client = createClient(CLIENT_TEST_PORT, 1500); + + const runSuccessfulRequest = async () => { + const mockHeaders = { 'apns-someheader': 'somevalue' }; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const bundleId = BUNDLE_ID; + const result = await client.write(mockNotification, bundleId, 'channels', 'post'); + expect(result).to.deep.equal({ bundleId }); + }; + expect(establishedConnections).to.equal(0); // should not establish a connection until it's needed + // Validate that when multiple valid requests arrive concurrently, + // only one HTTP/2 connection gets established + const promises = []; + for (let i = 0; i < LOAD_TEST_BATCH_SIZE; i++) { + promises.push(runSuccessfulRequest()); + } + + await Promise.all(promises); + expect(establishedConnections).to.equal(2); // should establish a connection to the server and reuse it + expect(requestsServed).to.equal(LOAD_TEST_BATCH_SIZE); + }); + + // https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/handling_notification_responses_from_apns + it('JSON decodes HTTP 400 responses', async () => { + let didRequest = false; + let establishedConnections = 0; + server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { + expect(requestBody).to.equal(MOCK_BODY); + // res.setHeader('X-Foo', 'bar'); + // res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.writeHead(400); + res.end('{"reason": "BadDeviceToken"}'); + didRequest = true; + }); + server.on('connection', () => (establishedConnections += 1)); + await new Promise(resolve => server.on('listening', resolve)); + + client = createClient(CLIENT_TEST_PORT); + const infoMessages = []; + const errorMessages = []; + const mockInfoLogger = message => { + infoMessages.push(message); + }; + const mockErrorLogger = message => { + errorMessages.push(message); + }; + mockInfoLogger.enabled = true; + mockErrorLogger.enabled = true; + client.setLogger(mockInfoLogger, mockErrorLogger); + + const runRequestWithBadDeviceToken = async () => { + const mockHeaders = { 'apns-someheader': 'somevalue' }; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const bundleId = BUNDLE_ID; + let receivedError; + try { + await client.write(mockNotification, bundleId, 'channels', 'post'); + } catch (e) { + receivedError = e; + } + expect(receivedError).to.exist; + expect(receivedError).to.deep.equal({ + bundleId, + response: { + reason: 'BadDeviceToken', + }, + status: 400, + }); + expect(didRequest).to.be.true; + didRequest = false; + }; + await runRequestWithBadDeviceToken(); + await runRequestWithBadDeviceToken(); + expect(establishedConnections).to.equal(2); // should establish a connection to the server and reuse it + expect(infoMessages).to.deep.equal([ + 'ManageChannelsSession connected', + 'Request ended with status 400 and responseData: {"reason": "BadDeviceToken"}', + 'ManageChannelsSession connected', + 'Request ended with status 400 and responseData: {"reason": "BadDeviceToken"}', + ]); + expect(errorMessages).to.deep.equal([]); + }); + + // node-apn started closing connections in response to a bug report where HTTP 500 responses + // persisted until a new connection was reopened + it('Closes connections when HTTP 500 responses are received', async () => { + let establishedConnections = 0; + let responseDelay = 50; + server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { + // Wait 50ms before sending the responses in parallel + setTimeout(() => { + expect(requestBody).to.equal(MOCK_BODY); + res.writeHead(500); + res.end('{"reason": "InternalServerError"}'); + }, responseDelay); + }); + server.on('connection', () => (establishedConnections += 1)); + await new Promise(resolve => server.on('listening', resolve)); + + client = createClient(CLIENT_TEST_PORT); + + const runRequestWithInternalServerError = async () => { + const mockHeaders = { 'apns-someheader': 'somevalue' }; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const bundleId = BUNDLE_ID; + let receivedError; + try { + await client.write(mockNotification, bundleId, 'channels', 'post'); + } catch (e) { + receivedError = e; + } + expect(receivedError).to.exist; + expect(receivedError.bundleId).to.equal(bundleId); + expect(receivedError.error).to.be.an.instanceof(VError); + expect(receivedError.error.message).to.have.string('stream ended unexpectedly'); + }; + await runRequestWithInternalServerError(); + await runRequestWithInternalServerError(); + await runRequestWithInternalServerError(); + expect(establishedConnections).to.equal(3); // should close and establish new connections on http 500 + // Validate that nothing wrong happens when multiple HTTP 500s are received simultaneously. + // (no segfaults, all promises get resolved, etc.) + responseDelay = 50; + await Promise.allSettled([ + runRequestWithInternalServerError(), + runRequestWithInternalServerError(), + runRequestWithInternalServerError(), + runRequestWithInternalServerError(), + ]); + expect(establishedConnections).to.equal(5); // should close and establish new connections on http 500 + }); + + it('Handles unexpected invalid JSON responses', async () => { + let establishedConnections = 0; + const responseDelay = 0; + server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { + // Wait 50ms before sending the responses in parallel + setTimeout(() => { + expect(requestBody).to.equal(MOCK_BODY); + res.writeHead(500); + res.end('PC LOAD LETTER'); + }, responseDelay); + }); + server.on('connection', () => (establishedConnections += 1)); + await new Promise(resolve => server.on('listening', resolve)); + + client = createClient(CLIENT_TEST_PORT); + + const runRequestWithInternalServerError = async () => { + const mockHeaders = { 'apns-someheader': 'somevalue' }; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const bundleId = BUNDLE_ID; + let receivedError; + try { + await client.write(mockNotification, bundleId, 'channels', 'post'); + } catch (e) { + receivedError = e; + } + // Should not happen, but if it does, the promise should resolve with an error + expect(receivedError).to.exist; + expect(receivedError.bundleId).to.equal(bundleId); + expect( + receivedError.error.message.startsWith( + 'Unexpected error processing APNs response: Unexpected token' + ) + ).to.equal(true); + }; + await runRequestWithInternalServerError(); + await runRequestWithInternalServerError(); + expect(establishedConnections).to.equal(2); // Currently reuses the connections. + }); + + it('Handles APNs timeouts', async () => { + let didGetRequest = false; + let didGetResponse = false; + server = createAndStartMockServer(TEST_PORT, (req, res, requestBody) => { + didGetRequest = true; + setTimeout(() => { + res.writeHead(200); + res.end(''); + didGetResponse = true; + }, 1900); + }); + client = createClient(CLIENT_TEST_PORT); + + const onListeningPromise = new Promise(resolve => server.on('listening', resolve)); + await onListeningPromise; + + const mockHeaders = { 'apns-someheader': 'somevalue' }; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const performRequestExpectingTimeout = async () => { + const bundleId = BUNDLE_ID; + let receivedError; + try { + await client.write(mockNotification, bundleId, 'channels', 'post'); + } catch (e) { + receivedError = e; + } + expect(receivedError).to.exist; + expect(receivedError).to.deep.equal({ + bundleId, + error: new VError('apn write timeout'), + }); + expect(didGetRequest).to.be.true; + expect(didGetResponse).to.be.false; + }; + await performRequestExpectingTimeout(); + didGetResponse = false; + didGetRequest = false; + // Should be able to have multiple in flight requests all get notified that the server is shutting down + await Promise.all([ + performRequestExpectingTimeout(), + performRequestExpectingTimeout(), + performRequestExpectingTimeout(), + performRequestExpectingTimeout(), + ]); + }); + + it('Handles goaway frames', async () => { + let didGetRequest = false; + let establishedConnections = 0; + server = createAndStartMockLowLevelServer(TEST_PORT, stream => { + const session = stream.session; + const errorCode = 1; + didGetRequest = true; + session.goaway(errorCode); + }); + server.on('connection', () => (establishedConnections += 1)); + client = createClient(CLIENT_TEST_PORT); + + const onListeningPromise = new Promise(resolve => server.on('listening', resolve)); + await onListeningPromise; + + const mockHeaders = { 'apns-someheader': 'somevalue' }; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const performRequestExpectingGoAway = async () => { + const bundleId = BUNDLE_ID; + let receivedError; + try { + await client.write(mockNotification, bundleId, 'channels', 'post'); + } catch (e) { + receivedError = e; + } + expect(receivedError).to.exist; + expect(receivedError.bundleId).to.equal(bundleId); + expect(receivedError.error).to.be.an.instanceof(VError); + expect(didGetRequest).to.be.true; + didGetRequest = false; + }; + await performRequestExpectingGoAway(); + await performRequestExpectingGoAway(); + expect(establishedConnections).to.equal(2); + }); + + it('Handles unexpected protocol errors (no response sent)', async () => { + let didGetRequest = false; + let establishedConnections = 0; + let responseTimeout = 0; + server = createAndStartMockLowLevelServer(TEST_PORT, stream => { + setTimeout(() => { + const session = stream.session; + didGetRequest = true; + if (session) { + session.destroy(); + } + }, responseTimeout); + }); + server.on('connection', () => (establishedConnections += 1)); + client = createClient(CLIENT_TEST_PORT); + + const onListeningPromise = new Promise(resolve => server.on('listening', resolve)); + await onListeningPromise; + + const mockHeaders = { 'apns-someheader': 'somevalue' }; + const mockNotification = { + headers: mockHeaders, + body: MOCK_BODY, + }; + const performRequestExpectingDisconnect = async () => { + const bundleId = BUNDLE_ID; + let receivedError; + try { + await client.write(mockNotification, bundleId, 'channels', 'post'); + } catch (e) { + receivedError = e; + } + expect(receivedError).to.exist; + expect(receivedError).to.deep.equal({ + bundleId, + error: new VError('stream ended unexpectedly with status null and empty body'), + }); + expect(didGetRequest).to.be.true; + }; + await performRequestExpectingDisconnect(); + didGetRequest = false; + await performRequestExpectingDisconnect(); + didGetRequest = false; + expect(establishedConnections).to.equal(2); + responseTimeout = 10; + await Promise.all([ + performRequestExpectingDisconnect(), + performRequestExpectingDisconnect(), + performRequestExpectingDisconnect(), + performRequestExpectingDisconnect(), + ]); + expect(establishedConnections).to.equal(4); + }); + + describe('write', () => {}); +}); diff --git a/test/notification/index.js b/test/notification/index.js index 3849817e..bd71743b 100644 --- a/test/notification/index.js +++ b/test/notification/index.js @@ -109,6 +109,51 @@ describe('Notification', function () { }); }); + describe('addPushTypeToPayloadIfNeeded', function () { + it('add liveactivity push-type to payload when it is missing', function () { + note.addPushTypeToPayloadIfNeeded(); + + expect(note.payload).to.deep.equal({ 'push-type': 'liveactivity' }); + }); + + it('do not overwrite push-type if it is already present', function () { + note.payload['push-type'] = 'alert'; + note.addPushTypeToPayloadIfNeeded(); + + expect(note.payload).to.deep.equal({ 'push-type': 'alert' }); + }); + + it('do not add push-type if rawPayload is present', function () { + const payload = { some: 'payload' }; + note = new Notification({ rawPayload: payload }); + note.addPushTypeToPayloadIfNeeded(); + + expect(note.rawPayload).to.deep.equal({ some: 'payload' }); + expect(compiledOutput()).to.deep.equal({ some: 'payload' }); + }); + }); + + describe('removeNonChannelRelatedProperties', function () { + it('headers only contains channel related properties', function () { + note.priority = 5; + note.id = '123e4567-e89b-12d3-a456-42665544000'; + note.pushType = 'alert'; + note.expiry = 1000; + note.topic = 'io.apn.node'; + note.collapseId = 'io.apn.collapse'; + note.requestId = 'io.apn.request'; + note.channelId = 'io.apn.channel'; + note.pushType = 'liveactivity'; + note.removeNonChannelRelatedProperties(); + + expect(note.headers()).to.deep.equal({ + 'apns-channel-id': 'io.apn.channel', + 'apns-expiration': 1000, + 'apns-request-id': 'io.apn.request', + }); + }); + }); + describe('headers', function () { it('contains no properties by default', function () { expect(note.headers()).to.deep.equal({}); diff --git a/test/provider.js b/test/provider.js index 6674c589..1dfa0142 100644 --- a/test/provider.js +++ b/test/provider.js @@ -41,53 +41,51 @@ describe('Provider', function () { }); }); - describe('send', function () { - describe('single notification behaviour', function () { + describe('send', async () => { + describe('single notification behaviour', async () => { let provider; - context('transmission succeeds', function () { - beforeEach(function () { + context('transmission succeeds', async () => { + beforeEach(async () => { provider = new Provider({ address: 'testapi' }); fakes.client.write.onCall(0).returns(Promise.resolve({ device: 'abcd1234' })); }); - it('invokes the writer with correct `this`', function () { - return provider.send(notificationDouble(), 'abcd1234').then(function () { - expect(fakes.client.write).to.be.calledOn(fakes.client); - }); + it('invokes the writer with correct `this`', async () => { + await provider.send(notificationDouble(), 'abcd1234'); + 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('writes the notification to the client once', async () => { + await provider.send(notificationDouble(), 'abcd1234'); + + const notification = notificationDouble(); + const builtNotification = { + headers: notification.headers(), + body: notification.compile(), + }; + const device = 'abcd1234'; + expect(fakes.client.write).to.be.calledOnce; + expect(fakes.client.write).to.be.calledWith(builtNotification, device, 'device', 'post'); }); - it('does not pass the array index to writer', function () { - return provider.send(notificationDouble(), 'abcd1234').then(function () { - expect(fakes.client.write.firstCall.args[2]).to.be.undefined; - }); + it('does not pass the array index to writer', async () => { + await provider.send(notificationDouble(), 'abcd1234'); + expect(fakes.client.write.firstCall.args[4]).to.be.undefined; }); - it('resolves with the device token in the sent array', function () { - return expect(provider.send(notificationDouble(), 'abcd1234')).to.become({ + it('resolves with the device token in the sent array', async () => { + const result = await provider.send(notificationDouble(), 'abcd1234'); + expect(result).to.deep.equal({ sent: [{ device: 'abcd1234' }], failed: [], }); }); }); - context('error occurs', function () { - let promise; - - beforeEach(function () { + context('error occurs', async () => { + it('resolves with the device token, status code and response in the failed array', async () => { const provider = new Provider({ address: 'testapi' }); fakes.client.write.onCall(0).returns( @@ -97,11 +95,27 @@ describe('Provider', function () { response: { reason: 'BadDeviceToken' }, }) ); - promise = provider.send(notificationDouble(), 'abcd1234'); + const result = await provider.send(notificationDouble(), 'abcd1234'); + + expect(result).to.deep.equal({ + sent: [], + failed: [{ device: 'abcd1234', status: '400', response: { reason: 'BadDeviceToken' } }], + }); }); - it('resolves with the device token, status code and response in the failed array', function () { - return expect(promise).to.eventually.deep.equal({ + it('rejects with the device token, status code and response in the failed array', async () => { + const provider = new Provider({ address: 'testapi' }); + + fakes.client.write.onCall(0).returns( + Promise.reject({ + device: 'abcd1234', + status: '400', + response: { reason: 'BadDeviceToken' }, + }) + ); + const result = await provider.send(notificationDouble(), 'abcd1234'); + + expect(result).to.deep.equal({ sent: [], failed: [{ device: 'abcd1234', status: '400', response: { reason: 'BadDeviceToken' } }], }); @@ -109,8 +123,8 @@ describe('Provider', function () { }); }); - context('when multiple tokens are passed', function () { - beforeEach(function () { + context('when multiple tokens are passed', async () => { + beforeEach(async () => { fakes.resolutions = [ { device: 'abcd1234' }, { device: 'adfe5969', status: '400', response: { reason: 'MissingTopic' } }, @@ -125,55 +139,479 @@ describe('Provider', function () { ]; }); - context('streams are always returned', function () { - let promise; + context('streams are always returned', async () => { + let response; - beforeEach(function () { + beforeEach(async () => { 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( + response = await provider.send( notificationDouble(), fakes.resolutions.map(res => res.device) ); + }); + + it('resolves with the sent notifications', async () => { + 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', async () => { + expect(response.failed[3].error).to.be.an.instanceof(Error); + response.failed[3].error = { message: response.failed[3].error.message }; + 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: { message: 'connection failed' } }, + ], + `Unexpected result: ${JSON.stringify(response.failed)}` + ); + }); + }); + }); + }); + + describe('broadcast', async () => { + describe('single notification behaviour', async () => { + let provider; + + context('transmission succeeds', async () => { + beforeEach(async () => { + provider = new Provider({ address: 'testapi' }); + + fakes.client.write.onCall(0).returns(Promise.resolve({ bundleId: 'abcd1234' })); + }); + + it('invokes the writer with correct `this`', async () => { + await provider.broadcast(notificationDouble(), 'abcd1234'); + expect(fakes.client.write).to.be.calledOn(fakes.client); + }); + + it('writes the notification to the client once', async () => { + await provider.broadcast(notificationDouble(), 'abcd1234'); + + const notification = notificationDouble(); + const builtNotification = { + headers: notification.headers(), + body: notification.compile(), + }; + const bundleId = 'abcd1234'; + expect(fakes.client.write).to.be.calledOnce; + expect(fakes.client.write).to.be.calledWith( + builtNotification, + bundleId, + 'broadcasts', + 'post' + ); + }); + + it('does not pass the array index to writer', async () => { + await provider.broadcast(notificationDouble(), 'abcd1234'); + expect(fakes.client.write.firstCall.args[4]).to.be.undefined; + }); + + it('resolves with the bundleId in the sent array', async () => { + const result = await provider.broadcast(notificationDouble(), 'abcd1234'); + expect(result).to.deep.equal({ + sent: [{ bundleId: 'abcd1234' }], + failed: [], + }); + }); + }); + + context('error occurs', async () => { + it('resolves with the bundleId, status code and response in the failed array', async () => { + const provider = new Provider({ address: 'testapi' }); + + fakes.client.write.onCall(0).returns( + Promise.resolve({ + bundleId: 'abcd1234', + status: '400', + response: { reason: 'BadDeviceToken' }, + }) + ); + const result = await provider.broadcast(notificationDouble(), 'abcd1234'); + + expect(result).to.deep.equal({ + sent: [], + failed: [ + { bundleId: 'abcd1234', status: '400', response: { reason: 'BadDeviceToken' } }, + ], + }); + }); + + it('rejects with the bundleId, status code and response in the failed array', async () => { + const provider = new Provider({ address: 'testapi' }); + + fakes.client.write.onCall(0).returns( + Promise.reject({ + bundleId: 'abcd1234', + status: '400', + response: { reason: 'BadDeviceToken' }, + }) + ); + const result = await provider.broadcast(notificationDouble(), 'abcd1234'); + + expect(result).to.deep.equal({ + sent: [], + failed: [ + { bundleId: 'abcd1234', status: '400', response: { reason: 'BadDeviceToken' } }, + ], + }); + }); + }); + }); + + context('when multiple notifications are passed', async () => { + beforeEach(async () => { + fakes.resolutions = [ + { bundleId: 'test123', 'apns-channel-id': 'abcd1234' }, + { + bundleId: 'test123', + 'apns-channel-id': 'adfe5969', + status: '400', + response: { reason: 'MissingTopic' }, + }, + { + bundleId: 'test123', + 'apns-channel-id': 'abcd1335', + status: '410', + response: { reason: 'BadDeviceToken', timestamp: 123456789 }, + }, + { bundleId: 'test123', 'apns-channel-id': 'bcfe4433' }, + { + bundleId: 'test123', + 'apns-channel-id': 'aabbc788', + status: '413', + response: { reason: 'PayloadTooLarge' }, + }, + { + bundleId: 'test123', + 'apns-channel-id': 'fbcde238', + error: new Error('connection failed'), + }, + ]; + }); + + context('streams are always returned', async () => { + let response; + + beforeEach(async () => { + 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])); + } + + response = await provider.broadcast( + fakes.resolutions.map(res => notificationDouble(res['apns-channel-id'])), + 'test123' + ); + }); + + it('resolves with the sent notifications', async () => { + expect(response.sent).to.deep.equal([ + { bundleId: 'test123', 'apns-channel-id': 'abcd1234' }, + { bundleId: 'test123', 'apns-channel-id': 'bcfe4433' }, + ]); + }); + + it('resolves with the bundleId, status code and response or error of the unsent notifications', async () => { + expect(response.failed[3].error).to.be.an.instanceof(Error); + response.failed[3].error = { message: response.failed[3].error.message }; + expect(response.failed).to.deep.equal( + [ + { + bundleId: 'test123', + 'apns-channel-id': 'adfe5969', + status: '400', + response: { reason: 'MissingTopic' }, + }, + { + bundleId: 'test123', + 'apns-channel-id': 'abcd1335', + status: '410', + response: { reason: 'BadDeviceToken', timestamp: 123456789 }, + }, + { + bundleId: 'test123', + 'apns-channel-id': 'aabbc788', + status: '413', + response: { reason: 'PayloadTooLarge' }, + }, + { + bundleId: 'test123', + 'apns-channel-id': 'fbcde238', + error: { message: 'connection failed' }, + }, + ], + `Unexpected result: ${JSON.stringify(response.failed)}` + ); + }); + }); + }); + }); + + describe('manageChannels', async () => { + describe('single notification behaviour', async () => { + let provider; + + context('transmission succeeds', async () => { + beforeEach(async () => { + provider = new Provider({ address: 'testapi' }); + + fakes.client.write.onCall(0).returns(Promise.resolve({ bundleId: 'abcd1234' })); + }); + + it('invokes the writer with correct `this`', async () => { + await provider.manageChannels(notificationDouble(), 'abcd1234', 'create'); + expect(fakes.client.write).to.be.calledOn(fakes.client); + }); + + it('writes the notification to the client once using create', async () => { + await provider.manageChannels(notificationDouble(), 'abcd1234', 'create'); - return promise; + const notification = notificationDouble(); + const builtNotification = { + headers: notification.headers(), + body: notification.compile(), + }; + const bundleId = 'abcd1234'; + expect(fakes.client.write).to.be.calledOnce; + expect(fakes.client.write).to.be.calledWith( + builtNotification, + bundleId, + 'channels', + 'post' + ); }); - it('resolves with the sent notifications', function () { - return promise.then(response => { - expect(response.sent).to.deep.equal([{ device: 'abcd1234' }, { device: 'bcfe4433' }]); + it('writes the notification to the client once using read', async () => { + await provider.manageChannels(notificationDouble(), 'abcd1234', 'read'); + + const notification = notificationDouble(); + const builtNotification = { + headers: notification.headers(), + body: notification.compile(), + }; + const bundleId = 'abcd1234'; + expect(fakes.client.write).to.be.calledOnce; + expect(fakes.client.write).to.be.calledWith( + builtNotification, + bundleId, + 'channels', + 'get' + ); + }); + + it('writes the notification to the client once using readAll', async () => { + await provider.manageChannels(notificationDouble(), 'abcd1234', 'readAll'); + + const notification = notificationDouble(); + const builtNotification = { + headers: notification.headers(), + body: notification.compile(), + }; + const bundleId = 'abcd1234'; + expect(fakes.client.write).to.be.calledOnce; + expect(fakes.client.write).to.be.calledWith( + builtNotification, + bundleId, + 'allChannels', + 'get' + ); + }); + + it('writes the notification to the client once using delete', async () => { + await provider.manageChannels(notificationDouble(), 'abcd1234', 'delete'); + + const notification = notificationDouble(); + const builtNotification = { + headers: notification.headers(), + body: notification.compile(), + }; + const bundleId = 'abcd1234'; + expect(fakes.client.write).to.be.calledOnce; + expect(fakes.client.write).to.be.calledWith( + builtNotification, + bundleId, + 'channels', + 'delete' + ); + }); + + it('does not pass the array index to writer', async () => { + await provider.manageChannels(notificationDouble(), 'abcd1234', 'create'); + expect(fakes.client.write.firstCall.args[5]).to.be.undefined; + }); + + it('resolves with the bundleId in the sent array', async () => { + const result = await provider.manageChannels(notificationDouble(), 'abcd1234', 'create'); + expect(result).to.deep.equal({ + sent: [{ bundleId: 'abcd1234' }], + failed: [], + }); + }); + }); + + context('error occurs', async () => { + it('throws error when unknown action is passed', async () => { + const provider = new Provider({ address: 'testapi' }); + let receivedError; + try { + await provider.manageChannels(notificationDouble(), 'abcd1234', 'hello'); + } catch (e) { + receivedError = e; + } + expect(receivedError).to.exist; + expect(receivedError.bundleId).to.equal('abcd1234'); + expect(receivedError.error.message.startsWith('the action "hello"')).to.equal(true); + }); + + it('resolves with the bundleId, status code and response in the failed array', async () => { + const provider = new Provider({ address: 'testapi' }); + + fakes.client.write.onCall(0).returns( + Promise.resolve({ + bundleId: 'abcd1234', + status: '400', + response: { reason: 'BadDeviceToken' }, + }) + ); + const result = await provider.manageChannels(notificationDouble(), 'abcd1234', 'create'); + + expect(result).to.deep.equal({ + sent: [], + failed: [ + { bundleId: 'abcd1234', status: '400', response: { reason: 'BadDeviceToken' } }, + ], }); }); - it('resolves with the device token, status code and response or error of the unsent notifications', function () { - return promise.then(response => { - expect(response.failed[3].error).to.be.an.instanceof(Error); - response.failed[3].error = { message: response.failed[3].error.message }; - 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: { message: 'connection failed' } }, - ], - `Unexpected result: ${JSON.stringify(response.failed)}` - ); + it('rejects with the bundleId, status code and response in the failed array', async () => { + const provider = new Provider({ address: 'testapi' }); + + fakes.client.write.onCall(0).returns( + Promise.reject({ + bundleId: 'abcd1234', + status: '400', + response: { reason: 'BadDeviceToken' }, + }) + ); + const result = await provider.manageChannels(notificationDouble(), 'abcd1234', 'create'); + + expect(result).to.deep.equal({ + sent: [], + failed: [ + { bundleId: 'abcd1234', status: '400', response: { reason: 'BadDeviceToken' } }, + ], }); }); }); }); + + context('when multiple notifications are passed', async () => { + beforeEach(async () => { + fakes.resolutions = [ + { bundleId: 'test123', 'apns-channel-id': 'abcd1234' }, + { + bundleId: 'test123', + 'apns-channel-id': 'adfe5969', + status: '400', + response: { reason: 'MissingTopic' }, + }, + { + bundleId: 'test123', + 'apns-channel-id': 'abcd1335', + status: '410', + response: { reason: 'BadDeviceToken', timestamp: 123456789 }, + }, + { bundleId: 'test123', 'apns-channel-id': 'bcfe4433' }, + { + bundleId: 'test123', + 'apns-channel-id': 'aabbc788', + status: '413', + response: { reason: 'PayloadTooLarge' }, + }, + { + bundleId: 'test123', + 'apns-channel-id': 'fbcde238', + error: new Error('connection failed'), + }, + ]; + }); + + context('streams are always returned', async () => { + let response; + + beforeEach(async () => { + 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])); + } + + response = await provider.manageChannels( + fakes.resolutions.map(res => notificationDouble(res['apns-channel-id'])), + 'test123', + 'create' + ); + }); + + it('resolves with the sent notifications', async () => { + expect(response.sent).to.deep.equal([ + { bundleId: 'test123', 'apns-channel-id': 'abcd1234' }, + { bundleId: 'test123', 'apns-channel-id': 'bcfe4433' }, + ]); + }); + + it('resolves with the bundleId, status code and response or error of the unsent notifications', async () => { + expect(response.failed[3].error).to.be.an.instanceof(Error); + response.failed[3].error = { message: response.failed[3].error.message }; + expect(response.failed).to.deep.equal( + [ + { + bundleId: 'test123', + 'apns-channel-id': 'adfe5969', + status: '400', + response: { reason: 'MissingTopic' }, + }, + { + bundleId: 'test123', + 'apns-channel-id': 'abcd1335', + status: '410', + response: { reason: 'BadDeviceToken', timestamp: 123456789 }, + }, + { + bundleId: 'test123', + 'apns-channel-id': 'aabbc788', + status: '413', + response: { reason: 'PayloadTooLarge' }, + }, + { + bundleId: 'test123', + 'apns-channel-id': 'fbcde238', + error: { message: 'connection failed' }, + }, + ], + `Unexpected result: ${JSON.stringify(response.failed)}` + ); + }); + }); + }); }); describe('shutdown', function () { - it('invokes shutdown on the client', function () { + it('invokes shutdown on the client', async () => { const callback = sinon.spy(); const provider = new Provider({}); provider.shutdown(callback); @@ -183,10 +621,12 @@ describe('Provider', function () { }); }); -function notificationDouble() { +function notificationDouble(pushType = undefined) { return { - headers: sinon.stub().returns({}), + headers: sinon.stub().returns({ pushType: pushType }), payload: { aps: { badge: 1 } }, + removeNonChannelRelatedProperties: sinon.stub(), + addPushTypeToPayloadIfNeeded: sinon.stub(), compile: function () { return JSON.stringify(this.payload); }, diff --git a/test/proxy.js b/test/proxy.js new file mode 100644 index 00000000..6ab5860d --- /dev/null +++ b/test/proxy.js @@ -0,0 +1,25 @@ +const VError = require('verror'); +const createProxySocket = require('../lib/util/proxy'); + +describe('Proxy Server', async () => { + it('can throw errors', async () => { + let receivedError; + try { + await createProxySocket( + { + host: '127.0.0.1', + port: 3311, + }, + { + host: '127.0.0.1', + port: 'NOT_A_PORT', + } + ); + } catch (e) { + receivedError = e; + } + expect(receivedError).to.exist; + expect(receivedError.error).to.be.an.instanceof(VError); + expect(receivedError.error.message).to.have.string('cannot connect to proxy server'); + }); +});