Skip to content

Commit

Permalink
Merge pull request #12 from marc1706/feature/expire_sub
Browse files Browse the repository at this point in the history
Feature: Add functionality to text expired subscriptions
  • Loading branch information
marc1706 committed Feb 27, 2024
2 parents d50bdc9 + 4e03bf6 commit 62c286e
Show file tree
Hide file tree
Showing 7 changed files with 226 additions and 15 deletions.
32 changes: 29 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,17 +65,43 @@ Additional fields are specified in square brackets.
- Output:
```
{
data: PushSubscriptionJSON[+clientHash]
data: PushSubscriptionJSON[+clientHash]
}
```

#### Expire subscription
- URL: `http://localhost:8090/expire-subscription/[+clientHash]`
- Input: None (expect for clientHash in URL)
- Output:
- Status:
- 200 for success
- 400 on error e.g. when subscription does not exist
- Body:
- None for success
- Error return on error

#### Send push notification
- URL: `PushSubscriptionJSON.endpoint` (format: `http://localhost:8090/notify/[+clientHash]`)
- Headers: See e.g. [RFC 8291](https://datatracker.ietf.org/doc/html/rfc8291) on required headers
- Input: Encrypted payload
- Output:
- Status: 201 for success, 400/410 on errors
- No body
- Status:
- 201 for success
- 400 on errors
- 410 on expired subscriptions
- Body
- Error:
```
{
error: { message: err.message }
}
```
- Expired subscription:
```
{
reason: 'Push subscription has unsubscribed or expired.',
}
```

#### Get endpoint notifications
- URL: `http://localhost:8090/get-notifications`
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "web-push-testing",
"version": "1.0.0",
"version": "1.1.0",
"description": "A server that can be run to test against a mocked API endpoint for web push without relying on flaky browser drivers.",
"main": "server.js",
"bin": {
Expand Down
45 changes: 44 additions & 1 deletion src/PushApiModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@
*
*/

class SubscriptionExpiredError extends Error {
constructor(message) {
super(message);
this.name = 'SubscriptionExpiredError';
this.message = 'Subscription expired';
}
}

class PushApiModel {
constructor() {
this.notifyUrl = '';
Expand Down Expand Up @@ -134,6 +142,7 @@ class PushApiModel {
publicKey: subscriptionDh.getPublicKey('base64'),
subscriptionDh,
auth: uniqueAuthKey,
isExpired: false,
};
this.subscriptions[uniqueClientHash] = subscriptionData;
return {
Expand All @@ -147,6 +156,32 @@ class PushApiModel {
});
}

/**
* Expire subscription with specified client hash
* @param {string} clientHash Unique client hash
* @returns {void}
*/
expireSubscription(clientHash) {
if (typeof this.subscriptions[clientHash] === 'undefined') {
throw new RangeError('Subscription with specified client hash does not exist');
} else {
this.subscriptions[clientHash].isExpired = true;
}
}

/**
* Check if subscription with specified client hash is expired
* @param {string} clientHash Unique client hash
* @returns {boolean} True if subscription is expired, false if not
*/
isSubscriptionExpired(clientHash) {
if (typeof this.subscriptions[clientHash] !== 'undefined') {
return this.subscriptions[clientHash].isExpired;
}

return false;
}

async validateAuthorizationHeader(clientHash, jwt) {
const jsonwebtoken = require('jsonwebtoken');
try {
Expand Down Expand Up @@ -239,7 +274,12 @@ class PushApiModel {
throw new RangeError('Client not subscribed');
}

if (this.isSubscriptionExpired(clientHash)) {
throw new SubscriptionExpiredError();
}

const currentSubscription = this.subscriptions[clientHash];

const isVapid = typeof currentSubscription.applicationServerKey !== 'undefined';

this.validateNotificationHeaders(currentSubscription, pushHeaders);
Expand Down Expand Up @@ -305,4 +345,7 @@ class PushApiModel {
}
}

module.exports = PushApiModel;
module.exports = {
PushApiModel,
SubscriptionExpiredError,
};
2 changes: 1 addition & 1 deletion src/bin/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
const TestingServer = require('../server.js');

const serverPort = JSON.parse(process.argv[2]);
const PushApiModel = require('../PushApiModel.js');
const {PushApiModel} = require('../PushApiModel.js');
const apiModel = new PushApiModel();

const server = new TestingServer(apiModel, serverPort);
Expand Down
36 changes: 30 additions & 6 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* https://opensource.org/licenses/MIT.
*
*/
const {SubscriptionExpiredError} = require('./PushApiModel');

let apiModel = {};

Expand Down Expand Up @@ -57,6 +58,7 @@ class WebPushTestingServer {
this._app.post('/status', this.getStatus);
this._app.post('/subscribe', this.subscribe);
this._app.post('/notify/:clientHash', this.handleNotification);
this._app.post('/expire-subscription/:clientHash', this.expireSubscription);
this._app.post('/get-notifications', this.getNotifications);
}

Expand Down Expand Up @@ -109,13 +111,35 @@ class WebPushTestingServer {
res.status(201).send(notificationReturn);
})
.catch(err => {
const status = err instanceof RangeError ? 400 : 410;
res.status(status).send({
error: {
message: err.message,
},
});
if (err instanceof SubscriptionExpiredError) {
res.status(410).send({
reason: 'Push subscription has unsubscribed or expired.',
});
} else {
res.status(400).send({
error: {
message: err.message,
},
});
}
});
}

expireSubscription(req, res) {
const {clientHash} = req.params;

try {
apiModel.expireSubscription(clientHash);
} catch (err) {
res.status(400).send({
error: {
message: err.message,
},
});
}

console.log('Expire subscription for ' + clientHash);
res.sendStatus(200);
}
}

Expand Down
67 changes: 66 additions & 1 deletion test/PushApiModelTest.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
*
*/

const PushApiModel = require('../src/PushApiModel');
const {PushApiModel, SubscriptionExpiredError} = require('../src/PushApiModel');
require('chai').should();
const {assert} = require('chai');
const webPush = require('web-push');
Expand Down Expand Up @@ -177,6 +177,41 @@ describe('Push API Model tests', () => {
});
});

describe('Expire subscription', () => {
it('Should expire subscription', async () => {
const model = new PushApiModel();
model.notifyUrl = 'https://localhost:12345';
const subscribeReturn = await model.subscribe({})
.catch(() => {
assert.fail('No error expected but exception is thrown');
});

assert.notTypeOf(subscribeReturn, 'undefined');
assert.hasAllKeys(subscribeReturn, ['endpoint', 'keys', 'clientHash']);
assert.hasAllKeys(subscribeReturn.keys, ['p256dh', 'auth']);
assert.hasAllKeys(model.subscriptions, [subscribeReturn.clientHash]);

assert.isFalse(model.isSubscriptionExpired(subscribeReturn.clientHash));
model.expireSubscription(subscribeReturn.clientHash);
assert.isTrue(model.isSubscriptionExpired(subscribeReturn.clientHash));
});

it('Invalid subscription is properly handled', async () => {
const model = new PushApiModel();

assert.isFalse(model.isSubscriptionExpired('doesNotExist'));

try {
model.expireSubscription('doesNotExist');
} catch (err) {
assert.instanceOf(err, RangeError);
assert.equal(err.message, 'Subscription with specified client hash does not exist');
}

assert.isFalse(model.isSubscriptionExpired('doesNotExist'));
});
});

describe('Validate notification headers', () => {
const input = [
{
Expand Down Expand Up @@ -461,6 +496,36 @@ describe('Push API Model tests', () => {
model.messages[testClientHash][0].should.equal('hello');
model.messages[testClientHash][1].should.equal('hello');
});

it('Throw an error for expired subscription', async () => {
const model = new PushApiModel();
const testClientHash = 'testClientHash';
const subscriptionPublicKey = 'BIanZceKFE49T82cl2HUWK_vLQPVQPq5eZHP7y0zLWP1qDjlWe7Vx7XS8qetnPOJTZyZJrV26FST20e6CvThcmc';
const subscriptionPrivateKey = 'zs96vCXedR-vvXDsGLQJXeus2Ui2InrWQM1w0bh8O90';
const testApplicationServerKey = 'BJxKEp-nlH4ezWmgipyizTbPGOB6jQIuARETjLNp5wxSbnyzJ6NRgolhMy4CVThCAc1H6l_UC38nkBqcLcQx96c';

const ecdh = crypto.createECDH('prime256v1');
ecdh.setPrivateKey(model.base64UrlDecode(subscriptionPrivateKey));
model.subscriptions[testClientHash] = {
applicationServerKey: testApplicationServerKey,
publicKey: subscriptionPublicKey,
subscriptionDh: ecdh,
auth: 'kZTCk82psaREuK7YOM5mHA',
isExpired: true,
};

// Create WebPush Authorization header
const pushHeaders = {};

const requestBody = model.base64UrlDecode('r6gvu5db98El53AoxLdf6qe-Y2fSp9o');

try {
await model.handleNotification(testClientHash, pushHeaders, requestBody);
} catch (err) {
assert.instanceOf(err, SubscriptionExpiredError);
assert.equal(err.message, 'Subscription expired');
}
});
});

it('should throw error on invalid authorization header', async () => {
Expand Down
57 changes: 55 additions & 2 deletions test/serverTest.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
*/

const WebPushTestingServer = require('../src/server.js');
const PushApiModel = require('../src/PushApiModel');
const {PushApiModel} = require('../src/PushApiModel');
const fetch = require('node-fetch');
const crypto = require('crypto');
const webPush = require('web-push');
Expand Down Expand Up @@ -240,6 +240,37 @@ describe('Push Server tests', () => {
});
});

describe('Expire non-existent subscription', () => {
it('Should return 400', async () => {
const model = new PushApiModel();
const port = 8990;

const server = new WebPushTestingServer(model, port);
startLogging();
server.startServer();

await fetch('http://localhost:' + port + '/status', {
method: 'POST',
}).then(response => {
response.status.should.equal(200);
consoleLogs.length.should.equal(1);
consoleLogs[0].should.match(/Server running/);
});

// Try expiring a non-existent subscription
await fetch('http://localhost:' + port + '/expire-subscription/doesNotExist', {
method: 'POST',
}).then(async response => {
server._server.close();
endLogging();

response.status.should.equal(400);
const responseBody = await response.json();
responseBody.error.message.should.equal('Subscription with specified client hash does not exist');
});
});
});

describe('Send notifications', () => {
const input = [
{
Expand All @@ -254,11 +285,18 @@ describe('Push Server tests', () => {
sendAuthorization: true,
expectedStatus: 201,
},
{
description: 'Expired notification with aes128gcm',
encoding: 'aes128gcm',
sendAuthorization: true,
expectedStatus: 410,
expectedError: 'Push subscription has unsubscribed or expired.',
},
{
description: 'Unsuccessful notification with wrong encoding',
encoding: 'wrong',
sendAuthorization: true,
expectedStatus: 410,
expectedStatus: 400,
expectedError: 'Unsupported encoding',
},
{
Expand Down Expand Up @@ -342,6 +380,17 @@ describe('Push Server tests', () => {
consoleLogs[0].should.match(/Server running/);
});

// Force subscription to expire
if (expectedStatus === 410) {
await fetch('http://localhost:' + port + '/expire-subscription/' + testClientHash, {
method: 'POST',
}).then(response => {
response.status.should.equal(200);
consoleLogs.length.should.equal(2);
consoleLogs[1].should.match(/Expire subscription for /);
});
}

await fetch('http://localhost:' + port + '/notify/' + testClientHash, {
method: 'POST',
headers: pushHeaders,
Expand All @@ -357,6 +406,10 @@ describe('Push Server tests', () => {
assert.hasAllKeys(notificationData, ['messages']);
notificationData.messages.length.should.equal(1);
notificationData.messages[0].should.equal('hello');
} else if (expectedStatus === 410) {
const responseBody = await response.json();
assert.hasAllKeys(responseBody, ['reason']);
responseBody.reason.should.equal(expectedError);
} else {
const responseBody = await response.json();
assert.hasAllKeys(responseBody, ['error']);
Expand Down

0 comments on commit 62c286e

Please sign in to comment.