367 lines
12 KiB
TypeScript
367 lines
12 KiB
TypeScript
|
import assert from 'assert';
|
||
|
import sinon from 'sinon';
|
||
|
|
||
|
import {MailEvent} from '../src/MailEvent';
|
||
|
import {InMemoryMailEventRepository as MailEventRepository} from '../src/InMemoryMailEventRepository';
|
||
|
import {MailEventService} from '../src/MailEventService';
|
||
|
|
||
|
const makePayloadEvent = (
|
||
|
type: string,
|
||
|
timestamp = Date.now()
|
||
|
) => ({
|
||
|
id: 'event-id',
|
||
|
timestamp: timestamp / 1000,
|
||
|
event: type,
|
||
|
message: {
|
||
|
headers: {
|
||
|
'message-id': 'message-id'
|
||
|
}
|
||
|
},
|
||
|
recipient: 'message-recipient'
|
||
|
});
|
||
|
|
||
|
const PAYLOAD_SIGNING_KEY = 'abc123';
|
||
|
|
||
|
describe('MailEventService', function () {
|
||
|
let repository: sinon.SinonStubbedInstance<MailEventRepository>;
|
||
|
let config: sinon.SinonStubbedInstance<any>;
|
||
|
let labs: sinon.SinonStubbedInstance<any>;
|
||
|
let service: MailEventService;
|
||
|
|
||
|
beforeEach(function () {
|
||
|
repository = sinon.createStubInstance(MailEventRepository);
|
||
|
labs = {
|
||
|
isSet: sinon.stub()
|
||
|
.withArgs(MailEventService.LABS_KEY)
|
||
|
.returns(true)
|
||
|
};
|
||
|
config = {
|
||
|
get: sinon.stub()
|
||
|
.withArgs(MailEventService.CONFIG_KEY_PAYLOAD_SIGNING_KEY)
|
||
|
.returns(PAYLOAD_SIGNING_KEY)
|
||
|
};
|
||
|
service = new MailEventService(repository, config, labs);
|
||
|
});
|
||
|
|
||
|
describe('processPayload', function () {
|
||
|
it('should reject if labs flag is false', async function () {
|
||
|
labs.isSet.withArgs(MailEventService.LABS_KEY).returns(false);
|
||
|
|
||
|
await assert.rejects(
|
||
|
service.processPayload({} as any),
|
||
|
{
|
||
|
name: 'NotFoundError',
|
||
|
message: 'Resource could not be found.'
|
||
|
}
|
||
|
);
|
||
|
});
|
||
|
|
||
|
it('should reject if payload signing key is invalid', async function () {
|
||
|
config.get.withArgs(MailEventService.CONFIG_KEY_PAYLOAD_SIGNING_KEY).returns(undefined);
|
||
|
|
||
|
await assert.rejects(
|
||
|
service.processPayload({} as any),
|
||
|
{
|
||
|
name: 'InternalServerError',
|
||
|
message: 'payload signing key is not configured'
|
||
|
}
|
||
|
);
|
||
|
});
|
||
|
|
||
|
it('should reject if payload verification fails', async function () {
|
||
|
await assert.rejects(
|
||
|
service.processPayload({
|
||
|
signature: 'foobarbaz',
|
||
|
mail_events: [
|
||
|
makePayloadEvent('opened')
|
||
|
]
|
||
|
} as any),
|
||
|
{
|
||
|
name: 'UnauthorizedError',
|
||
|
message: '"signature" is invalid'
|
||
|
}
|
||
|
);
|
||
|
});
|
||
|
|
||
|
it('should store a single event', async function () {
|
||
|
const payloadEvent = makePayloadEvent('opened');
|
||
|
|
||
|
// Ensure a fixed timestamp is used so that we know the signature up front
|
||
|
payloadEvent.timestamp = 1686665992511 / 1000;
|
||
|
|
||
|
const payload = {
|
||
|
signature: '9f2567330688b82759600fad93c93b3e8f571d397c33688a8620400af20b79b3',
|
||
|
mail_events: [
|
||
|
payloadEvent
|
||
|
]
|
||
|
};
|
||
|
|
||
|
await service.processPayload(payload);
|
||
|
|
||
|
const storedEvent = repository.save.getCall(0).args[0];
|
||
|
|
||
|
assert.ok(storedEvent instanceof MailEvent);
|
||
|
assert.equal(storedEvent.id, payloadEvent.id);
|
||
|
});
|
||
|
|
||
|
it('should store multiple events', async function () {
|
||
|
const events = [
|
||
|
makePayloadEvent('opened'),
|
||
|
makePayloadEvent('opened')
|
||
|
];
|
||
|
|
||
|
// Ensure fixed timestamps are used so that we know the signature up front
|
||
|
events[0].timestamp = 1686665992511 / 1000;
|
||
|
events[1].timestamp = 1686665992512 / 1000;
|
||
|
|
||
|
const payload = {
|
||
|
signature: '02959cc9731ee575b66969a508f545d19c5968b42a03fa398ce9c93d8e7df0a5',
|
||
|
mail_events: events
|
||
|
};
|
||
|
|
||
|
await service.processPayload(payload);
|
||
|
|
||
|
assert.ok(repository.save.calledTwice);
|
||
|
});
|
||
|
|
||
|
it('should ignore unknown events', async function () {
|
||
|
const events = [
|
||
|
makePayloadEvent('unknown-event'),
|
||
|
makePayloadEvent('opened')
|
||
|
];
|
||
|
|
||
|
// Ensure fixed timestamps are used so that we know the signature up front
|
||
|
events[0].timestamp = 1686665992511 / 1000;
|
||
|
events[1].timestamp = 1686665992512 / 1000;
|
||
|
|
||
|
const payload = {
|
||
|
signature: 'd6de350faa9ec56d739ec7ffd5cb4230f90f583df05fe59a6c1a41afac7048df',
|
||
|
mail_events: events
|
||
|
};
|
||
|
|
||
|
await service.processPayload(payload);
|
||
|
|
||
|
assert.ok(repository.save.calledOnce);
|
||
|
assert.equal(repository.save.getCall(0).args[0].type, 'opened');
|
||
|
});
|
||
|
|
||
|
it('should ensure event timestamps are converted to ms', async function () {
|
||
|
const payloadEvent = makePayloadEvent('opened');
|
||
|
|
||
|
// Ensure a fixed timestamp is used so that we know the signature up front
|
||
|
payloadEvent.timestamp = 1686665992511 / 1000;
|
||
|
|
||
|
const payload = {
|
||
|
signature: '9f2567330688b82759600fad93c93b3e8f571d397c33688a8620400af20b79b3',
|
||
|
mail_events: [
|
||
|
payloadEvent
|
||
|
]
|
||
|
};
|
||
|
|
||
|
await service.processPayload(payload);
|
||
|
|
||
|
assert.ok(repository.save.calledOnce);
|
||
|
|
||
|
const storedEvent = repository.save.getCall(0).args[0];
|
||
|
|
||
|
assert.equal(storedEvent.timestampMs, payloadEvent.timestamp * 1000);
|
||
|
});
|
||
|
|
||
|
it('should reject if an event can not be stored', async function () {
|
||
|
const payloadEvent = makePayloadEvent('opened');
|
||
|
|
||
|
// Ensure a fixed timestamp is used so that we know the signature up front
|
||
|
payloadEvent.timestamp = 1686665992511 / 1000;
|
||
|
|
||
|
const payload = {
|
||
|
signature: '9f2567330688b82759600fad93c93b3e8f571d397c33688a8620400af20b79b3',
|
||
|
mail_events: [
|
||
|
payloadEvent
|
||
|
]
|
||
|
};
|
||
|
|
||
|
repository.save.rejects(new Error('foobarbaz'));
|
||
|
|
||
|
await assert.rejects(
|
||
|
service.processPayload(payload),
|
||
|
{
|
||
|
name: 'InternalServerError',
|
||
|
message: 'Event could not be stored'
|
||
|
}
|
||
|
);
|
||
|
});
|
||
|
});
|
||
|
|
||
|
describe('validatePayload', function () {
|
||
|
it('should validate that the payload contains a signature', function () {
|
||
|
assert.throws(
|
||
|
() => service.validatePayload({} as any),
|
||
|
{
|
||
|
name: 'ValidationError',
|
||
|
message: 'Payload is missing "signature"'
|
||
|
}
|
||
|
);
|
||
|
});
|
||
|
|
||
|
it('should validate that the payload contains a valid signature', function () {
|
||
|
assert.throws(() => {
|
||
|
service.validatePayload({
|
||
|
signature: {}
|
||
|
} as any);
|
||
|
}, {
|
||
|
name: 'ValidationError',
|
||
|
message: '"signature" is invalid'
|
||
|
});
|
||
|
});
|
||
|
|
||
|
it('should validate that the payload contains events', function () {
|
||
|
assert.throws(() => {
|
||
|
service.validatePayload({
|
||
|
signature: 'foobarbaz'
|
||
|
} as any);
|
||
|
}, {
|
||
|
name: 'ValidationError',
|
||
|
message: 'Payload is missing "mail_events"'
|
||
|
});
|
||
|
});
|
||
|
|
||
|
it('should validate that the payload contains valid events', function () {
|
||
|
assert.throws(() => {
|
||
|
service.validatePayload({
|
||
|
signature: 'foobarbaz',
|
||
|
mail_events: {}
|
||
|
} as any);
|
||
|
}, {
|
||
|
name: 'ValidationError',
|
||
|
message: '"mail_events" is not an array'
|
||
|
});
|
||
|
});
|
||
|
|
||
|
it('should validate that events in the payload have an id', function () {
|
||
|
const malformedPayloadEvent = makePayloadEvent('opened') as any;
|
||
|
delete malformedPayloadEvent.id;
|
||
|
|
||
|
const payload = {
|
||
|
signature: 'foobarbaz',
|
||
|
mail_events: [
|
||
|
makePayloadEvent('opened'),
|
||
|
malformedPayloadEvent
|
||
|
]
|
||
|
};
|
||
|
|
||
|
assert.throws(
|
||
|
() => service.validatePayload(payload),
|
||
|
{
|
||
|
name: 'ValidationError',
|
||
|
message: 'Event [1] is missing "id"'
|
||
|
}
|
||
|
);
|
||
|
});
|
||
|
|
||
|
it('should validate that events in the payload have an timestamp', function () {
|
||
|
const malformedPayloadEvent = makePayloadEvent('opened') as any;
|
||
|
delete malformedPayloadEvent.timestamp;
|
||
|
|
||
|
const payload = {
|
||
|
signature: 'foobarbaz',
|
||
|
mail_events: [
|
||
|
makePayloadEvent('opened'),
|
||
|
malformedPayloadEvent
|
||
|
]
|
||
|
};
|
||
|
|
||
|
assert.throws(
|
||
|
() => service.validatePayload(payload),
|
||
|
{
|
||
|
name: 'ValidationError',
|
||
|
message: 'Event [1] is missing "timestamp"'
|
||
|
}
|
||
|
);
|
||
|
});
|
||
|
|
||
|
it('should validate that events in the payload have an event', function () {
|
||
|
const malformedPayloadEvent = makePayloadEvent('opened') as any;
|
||
|
delete malformedPayloadEvent.event;
|
||
|
|
||
|
const payload = {
|
||
|
signature: 'foobarbaz',
|
||
|
mail_events: [
|
||
|
makePayloadEvent('opened'),
|
||
|
malformedPayloadEvent
|
||
|
]
|
||
|
};
|
||
|
|
||
|
assert.throws(
|
||
|
() => service.validatePayload(payload),
|
||
|
{
|
||
|
name: 'ValidationError',
|
||
|
message: 'Event [1] is missing "event"'
|
||
|
}
|
||
|
);
|
||
|
});
|
||
|
|
||
|
it('should validate that events in the payload have a message', function () {
|
||
|
const malformedPayloadEvent = makePayloadEvent('opened') as any;
|
||
|
delete malformedPayloadEvent.message;
|
||
|
|
||
|
const payload = {
|
||
|
signature: 'foobarbaz',
|
||
|
mail_events: [
|
||
|
makePayloadEvent('opened'),
|
||
|
malformedPayloadEvent
|
||
|
]
|
||
|
};
|
||
|
|
||
|
assert.throws(
|
||
|
() => service.validatePayload(payload),
|
||
|
{
|
||
|
name: 'ValidationError',
|
||
|
message: 'Event [1] is missing "message"'
|
||
|
}
|
||
|
);
|
||
|
});
|
||
|
|
||
|
it('should validate that events in the payload have a recipient', function () {
|
||
|
const malformedPayloadEvent = makePayloadEvent('opened') as any;
|
||
|
delete malformedPayloadEvent.recipient;
|
||
|
|
||
|
const payload = {
|
||
|
signature: 'foobarbaz',
|
||
|
mail_events: [
|
||
|
makePayloadEvent('opened'),
|
||
|
malformedPayloadEvent
|
||
|
]
|
||
|
};
|
||
|
|
||
|
assert.throws(
|
||
|
() => service.validatePayload(payload),
|
||
|
{
|
||
|
name: 'ValidationError',
|
||
|
message: 'Event [1] is missing "recipient"'
|
||
|
}
|
||
|
);
|
||
|
});
|
||
|
|
||
|
it('should validate that "message.headers.message-id" is present on an event', function () {
|
||
|
const malformedPayloadEvent = makePayloadEvent('opened') as any;
|
||
|
delete malformedPayloadEvent.message.headers;
|
||
|
|
||
|
const payload = {
|
||
|
signature: 'foobarbaz',
|
||
|
mail_events: [
|
||
|
makePayloadEvent('opened'),
|
||
|
malformedPayloadEvent
|
||
|
]
|
||
|
};
|
||
|
|
||
|
assert.throws(
|
||
|
() => service.validatePayload(payload),
|
||
|
{
|
||
|
name: 'ValidationError',
|
||
|
message: 'Event [1] is missing "message.headers.message-id"'
|
||
|
}
|
||
|
);
|
||
|
});
|
||
|
});
|
||
|
});
|