diff --git a/build.gradle b/build.gradle index 8f5be63f7..56b3e88e5 100644 --- a/build.gradle +++ b/build.gradle @@ -39,6 +39,7 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5' testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.8.2' + testImplementation 'org.assertj:assertj-core:3.27.3' testImplementation 'org.apache.commons:commons-lang3:3.12.0' compileOnly 'org.jetbrains:annotations:24.1.0' } diff --git a/docs/webhooks/webhooks_overview/webhooks_overview.md b/docs/webhooks/webhooks_overview/webhooks_overview.md index 274e3643e..8a6d192b4 100644 --- a/docs/webhooks/webhooks_overview/webhooks_overview.md +++ b/docs/webhooks/webhooks_overview/webhooks_overview.md @@ -85,7 +85,57 @@ 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. `gunzipPayload`, `decodeSqsPayload`, `decodeSnsPayload`, `parseEvent`, and `verifyAndParseWebhook` raise `InvalidWebhookException` on failure paths that involve those primitives. `parseSqs` / `parseSns` decode and parse only (no HMAC) — they raise the same exception class for malformed base64, gzip, or JSON. + +#### SQS / SNS payloads + +The same gzip + base64 wire format applies. Pass the SQS message `Body` string, or the SNS notification body (full JSON envelope or pre-extracted `Message`). Stream does **not** ship an application-level `X-Signature` on these channels — IAM and AWS SNS authenticity cover the transport. + +```java +// SQS Body (string) +Event event = client.parseSqs(message.body()); +// SNS: envelope or Message field +event = client.parseSns(notificationBody); +``` + +Instance methods delegate to `App.parseSqs` / `App.parseSns` — no API secret is used for decoding. ## Webhook types In addition to the above there are 3 special webhooks. diff --git a/src/main/java/io/getstream/chat/java/exceptions/InvalidWebhookError.java b/src/main/java/io/getstream/chat/java/exceptions/InvalidWebhookError.java new file mode 100644 index 000000000..2f9ae1cee --- /dev/null +++ b/src/main/java/io/getstream/chat/java/exceptions/InvalidWebhookError.java @@ -0,0 +1,29 @@ +package io.getstream.chat.java.exceptions; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Raised by every webhook ingestion primitive when the request cannot be safely turned into a typed + * event. A single exception type lets handler code use one catch arm and, when needed, branch on + * the failure-mode message constants exposed here. + * + *
Unchecked so webhook handlers don't have to declare {@code throws} on every helper call —
+ * matches the JVM convention for input-validation failures (cf. {@link IllegalArgumentException}).
+ */
+public class InvalidWebhookError extends RuntimeException {
+ private static final long serialVersionUID = 1L;
+
+ public static final String SIGNATURE_MISMATCH = "signature mismatch";
+ public static final String INVALID_BASE64 = "invalid base64 encoding";
+ public static final String GZIP_FAILED = "gzip decompression failed";
+ public static final String INVALID_JSON = "invalid JSON payload";
+
+ public InvalidWebhookError(@NotNull String message) {
+ super(message);
+ }
+
+ public InvalidWebhookError(@NotNull String message, @Nullable Throwable cause) {
+ super(message, cause);
+ }
+}
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 c57eab00a..df90867ce 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,21 @@
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.exceptions.InvalidWebhookError;
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 +29,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 +600,7 @@ public static class DeviceError {
@Builder
@Getter
- @EqualsAndHashCode(callSuper = false)
+ @EqualsAndHashCode
public static class AsyncModerationCallback {
@Nullable
@JsonProperty("mode")
@@ -599,7 +613,7 @@ public static class AsyncModerationCallback {
@Builder
@Getter
- @EqualsAndHashCode(callSuper = false)
+ @EqualsAndHashCode
public static class AsyncModerationConfigRequestObject {
@Nullable
@JsonProperty("callback")
@@ -612,7 +626,7 @@ public static class AsyncModerationConfigRequestObject {
@Builder
@Getter
- @EqualsAndHashCode(callSuper = false)
+ @EqualsAndHashCode
public static class FileUploadConfigRequestObject {
@Nullable
@@ -644,7 +658,7 @@ public static FileUploadConfigRequestObject buildFrom(
@Builder
@Getter
- @EqualsAndHashCode(callSuper = false)
+ @EqualsAndHashCode
public static class APNConfigRequestObject {
@Nullable
@JsonProperty("development")
@@ -690,7 +704,7 @@ public static APNConfigRequestObject buildFrom(@Nullable APNConfig aPNConfig) {
@Builder
@Getter
- @EqualsAndHashCode(callSuper = false)
+ @EqualsAndHashCode
public static class FirebaseConfigRequestObject {
@Nullable
@JsonProperty("server_key")
@@ -720,7 +734,7 @@ public static FirebaseConfigRequestObject buildFrom(@Nullable FirebaseConfig fir
@Builder
@Getter
- @EqualsAndHashCode(callSuper = false)
+ @EqualsAndHashCode
public static class HuaweiConfigRequestObject {
@Nullable
@JsonProperty("id")
@@ -733,7 +747,7 @@ public static class HuaweiConfigRequestObject {
@Builder
@Getter
- @EqualsAndHashCode(callSuper = false)
+ @EqualsAndHashCode
public static class PushConfigRequestObject {
@Nullable
@JsonProperty("version")
@@ -764,7 +778,7 @@ protected Call 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 +1469,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 +1479,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 +1524,171 @@ 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 InvalidWebhookError(InvalidWebhookError.GZIP_FAILED, 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 InvalidWebhookError(InvalidWebhookError.INVALID_BASE64, 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 InvalidWebhookError 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 InvalidWebhookError(InvalidWebhookError.INVALID_JSON, e);
+ }
+ }
+
+ private static @NotNull Event verifyAndParseInternal(
+ @NotNull byte[] payload, @NotNull String signature, @NotNull String secret) {
+ if (!verifySignature(payload, signature, secret)) {
+ throw new InvalidWebhookError(InvalidWebhookError.SIGNATURE_MISMATCH);
+ }
+ 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 InvalidWebhookError when the signature does not match, 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) and return the parsed {@link Event}.
+ * Stream does not HMAC-sign SQS message bodies.
+ */
+ public static @NotNull Event parseSqs(@NotNull String messageBody) {
+ return parseEvent(decodeSqsPayload(messageBody));
+ }
+
+ /**
+ * Decode an SNS-delivered payload (unwraps envelope JSON when needed) and return the parsed
+ * {@link Event}. No HMAC verification.
+ */
+ public static @NotNull Event parseSns(@NotNull String notificationBody) {
+ return parseEvent(decodeSnsPayload(notificationBody));
+ }
+
+ 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 f73b3ab33..abff41a90 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,44 @@ 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());
+ }
+
+ /**
+ * Parse an SQS-delivered webhook body (decode only).
+ *
+ * Instance-method counterpart to {@link App#parseSqs(String)}.
+ *
+ * @param body SQS message {@code Body} string
+ * @return parsed {@link Event}
+ */
+ default @NotNull Event parseSqs(@NotNull String body) {
+ return App.parseSqs(body);
+ }
+
+ /**
+ * Parse an SNS-delivered webhook body (unwrap envelope when needed).
+ *
+ * Instance-method counterpart to {@link App#parseSns(String)}.
+ *
+ * @param notificationBody raw SNS POST body or pre-extracted {@code Message}
+ * @return parsed {@link Event}
+ */
+ default @NotNull Event parseSns(@NotNull String notificationBody) {
+ return App.parseSns(notificationBody);
+ }
+
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 000000000..64b87a1a9
--- /dev/null
+++ b/src/test/java/io/getstream/chat/java/WebhookCompressionTest.java
@@ -0,0 +1,350 @@
+package io.getstream.chat.java;
+
+import io.getstream.chat.java.exceptions.InvalidWebhookError;
+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.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};
+ InvalidWebhookError ex =
+ Assertions.assertThrows(InvalidWebhookError.class, () -> App.gunzipPayload(bad));
+ Assertions.assertEquals(InvalidWebhookError.GZIP_FAILED, ex.getMessage());
+ }
+
+ @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() {
+ InvalidWebhookError ex =
+ Assertions.assertThrows(
+ InvalidWebhookError.class, () -> App.decodeSqsPayload("!!!not-base64!!!"));
+ Assertions.assertEquals(InvalidWebhookError.INVALID_BASE64, ex.getMessage());
+ }
+
+ @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 InvalidWebhookError on malformed JSON")
+ void parseEvent_malformed() {
+ InvalidWebhookError ex =
+ Assertions.assertThrows(
+ InvalidWebhookError.class,
+ () -> App.parseEvent("not json".getBytes(StandardCharsets.UTF_8)));
+ Assertions.assertEquals(InvalidWebhookError.INVALID_JSON, ex.getMessage());
+ }
+
+ @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 InvalidWebhookError on signature mismatch")
+ void verifyAndParseWebhook_signatureMismatch() {
+ byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8);
+ InvalidWebhookError ex =
+ Assertions.assertThrows(
+ InvalidWebhookError.class,
+ () -> App.verifyAndParseWebhook(raw, "0".repeat(64), API_SECRET));
+ Assertions.assertEquals(InvalidWebhookError.SIGNATURE_MISMATCH, ex.getMessage());
+ }
+
+ @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(
+ InvalidWebhookError.class,
+ () -> App.verifyAndParseWebhook(compressed, sigOverCompressed, API_SECRET));
+ }
+
+ @Test
+ @DisplayName("parseSqs parses base64-only message body")
+ void parseSqs_base64Only() throws Exception {
+ byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8);
+ Event ev = App.parseSqs(base64(raw));
+ Assertions.assertEquals("message.new", ev.getType());
+ }
+
+ @Test
+ @DisplayName("parseSqs parses base64 + gzip message body")
+ void parseSqs_base64Gzip() throws Exception {
+ byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8);
+ Event ev = App.parseSqs(base64(gzip(raw)));
+ Assertions.assertEquals("message.new", ev.getType());
+ }
+
+ @Test
+ @DisplayName("parseSns parses base64 + gzip notification")
+ void parseSns_base64Gzip() throws Exception {
+ byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8);
+ Event ev = App.parseSns(base64(gzip(raw)));
+ Assertions.assertEquals("message.new", ev.getType());
+ }
+
+ @Test
+ @DisplayName("parseSns and parseSqs return identical events for pre-extracted Message")
+ void parseSns_matchesSqs() throws Exception {
+ byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8);
+ String wrapped = base64(gzip(raw));
+ Event sns = App.parseSns(wrapped);
+ Event sqs = App.parseSqs(wrapped);
+ Assertions.assertEquals(sqs.getType(), sns.getType());
+ }
+
+ @Test
+ @DisplayName("parseSns parses a full SNS HTTP notification envelope")
+ void parseSns_fullEnvelope() throws Exception {
+ byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8);
+ String envelope = snsEnvelope(base64(gzip(raw)));
+ Event ev = App.parseSns(envelope);
+ Assertions.assertEquals("message.new", ev.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)));
+ }
+
+ private static final class StubClient implements Client {
+ private final String apiSecret;
+
+ StubClient(String apiSecret) {
+ this.apiSecret = apiSecret;
+ }
+
+ @Override
+ public @NotNull