Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(api): update via SDK Studio #9

Merged
merged 1 commit into from
Jun 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 4 additions & 9 deletions src/resources/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,12 @@ export class Webhooks extends APIResource {
}

private validateSecret(secret: string | null | undefined): asserts secret is string {
if (!secret) {
if (typeof secret !== 'string') {
throw new Error(
"The webhook secret must either be set using the env var, METRONOME_WEBHOOK_SECRET, on the client class, Metronome({ webhook_secret: '123' }), or passed to this function",
);
}

const buf = Buffer.from(secret, 'hex');
if (buf.toString('hex') !== secret) {
throw new Error(`Given secret is not valid`);
}

return;
}

Expand Down Expand Up @@ -90,8 +85,8 @@ export class Webhooks extends APIResource {
const msgDate = getRequiredHeader(headers, 'Date');
const msgSignature = getRequiredHeader(headers, 'Metronome-Webhook-Signature');

const now = Math.floor(new Date(msgDate).valueOf() / 1000);
const timestampSeconds = parseInt(msgDate, 10);
const now = Date.now();
const timestampSeconds = Math.floor(new Date(msgDate).valueOf());

if (isNaN(timestampSeconds)) {
throw new Error(`Invalid timestamp header: ${msgDate}`);
Expand Down Expand Up @@ -120,6 +115,6 @@ export class Webhooks extends APIResource {
return;
}

throw new Error('None of the given webhook signatures match the expected signature');
throw new Error('The given webhook signature does not match the expected signature');
}
}
149 changes: 51 additions & 98 deletions tests/api-resources/webhooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,26 @@ const metronome = new Metronome({
});

describe('resource webhooks', () => {
const payload = `{"company_id":"720be419-0293-4d32-a707-32179b0827ab"}`;
const signature = 'm7y0TV2C+hlHxU42wCieApTSTaA8/047OAplBqxIV/s=';
const requestBody = `{
"id": "b2c9e307-624e-4e7d-a5a4-1b74107d78c4",
"type": "widget_created",
"properties": {
"customer_id": "5f794d50-085a-4db6-8d15-286e518b7225",
"widget_id": "0891458d-b6f0-4fdd-a41e-380aae1a1e38"
}
}`;
const payload = Buffer.from(requestBody).toString('utf8');
const signature = 'b82652fa2246cf1d8a27e591f155c865f68b46c19b9213fd9c052f2419b4742b';
const date = 'Mon, 02 Jan 2006 22:04:05 GMT';
const headers = {
Date: date,
'Metronome-Webhook-Signature': signature,
};
const secret = '5WbX5kEWLlfzsGNjH64I8lOOqUB6e8FH';
const secret = 'correct-horse-battery-staple';
const fakeNow = new Date(date).valueOf();

beforeEach(() => {
jest.spyOn(global.Date, 'valueOf').mockImplementation(() => fakeNow);
jest.spyOn(global.Date, 'now').mockImplementation(() => fakeNow);
});

afterEach(() => {
Expand All @@ -33,111 +41,56 @@ describe('resource webhooks', () => {
});
});

describe('verifySignature', () => {
it('should pass for valid signature', () => {
metronome.webhooks.verifySignature(payload, headers, secret);
});

it('should throw for timestamp outside threshold', () => {
jest.spyOn(global.Date, 'now').mockImplementation(() => fakeNow + 360000); // 6 minutes
expect(() =>
metronome.webhooks.verifySignature(payload, headers, secret),
).toThrowErrorMatchingInlineSnapshot(`"Webhook timestamp is too old"`);

jest.spyOn(global.Date, 'now').mockImplementation(() => fakeNow - 360000); // 6 minutes
expect(() =>
metronome.webhooks.verifySignature(payload, headers, secret),
).toThrowErrorMatchingInlineSnapshot(`"Webhook timestamp is too new"`);
});

it('should throw an error for invalid secret format', () => {
expect(() => {
metronome.webhooks.verifySignature(payload, headers, 'invalid secret');
}).toThrowErrorMatchingInlineSnapshot(`"Given secret is not valid"`);
});
it('should pass for valid signature', () => {
metronome.webhooks.verifySignature(payload, headers, secret);
});

it('should throw for invalid signature', () => {
expect(() =>
metronome.webhooks.verifySignature(payload, headers, Buffer.from('foo').toString('base64')),
).toThrowErrorMatchingInlineSnapshot(
`"None of the given webhook signatures match the expected signature"`,
);
});
it('should throw for timestamp outside threshold', () => {
jest.spyOn(global.Date, 'now').mockImplementation(() => fakeNow + 360000); // 6 minutes
expect(() =>
metronome.webhooks.verifySignature(payload, headers, secret),
).toThrowErrorMatchingInlineSnapshot(`"Webhook timestamp is too old"`);

it('should pass for multiple signatures', () => {
const invalidSignature = Buffer.from('my_sig').toString('base64');
metronome.webhooks.verifySignature(
payload,
{
...headers,
'Metronome-Webhook-Signature': `${invalidSignature} ${signature}`,
},
secret,
);
});

it('should throw for invalid timestamp', () => {
expect(() =>
metronome.webhooks.verifySignature(
payload,
{
...headers,
Date: 'nan',
},
secret,
),
).toThrowErrorMatchingInlineSnapshot(`"Invalid timestamp header: nan"`);
});
jest.spyOn(global.Date, 'now').mockImplementation(() => fakeNow - 360000); // 6 minutes
expect(() =>
metronome.webhooks.verifySignature(payload, headers, secret),
).toThrowErrorMatchingInlineSnapshot(`"Webhook timestamp is too new"`);
});

it('should throw for different signature version', () => {
expect(() =>
metronome.webhooks.verifySignature(
payload,
{
...headers,
'Metronome-Webhook-Signature': signature,
},
it('should throw an error for invalid secret format', () => {
expect(() => {
metronome.webhooks.verifySignature(payload, headers, 'invalid secret');
}).toThrowErrorMatchingInlineSnapshot(
`"The given webhook signature does not match the expected signature"`,
);
});

secret,
),
).toThrowErrorMatchingInlineSnapshot(
`"None of the given webhook signatures match the expected signature"`,
);
});
it('should throw for invalid signature', () => {
expect(() =>
metronome.webhooks.verifySignature(payload, headers, Buffer.from('foo').toString('base64')),
).toThrowErrorMatchingInlineSnapshot(
`"The given webhook signature does not match the expected signature"`,
);
});

it('should pass for multiple signatures with different version', () => {
it('should throw for invalid timestamp', () => {
expect(() =>
metronome.webhooks.verifySignature(
payload,
{
...headers,
'Metronome-Webhook-Signature': `${signature} ${signature}`,
Date: 'nan',
},
secret,
);
});

it('should throw if signature version is missing', () => {
expect(() =>
metronome.webhooks.verifySignature(
payload,
{
...headers,
'Metronome-Webhook-Signature': signature,
},

secret,
),
).toThrowErrorMatchingInlineSnapshot(
`"None of the given webhook signatures match the expected signature"`,
);
});
),
).toThrowErrorMatchingInlineSnapshot(`"Invalid timestamp header: nan"`);
});

it('should throw if payload is not a string', () => {
expect(() =>
metronome.webhooks.verifySignature({ payload: 'not a string' } as any, headers, secret),
).toThrowErrorMatchingInlineSnapshot(
`"Webhook body must be passed as the raw JSON string sent from the server (do not parse it first)."`,
);
});
it('should throw if payload is not a string', () => {
expect(() =>
metronome.webhooks.verifySignature({ payload: 'not a string' } as any, headers, secret),
).toThrowErrorMatchingInlineSnapshot(
`"Webhook body must be passed as the raw JSON string sent from the server (do not parse it first)."`,
);
});
});