apn
Version:
An interface to the Apple Push Notification service for Node.js
775 lines (622 loc) • 29.7 kB
JavaScript
;
const sinon = require("sinon");
const stream = require("stream");
const EventEmitter = require("events");
function builtNotification() {
return {
headers: {},
body: JSON.stringify({ aps: { badge: 1 } }),
};
}
function FakeStream(deviceId, statusCode, response) {
const fakeStream = new stream.Transform({
transform: sinon.spy(function(chunk, encoding, callback) {
expect(this.headers).to.be.calledOnce;
const headers = this.headers.firstCall.args[0];
expect(headers[":path"].substring(10)).to.equal(deviceId);
this.emit("headers", {
":status": statusCode
});
callback(null, Buffer.from(JSON.stringify(response) || ""));
})
});
fakeStream.headers = sinon.stub();
return fakeStream;
}
describe("Client", function () {
let fakes, Client;
beforeEach(function () {
fakes = {
config: sinon.stub(),
EndpointManager: sinon.stub(),
endpointManager: new EventEmitter(),
};
fakes.EndpointManager.returns(fakes.endpointManager);
fakes.endpointManager.shutdown = sinon.stub();
Client = require("../lib/client")(fakes);
});
describe("constructor", function () {
it("prepares the configuration with passed options", function () {
let options = { production: true };
let client = new Client(options);
expect(fakes.config).to.be.calledWith(options);
});
describe("EndpointManager instance", function() {
it("is created", function () {
let client = new Client();
expect(fakes.EndpointManager).to.be.calledOnce;
expect(fakes.EndpointManager).to.be.calledWithNew;
});
it("is passed the prepared configuration", function () {
const returnSentinel = { "configKey": "configValue"};
fakes.config.returns(returnSentinel);
let client = new Client({});
expect(fakes.EndpointManager).to.be.calledWith(returnSentinel);
});
});
});
describe("write", function () {
beforeEach(function () {
fakes.config.returnsArg(0);
fakes.endpointManager.getStream = sinon.stub();
fakes.EndpointManager.returns(fakes.endpointManager);
});
context("a stream is available", function () {
let client;
context("transmission succeeds", function () {
beforeEach( function () {
client = new Client( { address: "testapi" } );
fakes.stream = new FakeStream("abcd1234", "200");
fakes.endpointManager.getStream.onCall(0).returns(fakes.stream);
});
it("attempts to acquire one stream", function () {
return client.write(builtNotification(), "abcd1234")
.then(function () {
expect(fakes.endpointManager.getStream).to.be.calledOnce;
});
});
describe("headers", function () {
it("sends the required HTTP/2 headers", function () {
return client.write(builtNotification(), "abcd1234")
.then(function () {
expect(fakes.stream.headers).to.be.calledWithMatch( {
":scheme": "https",
":method": "POST",
":authority": "testapi",
":path": "/3/device/abcd1234",
});
});
});
it("does not include apns headers when not required", function () {
return client.write(builtNotification(), "abcd1234")
.then(function () {
["apns-id", "apns-priority", "apns-expiration", "apns-topic"].forEach( header => {
expect(fakes.stream.headers).to.not.be.calledWithMatch(sinon.match.has(header));
});
});
});
it("sends the notification-specific apns headers when specified", function () {
let notification = builtNotification();
notification.headers = {
"apns-id": "123e4567-e89b-12d3-a456-42665544000",
"apns-priority": 5,
"apns-expiration": 123,
"apns-topic": "io.apn.node",
};
return client.write(notification, "abcd1234")
.then(function () {
expect(fakes.stream.headers).to.be.calledWithMatch( {
"apns-id": "123e4567-e89b-12d3-a456-42665544000",
"apns-priority": 5,
"apns-expiration": 123,
"apns-topic": "io.apn.node",
});
});
});
context("when token authentication is enabled", function () {
beforeEach(function () {
fakes.token = {
generation: 0,
current: "fake-token",
regenerate: sinon.stub(),
isExpired: sinon.stub()
};
client = new Client( { address: "testapi", token: fakes.token } );
fakes.stream = new FakeStream("abcd1234", "200");
fakes.endpointManager.getStream.onCall(0).returns(fakes.stream);
});
it("sends the bearer token", function () {
let notification = builtNotification();
return client.write(notification, "abcd1234").then(function () {
expect(fakes.stream.headers).to.be.calledWithMatch({
authorization: "bearer fake-token",
});
});
});
});
context("when token authentication is disabled", function () {
beforeEach(function () {
client = new Client( { address: "testapi" } );
fakes.stream = new FakeStream("abcd1234", "200");
fakes.endpointManager.getStream.onCall(0).returns(fakes.stream);
});
it("does not set an authorization header", function () {
let notification = builtNotification();
return client.write(notification, "abcd1234").then(function () {
expect(fakes.stream.headers.firstCall.args[0]).to.not.have.property("authorization");
})
});
})
});
it("writes the notification data to the pipe", function () {
const notification = builtNotification();
return client.write(notification, "abcd1234")
.then(function () {
expect(fakes.stream._transform).to.be.calledWithMatch(actual => actual.equals(Buffer.from(notification.body)));
});
});
it("ends the stream", function () {
sinon.spy(fakes.stream, "end");
return client.write(builtNotification(), "abcd1234")
.then(function () {
expect(fakes.stream.end).to.be.calledOnce;
});
});
it("resolves with the device token", function () {
return expect(client.write(builtNotification(), "abcd1234"))
.to.become({ device: "abcd1234" });
});
});
context("error occurs", function () {
let promise;
context("general case", function () {
beforeEach(function () {
const client = new Client( { address: "testapi" } );
fakes.stream = new FakeStream("abcd1234", "400", { "reason" : "BadDeviceToken" });
fakes.endpointManager.getStream.onCall(0).returns(fakes.stream);
promise = client.write(builtNotification(), "abcd1234");
});
it("resolves with the device token, status code and response", function () {
return expect(promise).to.eventually.deep.equal({ status: "400", device: "abcd1234", response: { reason: "BadDeviceToken" }});
});
})
context("ExpiredProviderToken", function () {
beforeEach(function () {
let tokenGenerator = sinon.stub().returns("fake-token");
const client = new Client( { address: "testapi", token: tokenGenerator });
})
});
});
context("stream ends without completing request", function () {
let promise;
beforeEach(function () {
const client = new Client( { address: "testapi" } );
fakes.stream = new stream.Transform({
transform: function(chunk, encoding, callback) {}
});
fakes.stream.headers = sinon.stub();
fakes.endpointManager.getStream.onCall(0).returns(fakes.stream);
promise = client.write(builtNotification(), "abcd1234");
fakes.stream.push(null);
});
it("resolves with an object containing the device token", function () {
return expect(promise).to.eventually.have.property("device", "abcd1234");
});
it("resolves with an object containing an error", function () {
return promise.then( (response) => {
expect(response).to.have.property("error");
expect(response.error).to.be.an.instanceOf(Error);
expect(response.error).to.match(/stream ended unexpectedly/);
});
});
});
context("stream is unprocessed", function () {
let promise;
beforeEach(function () {
const client = new Client( { address: "testapi" } );
fakes.stream = new stream.Transform({
transform: function(chunk, encoding, callback) {}
});
fakes.stream.headers = sinon.stub();
fakes.secondStream = FakeStream("abcd1234", "200");
fakes.endpointManager.getStream.onCall(0).returns(fakes.stream);
fakes.endpointManager.getStream.onCall(1).returns(fakes.secondStream);
promise = client.write(builtNotification(), "abcd1234");
setImmediate(() => {
fakes.stream.emit("unprocessed");
});
});
it("attempts to resend on a new stream", function (done) {
setImmediate(() => {
expect(fakes.endpointManager.getStream).to.be.calledTwice;
done();
});
});
it("fulfills the promise", function () {
return expect(promise).to.eventually.deep.equal({ device: "abcd1234" });
});
});
context("stream error occurs", function () {
let promise;
beforeEach(function () {
const client = new Client( { address: "testapi" } );
fakes.stream = new stream.Transform({
transform: function(chunk, encoding, callback) {}
});
fakes.stream.headers = sinon.stub();
fakes.endpointManager.getStream.onCall(0).returns(fakes.stream);
promise = client.write(builtNotification(), "abcd1234");
});
context("passing an Error", function () {
beforeEach(function () {
fakes.stream.emit("error", new Error("stream error"));
});
it("resolves with an object containing the device token", function () {
return expect(promise).to.eventually.have.property("device", "abcd1234");
});
it("resolves with an object containing a wrapped error", function () {
return promise.then( (response) => {
expect(response.error).to.be.an.instanceOf(Error);
expect(response.error).to.match(/apn write failed/);
expect(response.error.cause()).to.be.an.instanceOf(Error).and.match(/stream error/);
});
});
});
context("passing a string", function () {
it("resolves with the device token and an error", function () {
fakes.stream.emit("error", "stream error");
return promise.then( (response) => {
expect(response).to.have.property("device", "abcd1234");
expect(response.error).to.to.be.an.instanceOf(Error);
expect(response.error).to.match(/apn write failed/);
expect(response.error).to.match(/stream error/);
});
});
});
});
});
context("no new stream is returned but the endpoint later wakes up", function () {
let notification, promise;
beforeEach( function () {
const client = new Client( { address: "testapi" } );
fakes.stream = new FakeStream("abcd1234", "200");
fakes.endpointManager.getStream.onCall(0).returns(null);
fakes.endpointManager.getStream.onCall(1).returns(fakes.stream);
notification = builtNotification();
promise = client.write(notification, "abcd1234");
expect(fakes.stream.headers).to.not.be.called;
fakes.endpointManager.emit("wakeup");
return promise;
});
it("sends the required headers to the newly available stream", function () {
expect(fakes.stream.headers).to.be.calledWithMatch( {
":scheme": "https",
":method": "POST",
":authority": "testapi",
":path": "/3/device/abcd1234",
});
});
it("writes the notification data to the pipe", function () {
expect(fakes.stream._transform).to.be.calledWithMatch(actual => actual.equals(Buffer.from(notification.body)));
});
});
context("when 5 successive notifications are sent", function () {
beforeEach(function () {
fakes.streams = [
new FakeStream("abcd1234", "200"),
new FakeStream("adfe5969", "400", { reason: "MissingTopic" }),
new FakeStream("abcd1335", "410", { reason: "BadDeviceToken", timestamp: 123456789 }),
new FakeStream("bcfe4433", "200"),
new FakeStream("aabbc788", "413", { reason: "PayloadTooLarge" }),
];
});
context("streams are always returned", function () {
let promises;
beforeEach( function () {
const client = new Client( { address: "testapi" } );
fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[0]);
fakes.endpointManager.getStream.onCall(1).returns(fakes.streams[1]);
fakes.endpointManager.getStream.onCall(2).returns(fakes.streams[2]);
fakes.endpointManager.getStream.onCall(3).returns(fakes.streams[3]);
fakes.endpointManager.getStream.onCall(4).returns(fakes.streams[4]);
promises = Promise.all([
client.write(builtNotification(), "abcd1234"),
client.write(builtNotification(), "adfe5969"),
client.write(builtNotification(), "abcd1335"),
client.write(builtNotification(), "bcfe4433"),
client.write(builtNotification(), "aabbc788"),
]);
return promises;
});
it("sends the required headers for each stream", function () {
expect(fakes.streams[0].headers).to.be.calledWithMatch( { ":path": "/3/device/abcd1234" } );
expect(fakes.streams[1].headers).to.be.calledWithMatch( { ":path": "/3/device/adfe5969" } );
expect(fakes.streams[2].headers).to.be.calledWithMatch( { ":path": "/3/device/abcd1335" } );
expect(fakes.streams[3].headers).to.be.calledWithMatch( { ":path": "/3/device/bcfe4433" } );
expect(fakes.streams[4].headers).to.be.calledWithMatch( { ":path": "/3/device/aabbc788" } );
});
it("writes the notification data for each stream", function () {
fakes.streams.forEach( stream => {
expect(stream._transform).to.be.calledWithMatch(actual => actual.equals(Buffer.from(builtNotification().body)));
});
});
it("resolves with the notification outcomes", function () {
return expect(promises).to.eventually.deep.equal([
{ device: "abcd1234"},
{ device: "adfe5969", status: "400", response: { reason: "MissingTopic" } },
{ device: "abcd1335", status: "410", response: { reason: "BadDeviceToken", timestamp: 123456789 } },
{ device: "bcfe4433"},
{ device: "aabbc788", status: "413", response: { reason: "PayloadTooLarge" } },
]);
});
});
context("some streams return, others wake up later", function () {
let promises;
beforeEach( function() {
const client = new Client( { address: "testapi" } );
fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[0]);
fakes.endpointManager.getStream.onCall(1).returns(fakes.streams[1]);
promises = Promise.all([
client.write(builtNotification(), "abcd1234"),
client.write(builtNotification(), "adfe5969"),
client.write(builtNotification(), "abcd1335"),
client.write(builtNotification(), "bcfe4433"),
client.write(builtNotification(), "aabbc788"),
]);
setTimeout(function () {
fakes.endpointManager.getStream.reset();
fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[2]);
fakes.endpointManager.getStream.onCall(1).returns(null);
fakes.endpointManager.emit("wakeup");
}, 1);
setTimeout(function () {
fakes.endpointManager.getStream.reset();
fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[3]);
fakes.endpointManager.getStream.onCall(1).returns(fakes.streams[4]);
fakes.endpointManager.emit("wakeup");
}, 2);
return promises;
});
it("sends the correct device ID for each stream", function () {
expect(fakes.streams[0].headers).to.be.calledWithMatch({":path": "/3/device/abcd1234"});
expect(fakes.streams[1].headers).to.be.calledWithMatch({":path": "/3/device/adfe5969"});
expect(fakes.streams[2].headers).to.be.calledWithMatch({":path": "/3/device/abcd1335"});
expect(fakes.streams[3].headers).to.be.calledWithMatch({":path": "/3/device/bcfe4433"});
expect(fakes.streams[4].headers).to.be.calledWithMatch({":path": "/3/device/aabbc788"});
});
it("writes the notification data for each stream", function () {
fakes.streams.forEach( stream => {
expect(stream._transform).to.be.calledWithMatch(actual => actual.equals(Buffer.from(builtNotification().body)));
});
});
it("resolves with the notification reponses", function () {
return expect(promises).to.eventually.deep.equal([
{ device: "abcd1234"},
{ device: "adfe5969", status: "400", response: { reason: "MissingTopic" } },
{ device: "abcd1335", status: "410", response: { reason: "BadDeviceToken", timestamp: 123456789 } },
{ device: "bcfe4433"},
{ device: "aabbc788", status: "413", response: { reason: "PayloadTooLarge" } },
]);
});
});
context("connection fails", function () {
let promises, client;
beforeEach( function() {
client = new Client( { address: "testapi" } );
fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[0]);
promises = Promise.all([
client.write(builtNotification(), "abcd1234"),
client.write(builtNotification(), "adfe5969"),
client.write(builtNotification(), "abcd1335"),
]);
setTimeout(function () {
fakes.endpointManager.getStream.reset();
fakes.endpointManager.emit("error", new Error("endpoint failed"));
}, 1);
return promises;
});
it("resolves with 1 success", function () {
return promises.then( response => {
expect(response[0]).to.deep.equal({ device: "abcd1234" });
});
});
it("resolves with 2 errors", function () {
return promises.then( response => {
expect(response[1]).to.have.property("device", "adfe5969");
expect(response[1]).to.have.nested.property("error.message", "endpoint failed");
expect(response[2]).to.have.property("device", "abcd1335");
expect(response[2]).to.have.nested.property("error.message", "endpoint failed");
})
});
it("clears the queue", function () {
return promises.then( () => {
expect(client.queue.length).to.equal(0);
});
});
});
});
describe("token generator behaviour", function () {
beforeEach(function () {
fakes.token = {
generation: 0,
current: "fake-token",
regenerate: sinon.stub(),
isExpired: sinon.stub()
}
fakes.streams = [
new FakeStream("abcd1234", "200"),
new FakeStream("adfe5969", "400", { reason: "MissingTopic" }),
new FakeStream("abcd1335", "410", { reason: "BadDeviceToken", timestamp: 123456789 }),
];
});
it("reuses the token", function () {
const client = new Client( { address: "testapi", token: fakes.token } );
fakes.token.regenerate = function () {
fakes.token.generation = 1;
fakes.token.current = "second-token";
}
fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[0]);
fakes.endpointManager.getStream.onCall(1).returns(fakes.streams[1]);
fakes.endpointManager.getStream.onCall(2).returns(fakes.streams[2]);
return Promise.all([
client.write(builtNotification(), "abcd1234"),
client.write(builtNotification(), "adfe5969"),
client.write(builtNotification(), "abcd1335"),
]).then(function () {
expect(fakes.streams[0].headers).to.be.calledWithMatch({ authorization: "bearer fake-token" });
expect(fakes.streams[1].headers).to.be.calledWithMatch({ authorization: "bearer fake-token" });
expect(fakes.streams[2].headers).to.be.calledWithMatch({ authorization: "bearer fake-token" });
});
});
context("token expires", function () {
beforeEach(function () {
fakes.token.regenerate = function (generation) {
if (generation === fakes.token.generation) {
fakes.token.generation += 1;
fakes.token.current = "token-" + fakes.token.generation;
}
}
});
it("resends the notification with a new token", function () {
fakes.streams = [
new FakeStream("adfe5969", "403", { reason: "ExpiredProviderToken" }),
new FakeStream("adfe5969", "200"),
];
fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[0]);
const client = new Client( { address: "testapi", token: fakes.token } );
const promise = client.write(builtNotification(), "adfe5969");
setTimeout(function () {
fakes.endpointManager.getStream.reset();
fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[1]);
fakes.endpointManager.emit("wakeup");
}, 1);
return promise.then(function () {
expect(fakes.streams[0].headers).to.be.calledWithMatch({ authorization: "bearer fake-token" });
expect(fakes.streams[1].headers).to.be.calledWithMatch({ authorization: "bearer token-1" });
});
});
it("only regenerates the token once per-expiry", function () {
fakes.streams = [
new FakeStream("abcd1234", "200"),
new FakeStream("adfe5969", "403", { reason: "ExpiredProviderToken" }),
new FakeStream("abcd1335", "403", { reason: "ExpiredProviderToken" }),
new FakeStream("adfe5969", "400", { reason: "MissingTopic" }),
new FakeStream("abcd1335", "410", { reason: "BadDeviceToken", timestamp: 123456789 }),
];
fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[0]);
fakes.endpointManager.getStream.onCall(1).returns(fakes.streams[1]);
fakes.endpointManager.getStream.onCall(2).returns(fakes.streams[2]);
const client = new Client( { address: "testapi", token: fakes.token } );
const promises = Promise.all([
client.write(builtNotification(), "abcd1234"),
client.write(builtNotification(), "adfe5969"),
client.write(builtNotification(), "abcd1335"),
]);
setTimeout(function () {
fakes.endpointManager.getStream.reset();
fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[3]);
fakes.endpointManager.getStream.onCall(1).returns(fakes.streams[4]);
fakes.endpointManager.emit("wakeup");
}, 1);
return promises.then(function () {
expect(fakes.streams[0].headers).to.be.calledWithMatch({ authorization: "bearer fake-token" });
expect(fakes.streams[1].headers).to.be.calledWithMatch({ authorization: "bearer fake-token" });
expect(fakes.streams[2].headers).to.be.calledWithMatch({ authorization: "bearer fake-token" });
expect(fakes.streams[3].headers).to.be.calledWithMatch({ authorization: "bearer token-1" });
expect(fakes.streams[4].headers).to.be.calledWithMatch({ authorization: "bearer token-1" });
});
});
it("abandons sending after 3 ExpiredProviderToken failures", function () {
fakes.streams = [
new FakeStream("adfe5969", "403", { reason: "ExpiredProviderToken" }),
new FakeStream("adfe5969", "403", { reason: "ExpiredProviderToken" }),
new FakeStream("adfe5969", "403", { reason: "ExpiredProviderToken" }),
];
fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[0]);
fakes.endpointManager.getStream.onCall(1).returns(fakes.streams[1]);
fakes.endpointManager.getStream.onCall(2).returns(fakes.streams[2]);
const client = new Client( { address: "testapi", token: fakes.token } );
return expect(client.write(builtNotification(), "adfe5969")).to.eventually.have.property("status", "403");
});
it("regenerate token", function () {
fakes.stream = new FakeStream("abcd1234", "200");
fakes.endpointManager.getStream.onCall(0).returns(fakes.stream);
fakes.token.isExpired = function (current, validSeconds) {
return true;
}
let client = new Client({
address: "testapi",
token: fakes.token
});
return client.write(builtNotification(), "abcd1234")
.then(function () {
expect(fakes.token.generation).to.equal(1);
});
});
it("internal server error", function () {
fakes.stream = new FakeStream("abcd1234", "500", { reason: "InternalServerError" });
fakes.stream.connection = sinon.stub();
fakes.stream.connection.close = sinon.stub();
fakes.endpointManager.getStream.onCall(0).returns(fakes.stream);
let client = new Client({
address: "testapi",
token: fakes.token
});
return expect(client.write(builtNotification(), "abcd1234")).to.eventually.have.nested.property("error.jse_shortmsg","Error 500, stream ended unexpectedly");
});
});
});
});
describe("shutdown", function () {
beforeEach(function () {
fakes.config.returnsArg(0);
fakes.endpointManager.getStream = sinon.stub();
fakes.EndpointManager.returns(fakes.endpointManager);
});
context("with no pending notifications", function () {
it("invokes shutdown on endpoint manager", function () {
let client = new Client();
client.shutdown();
expect(fakes.endpointManager.shutdown).to.be.calledOnce;
});
});
context("with pending notifications", function () {
it("invokes shutdown on endpoint manager after queue drains", function () {
let client = new Client({ address: "none" });
fakes.streams = [
new FakeStream("abcd1234", "200"),
new FakeStream("adfe5969", "400", { reason: "MissingTopic" }),
new FakeStream("abcd1335", "410", { reason: "BadDeviceToken", timestamp: 123456789 }),
new FakeStream("bcfe4433", "200"),
new FakeStream("aabbc788", "413", { reason: "PayloadTooLarge" }),
];
fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[0]);
fakes.endpointManager.getStream.onCall(1).returns(fakes.streams[1]);
let promises = Promise.all([
client.write(builtNotification(), "abcd1234"),
client.write(builtNotification(), "adfe5969"),
client.write(builtNotification(), "abcd1335"),
client.write(builtNotification(), "bcfe4433"),
client.write(builtNotification(), "aabbc788"),
]);
client.shutdown();
expect(fakes.endpointManager.shutdown).to.not.be.called;
setTimeout(function () {
fakes.endpointManager.getStream.reset();
fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[2]);
fakes.endpointManager.getStream.onCall(1).returns(null);
fakes.endpointManager.emit("wakeup");
}, 1);
setTimeout(function () {
fakes.endpointManager.getStream.reset();
fakes.endpointManager.getStream.onCall(0).returns(fakes.streams[3]);
fakes.endpointManager.getStream.onCall(1).returns(fakes.streams[4]);
fakes.endpointManager.emit("wakeup");
}, 2);
return promises.then( () => {
expect(fakes.endpointManager.shutdown).to.have.been.called;
});
});
});
});
});