diff --git a/docs/webhooks/webhooks_overview/webhooks_overview.md b/docs/webhooks/webhooks_overview/webhooks_overview.md index 274e3643..cbb59f9d 100644 --- a/docs/webhooks/webhooks_overview/webhooks_overview.md +++ b/docs/webhooks/webhooks_overview/webhooks_overview.md @@ -85,6 +85,52 @@ All webhook requests contain these headers: | X-Webhook-Attempt | Number of webhook request attempt starting from 1 | 1 | | 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 | +| Content-Encoding | Compression algorithm applied to the request body. Only set when webhook compression is enabled on the app | `gzip` | + +### 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 + +Use `App.verifyAndDecodeWebhook` to handle decompression and signature verification in one call. It returns the raw JSON bytes ready to parse: + +```java +// rawBody — bytes read straight from the HTTP request body +// signature — value of the X-Signature header +// contentEncoding — value of the Content-Encoding header (null when absent) +byte[] json = App.verifyAndDecodeWebhook(rawBody, signature, contentEncoding); +// json now contains the uncompressed JSON; parse it as usual. +``` + +If you prefer to handle the steps yourself, the primitives are also exposed: + +```java +byte[] json = App.decompressWebhookBody(rawBody, contentEncoding); +boolean valid = App.verifyWebhookSignature(apiSecret, json, signature); +``` + +This SDK supports `gzip` only — gzip uses the JDK and adds no external dependencies. Any other `Content-Encoding` value raises an `IllegalStateException`; if you see one in production, set `webhook_compression_algorithm` back to `gzip` (or `""` to disable compression) on the app via `App.update()` or the dashboard. + +#### SQS / SNS payloads + +The same helper handles compressed messages delivered through SQS or SNS. There the compressed body is base64-wrapped so it stays valid UTF-8 over the queue. Pass the encoding values that arrived with the message (typically as message attributes such as `Content-Encoding`, `payload_encoding`, and `X-Signature`) as the extra `payloadEncoding` argument: + +```java +// body — the SQS Body / SNS Message string, decoded to bytes +// signature — X-Signature attribute value +// contentEncoding — "gzip" when compression is enabled, otherwise null +// payloadEncoding — "base64" for SQS / SNS firehose payloads +byte[] json = App.verifyAndDecodeWebhook(body, signature, contentEncoding, payloadEncoding); +``` + +The signature is always computed over the innermost (uncompressed, base64-decoded) JSON, regardless of transport. ## Webhook types diff --git a/src/main/java/io/getstream/chat/java/models/App.java b/src/main/java/io/getstream/chat/java/models/App.java index c57eab00..19fe94b0 100644 --- a/src/main/java/io/getstream/chat/java/models/App.java +++ b/src/main/java/io/getstream/chat/java/models/App.java @@ -22,14 +22,20 @@ import io.getstream.chat.java.models.framework.StreamResponseObject; import io.getstream.chat.java.services.AppService; import io.getstream.chat.java.services.framework.Client; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.security.InvalidKeyException; import java.security.Key; +import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.util.Base64; import java.util.Date; import java.util.List; import java.util.Map; +import java.util.zip.GZIPInputStream; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import lombok.*; @@ -586,7 +592,7 @@ public static class DeviceError { @Builder @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode public static class AsyncModerationCallback { @Nullable @JsonProperty("mode") @@ -599,7 +605,7 @@ public static class AsyncModerationCallback { @Builder @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode public static class AsyncModerationConfigRequestObject { @Nullable @JsonProperty("callback") @@ -612,7 +618,7 @@ public static class AsyncModerationConfigRequestObject { @Builder @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode public static class FileUploadConfigRequestObject { @Nullable @@ -644,7 +650,7 @@ public static FileUploadConfigRequestObject buildFrom( @Builder @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode public static class APNConfigRequestObject { @Nullable @JsonProperty("development") @@ -690,7 +696,7 @@ public static APNConfigRequestObject buildFrom(@Nullable APNConfig aPNConfig) { @Builder @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode public static class FirebaseConfigRequestObject { @Nullable @JsonProperty("server_key") @@ -720,7 +726,7 @@ public static FirebaseConfigRequestObject buildFrom(@Nullable FirebaseConfig fir @Builder @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode public static class HuaweiConfigRequestObject { @Nullable @JsonProperty("id") @@ -733,7 +739,7 @@ public static class HuaweiConfigRequestObject { @Builder @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode public static class PushConfigRequestObject { @Nullable @JsonProperty("version") @@ -764,7 +770,7 @@ protected Call generateCall(Client client) { } @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode public static class DeletePushProviderRequest extends StreamRequest { private String providerType; private String name; @@ -785,7 +791,7 @@ protected Call generateCall(Client client) { builderMethodName = "", buildMethodName = "internalBuild") @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode public static class AppUpdateRequestData { @Nullable @JsonProperty("disable_auth_checks") @@ -976,7 +982,7 @@ public boolean equals(Object o) { @Builder @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode @NoArgsConstructor @AllArgsConstructor public static class AppGetRateLimitsRequest extends StreamRequest { @@ -1008,7 +1014,7 @@ protected Call generateCall(Client client) { builderMethodName = "", buildMethodName = "internalBuild") @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode public static class AppCheckSqsRequestData { @Nullable @JsonProperty("sqs_url") @@ -1035,7 +1041,7 @@ protected Call generateCall(Client client) { builderMethodName = "", buildMethodName = "internalBuild") @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode public static class AppCheckSnsRequestData { @Nullable @JsonProperty("sns_topic_arn") @@ -1062,7 +1068,7 @@ protected Call generateCall(Client client) { builderMethodName = "", buildMethodName = "internalBuild") @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode public static class AppCheckPushRequestData { @Nullable @JsonProperty("message_id") @@ -1110,7 +1116,7 @@ protected Call generateCall(Client client) { @AllArgsConstructor @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode public static class AppRevokeTokensRequest extends StreamRequest { @Nullable private Date revokeTokensIssuedBefore; @@ -1440,7 +1446,11 @@ public static DeletePushProviderRequest deletePushProvider( } /** - * Validates if hmac signature is correct for message body. + * Validates if hmac signature is correct for the message body. + * + *

Kept for backward compatibility. New integrations should call {@link + * #verifyAndParseWebhook(byte[], String)} (or the SQS / SNS variants), which also handle gzip + * payload compression. * * @param body raw body from http request converted to a string. * @param signature the signature provided in X-Signature header @@ -1451,7 +1461,8 @@ public boolean verifyWebhook(@NotNull String body, @NotNull String signature) { } /** - * Validates if hmac signature is correct for message body. + * Validates if hmac signature is correct for message body. Backward-compatible alias for {@link + * #verifySignature(byte[], String, String)}. * * @param apiSecret the secret key * @param body raw body from http request converted to a string. @@ -1460,12 +1471,44 @@ public boolean verifyWebhook(@NotNull String body, @NotNull String signature) { */ public static boolean verifyWebhookSignature( @NotNull String apiSecret, @NotNull String body, @NotNull String signature) { + return verifySignature(body.getBytes(StandardCharsets.UTF_8), signature, apiSecret); + } + + /** + * Validates if hmac signature is correct for the message body using the singleton client's API + * secret. + * + * @param body the message body + * @param signature the signature provided in X-Signature header + * @return true if the signature is valid + */ + public static boolean verifyWebhookSignature(@NotNull String body, @NotNull String signature) { + return verifySignature( + body.getBytes(StandardCharsets.UTF_8), signature, Client.getInstance().getApiSecret()); + } + + /** + * Constant-time HMAC-SHA256 verification of {@code signature} against the digest of {@code body} + * using {@code 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 body the uncompressed body bytes + * @param signature the signature provided in {@code X-Signature} + * @param secret the app's API secret + * @return true if the signature matches + */ + public static boolean verifySignature( + @NotNull byte[] body, @NotNull String signature, @NotNull String secret) { try { - Key sk = new SecretKeySpec(apiSecret.getBytes(), "HmacSHA256"); + Key sk = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); Mac mac = Mac.getInstance(sk.getAlgorithm()); mac.init(sk); - final byte[] hmac = mac.doFinal(body.getBytes(StandardCharsets.UTF_8)); - return bytesToHex(hmac).equals(signature); + final byte[] hmac = mac.doFinal(body); + return MessageDigest.isEqual( + bytesToHex(hmac).getBytes(StandardCharsets.UTF_8), + signature.getBytes(StandardCharsets.UTF_8)); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException("Should not happen. Could not find HmacSHA256", e); } catch (InvalidKeyException e) { @@ -1473,16 +1516,135 @@ public static boolean verifyWebhookSignature( } } + private static final byte[] GZIP_MAGIC = new byte[] {0x1f, (byte) 0x8b}; + /** - * Validates if hmac signature is correct for message body. + * Returns {@code body} unchanged unless it starts with the gzip magic ({@code 1f 8b}, per RFC + * 1952), in which case the gzip stream is inflated and the decompressed bytes are returned. * - * @param body the message body - * @param signature the signature provided in X-Signature header - * @return true if the signature is valid + *

Magic-byte detection (rather than relying on a header) lets the same handler stay correct + * when middleware auto-decompresses the request before your code sees it. */ - public static boolean verifyWebhookSignature(@NotNull String body, @NotNull String signature) { - String apiSecret = Client.getInstance().getApiSecret(); - return verifyWebhookSignature(apiSecret, body, signature); + public static byte[] ungzipPayload(@NotNull byte[] body) { + if (body.length < 2 || body[0] != GZIP_MAGIC[0] || body[1] != GZIP_MAGIC[1]) { + return body; + } + try (GZIPInputStream in = new GZIPInputStream(new ByteArrayInputStream(body))) { + return readAll(in); + } catch (IOException e) { + throw new IllegalStateException("failed to decompress gzip payload", e); + } + } + + /** + * Reverses the SQS firehose envelope: the message {@code Body} is base64-decoded and, when the + * result begins with the gzip magic, it is gzip-decompressed. The same call works whether or not + * Stream is currently compressing payloads. + * + * @param body the SQS message {@code Body} + * @return the raw JSON bytes Stream signed + */ + public static byte[] decodeSqsPayload(@NotNull String body) { + byte[] decoded; + try { + decoded = Base64.getDecoder().decode(body); + } catch (IllegalArgumentException e) { + throw new IllegalStateException("failed to base64-decode payload", e); + } + return ungzipPayload(decoded); + } + + /** + * Byte-for-byte identical to {@link #decodeSqsPayload(String)}; exposed under both names so call + * sites read intent. + */ + public static byte[] decodeSnsPayload(@NotNull String message) { + return decodeSqsPayload(message); + } + + /** + * Parse a JSON-encoded webhook event into a typed {@link Event}. Unknown event types still parse + * successfully because {@link Event#getType()} is a free-form string. + * + * @throws IllegalStateException when the bytes are not valid JSON + */ + public static @NotNull Event parseEvent(@NotNull byte[] payload) { + try { + return new com.fasterxml.jackson.databind.ObjectMapper().readValue(payload, Event.class); + } catch (IOException e) { + throw new IllegalStateException("failed to parse webhook event", e); + } + } + + private static @NotNull Event verifyAndParseInternal( + @NotNull byte[] payload, @NotNull String signature, @NotNull String secret) { + if (!verifySignature(payload, signature, secret)) { + throw new SecurityException("invalid webhook signature"); + } + return parseEvent(payload); + } + + /** + * Decompresses {@code body} when gzipped, verifies the HMAC {@code signature}, and returns the + * parsed {@link Event}. Works for HTTP webhooks regardless of whether payload compression is + * enabled. + * + * @param body raw HTTP request body bytes Stream signed + * @param signature value of the {@code X-Signature} header + * @param secret the app's API secret + * @return the parsed event + * @throws SecurityException when the signature does not match + * @throws IllegalStateException when the gzip envelope is malformed or the payload is not JSON + */ + public static @NotNull Event verifyAndParseWebhook( + @NotNull byte[] body, @NotNull String signature, @NotNull String secret) { + return verifyAndParseInternal(ungzipPayload(body), signature, secret); + } + + /** Singleton-secret overload: uses the API secret of the configured {@link Client} singleton. */ + public static @NotNull Event verifyAndParseWebhook( + @NotNull byte[] body, @NotNull String signature) { + return verifyAndParseWebhook(body, signature, Client.getInstance().getApiSecret()); + } + + /** + * Decode the SQS {@code Body} (base64, then gzip-if-magic), verify the HMAC {@code signature} + * from the {@code X-Signature} message attribute, and return the parsed {@link Event}. + */ + public static @NotNull Event verifyAndParseSqs( + @NotNull String messageBody, @NotNull String signature, @NotNull String secret) { + return verifyAndParseInternal(decodeSqsPayload(messageBody), signature, secret); + } + + /** Singleton-secret overload of {@link #verifyAndParseSqs(String, String, String)}. */ + public static @NotNull Event verifyAndParseSqs( + @NotNull String messageBody, @NotNull String signature) { + return verifyAndParseSqs(messageBody, signature, Client.getInstance().getApiSecret()); + } + + /** + * Decode the SNS notification {@code Message} (identical to SQS handling), verify the HMAC {@code + * signature} from the {@code X-Signature} message attribute, and return the parsed {@link Event}. + */ + public static @NotNull Event verifyAndParseSns( + @NotNull String message, @NotNull String signature, @NotNull String secret) { + return verifyAndParseInternal(decodeSnsPayload(message), signature, secret); + } + + /** Singleton-secret overload of {@link #verifyAndParseSns(String, String, String)}. */ + public static @NotNull Event verifyAndParseSns( + @NotNull String message, @NotNull String signature) { + return verifyAndParseSns(message, signature, Client.getInstance().getApiSecret()); + } + + private static byte[] readAll(InputStream in) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buf = new byte[4096]; + int n; + while ((n = in.read(buf)) != -1) { + out.write(buf, 0, n); + } + return out.toByteArray(); } private static String bytesToHex(byte[] hash) { diff --git a/src/test/java/io/getstream/chat/java/WebhookCompressionTest.java b/src/test/java/io/getstream/chat/java/WebhookCompressionTest.java new file mode 100644 index 00000000..03cab9d9 --- /dev/null +++ b/src/test/java/io/getstream/chat/java/WebhookCompressionTest.java @@ -0,0 +1,245 @@ +package io.getstream.chat.java; + +import io.getstream.chat.java.models.App; +import io.getstream.chat.java.models.Event; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.zip.GZIPOutputStream; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class WebhookCompressionTest { + + private static final String API_SECRET = "tsec2"; + private static final String JSON_BODY = + "{\"type\":\"message.new\",\"message\":{\"text\":\"the quick brown fox\"}}"; + + private static byte[] gzip(byte[] raw) throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (GZIPOutputStream gz = new GZIPOutputStream(out)) { + gz.write(raw); + } + return out.toByteArray(); + } + + private static String base64(byte[] raw) { + return Base64.getEncoder().encodeToString(raw); + } + + private static String hmacSHA256Hex(String secret, byte[] body) throws Exception { + javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256"); + mac.init( + new javax.crypto.spec.SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + byte[] hmac = mac.doFinal(body); + StringBuilder hex = new StringBuilder(2 * hmac.length); + for (byte b : hmac) { + String h = Integer.toHexString(0xff & b); + if (h.length() == 1) { + hex.append('0'); + } + hex.append(h); + } + return hex.toString(); + } + + @Test + @DisplayName("ungzipPayload passes through plain bytes unchanged") + void ungzipPayload_passthroughPlainBytes() { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + Assertions.assertArrayEquals(raw, App.ungzipPayload(raw)); + } + + @Test + @DisplayName("ungzipPayload inflates gzip-magic bytes") + void ungzipPayload_inflatesGzip() throws Exception { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + Assertions.assertArrayEquals(raw, App.ungzipPayload(gzip(raw))); + } + + @Test + @DisplayName("ungzipPayload returns empty input unchanged") + void ungzipPayload_emptyInput() { + Assertions.assertArrayEquals(new byte[0], App.ungzipPayload(new byte[0])); + } + + @Test + @DisplayName("ungzipPayload throws on truncated gzip with magic") + void ungzipPayload_truncatedGzipThrows() { + byte[] bad = new byte[] {0x1f, (byte) 0x8b, 0x08, 0, 0, 0}; + Assertions.assertThrows(IllegalStateException.class, () -> App.ungzipPayload(bad)); + } + + @Test + @DisplayName("decodeSqsPayload decodes base64 only when no compression") + void decodeSqsPayload_base64Only() { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + Assertions.assertArrayEquals(raw, App.decodeSqsPayload(base64(raw))); + } + + @Test + @DisplayName("decodeSqsPayload decodes base64 + gzip") + void decodeSqsPayload_base64Gzip() throws Exception { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + Assertions.assertArrayEquals(raw, App.decodeSqsPayload(base64(gzip(raw)))); + } + + @Test + @DisplayName("decodeSqsPayload throws on malformed base64") + void decodeSqsPayload_malformedBase64() { + Assertions.assertThrows( + IllegalStateException.class, () -> App.decodeSqsPayload("!!!not-base64!!!")); + } + + @Test + @DisplayName("decodeSnsPayload aliases decodeSqsPayload") + void decodeSnsPayload_aliasesSqs() throws Exception { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + String wrapped = base64(gzip(raw)); + Assertions.assertArrayEquals(App.decodeSqsPayload(wrapped), App.decodeSnsPayload(wrapped)); + } + + @Test + @DisplayName("verifySignature returns true for matching HMAC") + void verifySignature_matching() throws Exception { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + String sig = hmacSHA256Hex(API_SECRET, raw); + Assertions.assertTrue(App.verifySignature(raw, sig, API_SECRET)); + } + + @Test + @DisplayName("verifySignature returns false for mismatched signature") + void verifySignature_mismatched() { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + Assertions.assertFalse(App.verifySignature(raw, "0".repeat(64), API_SECRET)); + } + + @Test + @DisplayName("verifySignature returns false when computed over compressed bytes") + void verifySignature_overCompressedRejected() throws Exception { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + byte[] compressed = gzip(raw); + String sigOverCompressed = hmacSHA256Hex(API_SECRET, compressed); + Assertions.assertFalse(App.verifySignature(raw, sigOverCompressed, API_SECRET)); + } + + @Test + @DisplayName("parseEvent parses known event type into typed Event") + void parseEvent_known() { + Event ev = App.parseEvent(JSON_BODY.getBytes(StandardCharsets.UTF_8)); + Assertions.assertEquals("message.new", ev.getType()); + Assertions.assertNotNull(ev.getMessage()); + Assertions.assertEquals("the quick brown fox", ev.getMessage().getText()); + } + + @Test + @DisplayName("parseEvent handles unknown event types") + void parseEvent_unknownType() { + Event ev = + App.parseEvent( + "{\"type\":\"a.future.event\",\"custom\":42}".getBytes(StandardCharsets.UTF_8)); + Assertions.assertEquals("a.future.event", ev.getType()); + } + + @Test + @DisplayName("parseEvent throws on malformed JSON") + void parseEvent_malformed() { + Assertions.assertThrows( + IllegalStateException.class, + () -> App.parseEvent("not json".getBytes(StandardCharsets.UTF_8))); + } + + @Test + @DisplayName("verifyAndParseWebhook parses plain JSON body with valid signature") + void verifyAndParseWebhook_plain() throws Exception { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + String sig = hmacSHA256Hex(API_SECRET, raw); + Event ev = App.verifyAndParseWebhook(raw, sig, API_SECRET); + Assertions.assertEquals("message.new", ev.getType()); + } + + @Test + @DisplayName("verifyAndParseWebhook parses gzip-compressed body") + void verifyAndParseWebhook_gzip() throws Exception { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + String sig = hmacSHA256Hex(API_SECRET, raw); + Event ev = App.verifyAndParseWebhook(gzip(raw), sig, API_SECRET); + Assertions.assertEquals("message.new", ev.getType()); + } + + @Test + @DisplayName("verifyAndParseWebhook throws SecurityException on signature mismatch") + void verifyAndParseWebhook_signatureMismatch() { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + Assertions.assertThrows( + SecurityException.class, () -> App.verifyAndParseWebhook(raw, "0".repeat(64), API_SECRET)); + } + + @Test + @DisplayName("verifyAndParseWebhook rejects signature computed over compressed bytes") + void verifyAndParseWebhook_signatureOverCompressed() throws Exception { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + byte[] compressed = gzip(raw); + String sigOverCompressed = hmacSHA256Hex(API_SECRET, compressed); + Assertions.assertThrows( + SecurityException.class, + () -> App.verifyAndParseWebhook(compressed, sigOverCompressed, API_SECRET)); + } + + @Test + @DisplayName("verifyAndParseSqs parses base64-only message body") + void verifyAndParseSqs_base64Only() throws Exception { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + String sig = hmacSHA256Hex(API_SECRET, raw); + Event ev = App.verifyAndParseSqs(base64(raw), sig, API_SECRET); + Assertions.assertEquals("message.new", ev.getType()); + } + + @Test + @DisplayName("verifyAndParseSqs parses base64 + gzip message body") + void verifyAndParseSqs_base64Gzip() throws Exception { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + String sig = hmacSHA256Hex(API_SECRET, raw); + Event ev = App.verifyAndParseSqs(base64(gzip(raw)), sig, API_SECRET); + Assertions.assertEquals("message.new", ev.getType()); + } + + @Test + @DisplayName("verifyAndParseSqs rejects signature over wrapped bytes") + void verifyAndParseSqs_signatureOverWrapped() throws Exception { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + String wrapped = base64(gzip(raw)); + String sigOverWrapped = hmacSHA256Hex(API_SECRET, wrapped.getBytes(StandardCharsets.UTF_8)); + Assertions.assertThrows( + SecurityException.class, () -> App.verifyAndParseSqs(wrapped, sigOverWrapped, API_SECRET)); + } + + @Test + @DisplayName("verifyAndParseSns parses base64 + gzip notification") + void verifyAndParseSns_base64Gzip() throws Exception { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + String sig = hmacSHA256Hex(API_SECRET, raw); + Event ev = App.verifyAndParseSns(base64(gzip(raw)), sig, API_SECRET); + Assertions.assertEquals("message.new", ev.getType()); + } + + @Test + @DisplayName("verifyAndParseSns and verifyAndParseSqs return identical events") + void verifyAndParseSns_matchesSqs() throws Exception { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + String sig = hmacSHA256Hex(API_SECRET, raw); + String wrapped = base64(gzip(raw)); + Event sns = App.verifyAndParseSns(wrapped, sig, API_SECRET); + Event sqs = App.verifyAndParseSqs(wrapped, sig, API_SECRET); + Assertions.assertEquals(sqs.getType(), sns.getType()); + } + + @Test + @DisplayName("verifyWebhookSignature backward compatibility still validates HMAC") + void verifyWebhookSignature_backwardCompat() throws Exception { + String sig = hmacSHA256Hex(API_SECRET, JSON_BODY.getBytes(StandardCharsets.UTF_8)); + Assertions.assertTrue(App.verifyWebhookSignature(API_SECRET, JSON_BODY, sig)); + Assertions.assertFalse(App.verifyWebhookSignature(API_SECRET, JSON_BODY, "0".repeat(64))); + } +}