Skip to content
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
284 changes: 284 additions & 0 deletions __tests__/webhook-compression.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
import crypto from 'crypto';
import zlib from 'zlib';

import { beforeEach, describe, expect, it } from 'vitest';

import { StreamClient } from '../src/StreamClient';
import {
decodeSnsPayload,
decodeSqsPayload,
gunzipPayload,
InvalidWebhookError,
InvalidWebhookErrorMessages,
parseEvent,
parseSns,
parseSqs,
verifyAndParseWebhook,
verifySignature,
} from '../src/utils/webhook';

const JSON_BODY =
'{"type":"user.updated","created_at":"2026-04-08T14:23:35.879421674Z","user":{"id":"u-1"}}';
const API_KEY = 'tkey';
const API_SECRET = 'tsec2';

const sign = (body: Buffer | string) =>
crypto
.createHmac('sha256', Buffer.from(API_SECRET, 'utf8'))
.update(body)
.digest('hex');

const gzip = (body: Buffer | string) =>
zlib.gzipSync(Buffer.isBuffer(body) ? body : Buffer.from(body));

const base64 = (body: Buffer | string) =>
(Buffer.isBuffer(body) ? body : Buffer.from(body)).toString('base64');

const snsEnvelope = (innerMessage: string) =>
JSON.stringify({
Type: 'Notification',
MessageId: '22b80b92-fdea-4c2c-8f9d-bdfb0c7bf324',
TopicArn: 'arn:aws:sns:us-east-1:123456789012:stream-webhooks',
Message: innerMessage,
Timestamp: '2026-05-11T10:00:00.000Z',
SignatureVersion: '1',
MessageAttributes: {
'X-Signature': { Type: 'String', Value: '<signature placeholder>' },
},
});

describe('Webhook verification + parsing', () => {
let client: StreamClient;

beforeEach(() => {
client = new StreamClient(API_KEY, API_SECRET);
});

describe('verifyWebhook (legacy boolean helper, unchanged)', () => {
it('validates a plain JSON body with its HMAC signature', () => {
expect(client.verifyWebhook(JSON_BODY, sign(JSON_BODY))).toBe(true);
});

it('rejects when signature is wrong', () => {
expect(client.verifyWebhook(JSON_BODY, 'deadbeef')).toBe(false);
});
});

describe('verifySignature', () => {
it('returns true for matching HMAC', () => {
expect(verifySignature(JSON_BODY, sign(JSON_BODY), API_SECRET)).toBe(
true,
);
});

it('returns false for mismatched signature', () => {
expect(verifySignature(JSON_BODY, '0'.repeat(64), API_SECRET)).toBe(
false,
);
});

it('returns false for wrong secret', () => {
const sig = crypto
.createHmac('sha256', 'other')
.update(JSON_BODY)
.digest('hex');
expect(verifySignature(JSON_BODY, sig, API_SECRET)).toBe(false);
});

it('rejects signatures computed over compressed bytes', () => {
const compressed = gzip(JSON_BODY);
expect(verifySignature(JSON_BODY, sign(compressed), API_SECRET)).toBe(
false,
);
});
});

describe('gunzipPayload', () => {
it('passes through plain bytes unchanged', () => {
expect(gunzipPayload(JSON_BODY).toString('utf8')).toBe(JSON_BODY);
});

it('passes through Buffer input unchanged', () => {
expect(gunzipPayload(Buffer.from(JSON_BODY)).toString('utf8')).toBe(
JSON_BODY,
);
});

it('inflates gzip-magic bytes', () => {
expect(gunzipPayload(gzip(JSON_BODY)).toString('utf8')).toBe(JSON_BODY);
});

it('returns Buffer in all cases', () => {
expect(Buffer.isBuffer(gunzipPayload(JSON_BODY))).toBe(true);
expect(Buffer.isBuffer(gunzipPayload(gzip(JSON_BODY)))).toBe(true);
});

it('handles empty input', () => {
expect(gunzipPayload(Buffer.alloc(0)).length).toBe(0);
});

it('throws InvalidWebhookError on truncated gzip with magic', () => {
const bad = Buffer.concat([
Buffer.from([0x1f, 0x8b]),
Buffer.from([0, 0, 0]),
]);
expect(() => gunzipPayload(bad)).toThrow(InvalidWebhookError);
expect(() => gunzipPayload(bad)).toThrow(
InvalidWebhookErrorMessages.gzipFailed,
);
});
});

describe('decodeSqsPayload', () => {
it('decodes base64 only (no compression)', () => {
expect(decodeSqsPayload(base64(JSON_BODY)).toString('utf8')).toBe(
JSON_BODY,
);
});

it('decodes base64 + gzip', () => {
expect(decodeSqsPayload(base64(gzip(JSON_BODY))).toString('utf8')).toBe(
JSON_BODY,
);
});

it('throws InvalidWebhookError on malformed base64', () => {
expect(() => decodeSqsPayload('!!!not-base64!!!')).toThrow(
InvalidWebhookError,
);
expect(() => decodeSqsPayload('!!!not-base64!!!')).toThrow(
InvalidWebhookErrorMessages.invalidBase64,
);
});
});

describe('decodeSnsPayload', () => {
it('treats a pre-extracted Message identically to decodeSqsPayload', () => {
const wrapped = base64(gzip(JSON_BODY));
expect(decodeSnsPayload(wrapped).equals(decodeSqsPayload(wrapped))).toBe(
true,
);
});

it('round-trips base64 + gzip (pre-extracted Message)', () => {
expect(decodeSnsPayload(base64(gzip(JSON_BODY))).toString('utf8')).toBe(
JSON_BODY,
);
});

it('unwraps a full SNS HTTP notification envelope', () => {
const wrapped = base64(gzip(JSON_BODY));
const envelope = snsEnvelope(wrapped);
expect(decodeSnsPayload(envelope).toString('utf8')).toBe(JSON_BODY);
});

it('handles whitespace before the envelope JSON', () => {
const wrapped = base64(gzip(JSON_BODY));
const envelope = `\n ${snsEnvelope(wrapped)}`;
expect(decodeSnsPayload(envelope).toString('utf8')).toBe(JSON_BODY);
});
});

describe('parseEvent', () => {
it('parses Buffer payload into a typed event', () => {
const ev = parseEvent(Buffer.from(JSON_BODY));
expect(ev.type).toBe('user.updated');
});

it('parses string payload', () => {
const ev = parseEvent(JSON_BODY);
expect(ev.type).toBe('user.updated');
});

it('throws InvalidWebhookError on malformed JSON', () => {
expect(() => parseEvent('not json')).toThrow(InvalidWebhookError);
expect(() => parseEvent('not json')).toThrow(
InvalidWebhookErrorMessages.invalidJson,
);
});
});

describe('verifyAndParseWebhook', () => {
it('parses a plain HTTP webhook with a valid signature', () => {
const ev = client.verifyAndParseWebhook(JSON_BODY, sign(JSON_BODY));
expect(ev.type).toBe('user.updated');
});

it('parses a gzip-compressed HTTP webhook', () => {
const ev = client.verifyAndParseWebhook(gzip(JSON_BODY), sign(JSON_BODY));
expect(ev.type).toBe('user.updated');
});

it('throws InvalidWebhookError on signature mismatch', () => {
expect(() => client.verifyAndParseWebhook(JSON_BODY, 'deadbeef')).toThrow(
InvalidWebhookError,
);
expect(() => client.verifyAndParseWebhook(JSON_BODY, 'deadbeef')).toThrow(
InvalidWebhookErrorMessages.signatureMismatch,
);
});

it('rejects a gzip body when the signature was computed over compressed bytes', () => {
const compressed = gzip(JSON_BODY);
expect(() =>
client.verifyAndParseWebhook(compressed, sign(compressed)),
).toThrow(InvalidWebhookError);
});

it('also works as a package-level function', () => {
const ev = verifyAndParseWebhook(JSON_BODY, sign(JSON_BODY), API_SECRET);
expect(ev.type).toBe('user.updated');
});
});

describe('parseSqs', () => {
it('parses a base64-only SQS body', () => {
const ev = client.parseSqs(base64(JSON_BODY));
expect(ev.type).toBe('user.updated');
});

it('parses a base64 + gzip SQS body', () => {
const wrapped = base64(gzip(JSON_BODY));
const ev = client.parseSqs(wrapped);
expect(ev.type).toBe('user.updated');
});

it('also works as a package-level function', () => {
const wrapped = base64(gzip(JSON_BODY));
const ev = parseSqs(wrapped);
expect(ev.type).toBe('user.updated');
});

it('surfaces malformed base64 as InvalidWebhookError', () => {
expect(() => client.parseSqs('!!!not-base64!!!')).toThrow(
InvalidWebhookError,
);
});
});

describe('parseSns', () => {
it('parses a pre-extracted base64 + gzip SNS message', () => {
const wrapped = base64(gzip(JSON_BODY));
const ev = client.parseSns(wrapped);
expect(ev.type).toBe('user.updated');
});

it('produces the same event as parseSqs (pre-extracted Message)', () => {
const wrapped = base64(gzip(JSON_BODY));
expect(client.parseSns(wrapped)).toEqual(client.parseSqs(wrapped));
});

it('parses a full SNS HTTP notification envelope', () => {
const wrapped = base64(gzip(JSON_BODY));
const envelope = snsEnvelope(wrapped);
const ev = client.parseSns(envelope);
expect(ev.type).toBe('user.updated');
});

it('also works as a package-level function', () => {
const wrapped = base64(gzip(JSON_BODY));
const ev = parseSns(wrapped);
expect(ev.type).toBe('user.updated');
});
});
});
4 changes: 4 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,7 @@ export * from './src/StreamVideoClient';
export * from './src/gen/models';
export * from './src/StreamFeedsClient';
export * from './src/StreamFeed';
export {
InvalidWebhookError,
InvalidWebhookErrorMessages,
} from './src/utils/webhook';
48 changes: 48 additions & 0 deletions src/StreamClient.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { JWTServerToken, JWTUserToken } from './utils/create-token';
import {
InvalidWebhookError,
parseSns as parseSnsHelper,
parseSqs as parseSqsHelper,
verifyAndParseWebhook as verifyAndParseWebhookHelper,
} from './utils/webhook';
import { CommonApi } from './gen/common/CommonApi';
import { StreamVideoClient } from './StreamVideoClient';
import crypto from 'crypto';
Expand Down Expand Up @@ -253,4 +259,46 @@ export class StreamClient extends CommonApi {
return false;
}
};

/**
* Verify and parse an HTTP webhook event.
*
* Decompresses `rawBody` when gzipped (detected from the body bytes),
* verifies the `X-Signature` header against the app's API secret, and
* returns the parsed typed event. Works whether or not Stream is
* currently compressing payloads for this app, and stays correct behind
* middleware that auto-decompresses the request.
*
* @param rawBody Raw HTTP request body bytes Stream signed
* @param signature Value of the `X-Signature` header
* @throws {InvalidWebhookError} When the signature does not match or
* the gzip / JSON envelope is malformed.
*/
verifyAndParseWebhook = (rawBody: string | Buffer, signature: string) => {
if (!this.secret) {
throw new InvalidWebhookError(
'cannot verify webhook signature without an API secret on the client',
);
}
return verifyAndParseWebhookHelper(rawBody, signature, this.secret);
};

/**
* Parse an SQS firehose event: decodes the message `Body` (base64 +
* optional gzip) and returns the parsed typed event. No HMAC
* verification (Stream does not sign SQS bodies).
*
* @param messageBody SQS message `Body` string
* @throws {InvalidWebhookError} When the base64 / gzip envelope is malformed.
*/
parseSqs = (messageBody: string) => parseSqsHelper(messageBody);

/**
* Parse an SNS-delivered event (unwraps envelope JSON when needed, then
* same decode path as SQS). No HMAC verification.
*
* @param notificationBody Raw SNS POST body or pre-extracted `Message` string
* @throws {InvalidWebhookError} When the envelope cannot be decoded.
*/
parseSns = (notificationBody: string) => parseSnsHelper(notificationBody);
}
Loading