diff --git a/README.md b/README.md index 2f891b767..914c02f63 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ yarn start ## 📚 More Code Examples -Read up more on [Logging](./docs/logging.md) and [User Token](./docs/userToken.md) or visit our [documentation](https://getstream.io/chat/docs/) for more examples. +Read up more on [Logging](./docs/logging.md), [User Token](./docs/userToken.md), and [Webhooks](./docs/webhooks.md) (including compressed payloads and SQS / SNS delivery) or visit our [documentation](https://getstream.io/chat/docs/) for more examples. ## ✍️ Contributing diff --git a/docs/webhooks.md b/docs/webhooks.md new file mode 100644 index 000000000..8bcf95a78 --- /dev/null +++ b/docs/webhooks.md @@ -0,0 +1,234 @@ +# Webhooks + +Stream chat can deliver real-time events to your backend over HTTP webhooks +or via SQS / SNS firehose. HTTP webhook payloads are signed with +HMAC-SHA256 using your app's API secret so you can verify they actually +came from Stream. SQS / SNS deliveries ride AWS-internal transports +(IAM-authenticated queues, AWS-signed SNS notifications) and are not +HMAC-signed by Stream — the AWS transport itself is the auth layer. + +The SDK exposes three transport-specific helpers: + +- `verifyAndParseWebhook` — gunzips, verifies the `X-Signature` header, + and returns the parsed `Event` for HTTP webhooks. +- `parseSqs` — base64-decodes (and gunzips, if needed) an SQS message + body and returns the parsed `Event`. No HMAC step. +- `parseSns` — same as `parseSqs`, but also unwraps the SNS HTTP + notification envelope when given the full envelope JSON. No HMAC step. + +Each helper exists both as a method on `StreamChat` and as a standalone +function (useful in serverless handlers where you don't keep a client +around). + +## Verifying an HTTP webhook (legacy boolean helper) + +The classic `verifyWebhook` helper takes the raw HTTP request body plus the +`x-signature` header and returns a boolean. Use it when you already parse +the JSON yourself and just want to confirm the request is authentic. + +```js +const { StreamChat } = require('stream-chat'); + +const client = new StreamChat('api_key', 'api_secret'); + +app.post('/webhooks/stream', express.raw({ type: '*/*' }), (req, res) => { + const valid = client.verifyWebhook(req.body, req.headers['x-signature']); + if (!valid) return res.sendStatus(401); + const event = JSON.parse(req.body.toString('utf8')); + // ...handle the event + res.sendStatus(200); +}); +``` + +## Compressed webhook bodies + +GZIP compression can be enabled for hooks payloads from the Dashboard. +Enabling compression reduces the payload size significantly (often 70–90% +smaller) reducing your bandwidth usage on Stream. The computation overhead +introduced by the decompression step is usually negligible and offset by +the much smaller payload. + +When payload compression is enabled, webhook HTTP requests will include the +`Content-Encoding: gzip` header and the request body will be compressed +with GZIP. Some HTTP servers and middleware (Rails, Django, Laravel, Spring +Boot, ASP.NET) handle this transparently and strip the header before your +handler runs — in that case the body you see is already raw JSON. + +The SDK detects compression from the **first two bytes of the body** +(`1f 8b`, the gzip magic per RFC 1952) rather than the `Content-Encoding` +header, so the same handler stays correct whether or not your framework +auto-decompresses the request. + +Before enabling compression, make sure that: + +- Your backend integration is using a recent version of our official SDKs + with compression support +- If you don't use an official SDK, make sure that your code supports + receiving compressed payloads +- The payload signature check is done on the **uncompressed** payload + +## `verifyAndParseWebhook` + +`verifyAndParseWebhook` is the recommended helper for HTTP webhooks. It +gunzips the body when needed, verifies the HMAC signature, parses the JSON, +and returns the typed `Event`. Every failure mode (signature mismatch, +malformed gzip, malformed base64 on the SQS/SNS variants, invalid JSON) +is surfaced through a single unified error class - `InvalidWebhookError` - +so a single `catch` arm covers all of them. Use `err.message` (or the +exported `InvalidWebhookErrorMessages` constants) when you need to +distinguish between failure modes. + +```js +const { StreamChat, InvalidWebhookError } = require('stream-chat'); + +const client = new StreamChat('api_key', 'api_secret'); + +// Use `express.raw` so `req.body` stays as a Buffer. +app.post('/webhooks/stream', express.raw({ type: '*/*' }), (req, res) => { + try { + const event = client.verifyAndParseWebhook(req.body, req.headers['x-signature']); + // ...handle the event (event.type, event.message, etc.) + res.sendStatus(200); + } catch (err) { + if (err instanceof InvalidWebhookError) { + return res.sendStatus(401); + } + throw err; + } +}); +``` + +The same helper is also exported as a standalone, stateless function that +takes the secret explicitly: + +```js +const { verifyAndParseWebhook } = require('stream-chat'); + +const event = verifyAndParseWebhook(rawBody, signature, apiSecret); +``` + +## SQS / SNS firehose delivery + +Stream can also fan webhook events out through Amazon SQS or SNS. Both +transports require valid UTF-8 message bodies, so the JSON (or its gzipped +bytes when compression is enabled) is base64-encoded before being placed in +the message. + +Stream does not include an `X-Signature` on SQS or SNS deliveries — those +rely on the AWS transport's own authentication (IAM-authenticated queues +for SQS, AWS-signed SNS notifications). `parseSqs` and `parseSns` are +decode-and-parse helpers; there is no HMAC step. The HTTP webhook path +(`verifyAndParseWebhook`) keeps signature verification because that's the +only surface where `X-Signature` actually arrives. + +Use `parseSqs` for SQS messages. It base64-decodes the body, gunzips when +the decoded bytes start with the gzip magic, and returns the parsed +`Event`. + +```js +const { StreamChat, InvalidWebhookError } = require('stream-chat'); + +const client = new StreamChat('api_key', 'api_secret'); + +async function handleSqsMessage(message) { + try { + const event = client.parseSqs(message.Body); + // ...handle the event + } catch (err) { + if (err instanceof InvalidWebhookError) { + // drop the message or move it to a dead-letter queue + return; + } + throw err; + } +} +``` + +For SNS, pass either the full notification envelope JSON or the +pre-extracted `Message` field to `parseSns`: + +```js +const { StreamChat, InvalidWebhookError } = require('stream-chat'); + +const client = new StreamChat('api_key', 'api_secret'); + +async function handleSnsNotification(envelopeBody) { + // `envelopeBody` is the JSON SNS posts to your HTTPS endpoint, or the + // record you pull off SQS-via-SNS. + const event = client.parseSns(envelopeBody); + // ...handle the event +} +``` + +`parseSqs` and `parseSns` are also exported as standalone, stateless +functions: + +```js +const { parseSqs, parseSns } = require('stream-chat'); + +const event = parseSqs(messageBody); +``` + +## Lower-level building blocks + +If you need finer control (for example, to verify a signature without +parsing the JSON, or to inflate a payload yourself), the SDK also exports: + +- `gunzipPayload(body)` — returns the raw body as a `Buffer`, gunzipping + it when the first two bytes match the gzip magic. Plain bodies pass + through unchanged. +- `decodeSqsPayload(body)` / `decodeSnsPayload(body)` — base64-decodes + the SQS/SNS body and then gunzips if needed. Throws + `InvalidWebhookError` on malformed base64. +- `parseEvent(payload)` — `JSON.parse` plus the `Event` type cast. +- `verifySignature(body, signature, secret)` — constant-time HMAC-SHA256 + comparison. The signature must be computed over the uncompressed, + base64-decoded JSON. + +## API reference + +| Method | Returns | Throws | +| ------------------------------------------------------------ | --------- | ----------------------------------------------------------------------------------- | +| `client.verifyWebhook(body, sig)` | `boolean` | never | +| `client.verifyAndParseWebhook(rawBody, sig)` | `Event` | `InvalidWebhookError` for signature mismatch, missing secret, or bad gzip envelope | +| `client.parseSqs(messageBody)` | `Event` | `InvalidWebhookError` for bad base64 / gzip or invalid JSON | +| `client.parseSns(notificationBody)` | `Event` | `InvalidWebhookError` for bad base64 / gzip or invalid JSON | +| `verifyAndParseWebhook(rawBody, sig, secret)` _(standalone)_ | `Event` | `InvalidWebhookError` for signature mismatch or bad gzip envelope | +| `parseSqs(messageBody)` _(standalone)_ | `Event` | `InvalidWebhookError` for bad base64 / gzip or invalid JSON | +| `parseSns(notificationBody)` _(standalone)_ | `Event` | `InvalidWebhookError` for bad base64 / gzip or invalid JSON | +| `verifySignature(body, sig, secret)` | `boolean` | never | +| `gunzipPayload(body)` | `Buffer` | `InvalidWebhookError` when the body starts with the gzip magic but fails to inflate | +| `decodeSqsPayload(body)` / `decodeSnsPayload(body)` | `Buffer` | `InvalidWebhookError` for malformed base64 or bad gzip bytes | +| `parseEvent(payload)` | `Event` | `InvalidWebhookError` when the payload is not valid JSON | + +`parseSqs` and `parseSns` take a single argument (the message body or SNS +envelope / pre-extracted message). They never accept a signature: Stream +does not ship an `X-Signature` on SQS or SNS deliveries — those rely on +the AWS transport's own authentication (IAM-authenticated queues, +AWS-signed SNS notifications). The HTTP webhook path +(`verifyAndParseWebhook`) keeps signature verification because that's the +only surface where `X-Signature` actually arrives. + +`InvalidWebhookError` (and the `InvalidWebhookErrorMessages` constants) is +exported from the package root and from `stream-chat/dist/types/signing`. +Every webhook verification + parsing path in this SDK terminates at this +single error class, so a single `catch` arm is enough to convert auth and +format failures into a `401` / `403` response (HTTP) or a drop / +dead-letter decision (SQS / SNS). Filter on `err.message` when you need +mode-specific behaviour: + +```js +const { InvalidWebhookError, InvalidWebhookErrorMessages } = require('stream-chat'); + +try { + const event = client.verifyAndParseWebhook(req.body, req.headers['x-signature']); +} catch (err) { + if (err instanceof InvalidWebhookError) { + if (err.message === InvalidWebhookErrorMessages.signatureMismatch) { + return res.sendStatus(401); + } + return res.sendStatus(400); + } + throw err; +} +``` diff --git a/package.json b/package.json index de5b0854b..01c3870fb 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "browser": { "https": false, "crypto": false, + "zlib": false, "jsonwebtoken": false, "ws": false }, diff --git a/src/client.ts b/src/client.ts index fc64609af..6d77bb3df 100644 --- a/src/client.ts +++ b/src/client.ts @@ -9,7 +9,15 @@ import { Channel } from './channel'; import { ClientState } from './client_state'; import { StableWSConnection } from './connection'; import { UploadManager } from './uploadManager'; -import { CheckSignature, DevToken, JWTUserToken } from './signing'; +import { + DevToken, + InvalidWebhookError, + JWTUserToken, + parseSns as parseSnsHelper, + parseSqs as parseSqsHelper, + verifyAndParseWebhook as verifyAndParseWebhookHelper, + verifySignature, +} from './signing'; import { TokenManager } from './token_manager'; import { WSConnectionFallback } from './connection_fallback'; import { Campaign } from './campaign'; @@ -3639,7 +3647,53 @@ export class StreamChat { * @returns {boolean} */ verifyWebhook(requestBody: string | Buffer, xSignature: string) { - return !!this.secret && CheckSignature(requestBody, this.secret, xSignature); + return !!this.secret && verifySignature(requestBody, xSignature, this.secret); + } + + /** + * 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 `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 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 `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) { + return 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) { + return parseSnsHelper(notificationBody); } /** getPermission - gets the definition for a permission diff --git a/src/signing.ts b/src/signing.ts index 661a77190..648e585ca 100644 --- a/src/signing.ts +++ b/src/signing.ts @@ -1,7 +1,8 @@ import jwt from 'jsonwebtoken'; import crypto from 'crypto'; +import zlib from 'zlib'; import { decodeBase64, encodeBase64 } from './base64'; -import type { UR } from './types'; +import type { Event, UR } from './types'; /** * Creates the JWT token that can be used for a UserSession @@ -84,19 +85,206 @@ export function DevToken(userId: string) { } /** + * Constant-time HMAC-SHA256 verification of `signature` against the + * digest of `body` using `secret` as the key. The signature is always + * computed over the **uncompressed** JSON bytes, so callers that + * decoded a gzipped or base64-wrapped payload must pass the inflated + * bytes here. * - * @param {string | Buffer} body the signed message - * @param {string} secret the shared secret used to generate the signature (Stream API secret) - * @param {string} signature the signature to validate - * @return {boolean} + * The legacy `client.verifyWebhook` helper wraps this function, so + * callers that have already migrated to `verifyAndParseWebhook`, + * `parseSqs`, or `parseSns` rarely need to invoke this + * directly. */ -export function CheckSignature(body: string | Buffer, secret: string, signature: string) { +export function verifySignature( + body: string | Buffer, + signature: string, + secret: string, +): boolean { const key = Buffer.from(secret, 'utf8'); const hash = crypto.createHmac('sha256', key).update(body).digest('hex'); - try { return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(signature)); } catch { return false; } } + +/** + * @deprecated Use {@link verifySignature} - same logic, parameters + * reordered to match the cross-SDK contract + * (`verifySignature(body, signature, secret)`). + */ +export function CheckSignature(body: string | Buffer, secret: string, signature: string) { + return verifySignature(body, signature, secret); +} + +/** + * Canonical failure-mode messages for {@link InvalidWebhookError}. + * + * Customers that prefer exact-match filtering (security logging, retry + * policy) over substring matches can compare `err.message` to these + * constants instead of pattern-matching free-form text. + */ +export const InvalidWebhookErrorMessages = { + signatureMismatch: 'signature mismatch', + invalidBase64: 'invalid base64 encoding', + gzipFailed: 'gzip decompression failed', + invalidJson: 'invalid JSON payload', +} as const; + +/** + * Thrown by {@link verifyAndParseWebhook} when the supplied `x-signature` does not + * match the HMAC of the uncompressed payload, and by all webhook helpers (including + * {@link parseSqs} / {@link parseSns}) when a gzip / base64 / JSON envelope is malformed. + * + * The message identifies which failure mode fired. See + * {@link InvalidWebhookErrorMessages} for the canonical strings. + */ +export class InvalidWebhookError extends Error { + public name = 'InvalidWebhookError'; + + constructor(message: string = InvalidWebhookErrorMessages.signatureMismatch) { + super(message); + } +} + +/** + * Returns `body` as a `Buffer`, gzip-decompressed when its first two + * bytes match the gzip magic (`1f 8b`, per RFC 1952). When the body is + * plain JSON (no compression, or middleware already decompressed), the + * bytes are returned unchanged. + * + * Magic-byte detection (rather than relying on a header) keeps the + * same handler correct when middleware - Express, Next.js, AWS Lambda + * - auto-decompresses the request before your code sees it. + */ +export function gunzipPayload(rawBody: string | Buffer): Buffer { + const GZIP_MAGIC = Buffer.from([0x1f, 0x8b]); + + const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody); + if (body.length >= 2 && body.subarray(0, 2).equals(GZIP_MAGIC)) { + try { + return zlib.gunzipSync(body); + } catch { + throw new InvalidWebhookError(InvalidWebhookErrorMessages.gzipFailed); + } + } + return body; +} + +/** + * Reverses the SQS firehose envelope: the message `Body` is + * base64-decoded, then the result is gzip-decompressed when it begins + * with the gzip magic. Returns the raw JSON `Buffer` Stream signed. + * + * SQS bodies are always base64-encoded so they remain valid UTF-8 over + * the queue. The same call works whether or not Stream is currently + * compressing payloads for this app. + */ +export function decodeSqsPayload(body: string): Buffer { + // Reject anything that isn't canonical base64 up front. Node's base64 + // decoder is permissive (silently strips characters outside the + // alphabet, accepts both standard and URL-safe variants), so we have + // to be strict here to avoid silently corrupting the body before the + // signature check runs. + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(body) || body.length % 4 !== 0) { + throw new InvalidWebhookError(InvalidWebhookErrorMessages.invalidBase64); + } + const decoded = Buffer.from(body, 'base64'); + if (decoded.toString('base64').length !== body.length) { + throw new InvalidWebhookError(InvalidWebhookErrorMessages.invalidBase64); + } + return gunzipPayload(decoded); +} + +/** + * Reverses an SNS HTTP notification envelope. When `notificationBody` + * is a JSON envelope (`{"Type":"Notification","Message":"..."}`), the + * inner `Message` field is extracted and run through the SQS pipeline + * (base64-decode, then gzip-if-magic). When the input is not a JSON + * envelope it is treated as the already-extracted `Message` string, + * so call sites that pre-unwrap continue to work. + */ +export function decodeSnsPayload(notificationBody: string): Buffer { + const inner = extractSnsMessage(notificationBody); + return decodeSqsPayload(inner ?? notificationBody); +} + +function extractSnsMessage(notificationBody: string): string | null { + const trimmed = notificationBody.replace(/^[\s\uFEFF]+/, ''); + if (!trimmed.startsWith('{')) { + return null; + } + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return null; + } + if ( + parsed === null || + typeof parsed !== 'object' || + Array.isArray(parsed) || + typeof (parsed as { Message?: unknown }).Message !== 'string' + ) { + return null; + } + return (parsed as { Message: string }).Message; +} + +/** + * Parse a JSON-encoded webhook event into a typed {@link Event}. New + * event types Stream introduces still parse successfully - the runtime + * shape is the JSON Stream sent and the `type` field stays preserved. + */ +export function parseEvent(payload: Buffer | string): Event { + const text = Buffer.isBuffer(payload) ? payload.toString('utf8') : payload; + try { + return JSON.parse(text) as Event; + } catch { + throw new InvalidWebhookError(InvalidWebhookErrorMessages.invalidJson); + } +} + +function verifyAndParse(payload: Buffer, signature: string, secret: string): Event { + if (!verifySignature(payload, signature, secret)) { + throw new InvalidWebhookError(InvalidWebhookErrorMessages.signatureMismatch); + } + return parseEvent(payload); +} + +/** + * Decompress (when gzipped), verify the HMAC `signature`, and return + * the parsed {@link Event}. + * + * @param rawBody Raw HTTP request body bytes Stream signed + * @param signature Value of the `X-Signature` header + * @param secret Your app's API secret + * @throws {InvalidWebhookError} When the signature does not match or + * the gzip envelope is malformed. + */ +export function verifyAndParseWebhook( + rawBody: string | Buffer, + signature: string, + secret: string, +): Event { + return verifyAndParse(gunzipPayload(rawBody), signature, secret); +} + +/** + * Decode the SQS message `Body` (base64, then gzip-if-magic) and return + * the parsed {@link Event}. Stream does not attach an application-level HMAC + * to SQS deliveries — use {@link verifyAndParseWebhook} for HTTP webhooks. + */ +export function parseSqs(messageBody: string): Event { + return parseEvent(decodeSqsPayload(messageBody)); +} + +/** + * Decode an SNS notification (unwrap the JSON envelope when needed; same + * inner format as SQS). No application-level HMAC verification. + */ +export function parseSns(notificationBody: string): Event { + return parseEvent(decodeSnsPayload(notificationBody)); +} diff --git a/test/unit/webhook-compression.test.ts b/test/unit/webhook-compression.test.ts new file mode 100644 index 000000000..d6cfdbf74 --- /dev/null +++ b/test/unit/webhook-compression.test.ts @@ -0,0 +1,274 @@ +import crypto from 'crypto'; +import zlib from 'zlib'; + +import { describe, it, expect, beforeEach } from 'vitest'; + +import { StreamChat } from '../../src/client'; +import { + decodeSnsPayload, + decodeSqsPayload, + gunzipPayload, + parseEvent, + parseSns, + parseSqs, + verifyAndParseWebhook, + verifySignature, + InvalidWebhookError, + InvalidWebhookErrorMessages, +} from '../../src/signing'; + +const JSON_BODY = '{"type":"message.new","message":{"text":"the quick brown fox"}}'; +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: '' }, + }, + }); + +describe('Webhook verification + parsing', () => { + let client: StreamChat; + + beforeEach(() => { + client = new StreamChat('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', () => { + const out = gunzipPayload(JSON_BODY); + expect(out.toString('utf8')).toBe(JSON_BODY); + }); + + it('passes through Buffer input unchanged', () => { + const out = gunzipPayload(Buffer.from(JSON_BODY)); + expect(out.toString('utf8')).toBe(JSON_BODY); + }); + + it('inflates gzip-magic bytes', () => { + const out = gunzipPayload(gzip(JSON_BODY)); + expect(out.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('message.new'); + expect(ev.message?.text).toBe('the quick brown fox'); + }); + + it('parses string payload', () => { + const ev = parseEvent(JSON_BODY); + expect(ev.type).toBe('message.new'); + }); + + it('still parses unknown event types at runtime', () => { + const ev = parseEvent('{"type":"a.future.event","custom":42}'); + expect(ev.type).toBe('a.future.event'); + }); + + 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('message.new'); + expect(ev.message?.text).toBe('the quick brown fox'); + }); + + it('parses a gzip-compressed HTTP webhook', () => { + const ev = client.verifyAndParseWebhook(gzip(JSON_BODY), sign(JSON_BODY)); + expect(ev.type).toBe('message.new'); + }); + + 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('throws InvalidWebhookError when the client has no API secret', () => { + const secretless = new StreamChat('api_key'); + expect(() => secretless.verifyAndParseWebhook(JSON_BODY, 'sig')).toThrow( + InvalidWebhookError, + ); + }); + + it('also works as a package-level function', () => { + const ev = verifyAndParseWebhook(JSON_BODY, sign(JSON_BODY), API_SECRET); + expect(ev.type).toBe('message.new'); + }); + }); + + describe('parseSqs', () => { + it('parses a base64-only SQS body', () => { + const ev = client.parseSqs(base64(JSON_BODY)); + expect(ev.type).toBe('message.new'); + }); + + it('parses a base64 + gzip SQS body', () => { + const wrapped = base64(gzip(JSON_BODY)); + const ev = client.parseSqs(wrapped); + expect(ev.type).toBe('message.new'); + }); + + it('also works as a package-level function', () => { + const wrapped = base64(gzip(JSON_BODY)); + const ev = parseSqs(wrapped); + expect(ev.type).toBe('message.new'); + }); + + it('surfaces malformed base64 as InvalidWebhookError', () => { + expect(() => client.parseSqs('!!!not-base64!!!')).toThrow(InvalidWebhookError); + }); + + it('does not require an API secret on the client', () => { + const secretless = new StreamChat('api_key'); + const wrapped = base64(gzip(JSON_BODY)); + expect(secretless.parseSqs(wrapped).type).toBe('message.new'); + }); + }); + + 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('message.new'); + }); + + 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('message.new'); + }); + + it('also works as a package-level function', () => { + const wrapped = base64(gzip(JSON_BODY)); + const ev = parseSns(wrapped); + expect(ev.type).toBe('message.new'); + }); + }); +});