From a2290fbb46cfc7694127fa7cb5cb2bca418e7636 Mon Sep 17 00:00:00 2001 From: David Brownman <1231935+xavdid@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:07:10 -0700 Subject: [PATCH 1/2] add/adjust event parsing helpers (#2256) * Add cloud provider event parsing methods to StripeClient * add missing methods & tests * PR feedback --- src/main/java/com/stripe/StripeClient.java | 47 +++- .../model/v2/core/EventNotification.java | 33 ++- src/main/java/com/stripe/net/Webhook.java | 121 ++++++++- .../stripe/net/CloudProviderEventTest.java | 236 ++++++++++++++++++ src/test/java/com/stripe/net/WebhookTest.java | 164 ++++++++---- 5 files changed, 533 insertions(+), 68 deletions(-) create mode 100644 src/test/java/com/stripe/net/CloudProviderEventTest.java diff --git a/src/main/java/com/stripe/StripeClient.java b/src/main/java/com/stripe/StripeClient.java index 5be81eff127..c1cb86882f9 100644 --- a/src/main/java/com/stripe/StripeClient.java +++ b/src/main/java/com/stripe/StripeClient.java @@ -2,6 +2,7 @@ import com.stripe.exception.SignatureVerificationException; import com.stripe.exception.StripeException; +import com.stripe.model.Event; import com.stripe.model.StripeObject; import com.stripe.model.v2.core.EventNotification; import com.stripe.net.*; @@ -58,9 +59,10 @@ public EventNotification parseEventNotification(String payload, String sigHeader } /** - * Returns an StripeEvent instance using the provided JSON payload. Throws a JsonSyntaxException - * if the payload is not valid JSON, and a SignatureVerificationException if the signature - * verification fails for any reason. + * Constructs a thin event + * notification from an incoming webhook after verifying its authenticity. To work with a + * webhook that has already been verified (i.e. one from a cloud provider, an asynchronous queue, + * or during testing), see {@code parseEventNotificationWithoutVerification}. * * @param payload the payload sent by Stripe. * @param sigHeader the contents of the signature header sent by Stripe. @@ -117,6 +119,45 @@ public com.stripe.model.Event constructEvent( return event; } + /** + * Constructs a snapshot + * event from an incoming webhook without first verifying its authenticity. Should be used + * after calling {@code Webhook.Signature.verifyHeader(...)} or with input from a trusted source + * (such as AWS EventBridge, + * or Azure Event Grid + * payload). Or, to verify & construct in a single call, use {@code constructEvent(...)} + * instead. + * + * @param payload the JSON payload: a raw Stripe Event or an AWS EventBridge/Azure Event Grid + * envelope. + * @return the Event instance. + * @throws IllegalArgumentException if the payload is a thin event notification, or if the format + * is not recognized. + */ + public com.stripe.model.Event constructEventWithoutVerification(String payload) { + Event event = Webhook.constructEventWithoutVerification(payload); + event.setResponseGetter(this.getResponseGetter()); + return event; + } + + /** + * Constructs a thin event + * notification from an incoming webhook without first verifying its authenticity. Should be + * used after calling {@code Webhook.Signature.verifyHeader(...)} or with input from a trusted + * source (such as AWS + * EventBridge, or Azure Event + * Grid payload). Or, to verify & parse in a single call, use {@code + * parseEventNotification(...)} instead. + * + * @param payload the JSON payload: a raw Stripe Event, or an AWS EventBridge/Azure Event Grid + * envelope. + * @return the EventNotification instance. + * @throws IllegalArgumentException if the payload format is not recognized. + */ + public EventNotification parseEventNotificationWithoutVerification(String payload) { + return EventNotification.fromJson(Webhook.maybeExtractFromCloudProviderEnvelope(payload), this); + } + // The beginning of the section generated from our OpenAPI spec public com.stripe.service.V1Services v1() { return new com.stripe.service.V1Services(this.getResponseGetter()); diff --git a/src/main/java/com/stripe/model/v2/core/EventNotification.java b/src/main/java/com/stripe/model/v2/core/EventNotification.java index 374abbb60f8..2e98698a9a5 100644 --- a/src/main/java/com/stripe/model/v2/core/EventNotification.java +++ b/src/main/java/com/stripe/model/v2/core/EventNotification.java @@ -86,17 +86,34 @@ public static class Reason { /** * Helper for constructing an Event Notification. Doesn't perform signature validation, so you * should use {@link com.stripe.StripeClient#parseEventNotification} instead for initial handling. - * This is useful in unit tests and working with EventNotifications that you've already validated - * the authenticity of. + * This is useful in unit tests and working with EventNotifications whose authenticity you've + * already validated. */ public static EventNotification fromJson(String payload, StripeClient client) { // don't love the double json parse here, but I don't think we can avoid it? - JsonObject jsonObject = ApiResource.GSON.fromJson(payload, JsonObject.class).getAsJsonObject(); + return EventNotification.fromJson( + ApiResource.GSON.fromJson(payload, JsonObject.class).getAsJsonObject(), client); + } - if (jsonObject.has("object") && "event".equals(jsonObject.get("object").getAsString())) { - throw new IllegalArgumentException( - "You passed a webhook payload to StripeClient.parseEventNotification, which expects an event notification." - + " Use StripeClient.constructEvent instead."); + /** + * Helper for constructing an Event Notification. Doesn't perform signature validation, so you + * should use {@link com.stripe.StripeClient#parseEventNotification} instead for initial handling. + * This is useful in unit tests and working with EventNotifications whose authenticity you've + * already validated. + */ + public static EventNotification fromJson(JsonObject jsonObject, StripeClient client) { + if (jsonObject.has("object")) { + String object = jsonObject.get("object").getAsString(); + if ("event".equals(object)) { + throw new IllegalArgumentException( + "You passed a webhook payload to a method that expects an event notification. Use the corresponding constructEvent method instead."); + } + if (!"v2.core.event".equals(object)) { + throw new IllegalArgumentException( + "Unexpected object type '" + + object + + "'. Expected 'v2.core.event' for an event notification."); + } } Class cls = @@ -105,7 +122,7 @@ public static EventNotification fromJson(String payload, StripeClient client) { cls = UnknownEventNotification.class; } - EventNotification e = ApiResource.GSON.fromJson(payload, cls); + EventNotification e = ApiResource.GSON.fromJson(jsonObject, cls); e.client = client; return e; diff --git a/src/main/java/com/stripe/net/Webhook.java b/src/main/java/com/stripe/net/Webhook.java index 09505f70873..e5b8b5d0681 100644 --- a/src/main/java/com/stripe/net/Webhook.java +++ b/src/main/java/com/stripe/net/Webhook.java @@ -1,5 +1,6 @@ package com.stripe.net; +import com.google.gson.JsonObject; import com.stripe.exception.SignatureVerificationException; import com.stripe.model.Event; import com.stripe.model.StripeObject; @@ -53,9 +54,10 @@ public static Event constructEvent( } /** - * Returns an Event instance using the provided JSON payload. Throws a JsonSyntaxException if the - * payload is not valid JSON, a SignatureVerificationException if the signature verification fails - * for any reason, and an IllegalArgumentException if you pass the wrong type of input. + * Constructs a snapshot + * event from an incoming webhook after verifying its authenticity. To work with a webhook + * that has already been verified (i.e. one from a cloud provider, an asynchronous queue, or + * during testing), see {@code constructEventWithoutVerification}. * * @param payload the payload sent by Stripe. * @param sigHeader the contents of the signature header sent by Stripe. @@ -69,28 +71,90 @@ public static Event constructEvent( public static Event constructEvent( String payload, String sigHeader, String secret, long tolerance, Clock clock) throws SignatureVerificationException { - Event event = - StripeObject.deserializeStripeObject( - payload, Event.class, ApiResource.getGlobalResponseGetter()); + Signature.verifyHeader(payload, sigHeader, secret, tolerance, clock); + + return buildV1Event(payload); + } + + /** + * Constructs a snapshot + * event from an incoming webhook without first verifying its authenticity. Should be used + * after calling {@code Webhook.Signature.verifyHeader(...)} or with input from a trusted source + * (such as AWS EventBridge, + * or Azure Event Grid + * payload). Or, to verify & construct in a single call, use {@code + * Webhook.constructEvent(...)} instead. + * + * @param payload the payload sent by Stripe, or a cloud provider envelope wrapping it. + * @return the Event instance + * @throws IllegalArgumentException if the payload is a v2 thin event notification. + */ + public static Event constructEventWithoutVerification(String payload) { + return buildV1Event(maybeExtractFromCloudProviderEnvelope(payload)); + } - if ("v2.core.event".equals(event.getObject())) { + private static Event buildV1Event(String payload) { + return buildV1Event(ApiResource.GSON.fromJson(payload, JsonObject.class)); + } + + private static Event buildV1Event(JsonObject jsonObject) { + if (jsonObject.has("object") + && "v2.core.event".equals(jsonObject.get("object").getAsString())) { throw new IllegalArgumentException( - "You passed an event notification to Webhook.constructEvent, which expects a webhook payload." - + " Use StripeClient.parseEventNotification instead."); + "You passed an event notification to Webhook method, which expects a webhook payload. Use the corresponding parseEventNotification method instead."); } - Signature.verifyHeader(payload, sigHeader, secret, tolerance, clock); + Event event = + StripeObject.deserializeStripeObject( + jsonObject, Event.class, ApiResource.getGlobalResponseGetter()); + // StripeObjects source their raw JSON object from their last response, but constructed webhooks // don't have that // in order to make the raw object available on parsed events, we fake the response. if (event.getLastResponse() == null) { event.setLastResponse( - new StripeResponse(200, HttpHeaders.of(Collections.emptyMap()), payload)); + new StripeResponse(200, HttpHeaders.of(Collections.emptyMap()), jsonObject.toString())); } return event; } + /** + * Parses a JSON payload (or cloud provider envelope) and returns the inner Stripe event JSON + * object. If the payload is already a raw Stripe event (object is "event" or "v2.core.event"), it + * is returned as-is. If it is an AWS EventBridge or Azure Event Grid envelope, the inner event is + * extracted. Throws {@link IllegalArgumentException} for unrecognized formats. + * + * @param payload the raw JSON string. + * @return the inner event as a {@link JsonObject}. + */ + public static JsonObject maybeExtractFromCloudProviderEnvelope(String payload) { + JsonObject root = ApiResource.GSON.fromJson(payload, JsonObject.class); + + // AWS + // https://docs.stripe.com/event-destinations/eventbridge#event-structure + if (root.has("detail")) { + return root.get("detail").getAsJsonObject(); + } + + // Azure + // https://docs.stripe.com/event-destinations/eventgrid#event-structure + if (root.has("specversion") && root.has("data")) { + return root.get("data").getAsJsonObject(); + } + + // Raw Stripe event passed directly: pass through as-is + if (root.has("object") && root.get("object").isJsonPrimitive()) { + String object = root.get("object").getAsString(); + if ("event".equals(object) || "v2.core.event".equals(object)) { + return root; + } + } + + throw new IllegalArgumentException( + "Unrecognized event format. The payload must be an AWS EventBridge/Azure Event Grid event envelope or a Stripe webhook (thin event notification or snapshot)."); + } + public static final class Signature { public static final String EXPECTED_SCHEME = "v1"; @@ -112,8 +176,10 @@ public static boolean verifyHeader( } /** - * Verifies the signature header sent by Stripe. Throws a SignatureVerificationException if the - * verification fails for any reason. + * Verifies the authenticity (and recency) of a webhook, throwing a {@code + * SignatureVerificationException} if there's a mismatch. Useful for quickly validating incoming + * webhooks before storing them for later processing (at which time you can use the {@code + * *WithoutVerification} methods for parsing). * * @param payload the payload sent by Stripe. * @param sigHeader the contents of the signature header sent by Stripe. @@ -171,6 +237,35 @@ public static boolean verifyHeader( return true; } + /** + * Generates a {@code Stripe-Signature} header for the given payload and secret using the + * current timestamp. + * + * @param payload the payload to sign. + * @param secret the webhook secret. + * @return the generated signature header string. + */ + public static String generateSignatureHeader(String payload, String secret) + throws NoSuchAlgorithmException, InvalidKeyException { + return generateSignatureHeader(payload, secret, Util.getTimeNow()); + } + + /** + * Compute the {@code Stripe-Signature} header for a given webhook body & secret. Useful for + * signing payloads in unit tests. + * + * @param payload the payload to sign. + * @param secret the webhook secret. + * @param timestamp the timestamp to use (seconds since epoch). + * @return the generated signature header string. + */ + public static String generateSignatureHeader(String payload, String secret, long timestamp) + throws NoSuchAlgorithmException, InvalidKeyException { + String payloadToSign = String.format("%d.%s", timestamp, payload); + String signature = computeSignature(payloadToSign, secret); + return String.format("t=%d,%s=%s", timestamp, EXPECTED_SCHEME, signature); + } + /** * Extracts the timestamp in a signature header. * diff --git a/src/test/java/com/stripe/net/CloudProviderEventTest.java b/src/test/java/com/stripe/net/CloudProviderEventTest.java new file mode 100644 index 00000000000..11234e560df --- /dev/null +++ b/src/test/java/com/stripe/net/CloudProviderEventTest.java @@ -0,0 +1,236 @@ +package com.stripe.net; + +import static org.junit.jupiter.api.Assertions.*; + +import com.google.gson.JsonObject; +import com.google.gson.JsonSyntaxException; +import com.stripe.BaseStripeTest; +import com.stripe.StripeClient; +import com.stripe.model.Event; +import com.stripe.model.v2.core.EventNotification; +import org.junit.jupiter.api.Test; + +public class CloudProviderEventTest extends BaseStripeTest { + + private static final String EVENTBRIDGE_PAYLOAD = + "{\"version\":\"0\",\"id\":\"17e8dff5-d6cd-3770-ace9-aeac02b6ac3f\"," + + "\"detail-type\":\"customer.created\"," + + "\"source\":\"aws.partner/stripe.com/ed_123\"," + + "\"account\":\"506417113029\"," + + "\"time\":\"2024-03-07T18:27:56Z\"," + + "\"region\":\"us-west-2\"," + + "\"resources\":[]," + + "\"detail\":{" + + "\"id\":\"evt_test_123\"," + + "\"object\":\"event\"," + + "\"api_version\":\"2023-10-16\"," + + "\"created\":1709836076," + + "\"data\":{\"object\":{\"id\":\"cus_123\",\"object\":\"customer\"}}," + + "\"livemode\":true," + + "\"pending_webhooks\":0," + + "\"request\":{\"id\":\"req_123\",\"idempotency_key\":null}," + + "\"type\":\"customer.created\"}}"; + + private static final String EVENTGRID_PAYLOAD = + "{\"specversion\":\"1.0\"," + + "\"type\":\"customer.created\"," + + "\"source\":\"/providers/stripe/ed_test_123\"," + + "\"id\":\"9aeb0fdf-c01e-0131-0922-9eb54906e209\"," + + "\"time\":\"2025-07-11T14:30:00Z\"," + + "\"subject\":null," + + "\"dataContentType\":\"application/cloudevents+json\"," + + "\"data\":{" + + "\"id\":\"evt_test_456\"," + + "\"object\":\"event\"," + + "\"api_version\":\"2023-10-16\"," + + "\"created\":1709836076," + + "\"data\":{\"object\":{\"id\":\"cus_456\",\"object\":\"customer\"}}," + + "\"livemode\":false," + + "\"pending_webhooks\":0," + + "\"request\":{\"id\":\"req_456\",\"idempotency_key\":null}," + + "\"type\":\"customer.created\"}}"; + + private static final String EVENTBRIDGE_NOTIFICATION_PAYLOAD = + "{\"version\":\"0\",\"id\":\"17e8dff5-d6cd-3770-ace9-aeac02b6ac3f\"," + + "\"detail-type\":\"v2.core.event_destination.ping\"," + + "\"source\":\"aws.partner/stripe.com/ed_123\"," + + "\"detail\":{" + + "\"id\":\"evt_test_789\"," + + "\"object\":\"v2.core.event\"," + + "\"type\":\"v2.core.event_destination.ping\"," + + "\"created\":\"2024-03-07T18:27:56.000Z\"," + + "\"livemode\":true}}"; + + private static final String EVENTGRID_NOTIFICATION_PAYLOAD = + "{\"specversion\":\"1.0\"," + + "\"type\":\"v2.core.event_destination.ping\"," + + "\"source\":\"/providers/stripe/ed_test_123\"," + + "\"id\":\"9aeb0fdf-c01e-0131-0922-9eb54906e209\"," + + "\"data\":{" + + "\"id\":\"evt_test_789\"," + + "\"object\":\"v2.core.event\"," + + "\"type\":\"v2.core.event_destination.ping\"," + + "\"created\":\"2024-03-07T18:27:56.000Z\"," + + "\"livemode\":true}}"; + + private static final String RAW_EVENT_PAYLOAD = + "{\"id\":\"evt_test_123\"," + + "\"object\":\"event\"," + + "\"api_version\":\"2023-10-16\"," + + "\"created\":1709836076," + + "\"data\":{\"object\":{\"id\":\"cus_123\",\"object\":\"customer\"}}," + + "\"livemode\":true," + + "\"pending_webhooks\":0," + + "\"request\":{\"id\":\"req_123\",\"idempotency_key\":null}," + + "\"type\":\"customer.created\"}"; + + // constructEventWithoutVerification tests + + @Test + public void testEventBridgeViaClient() { + StripeClient client = new StripeClient("sk_test_fake"); + Event event = client.constructEventWithoutVerification(EVENTBRIDGE_PAYLOAD); + assertNotNull(event); + assertEquals("evt_test_123", event.getId()); + assertEquals("customer.created", event.getType()); + } + + @Test + public void testEventGridViaClient() { + StripeClient client = new StripeClient("sk_test_fake"); + Event event = client.constructEventWithoutVerification(EVENTGRID_PAYLOAD); + assertNotNull(event); + assertEquals("evt_test_456", event.getId()); + assertEquals("customer.created", event.getType()); + } + + @Test + public void testRawEventViaClient() { + StripeClient client = new StripeClient("sk_test_fake"); + Event event = client.constructEventWithoutVerification(RAW_EVENT_PAYLOAD); + assertNotNull(event); + assertEquals("evt_test_123", event.getId()); + assertEquals("customer.created", event.getType()); + } + + @Test + public void testInvalidJsonViaClient() { + StripeClient client = new StripeClient("sk_test_fake"); + assertThrows( + JsonSyntaxException.class, + () -> client.constructEventWithoutVerification("not valid json")); + } + + @Test + public void testUnrecognizedFormatViaClient() { + StripeClient client = new StripeClient("sk_test_fake"); + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, + () -> client.constructEventWithoutVerification("{\"foo\":\"bar\"}")); + assertTrue(ex.getMessage().contains("Unrecognized event format")); + } + + // parseEventNotificationWithoutVerification tests + + @Test + public void testEventBridgeNotificationViaClient() { + StripeClient client = new StripeClient("sk_test_fake"); + EventNotification notification = + client.parseEventNotificationWithoutVerification(EVENTBRIDGE_NOTIFICATION_PAYLOAD); + assertNotNull(notification); + assertEquals("evt_test_789", notification.getId()); + } + + @Test + public void testEventGridNotificationViaClient() { + StripeClient client = new StripeClient("sk_test_fake"); + EventNotification notification = + client.parseEventNotificationWithoutVerification(EVENTGRID_NOTIFICATION_PAYLOAD); + assertNotNull(notification); + assertEquals("evt_test_789", notification.getId()); + } + + @Test + public void testParseNotificationWithV1EventSuggestsConstructEventWithoutVerification() { + StripeClient client = new StripeClient("sk_test_fake"); + assertThrows( + IllegalArgumentException.class, + () -> client.parseEventNotificationWithoutVerification(EVENTBRIDGE_PAYLOAD)); + } + + @Test + public void testParseNotificationInvalidJsonThrows() { + StripeClient client = new StripeClient("sk_test_fake"); + assertThrows( + JsonSyntaxException.class, + () -> client.parseEventNotificationWithoutVerification("not valid json")); + } + + @Test + public void testParseNotificationUnrecognizedFormatThrows() { + StripeClient client = new StripeClient("sk_test_fake"); + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, + () -> client.parseEventNotificationWithoutVerification("{\"foo\":\"bar\"}")); + assertTrue(ex.getMessage().contains("Unrecognized event format")); + } + + @Test + public void testParseNotificationRawV2NotificationPassthrough() { + StripeClient client = new StripeClient("sk_test_fake"); + String rawV2Payload = + "{\"id\":\"evt_234\"," + + "\"object\":\"v2.core.event\"," + + "\"type\":\"v2.core.event_destination.ping\"," + + "\"created\":\"2024-03-07T18:27:56.000Z\"," + + "\"livemode\":true}"; + EventNotification notification = client.parseEventNotificationWithoutVerification(rawV2Payload); + assertNotNull(notification); + assertEquals("evt_234", notification.getId()); + } + + @Test + public void testConstructEventWithoutVerificationRejectsV2ThinEvent() { + StripeClient client = new StripeClient("sk_test_fake"); + String v2Payload = + "{\"id\":\"evt_234\"," + + "\"object\":\"v2.core.event\"," + + "\"type\":\"v2.core.event_destination.ping\"," + + "\"created\":\"2024-03-07T18:27:56.000Z\"," + + "\"livemode\":true}"; + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, + () -> client.constructEventWithoutVerification(v2Payload)); + assertTrue(ex.getMessage().contains("parseEventNotification")); + } + + @Test + public void testAzureEnvelopeMissingDataThrows() { + StripeClient client = new StripeClient("sk_test_fake"); + String payloadMissingData = + "{\"specversion\":\"1.0\"," + + "\"type\":\"customer.created\"," + + "\"source\":\"/providers/stripe/ed_test_123\"," + + "\"id\":\"test-missing-data\"}"; + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, + () -> client.constructEventWithoutVerification(payloadMissingData)); + assertTrue(ex.getMessage().contains("Unrecognized event format")); + } + + @Test + public void testFromJsonUnexpectedObjectTypeThrows() { + StripeClient client = new StripeClient("sk_test_fake"); + JsonObject jsonObject = new JsonObject(); + jsonObject.addProperty("object", "customer"); + jsonObject.addProperty("type", "customer.created"); + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, () -> EventNotification.fromJson(jsonObject, client)); + assertTrue(ex.getMessage().contains("Unexpected object type")); + } +} diff --git a/src/test/java/com/stripe/net/WebhookTest.java b/src/test/java/com/stripe/net/WebhookTest.java index 3e1357b6bf3..cf7a7c7d9c7 100644 --- a/src/test/java/com/stripe/net/WebhookTest.java +++ b/src/test/java/com/stripe/net/WebhookTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.google.gson.JsonObject; import com.google.gson.JsonSyntaxException; import com.stripe.BaseStripeTest; import com.stripe.Stripe; @@ -76,7 +77,7 @@ public static String generateSigHeader(Map options) @Test public void testValidJsonAndHeader() throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException { - final String sigHeader = generateSigHeader(); + final String sigHeader = Webhook.Signature.generateSignatureHeader(payload, secret); final Event event = Webhook.constructEvent(payload, sigHeader, secret); @@ -86,10 +87,7 @@ public void testValidJsonAndHeader() @Test public void testValidJsonAndHeaderButOutsideTimeTolerance() throws NoSuchAlgorithmException, InvalidKeyException { - final Map options = new HashMap<>(); - options.put("timestamp", 1L); - - final String sigHeader = generateSigHeader(options); + final String sigHeader = Webhook.Signature.generateSignatureHeader(payload, secret, 1L); final Clock clock = Clock.fixed(Instant.ofEpochMilli(12000), ZoneId.of("UTC")); assertThrows( @@ -109,9 +107,7 @@ public void testValidJsonAndHeaderCanMakeRequestsOnDataObject() + "\"," + "\"object\": \"event\",\"data\": {\"object\": {\"id\": \"acct_123\",\"object\": \"account\"}}}"; - final Map options = new HashMap<>(); - options.put("payload", payload); - final String sigHeader = generateSigHeader(options); + final String sigHeader = Webhook.Signature.generateSignatureHeader(payload, secret); final Event event = Webhook.constructEvent(payload, sigHeader, secret); Account modelViaData = ((Account) event.getData().getObject()); @@ -125,9 +121,7 @@ public void testValidJsonAndHeaderCanMakeRequestsOnDataObject() public void testInvalidJson() throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException { final String payload = "this is not valid JSON"; - final Map options = new HashMap<>(); - options.put("payload", payload); - final String sigHeader = generateSigHeader(options); + final String sigHeader = Webhook.Signature.generateSignatureHeader(payload, secret); assertThrows( JsonSyntaxException.class, @@ -196,9 +190,8 @@ public void testNoValidSignatureForPayload() @Test public void testTimestampOutsideTolerance() throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException { - final Map options = new HashMap<>(); - options.put("timestamp", Webhook.Util.getTimeNow() - 15); - final String sigHeader = generateSigHeader(options); + final String sigHeader = + Webhook.Signature.generateSignatureHeader(payload, secret, Webhook.Util.getTimeNow() - 15); Throwable exception = assertThrows( @@ -212,7 +205,7 @@ public void testTimestampOutsideTolerance() @Test public void testValidHeaderAndSignature() throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException { - final String sigHeader = generateSigHeader(); + final String sigHeader = Webhook.Signature.generateSignatureHeader(payload, secret); assertTrue(Webhook.Signature.verifyHeader(payload, sigHeader, secret, 10, null)); } @@ -220,7 +213,9 @@ public void testValidHeaderAndSignature() @Test public void testHeaderContainsValidSignature() throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException { - final String sigHeader = String.format("%s,v1=bad_signature", generateSigHeader()); + final String sigHeader = + String.format( + "%s,v1=bad_signature", Webhook.Signature.generateSignatureHeader(payload, secret)); assertTrue(Webhook.Signature.verifyHeader(payload, sigHeader, secret, 10, null)); } @@ -228,9 +223,7 @@ public void testHeaderContainsValidSignature() @Test public void testTimestampOffButNoTolerance() throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException { - final Map options = new HashMap<>(); - options.put("timestamp", Long.valueOf(12345L)); - final String sigHeader = generateSigHeader(options); + final String sigHeader = Webhook.Signature.generateSignatureHeader(payload, secret, 12345L); assertTrue(Webhook.Signature.verifyHeader(payload, sigHeader, secret, 0, null)); } @@ -238,11 +231,7 @@ public void testTimestampOffButNoTolerance() @Test public void testTimestampWithClock() throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException { - - final Map options = new HashMap<>(); - options.put("timestamp", 11L); - - final String sigHeader = generateSigHeader(options); + final String sigHeader = Webhook.Signature.generateSignatureHeader(payload, secret, 11L); final Clock clock = Clock.fixed(Instant.ofEpochMilli(1), ZoneId.of("UTC")); assertTrue(Webhook.Signature.verifyHeader(payload, sigHeader, secret, 10, clock)); @@ -251,11 +240,7 @@ public void testTimestampWithClock() @Test public void testTimestampWithClockOutsideTolerance() throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException { - - final Map options = new HashMap<>(); - options.put("timestamp", 11L); - - final String sigHeader = generateSigHeader(options); + final String sigHeader = Webhook.Signature.generateSignatureHeader(payload, secret, 11L); final Clock clock = Clock.fixed(Instant.ofEpochMilli(12), ZoneId.of("UTC")); assertTrue(Webhook.Signature.verifyHeader(payload, sigHeader, secret, 10, clock)); @@ -282,9 +267,7 @@ public void testStripeClientConstructEvent() + "\"," + "\"object\": \"event\",\"data\": {\"object\": {\"id\": \"rdr_123\",\"object\": \"terminal.reader\"}}}"; - final Map options = new HashMap<>(); - options.put("payload", payload); - final String sigHeader = generateSigHeader(options); + final String sigHeader = Webhook.Signature.generateSignatureHeader(payload, secret); final Event event = client.constructEvent(payload, sigHeader, secret); @@ -315,9 +298,7 @@ public void testStripeClientConstructEventWithTolerance() + "\"," + "\"object\": \"event\",\"data\": {\"object\": {\"id\": \"rdr_123\",\"object\": \"terminal.reader\"}}}"; - final Map options = new HashMap<>(); - options.put("payload", payload); - final String sigHeader = generateSigHeader(options); + final String sigHeader = Webhook.Signature.generateSignatureHeader(payload, secret); final Event event = client.constructEvent(payload, sigHeader, secret, 500); @@ -331,19 +312,116 @@ public void testStripeClientConstructEventWithTolerance() public void testConstructEventWithRawJson() throws StripeException, NoSuchAlgorithmException, InvalidKeyException { - final Event event = Webhook.constructEvent(payload, generateSigHeader(), secret); + final Event event = + Webhook.constructEvent( + payload, Webhook.Signature.generateSignatureHeader(payload, secret), secret); assertNotNull(event.getRawJsonObject()); } + @Test + public void testGenerateSignatureHeaderWithTimestamp() + throws NoSuchAlgorithmException, InvalidKeyException, SignatureVerificationException { + final long timestamp = 1609459200L; + final String header = Webhook.Signature.generateSignatureHeader(payload, secret, timestamp); + + assertTrue(Webhook.Signature.verifyHeader(payload, header, secret, 0, null)); + assertTrue(header.startsWith(String.format("t=%d,v1=", timestamp))); + } + + @Test + public void testGenerateSignatureHeaderWithCurrentTimestamp() + throws NoSuchAlgorithmException, InvalidKeyException, SignatureVerificationException { + final String header = Webhook.Signature.generateSignatureHeader(payload, secret); + + assertTrue(Webhook.Signature.verifyHeader(payload, header, secret, 10, null)); + } + + @Test + public void testGenerateSignatureHeaderRoundtrip() + throws NoSuchAlgorithmException, InvalidKeyException, SignatureVerificationException { + String payload = "test_payload"; + String secret = "whsec_test_secret"; + String header = Webhook.Signature.generateSignatureHeader(payload, secret); + // Should not throw: + assertTrue(Webhook.Signature.verifyHeader(payload, header, secret, 300)); + } + + @Test + public void testParseEventNotification() + throws NoSuchAlgorithmException, InvalidKeyException, SignatureVerificationException { + StripeClient client = new StripeClient(new LiveStripeResponseGetter()); + + final String v2Payload = + "{\n" + + " \"id\": \"evt_test_webhook\",\n" + + " \"object\": \"v2.core.event\",\n" + + " \"type\": \"v1.billing.meter.no_meter_found\",\n" + + " \"livemode\": false,\n" + + " \"created\": \"2022-02-15T00:27:45.330Z\"\n" + + "}"; + + final String header = Webhook.Signature.generateSignatureHeader(v2Payload, secret); + + final com.stripe.model.v2.core.EventNotification notification = + client.parseEventNotification(v2Payload, header, secret); + + assertNotNull(notification); + assertEquals("evt_test_webhook", notification.getId()); + } + + @Test + public void testMaybeExtractPassesThroughV1Event() { + final String v1Payload = "{\"id\": \"evt_test_webhook\", \"object\": \"event\"}"; + final JsonObject result = Webhook.maybeExtractFromCloudProviderEnvelope(v1Payload); + assertEquals("event", result.get("object").getAsString()); + } + + @Test + public void testMaybeExtractPassesThroughV2Event() { + final String v2Payload = "{\"id\": \"evt_test_webhook\", \"object\": \"v2.core.event\"}"; + final JsonObject result = Webhook.maybeExtractFromCloudProviderEnvelope(v2Payload); + assertEquals("v2.core.event", result.get("object").getAsString()); + } + + @Test + public void testMaybeExtractThrowsForUnrecognizedFormat() { + final String unknownPayload = "{\"id\": \"evt_test_webhook\", \"object\": \"something_else\"}"; + assertThrows( + IllegalArgumentException.class, + () -> { + Webhook.maybeExtractFromCloudProviderEnvelope(unknownPayload); + }); + } + + @Test + public void testConstructEventWithoutVerification() { + final Event event = Webhook.constructEventWithoutVerification(payload); + + assertNotNull(event); + assertEquals("evt_test_webhook", event.getId()); + } + + @Test + public void testConstructEventWithoutVerificationRejectsV2Payload() { + final String v2Payload = + "{\n \"id\": \"evt_test_webhook\",\n \"object\": \"v2.core.event\"\n}"; + + Throwable exception = + assertThrows( + IllegalArgumentException.class, + () -> { + Webhook.constructEventWithoutVerification(v2Payload); + }); + assertTrue(exception.getMessage().contains("parseEventNotification")); + } + @Test public void testConstructEventRejectsV2Payload() throws NoSuchAlgorithmException, InvalidKeyException { final String v2Payload = "{\n \"id\": \"evt_test_webhook\",\n \"object\": \"v2.core.event\"\n}"; - final Map options = new HashMap<>(); - options.put("payload", v2Payload); - final String sigHeader = generateSigHeader(options); + final String sigHeader = Webhook.Signature.generateSignatureHeader(v2Payload, secret); Throwable exception = assertThrows( @@ -351,7 +429,7 @@ public void testConstructEventRejectsV2Payload() () -> { Webhook.constructEvent(v2Payload, sigHeader, secret); }); - assertTrue(exception.getMessage().contains("StripeClient.parseEventNotification")); + assertTrue(exception.getMessage().contains("parseEventNotification")); } @Test @@ -361,9 +439,7 @@ public void testClientConstructEventRejectsV2Payload() final String v2Payload = "{\n \"id\": \"evt_test_webhook\",\n \"object\": \"v2.core.event\"\n}"; - final Map options = new HashMap<>(); - options.put("payload", v2Payload); - final String sigHeader = generateSigHeader(options); + final String sigHeader = Webhook.Signature.generateSignatureHeader(v2Payload, secret); Throwable exception = assertThrows( @@ -371,6 +447,6 @@ public void testClientConstructEventRejectsV2Payload() () -> { client.constructEvent(v2Payload, sigHeader, secret); }); - assertTrue(exception.getMessage().contains("StripeClient.parseEventNotification")); + assertTrue(exception.getMessage().contains("parseEventNotification")); } } From 7bc55f1ea43d86d67b5d8081c84f57a315e5314b Mon Sep 17 00:00:00 2001 From: David Brownman <1231935+xavdid@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:23:50 -0700 Subject: [PATCH 2/2] add "object" to event notification (#2265) --- src/main/java/com/stripe/model/v2/core/EventNotification.java | 4 ++++ src/test/java/com/stripe/StripeClientTest.java | 1 + 2 files changed, 5 insertions(+) diff --git a/src/main/java/com/stripe/model/v2/core/EventNotification.java b/src/main/java/com/stripe/model/v2/core/EventNotification.java index 2e98698a9a5..0544219a18a 100644 --- a/src/main/java/com/stripe/model/v2/core/EventNotification.java +++ b/src/main/java/com/stripe/model/v2/core/EventNotification.java @@ -60,6 +60,10 @@ public static class Reason { @SerializedName("id") public String id; + /** String representing the object's type. Objects of the same type share the same value. */ + @SerializedName("object") + public String object; + /** The type of the event. */ @SerializedName("type") public String type; diff --git a/src/test/java/com/stripe/StripeClientTest.java b/src/test/java/com/stripe/StripeClientTest.java index aa97216e5a4..d53110efe61 100644 --- a/src/test/java/com/stripe/StripeClientTest.java +++ b/src/test/java/com/stripe/StripeClientTest.java @@ -246,6 +246,7 @@ public void parsesEventNotificationWithRelatedObject() client.parseEventNotification(v2EventNotificationWithRelatedObject, signature, secret); assertNotNull(eventNotification); assertEquals("evt_234", eventNotification.getId()); + assertEquals("v2.core.event", eventNotification.getObject()); assertEquals("v1.billing.meter.error_report_triggered", eventNotification.getType()); assertEquals(Instant.parse("2022-02-15T00:27:45.330Z"), eventNotification.created); assertEquals("org_123", eventNotification.getContext().toString());