diff --git a/docs/webhooks/webhooks_overview/webhooks_overview.md b/docs/webhooks/webhooks_overview/webhooks_overview.md index 274e3643..d0dc2098 100644 --- a/docs/webhooks/webhooks_overview/webhooks_overview.md +++ b/docs/webhooks/webhooks_overview/webhooks_overview.md @@ -85,6 +85,61 @@ 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.verifyAndParseWebhook` to handle decompression, signature verification, and JSON parsing in a single call. It returns a typed `Event`: + +```java +// rawBody — bytes read straight from the HTTP request body +// signature — value of the X-Signature header +// apiSecret — your app's API secret +Event event = App.verifyAndParseWebhook(rawBody, signature, apiSecret); +``` + +Or, if you already have a configured client, call the instance overload (it picks up the secret from the client): + +```java +Event event = client.verifyAndParseWebhook(rawBody, signature); +``` + +If you prefer to handle the steps yourself, the primitives are also exposed: + +```java +byte[] json = App.gunzipPayload(rawBody); // pass-through when the bytes aren't gzipped +boolean valid = App.verifySignature(json, signature, apiSecret); +Event event = App.parseEvent(json); +``` + +Detection is done via the gzip magic bytes (`1f 8b`, per RFC 1952), so the same helper stays correct whether or not your HTTP server already decompressed the body for you. Any non-gzip body is passed through unchanged. Malformed gzip envelopes raise an `IllegalStateException`. + +#### 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 SQS `Body` (or SNS `Message`) string directly — no manual base64 decoding required: + +```java +// messageBody — the SQS Body / SNS Message string (base64-encoded) +// signature — X-Signature message attribute value +// apiSecret — your app's API secret +Event event = App.verifyAndParseSqs(messageBody, signature, apiSecret); +// or, for SNS: +Event event = App.verifyAndParseSns(messageBody, signature, apiSecret); +``` + +Instance-method counterparts on `client` (`client.verifyAndParseSqs(...)`, `client.verifyAndParseSns(...)`) work the same way, using the configured client's API secret. + +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..5b956b13 100644 --- a/src/main/java/io/getstream/chat/java/models/App.java +++ b/src/main/java/io/getstream/chat/java/models/App.java @@ -1,14 +1,20 @@ package io.getstream.chat.java.models; +import com.fasterxml.jackson.annotation.JsonAutoDetect; import com.fasterxml.jackson.annotation.JsonEnumDefaultValue; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.PropertyAccessor; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.util.StdDateFormat; import io.getstream.chat.java.models.App.AppCheckPushRequestData.AppCheckPushRequest; import io.getstream.chat.java.models.App.AppCheckSnsRequestData.AppCheckSnsRequest; import io.getstream.chat.java.models.App.AppCheckSqsRequestData.AppCheckSqsRequest; @@ -22,14 +28,21 @@ 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.TimeZone; +import java.util.zip.GZIPInputStream; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import lombok.*; @@ -586,7 +599,7 @@ public static class DeviceError { @Builder @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode public static class AsyncModerationCallback { @Nullable @JsonProperty("mode") @@ -599,7 +612,7 @@ public static class AsyncModerationCallback { @Builder @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode public static class AsyncModerationConfigRequestObject { @Nullable @JsonProperty("callback") @@ -612,7 +625,7 @@ public static class AsyncModerationConfigRequestObject { @Builder @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode public static class FileUploadConfigRequestObject { @Nullable @@ -644,7 +657,7 @@ public static FileUploadConfigRequestObject buildFrom( @Builder @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode public static class APNConfigRequestObject { @Nullable @JsonProperty("development") @@ -690,7 +703,7 @@ public static APNConfigRequestObject buildFrom(@Nullable APNConfig aPNConfig) { @Builder @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode public static class FirebaseConfigRequestObject { @Nullable @JsonProperty("server_key") @@ -720,7 +733,7 @@ public static FirebaseConfigRequestObject buildFrom(@Nullable FirebaseConfig fir @Builder @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode public static class HuaweiConfigRequestObject { @Nullable @JsonProperty("id") @@ -733,7 +746,7 @@ public static class HuaweiConfigRequestObject { @Builder @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode public static class PushConfigRequestObject { @Nullable @JsonProperty("version") @@ -764,7 +777,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 +798,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 +989,7 @@ public boolean equals(Object o) { @Builder @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode @NoArgsConstructor @AllArgsConstructor public static class AppGetRateLimitsRequest extends StreamRequest { @@ -1008,7 +1021,7 @@ protected Call generateCall(Client client) { builderMethodName = "", buildMethodName = "internalBuild") @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode public static class AppCheckSqsRequestData { @Nullable @JsonProperty("sqs_url") @@ -1035,7 +1048,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 +1075,7 @@ protected Call generateCall(Client client) { builderMethodName = "", buildMethodName = "internalBuild") @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode public static class AppCheckPushRequestData { @Nullable @JsonProperty("message_id") @@ -1110,7 +1123,7 @@ protected Call generateCall(Client client) { @AllArgsConstructor @Getter - @EqualsAndHashCode(callSuper = false) + @EqualsAndHashCode public static class AppRevokeTokensRequest extends StreamRequest { @Nullable private Date revokeTokensIssuedBefore; @@ -1440,7 +1453,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 +1468,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 +1478,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 +1523,185 @@ 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[] gunzipPayload(@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 gunzipPayload(decoded); + } + + /** + * Reverses an SNS HTTP notification envelope. When {@code notificationBody} is a JSON envelope + * ({@code {"Type":"Notification","Message":"..."}}), the inner {@code 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 {@code Message} string, so call sites that + * pre-unwrap continue to work. + */ + public static byte[] decodeSnsPayload(@NotNull String notificationBody) { + String inner = extractSnsMessage(notificationBody); + return decodeSqsPayload(inner != null ? inner : notificationBody); + } + + /** + * Returns the inner {@code Message} field of an SNS HTTP notification envelope, or {@code null} + * when the input is not a JSON object that contains a {@code Message} string. + */ + private static String extractSnsMessage(@NotNull String notificationBody) { + int i = 0; + while (i < notificationBody.length() && Character.isWhitespace(notificationBody.charAt(i))) { + i++; + } + if (i >= notificationBody.length() || notificationBody.charAt(i) != '{') { + return null; + } + try { + JsonNode parsed = WEBHOOK_OBJECT_MAPPER.readTree(notificationBody); + if (parsed == null || !parsed.isObject()) { + return null; + } + JsonNode message = parsed.get("Message"); + return message != null && message.isTextual() ? message.asText() : null; + } catch (IOException e) { + return null; + } + } + + /** + * Shared {@link ObjectMapper} for webhook payload deserialization. Configured to match the + * Retrofit mapper in {@code DefaultClient}: tolerant of unknown JSON properties and unknown enum + * values, with the Stream-side ISO-8601 date format. This makes a webhook handler accept new + * fields / enum values added server-side without redeploys, and gives {@link Event} the same date + * parsing behavior as the rest of the SDK. + */ + private static final ObjectMapper WEBHOOK_OBJECT_MAPPER = buildWebhookObjectMapper(); + + private static ObjectMapper buildWebhookObjectMapper() { + final ObjectMapper mapper = new ObjectMapper(); + mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE); + mapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + mapper.enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE); + mapper.setDateFormat( + new StdDateFormat().withColonInTimeZone(true).withTimeZone(TimeZone.getTimeZone("UTC"))); + return mapper; + } + + /** + * 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; unknown nested fields and + * unknown enum values are tolerated so the handler stays forward-compatible with new Stream + * server releases. + * + * @throws IllegalStateException when the bytes are not valid JSON + */ + public static @NotNull Event parseEvent(@NotNull byte[] payload) { + try { + return WEBHOOK_OBJECT_MAPPER.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(gunzipPayload(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/main/java/io/getstream/chat/java/services/framework/Client.java b/src/main/java/io/getstream/chat/java/services/framework/Client.java index f73b3ab3..ddbd554f 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/Client.java +++ b/src/main/java/io/getstream/chat/java/services/framework/Client.java @@ -1,5 +1,7 @@ package io.getstream.chat.java.services.framework; +import io.getstream.chat.java.models.App; +import io.getstream.chat.java.models.Event; import java.time.Duration; import org.jetbrains.annotations.NotNull; @@ -19,6 +21,46 @@ public interface Client { void setTimeout(@NotNull Duration timeoutDuration); + /** + * Verify and parse an HTTP webhook event using this client's API secret. + * + *

Instance-method counterpart to {@link App#verifyAndParseWebhook(byte[], String, String)}; + * the call site stays a two-argument one-liner because the secret comes from the client. + * + * @param body raw HTTP request body bytes Stream signed + * @param signature value of the {@code X-Signature} header + * @return parsed {@link Event} + */ + default @NotNull Event verifyAndParseWebhook(@NotNull byte[] body, @NotNull String signature) { + return App.verifyAndParseWebhook(body, signature, getApiSecret()); + } + + /** + * Verify and parse an SQS firehose webhook event using this client's API secret. + * + *

Instance-method counterpart to {@link App#verifyAndParseSqs(String, String, String)}. + * + * @param messageBody SQS message {@code Body} (UTF-8 string) + * @param signature value of the {@code X-Signature} message attribute + * @return parsed {@link Event} + */ + default @NotNull Event verifyAndParseSqs(@NotNull String messageBody, @NotNull String signature) { + return App.verifyAndParseSqs(messageBody, signature, getApiSecret()); + } + + /** + * Verify and parse an SNS firehose webhook event using this client's API secret. + * + *

Instance-method counterpart to {@link App#verifyAndParseSns(String, String, String)}. + * + * @param message SNS notification {@code Message} field (UTF-8 string) + * @param signature value of the {@code X-Signature} message attribute + * @return parsed {@link Event} + */ + default @NotNull Event verifyAndParseSns(@NotNull String message, @NotNull String signature) { + return App.verifyAndParseSns(message, signature, getApiSecret()); + } + static Client getInstance() { return DefaultClient.getInstance(); } 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..dd013eeb --- /dev/null +++ b/src/test/java/io/getstream/chat/java/WebhookCompressionTest.java @@ -0,0 +1,399 @@ +package io.getstream.chat.java; + +import io.getstream.chat.java.models.App; +import io.getstream.chat.java.models.Event; +import io.getstream.chat.java.services.framework.Client; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Base64; +import java.util.zip.GZIPOutputStream; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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("gunzipPayload passes through plain bytes unchanged") + void gunzipPayload_passthroughPlainBytes() { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + Assertions.assertArrayEquals(raw, App.gunzipPayload(raw)); + } + + @Test + @DisplayName("gunzipPayload inflates gzip-magic bytes") + void gunzipPayload_inflatesGzip() throws Exception { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + Assertions.assertArrayEquals(raw, App.gunzipPayload(gzip(raw))); + } + + @Test + @DisplayName("gunzipPayload returns empty input unchanged") + void gunzipPayload_emptyInput() { + Assertions.assertArrayEquals(new byte[0], App.gunzipPayload(new byte[0])); + } + + @Test + @DisplayName("gunzipPayload throws on truncated gzip with magic") + void gunzipPayload_truncatedGzipThrows() { + byte[] bad = new byte[] {0x1f, (byte) 0x8b, 0x08, 0, 0, 0}; + Assertions.assertThrows(IllegalStateException.class, () -> App.gunzipPayload(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("decodeSqsPayload decodes Tommaso's plain helloworld fixture") + void decodeSqsPayload_helloworldBase64Fixture() { + Assertions.assertArrayEquals( + "helloworld".getBytes(StandardCharsets.UTF_8), App.decodeSqsPayload("aGVsbG93b3JsZA==")); + } + + @Test + @DisplayName("decodeSqsPayload decodes Tommaso's gzipped helloworld fixture") + void decodeSqsPayload_helloworldBase64GzipFixture() { + Assertions.assertArrayEquals( + "helloworld".getBytes(StandardCharsets.UTF_8), + App.decodeSqsPayload("H4sIAGrYAWoAA8tIzcnJL88vykkBAK0g6/kKAAAA")); + } + + @Test + @DisplayName("decodeSnsPayload treats pre-extracted Message identically to decodeSqsPayload") + void decodeSnsPayload_preExtractedMessage() 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("decodeSnsPayload unwraps a full SNS HTTP notification envelope") + void decodeSnsPayload_fullEnvelope() throws Exception { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + String wrapped = base64(gzip(raw)); + String envelope = snsEnvelope(wrapped); + Assertions.assertArrayEquals(raw, App.decodeSnsPayload(envelope)); + } + + @Test + @DisplayName("decodeSnsPayload handles whitespace before envelope JSON") + void decodeSnsPayload_envelopeWithLeadingWhitespace() throws Exception { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + String wrapped = base64(gzip(raw)); + String envelope = "\n " + snsEnvelope(wrapped); + Assertions.assertArrayEquals(raw, App.decodeSnsPayload(envelope)); + } + + private static String snsEnvelope(String innerMessage) { + return "{" + + "\"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 + @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 for pre-extracted Message") + 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("verifyAndParseSns parses a full SNS HTTP notification envelope") + void verifyAndParseSns_fullEnvelope() throws Exception { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + String sig = hmacSHA256Hex(API_SECRET, raw); + String envelope = snsEnvelope(base64(gzip(raw))); + Event ev = App.verifyAndParseSns(envelope, sig, API_SECRET); + Assertions.assertEquals("message.new", ev.getType()); + } + + @Test + @DisplayName("verifyAndParseSns rejects signature computed over the envelope JSON") + void verifyAndParseSns_rejectsSignatureOverEnvelope() throws Exception { + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + String envelope = snsEnvelope(base64(gzip(raw))); + String sigOverEnvelope = hmacSHA256Hex(API_SECRET, envelope.getBytes(StandardCharsets.UTF_8)); + Assertions.assertThrows( + SecurityException.class, + () -> App.verifyAndParseSns(envelope, sigOverEnvelope, API_SECRET)); + } + + @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))); + } + + private static final class StubClient implements Client { + private final String apiSecret; + + StubClient(String apiSecret) { + this.apiSecret = apiSecret; + } + + @Override + public @NotNull TService create(Class svcClass) { + throw new UnsupportedOperationException("stub client does not create services"); + } + + @Override + public @NotNull String getApiKey() { + return "stub-key"; + } + + @Override + public @NotNull String getApiSecret() { + return apiSecret; + } + + @Override + public void setTimeout(@NotNull Duration timeoutDuration) {} + } + + @Test + @DisplayName("Client.verifyAndParseWebhook delegates to static helper with client secret") + void clientInstance_verifyAndParseWebhook() throws Exception { + Client client = new StubClient(API_SECRET); + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + String sig = hmacSHA256Hex(API_SECRET, raw); + Event viaInstance = client.verifyAndParseWebhook(gzip(raw), sig); + Event viaStatic = App.verifyAndParseWebhook(gzip(raw), sig, API_SECRET); + Assertions.assertEquals(viaStatic.getType(), viaInstance.getType()); + Assertions.assertEquals("message.new", viaInstance.getType()); + } + + @Test + @DisplayName("Client.verifyAndParseSqs delegates to static helper with client secret") + void clientInstance_verifyAndParseSqs() throws Exception { + Client client = new StubClient(API_SECRET); + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + String sig = hmacSHA256Hex(API_SECRET, raw); + Event ev = client.verifyAndParseSqs(base64(gzip(raw)), sig); + Assertions.assertEquals("message.new", ev.getType()); + } + + @Test + @DisplayName("Client.verifyAndParseSns delegates to static helper with client secret") + void clientInstance_verifyAndParseSns() throws Exception { + Client client = new StubClient(API_SECRET); + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + String sig = hmacSHA256Hex(API_SECRET, raw); + Event ev = client.verifyAndParseSns(base64(gzip(raw)), sig); + Assertions.assertEquals("message.new", ev.getType()); + } + + @Test + @DisplayName("Client.verifyAndParseWebhook rejects mismatched signature") + void clientInstance_verifyAndParseWebhook_rejectsMismatch() { + Client client = new StubClient(API_SECRET); + byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8); + Assertions.assertThrows( + SecurityException.class, () -> client.verifyAndParseWebhook(raw, "0".repeat(64))); + } + + @Nested + @DisplayName("gunzipPayload golden fixtures") + class GunzipPayloadTest { + + @Test + @DisplayName("gunzipPayload inflates Tommaso's helloworld gzip fixture") + void gunzipPayload_helloworldFixture() { + Assertions.assertArrayEquals( + "helloworld".getBytes(StandardCharsets.UTF_8), + App.gunzipPayload( + Base64.getDecoder().decode("H4sIAGrYAWoAA8tIzcnJL88vykkBAK0g6/kKAAAA"))); + } + } +}