diff --git a/docs/webhooks/webhooks_overview/webhooks_overview.md b/docs/webhooks/webhooks_overview/webhooks_overview.md index c99e7a13..80f4442e 100644 --- a/docs/webhooks/webhooks_overview/webhooks_overview.md +++ b/docs/webhooks/webhooks_overview/webhooks_overview.md @@ -96,6 +96,73 @@ All webhook requests contain these headers: | X-Api-Key | Your application’s API key. Should be used to validate request signature | a1b23cdefgh4 | | X-Signature | HMAC signature of the request body. See Signature section | ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb | +## 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. + +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 + +The .NET SDK exposes three composite helpers — `VerifyAndParseWebhook`, `ParseSqs`, `ParseSns` — for HTTP webhooks and for SQS/SNS payloads. `VerifyAndParseWebhook` inflates the payload when gzipped (detected from the body bytes per RFC 1952), verifies the `X-Signature` HMAC against the **uncompressed** JSON using constant-time comparison, and returns `EventResponse`. **`ParseSqs`** / **`ParseSns`** decode (and for SNS, unwrap the envelope when needed) — **no application-level HMAC**. All failure modes throw `StreamInvalidWebhookException` with message constants on that type (`SignatureMismatch`, `InvalidBase64`, `GzipFailed`, `InvalidJson`). + +The same call works whether or not Stream is currently compressing payloads for your app, so handlers do not need to change when you flip the dashboard toggle. + +You can reach the helpers in three ways: + +- `IStreamClientFactory.VerifyAndParseWebhook` / `ParseSqs` / `ParseSns` — convenience wrappers on the top-level factory. +- `IAppClient` — the same surface scoped to the app client. +- `StreamChat.Clients.WebhookHelpers` — stateless statics when the API secret is supplied per call (`VerifyAndParseWebhook` only); **`ParseSqs` / `ParseSns`** take the message body alone. + +### ASP.NET Core handler + +```csharp +[ApiController] +[Route("webhooks/stream")] +public class StreamWebhookController : ControllerBase +{ + private readonly IStreamClientFactory _stream; + + public StreamWebhookController(IStreamClientFactory stream) => _stream = stream; + + [HttpPost] + public async Task ReceiveAsync() + { + // Read the raw bytes off the wire — do NOT bind to a model, you need the + // exact byte stream the server signed. + using var ms = new MemoryStream(); + await HttpContext.Request.Body.CopyToAsync(ms); + var rawBody = ms.ToArray(); + + var signature = HttpContext.Request.Headers["X-Signature"].ToString(); + + try + { + EventResponse ev = _stream.VerifyAndParseWebhook(rawBody, signature); + // ...handle ev.Type, ev.Message, etc... + return Ok(); + } + catch (StreamInvalidWebhookException) + { + return Unauthorized(); + } + } +} +``` + +### SQS / SNS firehose + +When events are delivered through SQS or SNS the (possibly gzipped) payload is wrapped in base64 so it stays valid UTF-8 over the queue. Pass the SQS `Body` string, or the SNS notification body (full envelope or pre-extracted `Message`). Stream does **not** attach an application-level `X-Signature` on these channels. + +```csharp +EventResponse ev = _stream.ParseSqs(message.Body); +// EventResponse sns = _stream.ParseSns(notificationBodyOrMessage); +``` + ## Webhook types In addition to the above there are 3 special webhooks. diff --git a/src/Clients/AppClient.cs b/src/Clients/AppClient.cs index 618dc254..ccfe2a30 100644 --- a/src/Clients/AppClient.cs +++ b/src/Clients/AppClient.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Net; -using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; using StreamChat.Models; @@ -77,13 +76,18 @@ public async Task DeletePushProviderAsync(PushProviderType provider HttpStatusCode.OK); public bool VerifyWebhook(string requestBody, string xSignature) - { - using (var sha = new HMACSHA256(Encoding.UTF8.GetBytes(_apiSecret))) - { - var sigBytes = sha.ComputeHash(Encoding.UTF8.GetBytes(requestBody)); - var sig = BitConverter.ToString(sigBytes).Replace("-", string.Empty).ToLowerInvariant(); - return sig == xSignature; - } - } + => requestBody != null + && xSignature != null + && WebhookHelpers.VerifySignature( + Encoding.UTF8.GetBytes(requestBody), xSignature, _apiSecret); + + public EventResponse VerifyAndParseWebhook(byte[] body, string signature) + => WebhookHelpers.VerifyAndParseWebhook(body, signature, _apiSecret); + + public EventResponse ParseSqs(string messageBody) + => WebhookHelpers.ParseSqs(messageBody); + + public EventResponse ParseSns(string message) + => WebhookHelpers.ParseSns(message); } } \ No newline at end of file diff --git a/src/Clients/IAppClient.cs b/src/Clients/IAppClient.cs index 6380ca83..14b6e102 100644 --- a/src/Clients/IAppClient.cs +++ b/src/Clients/IAppClient.cs @@ -73,5 +73,35 @@ public interface IAppClient /// The request body to validate. /// The signature provided in X-Signature header. bool VerifyWebhook(string requestBody, string xSignature); + + /// + /// Verify and parse an HTTP webhook event. + /// + /// + /// Decompresses when gzipped (detected from the + /// body bytes), verifies the X-Signature header against the + /// client's API secret, and returns the parsed . + /// The same call works whether or not Stream is currently compressing + /// payloads for this app, and stays correct behind middleware that + /// auto-decompresses the request. + /// + /// Raw HTTP request body bytes Stream signed. + /// Value of the X-Signature header. + /// + /// Thrown when the signature does not match or the gzip envelope is malformed. + /// + EventResponse VerifyAndParseWebhook(byte[] body, string signature); + + /// + /// Parse an SQS-delivered event (decode only; no HMAC). + /// + /// SQS message Body string. + EventResponse ParseSqs(string messageBody); + + /// + /// Parse an SNS-delivered event (unwrap envelope when needed; no HMAC). + /// + /// Raw SNS POST body or pre-extracted Message string. + EventResponse ParseSns(string message); } } \ No newline at end of file diff --git a/src/Clients/IStreamClientFactory.cs b/src/Clients/IStreamClientFactory.cs index 20d61e9a..ec3d9064 100644 --- a/src/Clients/IStreamClientFactory.cs +++ b/src/Clients/IStreamClientFactory.cs @@ -1,3 +1,5 @@ +using StreamChat.Models; + namespace StreamChat.Clients { /// @@ -12,6 +14,25 @@ public interface IStreamClientFactory /// https://getstream.io/chat/docs/dotnet-csharp/app_setting_overview/?language=csharp IAppClient GetAppClient(); + /// + /// Verify and parse an HTTP webhook event using this factory's API secret. + /// + /// + /// Convenience wrapper around + /// so callers that already hold the top-level factory don't need to reach + /// for first. See + /// https://getstream.io/chat/docs/dotnet-csharp/webhooks_overview/. + /// + /// Raw HTTP request body bytes Stream signed. + /// Value of the X-Signature header. + EventResponse VerifyAndParseWebhook(byte[] body, string signature); + + /// + EventResponse ParseSqs(string messageBody); + + /// + EventResponse ParseSns(string message); + /// /// Returns an instance. The returned client can be used as a singleton in your application. /// diff --git a/src/Clients/StreamClientFactory.cs b/src/Clients/StreamClientFactory.cs index 66d51629..cbfe7af9 100644 --- a/src/Clients/StreamClientFactory.cs +++ b/src/Clients/StreamClientFactory.cs @@ -99,6 +99,19 @@ public StreamClientFactory(string apiKey, string apiSecret, Action _appClient; + + /// + public EventResponse VerifyAndParseWebhook(byte[] body, string signature) + => _appClient.VerifyAndParseWebhook(body, signature); + + /// + public EventResponse ParseSqs(string messageBody) + => _appClient.ParseSqs(messageBody); + + /// + public EventResponse ParseSns(string message) + => _appClient.ParseSns(message); + public IBlocklistClient GetBlocklistClient() => _blocklistClient; public IChannelClient GetChannelClient() => _channelClient; public IChannelTypeClient GetChannelTypeClient() => _channelTypeClient; diff --git a/src/Clients/WebhookHelpers.cs b/src/Clients/WebhookHelpers.cs new file mode 100644 index 00000000..77982f87 --- /dev/null +++ b/src/Clients/WebhookHelpers.cs @@ -0,0 +1,323 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text; +using Newtonsoft.Json; +using StreamChat.Exceptions; +using StreamChat.Models; + +namespace StreamChat.Clients +{ + /// + /// Stateless helpers implementing the cross-SDK webhook contract documented at + /// https://getstream.io/chat/docs/node/webhooks_overview/. + /// + /// + /// The composite functions (, + /// , ) are the recommended + /// entry points; the primitives they compose are exposed so callers can + /// build custom flows or run individual steps in isolation. + /// + public static class WebhookHelpers + { + private static readonly byte[] GzipMagic = new byte[] { 0x1f, 0x8b }; + + /// + /// Returns verbatim when it is not gzipped, or the + /// inflated bytes when the first two bytes match the RFC 1952 gzip magic + /// number (0x1F 0x8B). The check is performed on the bytes themselves + /// so it works regardless of the Content-Encoding header. + /// + /// Raw payload bytes; never null. + /// When is null. + /// + /// When the body starts with the gzip magic but cannot be inflated. + /// + public static byte[] GunzipPayload(byte[] body) + { + if (body == null) + { + throw new ArgumentNullException(nameof(body)); + } + + if (body.Length < 2 || body[0] != GzipMagic[0] || body[1] != GzipMagic[1]) + { + return body; + } + + try + { + using (var input = new MemoryStream(body)) + using (var gzip = new GZipStream(input, CompressionMode.Decompress)) + using (var output = new MemoryStream()) + { + gzip.CopyTo(output); + return output.ToArray(); + } + } + catch (InvalidDataException ex) + { + throw new InvalidWebhookError(InvalidWebhookError.GzipFailed, ex); + } + } + + /// + /// Reverses the SQS firehose envelope: base64-decodes + /// and then inflates the result when it is gzipped. The returned bytes are the + /// raw JSON that Stream signed. + /// + /// SQS message Body string; never null. + /// When is null. + /// + /// When the body is not valid base64 or the inner payload is malformed gzip. + /// + public static byte[] DecodeSqsPayload(string body) + { + if (body == null) + { + throw new ArgumentNullException(nameof(body)); + } + + byte[] decoded; + try + { + decoded = Convert.FromBase64String(body); + } + catch (FormatException ex) + { + throw new InvalidWebhookError(InvalidWebhookError.InvalidBase64, ex); + } + + return GunzipPayload(decoded); + } + + /// + /// Reverses an SNS HTTP notification envelope. When + /// 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. + /// + /// SNS HTTP POST body, or a pre-extracted Message string. + /// When is null. + /// + /// When the extracted Message is not valid base64 or the inner payload is malformed gzip. + /// + public static byte[] DecodeSnsPayload(string notificationBody) + { + if (notificationBody == null) + { + throw new ArgumentNullException(nameof(notificationBody)); + } + + var inner = ExtractSnsMessage(notificationBody); + return DecodeSqsPayload(inner ?? notificationBody); + } + + private static string ExtractSnsMessage(string notificationBody) + { + var trimmed = notificationBody.TrimStart(); + if (trimmed.Length == 0 || trimmed[0] != '{') + { + return null; + } + + try + { + var envelope = JsonConvert.DeserializeObject(trimmed); + return envelope?.Message; + } + catch (JsonException) + { + return null; + } + } + + private sealed class SnsEnvelope + { + [JsonProperty("Message")] + public string Message { get; set; } + } + + /// + /// Returns true when the hex-encoded HMAC-SHA256 of + /// keyed by matches . + /// The byte comparison is constant-time. + /// + /// Raw bytes the server signed. + /// Hex-encoded HMAC-SHA256 (typically the X-Signature header). + /// Stream Chat API secret. + /// When any argument is null. + public static bool VerifySignature(byte[] body, string signature, string secret) + { + if (body == null) + { + throw new ArgumentNullException(nameof(body)); + } + + if (signature == null) + { + throw new ArgumentNullException(nameof(signature)); + } + + if (secret == null) + { + throw new ArgumentNullException(nameof(secret)); + } + + byte[] computed; + using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret))) + { + computed = hmac.ComputeHash(body); + } + + return TryHexToBytes(signature, out var provided) + && provided.Length == computed.Length + && FixedTimeEquals(computed, provided); + } + + /// + /// Deserialises the JSON in into an + /// . Unknown event types still parse — the + /// surrounding metadata (e.g. type, cid) is populated and + /// future-specific fields land in the CustomData bag. + /// + /// Raw UTF-8 JSON bytes. + /// When is null. + /// When the JSON cannot be parsed. + public static EventResponse ParseEvent(byte[] payload) + { + if (payload == null) + { + throw new ArgumentNullException(nameof(payload)); + } + + var json = Encoding.UTF8.GetString(payload); + try + { + return JsonConvert.DeserializeObject(json); + } + catch (JsonException ex) + { + throw new InvalidWebhookError(InvalidWebhookError.InvalidJson, ex); + } + } + + /// + /// Inflates when gzipped (detected from the body + /// bytes, independent of any Content-Encoding header), verifies the + /// HMAC-SHA256 signature in constant time, and returns the parsed event. + /// + /// Raw HTTP request body bytes Stream signed. + /// Hex-encoded HMAC-SHA256 from the X-Signature header. + /// Stream Chat API secret. + /// + /// When the signature does not match or the gzip / JSON envelope is malformed. + /// + public static EventResponse VerifyAndParseWebhook(byte[] body, string signature, string secret) + => VerifyAndParseInternal(GunzipPayload(body), signature, secret); + + /// + /// Decodes an SQS message body (base64, then optional gzip) and returns the parsed event. + /// + /// SQS message Body string. + /// + /// When the base64 / gzip / JSON envelope is malformed. + /// + public static EventResponse ParseSqs(string messageBody) + => ParseEvent(DecodeSqsPayload(messageBody)); + + /// + /// Decodes an SNS notification (unwraps the JSON envelope when needed; same inner format as SQS). + /// + /// SNS HTTP POST body, or a pre-extracted Message string. + /// + /// When the base64 / gzip / JSON envelope is malformed. + /// + public static EventResponse ParseSns(string notificationBody) + => ParseEvent(DecodeSnsPayload(notificationBody)); + + private static EventResponse VerifyAndParseInternal(byte[] payload, string signature, string secret) + { + if (!VerifySignature(payload, signature, secret)) + { + throw new InvalidWebhookError(InvalidWebhookError.SignatureMismatch); + } + + return ParseEvent(payload); + } + + private static bool TryHexToBytes(string hex, out byte[] result) + { + result = null; + if (hex == null || (hex.Length & 1) != 0) + { + return false; + } + + var buf = new byte[hex.Length / 2]; + for (int i = 0; i < buf.Length; i++) + { + int hi = HexNibble(hex[i * 2]); + int lo = HexNibble(hex[(i * 2) + 1]); + if (hi < 0 || lo < 0) + { + return false; + } + + buf[i] = (byte)((hi << 4) | lo); + } + + result = buf; + return true; + } + + private static int HexNibble(char c) + { + if (c >= '0' && c <= '9') + { + return c - '0'; + } + + if (c >= 'a' && c <= 'f') + { + return c - 'a' + 10; + } + + if (c >= 'A' && c <= 'F') + { + return c - 'A' + 10; + } + + return -1; + } + + private static bool FixedTimeEquals(byte[] left, byte[] right) + { +#if NETSTANDARD2_1 || NET5_0_OR_GREATER || NETCOREAPP2_1_OR_GREATER + return CryptographicOperations.FixedTimeEquals(left, right); +#else + if (left == null || right == null) + { + return false; + } + + if (left.Length != right.Length) + { + return false; + } + + int diff = 0; + for (int i = 0; i < left.Length; i++) + { + diff |= left[i] ^ right[i]; + } + + return diff == 0; +#endif + } + } +} diff --git a/src/Exceptions/InvalidWebhookError.cs b/src/Exceptions/InvalidWebhookError.cs new file mode 100644 index 00000000..06472f01 --- /dev/null +++ b/src/Exceptions/InvalidWebhookError.cs @@ -0,0 +1,46 @@ +using System; + +namespace StreamChat.Exceptions +{ + /// + /// Unified exception for every webhook verification and parsing failure + /// surfaced by , + /// , + /// , + /// the equivalent helpers on + /// , and the stateless + /// primitives in . A single + /// exception type is thrown for every failure mode so handlers only need + /// one catch arm; the specific cause is identified by the message + /// constants exposed on this class. + /// + /// + /// Covers all webhook envelope and content failures: HMAC signature + /// mismatch (), corrupt gzip + /// (), invalid base64 in the SQS / SNS envelope + /// (), malformed JSON + /// (), unparseable SNS envelopes, and any other + /// schema or structural defect detected before the parsed + /// is returned. Callers can + /// switch on when mode-specific behaviour + /// is required. + /// +#if !NETCORE + [Serializable] +#endif + public class InvalidWebhookError : StreamBaseException + { + public const string SignatureMismatch = "signature mismatch"; + public const string InvalidBase64 = "invalid base64 encoding"; + public const string GzipFailed = "gzip decompression failed"; + public const string InvalidJson = "invalid JSON payload"; + + public InvalidWebhookError(string message) : base(message) + { + } + + public InvalidWebhookError(string message, Exception innerException) : base(message, innerException) + { + } + } +} diff --git a/tests/WebhookCompressionTests.cs b/tests/WebhookCompressionTests.cs new file mode 100644 index 00000000..219b0d53 --- /dev/null +++ b/tests/WebhookCompressionTests.cs @@ -0,0 +1,405 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text; +using FluentAssertions; +using NUnit.Framework; +using StreamChat.Clients; +using StreamChat.Exceptions; + +#pragma warning disable SA1310 // Field names should not contain underscore — names mandated by the cross-SDK contract. + +namespace StreamChatTests +{ + /// + /// Unit tests for the webhook verification + parsing helpers added in + /// . + /// + /// + /// These tests do not extend on purpose - they + /// must run fully offline because they cover SDK-only behaviour that + /// does not touch the Stream API. Tests follow the arrange-act-assert + /// pattern divided by empty lines. + /// + [TestFixture] + public class WebhookCompressionTests + { + private const string JSON_BODY = "{\"type\":\"message.new\",\"message\":{\"text\":\"the quick brown fox\"}}"; + private const string API_SECRET = "tsec2"; + private const string API_KEY = "test-api-key"; + + private static IAppClient BuildAppClient(string secret = API_SECRET) + => new StreamClientFactory(API_KEY, secret).GetAppClient(); + + private static byte[] Gzip(byte[] input) + { + using (var output = new MemoryStream()) + { + using (var gzip = new GZipStream(output, CompressionLevel.Optimal, leaveOpen: true)) + { + gzip.Write(input, 0, input.Length); + } + + return output.ToArray(); + } + } + + private static string Base64Wrap(byte[] input) => Convert.ToBase64String(input); + + private static string HmacHex(string secret, byte[] data) + { + using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret))) + { + var hash = hmac.ComputeHash(data); + return BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant(); + } + } + + [Test] + public void VerifyWebhook_BackwardCompatibility_StillWorks() + { + var appClient = BuildAppClient(); + var signature = HmacHex(API_SECRET, Encoding.UTF8.GetBytes(JSON_BODY)); + + var ok = appClient.VerifyWebhook(JSON_BODY, signature); + var bad = appClient.VerifyWebhook(JSON_BODY, new string('0', 64)); + + ok.Should().BeTrue(); + bad.Should().BeFalse(); + } + + [Test] + public void VerifyAndParseWebhook_PlainBody() + { + var appClient = BuildAppClient(); + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var signature = HmacHex(API_SECRET, raw); + + var ev = appClient.VerifyAndParseWebhook(raw, signature); + + ev.Type.Should().Be("message.new"); + ev.Message.Should().NotBeNull(); + ev.Message.Text.Should().Be("the quick brown fox"); + } + + [Test] + public void VerifyAndParseWebhook_GzipBody() + { + var appClient = BuildAppClient(); + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var signature = HmacHex(API_SECRET, raw); + var gzipped = Gzip(raw); + + var ev = appClient.VerifyAndParseWebhook(gzipped, signature); + + ev.Type.Should().Be("message.new"); + } + + [Test] + public void VerifyAndParseWebhook_ThrowsOnSignatureMismatch() + { + var appClient = BuildAppClient(); + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var bogus = HmacHex("a-different-secret", raw); + + Action call = () => appClient.VerifyAndParseWebhook(raw, bogus); + + call.Should().Throw() + .WithMessage(InvalidWebhookError.SignatureMismatch); + } + + [Test] + public void VerifyAndParseWebhook_RejectsSignatureOverCompressedBytes() + { + var appClient = BuildAppClient(); + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var gzipped = Gzip(raw); + var sigOverCompressed = HmacHex(API_SECRET, gzipped); + + Action call = () => appClient.VerifyAndParseWebhook(gzipped, sigOverCompressed); + + call.Should().Throw() + .WithMessage(InvalidWebhookError.SignatureMismatch); + } + + [Test] + public void ParseSqs_Base64Only() + { + var appClient = BuildAppClient(); + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var wrapped = Base64Wrap(raw); + + var ev = appClient.ParseSqs(wrapped); + + ev.Type.Should().Be("message.new"); + } + + [Test] + public void ParseSqs_Base64PlusGzip() + { + var appClient = BuildAppClient(); + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var wrapped = Base64Wrap(Gzip(raw)); + + var ev = appClient.ParseSqs(wrapped); + + ev.Type.Should().Be("message.new"); + } + + [Test] + public void ParseSqs_ThrowsOnInvalidBase64() + { + var appClient = BuildAppClient(); + + Action call = () => appClient.ParseSqs("@@@-not-base64-@@@"); + + call.Should().Throw() + .WithMessage(InvalidWebhookError.InvalidBase64); + } + + [Test] + public void ParseSns_PreExtractedMessage_Base64PlusGzip() + { + var appClient = BuildAppClient(); + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var wrapped = Base64Wrap(Gzip(raw)); + + var ev = appClient.ParseSns(wrapped); + + ev.Type.Should().Be("message.new"); + } + + [Test] + public void ParseSns_PreExtractedMessage_MatchesSqs() + { + var appClient = BuildAppClient(); + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var wrapped = Base64Wrap(Gzip(raw)); + + var sns = appClient.ParseSns(wrapped); + var sqs = appClient.ParseSqs(wrapped); + + sns.Type.Should().Be(sqs.Type); + sns.Message.Text.Should().Be(sqs.Message.Text); + } + + [Test] + public void ParseSns_FullEnvelope() + { + var appClient = BuildAppClient(); + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var wrapped = Base64Wrap(Gzip(raw)); + var envelope = SnsEnvelope(wrapped); + + var ev = appClient.ParseSns(envelope); + + ev.Type.Should().Be("message.new"); + } + + [Test] + public void DecodeSnsPayload_UnwrapsFullEnvelope() + { + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var wrapped = Base64Wrap(Gzip(raw)); + var envelope = SnsEnvelope(wrapped); + + var output = WebhookHelpers.DecodeSnsPayload(envelope); + + output.Should().Equal(raw); + } + + [Test] + public void DecodeSnsPayload_EnvelopeWithLeadingWhitespace() + { + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var wrapped = Base64Wrap(Gzip(raw)); + var envelope = "\n " + SnsEnvelope(wrapped); + + var output = WebhookHelpers.DecodeSnsPayload(envelope); + + output.Should().Equal(raw); + } + + private static string SnsEnvelope(string innerMessage) + => "{" + + "\"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\":\"placeholder\"}}" + + "}"; + + [Test] + public void GunzipPayload_PassthroughPlainBytes() + { + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + + var output = WebhookHelpers.GunzipPayload(raw); + + output.Should().Equal(raw); + } + + [Test] + public void GunzipPayload_InflatesGzipBytes() + { + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var gzipped = Gzip(raw); + + var output = WebhookHelpers.GunzipPayload(gzipped); + + output.Should().Equal(raw); + } + + [Test] + public void GunzipPayload_ThrowsOnInvalidGzipBody() + { + // Valid gzip header + deflate flags + bogus payload, so the magic + // check passes but inflation fails with InvalidDataException. + var bad = new byte[] + { + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + }; + + Action call = () => WebhookHelpers.GunzipPayload(bad); + + call.Should().Throw() + .WithMessage(InvalidWebhookError.GzipFailed); + } + + [Test] + public void DecodeSqsPayload_Base64Only() + { + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var wrapped = Convert.ToBase64String(raw); + + var output = WebhookHelpers.DecodeSqsPayload(wrapped); + + output.Should().Equal(raw); + } + + [Test] + public void DecodeSqsPayload_Base64PlusGzip() + { + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var wrapped = Convert.ToBase64String(Gzip(raw)); + + var output = WebhookHelpers.DecodeSqsPayload(wrapped); + + output.Should().Equal(raw); + } + + [Test] + public void DecodeSnsPayload_AliasesDecodeSqsPayload() + { + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var wrapped = Convert.ToBase64String(Gzip(raw)); + + var sns = WebhookHelpers.DecodeSnsPayload(wrapped); + var sqs = WebhookHelpers.DecodeSqsPayload(wrapped); + + sns.Should().Equal(sqs); + } + + [Test] + public void VerifySignature_Matching() + { + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var sig = HmacHex(API_SECRET, raw); + + WebhookHelpers.VerifySignature(raw, sig, API_SECRET).Should().BeTrue(); + } + + [Test] + public void VerifySignature_Mismatched() + { + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + + WebhookHelpers.VerifySignature(raw, new string('0', 64), API_SECRET).Should().BeFalse(); + } + + [Test] + public void ParseEvent_KnownType() + { + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + + var ev = WebhookHelpers.ParseEvent(raw); + + ev.Type.Should().Be("message.new"); + ev.Message.Text.Should().Be("the quick brown fox"); + } + + [Test] + public void ParseEvent_UnknownTypeStillParses() + { + var raw = Encoding.UTF8.GetBytes("{\"type\":\"a.future.event\"}"); + + var ev = WebhookHelpers.ParseEvent(raw); + + ev.Type.Should().Be("a.future.event"); + } + + [Test] + public void ParseEvent_ThrowsOnMalformedJson() + { + var raw = Encoding.UTF8.GetBytes("not json"); + + Action call = () => WebhookHelpers.ParseEvent(raw); + + call.Should().Throw() + .WithMessage(InvalidWebhookError.InvalidJson); + } + + [Test] + public void Factory_VerifyAndParseWebhook_DelegatesToAppClient() + { + var factory = new StreamClientFactory(API_KEY, API_SECRET); + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var signature = HmacHex(API_SECRET, raw); + + var viaFactory = factory.VerifyAndParseWebhook(Gzip(raw), signature); + var viaAppClient = factory.GetAppClient().VerifyAndParseWebhook(Gzip(raw), signature); + + viaFactory.Type.Should().Be(viaAppClient.Type); + viaFactory.Type.Should().Be("message.new"); + } + + [Test] + public void Factory_ParseSqs_DelegatesToAppClient() + { + var factory = new StreamClientFactory(API_KEY, API_SECRET); + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var wrapped = Base64Wrap(Gzip(raw)); + + var ev = factory.ParseSqs(wrapped); + + ev.Type.Should().Be("message.new"); + } + + [Test] + public void Factory_ParseSns_DelegatesToAppClient() + { + var factory = new StreamClientFactory(API_KEY, API_SECRET); + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + var wrapped = Base64Wrap(Gzip(raw)); + + var ev = factory.ParseSns(wrapped); + + ev.Type.Should().Be("message.new"); + } + + [Test] + public void Factory_VerifyAndParseWebhook_RejectsMismatchedSignature() + { + var factory = new StreamClientFactory(API_KEY, API_SECRET); + var raw = Encoding.UTF8.GetBytes(JSON_BODY); + + Action call = () => factory.VerifyAndParseWebhook(raw, new string('0', 64)); + + call.Should().Throw(); + } + } +}