From f213c8d7e2c2e2eeab7c666d67b5119c0ab2c773 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Tue, 12 May 2026 13:43:07 +0200 Subject: [PATCH 1/8] feat(webhook): regenerate with CHA-2961 helpers + dual-API on Client --- CHANGELOG.md | 27 ++ generate.sh | 3 + lib/getstream_ruby/client.rb | 30 ++ .../models/async_export_error_event.rb | 2 +- .../models/query_bookmarks_request.rb | 12 +- lib/getstream_ruby/generated/webhook.rb | 212 ++++++++++++- .../webhooks/_invalid/bad_base64/body.gz | Bin 0 -> 79 bytes .../webhooks/_invalid/bad_base64/body.json | 1 + .../_invalid/bad_base64/expected.json | 1 + .../_invalid/bad_base64/signature.txt | 1 + .../_invalid/bad_base64/sns_notification.txt | 1 + .../webhooks/_invalid/bad_base64/sqs_body.txt | 1 + .../bad_base64/sqs_body_uncompressed.txt | 1 + .../webhooks/_invalid/bad_compression/body.gz | Bin 0 -> 16 bytes .../_invalid/bad_compression/body.json | 1 + .../_invalid/bad_compression/expected.json | 1 + .../_invalid/bad_compression/signature.txt | 1 + .../bad_compression/sns_notification.txt | 1 + .../_invalid/bad_compression/sqs_body.txt | 1 + .../bad_compression/sqs_body_uncompressed.txt | 1 + .../_invalid/bad_sns_envelope/body.gz | Bin 0 -> 79 bytes .../_invalid/bad_sns_envelope/body.json | 1 + .../_invalid/bad_sns_envelope/expected.json | 1 + .../_invalid/bad_sns_envelope/signature.txt | 1 + .../bad_sns_envelope/sns_notification.txt | 1 + .../_invalid/bad_sns_envelope/sqs_body.txt | 1 + .../sqs_body_uncompressed.txt | 1 + .../webhooks/_invalid/empty_body/body.gz | Bin 0 -> 23 bytes .../webhooks/_invalid/empty_body/body.json | 0 .../_invalid/empty_body/expected.json | 6 + .../_invalid/empty_body/signature.txt | 1 + .../_invalid/empty_body/sns_notification.txt | 1 + .../webhooks/_invalid/empty_body/sqs_body.txt | 1 + .../empty_body/sqs_body_uncompressed.txt | 0 .../webhooks/_invalid/malformed_json/body.gz | Bin 0 -> 39 bytes .../_invalid/malformed_json/body.json | 1 + .../_invalid/malformed_json/expected.json | 6 + .../_invalid/malformed_json/signature.txt | 1 + .../malformed_json/sns_notification.txt | 1 + .../_invalid/malformed_json/sqs_body.txt | 1 + .../malformed_json/sqs_body_uncompressed.txt | 1 + .../webhooks/_invalid/missing_type/body.gz | Bin 0 -> 58 bytes .../webhooks/_invalid/missing_type/body.json | 1 + .../_invalid/missing_type/expected.json | 6 + .../_invalid/missing_type/signature.txt | 1 + .../missing_type/sns_notification.txt | 1 + .../_invalid/missing_type/sqs_body.txt | 1 + .../missing_type/sqs_body_uncompressed.txt | 1 + .../webhooks/_invalid/tampered_body/body.gz | Bin 0 -> 95 bytes .../webhooks/_invalid/tampered_body/body.json | 1 + .../_invalid/tampered_body/expected.json | 7 + .../_invalid/tampered_body/signature.txt | 1 + .../tampered_body/sns_notification.txt | 1 + .../_invalid/tampered_body/sqs_body.txt | 1 + .../tampered_body/sqs_body_uncompressed.txt | 1 + .../webhooks/_invalid/unknown_type/body.gz | Bin 0 -> 83 bytes .../webhooks/_invalid/unknown_type/body.json | 1 + .../_invalid/unknown_type/expected.json | 6 + .../_invalid/unknown_type/signature.txt | 1 + .../unknown_type/sns_notification.txt | 1 + .../_invalid/unknown_type/sqs_body.txt | 1 + .../unknown_type/sqs_body_uncompressed.txt | 1 + .../webhooks/call.session_ended/body.gz | Bin 0 -> 86 bytes .../webhooks/call.session_ended/body.json | 1 + .../webhooks/call.session_ended/expected.json | 6 + .../webhooks/call.session_ended/signature.txt | 1 + .../call.session_ended/sns_notification.txt | 1 + .../webhooks/call.session_ended/sqs_body.txt | 1 + .../sqs_body_uncompressed.txt | 1 + .../webhooks/call.session_started/body.gz | Bin 0 -> 88 bytes .../webhooks/call.session_started/body.json | 1 + .../call.session_started/expected.json | 6 + .../call.session_started/signature.txt | 1 + .../call.session_started/sns_notification.txt | 1 + .../call.session_started/sqs_body.txt | 1 + .../sqs_body_uncompressed.txt | 1 + .../fixtures/webhooks/channel.created/body.gz | Bin 0 -> 77 bytes .../webhooks/channel.created/body.json | 1 + .../webhooks/channel.created/expected.json | 6 + .../webhooks/channel.created/signature.txt | 1 + .../channel.created/sns_notification.txt | 1 + .../webhooks/channel.created/sqs_body.txt | 1 + .../channel.created/sqs_body_uncompressed.txt | 1 + .../fixtures/webhooks/channel.deleted/body.gz | Bin 0 -> 83 bytes .../webhooks/channel.deleted/body.json | 1 + .../webhooks/channel.deleted/expected.json | 6 + .../webhooks/channel.deleted/signature.txt | 1 + .../channel.deleted/sns_notification.txt | 1 + .../webhooks/channel.deleted/sqs_body.txt | 1 + .../channel.deleted/sqs_body_uncompressed.txt | 1 + .../fixtures/webhooks/channel.updated/body.gz | Bin 0 -> 80 bytes .../webhooks/channel.updated/body.json | 1 + .../webhooks/channel.updated/expected.json | 6 + .../webhooks/channel.updated/signature.txt | 1 + .../channel.updated/sns_notification.txt | 1 + .../webhooks/channel.updated/sqs_body.txt | 1 + .../channel.updated/sqs_body_uncompressed.txt | 1 + .../webhooks/feeds.activity.added/body.gz | Bin 0 -> 88 bytes .../webhooks/feeds.activity.added/body.json | 1 + .../feeds.activity.added/expected.json | 6 + .../feeds.activity.added/signature.txt | 1 + .../feeds.activity.added/sns_notification.txt | 1 + .../feeds.activity.added/sqs_body.txt | 1 + .../sqs_body_uncompressed.txt | 1 + .../fixtures/webhooks/message.deleted/body.gz | Bin 0 -> 83 bytes .../webhooks/message.deleted/body.json | 1 + .../webhooks/message.deleted/expected.json | 6 + .../webhooks/message.deleted/signature.txt | 1 + .../message.deleted/sns_notification.txt | 1 + .../webhooks/message.deleted/sqs_body.txt | 1 + .../message.deleted/sqs_body_uncompressed.txt | 1 + test/fixtures/webhooks/message.new/body.gz | Bin 0 -> 79 bytes test/fixtures/webhooks/message.new/body.json | 1 + .../webhooks/message.new/expected.json | 6 + .../webhooks/message.new/signature.txt | 1 + .../webhooks/message.new/sns_notification.txt | 1 + .../webhooks/message.new/sqs_body.txt | 1 + .../message.new/sqs_body_uncompressed.txt | 1 + .../fixtures/webhooks/message.updated/body.gz | Bin 0 -> 80 bytes .../webhooks/message.updated/body.json | 1 + .../webhooks/message.updated/expected.json | 6 + .../webhooks/message.updated/signature.txt | 1 + .../message.updated/sns_notification.txt | 1 + .../webhooks/message.updated/sqs_body.txt | 1 + .../message.updated/sqs_body_uncompressed.txt | 1 + .../webhooks/moderation.flagged/body.gz | Bin 0 -> 86 bytes .../webhooks/moderation.flagged/body.json | 1 + .../webhooks/moderation.flagged/expected.json | 6 + .../webhooks/moderation.flagged/signature.txt | 1 + .../moderation.flagged/sns_notification.txt | 1 + .../webhooks/moderation.flagged/sqs_body.txt | 1 + .../sqs_body_uncompressed.txt | 1 + test/fixtures/webhooks/reaction.new/body.gz | Bin 0 -> 80 bytes test/fixtures/webhooks/reaction.new/body.json | 1 + .../webhooks/reaction.new/expected.json | 6 + .../webhooks/reaction.new/signature.txt | 1 + .../reaction.new/sns_notification.txt | 1 + .../webhooks/reaction.new/sqs_body.txt | 1 + .../reaction.new/sqs_body_uncompressed.txt | 1 + test/fixtures/webhooks/user.banned/body.gz | Bin 0 -> 79 bytes test/fixtures/webhooks/user.banned/body.json | 1 + .../webhooks/user.banned/expected.json | 6 + .../webhooks/user.banned/signature.txt | 1 + .../webhooks/user.banned/sns_notification.txt | 1 + .../webhooks/user.banned/sqs_body.txt | 1 + .../user.banned/sqs_body_uncompressed.txt | 1 + test/fixtures/webhooks/user.unbanned/body.gz | Bin 0 -> 81 bytes .../fixtures/webhooks/user.unbanned/body.json | 1 + .../webhooks/user.unbanned/expected.json | 6 + .../webhooks/user.unbanned/signature.txt | 1 + .../user.unbanned/sns_notification.txt | 1 + .../webhooks/user.unbanned/sqs_body.txt | 1 + .../user.unbanned/sqs_body_uncompressed.txt | 1 + test/webhook_test.rb | 281 +++++++++++++++++- 154 files changed, 776 insertions(+), 6 deletions(-) create mode 100644 test/fixtures/webhooks/_invalid/bad_base64/body.gz create mode 100644 test/fixtures/webhooks/_invalid/bad_base64/body.json create mode 100644 test/fixtures/webhooks/_invalid/bad_base64/expected.json create mode 100644 test/fixtures/webhooks/_invalid/bad_base64/signature.txt create mode 100644 test/fixtures/webhooks/_invalid/bad_base64/sns_notification.txt create mode 100644 test/fixtures/webhooks/_invalid/bad_base64/sqs_body.txt create mode 100644 test/fixtures/webhooks/_invalid/bad_base64/sqs_body_uncompressed.txt create mode 100644 test/fixtures/webhooks/_invalid/bad_compression/body.gz create mode 100644 test/fixtures/webhooks/_invalid/bad_compression/body.json create mode 100644 test/fixtures/webhooks/_invalid/bad_compression/expected.json create mode 100644 test/fixtures/webhooks/_invalid/bad_compression/signature.txt create mode 100644 test/fixtures/webhooks/_invalid/bad_compression/sns_notification.txt create mode 100644 test/fixtures/webhooks/_invalid/bad_compression/sqs_body.txt create mode 100644 test/fixtures/webhooks/_invalid/bad_compression/sqs_body_uncompressed.txt create mode 100644 test/fixtures/webhooks/_invalid/bad_sns_envelope/body.gz create mode 100644 test/fixtures/webhooks/_invalid/bad_sns_envelope/body.json create mode 100644 test/fixtures/webhooks/_invalid/bad_sns_envelope/expected.json create mode 100644 test/fixtures/webhooks/_invalid/bad_sns_envelope/signature.txt create mode 100644 test/fixtures/webhooks/_invalid/bad_sns_envelope/sns_notification.txt create mode 100644 test/fixtures/webhooks/_invalid/bad_sns_envelope/sqs_body.txt create mode 100644 test/fixtures/webhooks/_invalid/bad_sns_envelope/sqs_body_uncompressed.txt create mode 100644 test/fixtures/webhooks/_invalid/empty_body/body.gz create mode 100644 test/fixtures/webhooks/_invalid/empty_body/body.json create mode 100644 test/fixtures/webhooks/_invalid/empty_body/expected.json create mode 100644 test/fixtures/webhooks/_invalid/empty_body/signature.txt create mode 100644 test/fixtures/webhooks/_invalid/empty_body/sns_notification.txt create mode 100644 test/fixtures/webhooks/_invalid/empty_body/sqs_body.txt create mode 100644 test/fixtures/webhooks/_invalid/empty_body/sqs_body_uncompressed.txt create mode 100644 test/fixtures/webhooks/_invalid/malformed_json/body.gz create mode 100644 test/fixtures/webhooks/_invalid/malformed_json/body.json create mode 100644 test/fixtures/webhooks/_invalid/malformed_json/expected.json create mode 100644 test/fixtures/webhooks/_invalid/malformed_json/signature.txt create mode 100644 test/fixtures/webhooks/_invalid/malformed_json/sns_notification.txt create mode 100644 test/fixtures/webhooks/_invalid/malformed_json/sqs_body.txt create mode 100644 test/fixtures/webhooks/_invalid/malformed_json/sqs_body_uncompressed.txt create mode 100644 test/fixtures/webhooks/_invalid/missing_type/body.gz create mode 100644 test/fixtures/webhooks/_invalid/missing_type/body.json create mode 100644 test/fixtures/webhooks/_invalid/missing_type/expected.json create mode 100644 test/fixtures/webhooks/_invalid/missing_type/signature.txt create mode 100644 test/fixtures/webhooks/_invalid/missing_type/sns_notification.txt create mode 100644 test/fixtures/webhooks/_invalid/missing_type/sqs_body.txt create mode 100644 test/fixtures/webhooks/_invalid/missing_type/sqs_body_uncompressed.txt create mode 100644 test/fixtures/webhooks/_invalid/tampered_body/body.gz create mode 100644 test/fixtures/webhooks/_invalid/tampered_body/body.json create mode 100644 test/fixtures/webhooks/_invalid/tampered_body/expected.json create mode 100644 test/fixtures/webhooks/_invalid/tampered_body/signature.txt create mode 100644 test/fixtures/webhooks/_invalid/tampered_body/sns_notification.txt create mode 100644 test/fixtures/webhooks/_invalid/tampered_body/sqs_body.txt create mode 100644 test/fixtures/webhooks/_invalid/tampered_body/sqs_body_uncompressed.txt create mode 100644 test/fixtures/webhooks/_invalid/unknown_type/body.gz create mode 100644 test/fixtures/webhooks/_invalid/unknown_type/body.json create mode 100644 test/fixtures/webhooks/_invalid/unknown_type/expected.json create mode 100644 test/fixtures/webhooks/_invalid/unknown_type/signature.txt create mode 100644 test/fixtures/webhooks/_invalid/unknown_type/sns_notification.txt create mode 100644 test/fixtures/webhooks/_invalid/unknown_type/sqs_body.txt create mode 100644 test/fixtures/webhooks/_invalid/unknown_type/sqs_body_uncompressed.txt create mode 100644 test/fixtures/webhooks/call.session_ended/body.gz create mode 100644 test/fixtures/webhooks/call.session_ended/body.json create mode 100644 test/fixtures/webhooks/call.session_ended/expected.json create mode 100644 test/fixtures/webhooks/call.session_ended/signature.txt create mode 100644 test/fixtures/webhooks/call.session_ended/sns_notification.txt create mode 100644 test/fixtures/webhooks/call.session_ended/sqs_body.txt create mode 100644 test/fixtures/webhooks/call.session_ended/sqs_body_uncompressed.txt create mode 100644 test/fixtures/webhooks/call.session_started/body.gz create mode 100644 test/fixtures/webhooks/call.session_started/body.json create mode 100644 test/fixtures/webhooks/call.session_started/expected.json create mode 100644 test/fixtures/webhooks/call.session_started/signature.txt create mode 100644 test/fixtures/webhooks/call.session_started/sns_notification.txt create mode 100644 test/fixtures/webhooks/call.session_started/sqs_body.txt create mode 100644 test/fixtures/webhooks/call.session_started/sqs_body_uncompressed.txt create mode 100644 test/fixtures/webhooks/channel.created/body.gz create mode 100644 test/fixtures/webhooks/channel.created/body.json create mode 100644 test/fixtures/webhooks/channel.created/expected.json create mode 100644 test/fixtures/webhooks/channel.created/signature.txt create mode 100644 test/fixtures/webhooks/channel.created/sns_notification.txt create mode 100644 test/fixtures/webhooks/channel.created/sqs_body.txt create mode 100644 test/fixtures/webhooks/channel.created/sqs_body_uncompressed.txt create mode 100644 test/fixtures/webhooks/channel.deleted/body.gz create mode 100644 test/fixtures/webhooks/channel.deleted/body.json create mode 100644 test/fixtures/webhooks/channel.deleted/expected.json create mode 100644 test/fixtures/webhooks/channel.deleted/signature.txt create mode 100644 test/fixtures/webhooks/channel.deleted/sns_notification.txt create mode 100644 test/fixtures/webhooks/channel.deleted/sqs_body.txt create mode 100644 test/fixtures/webhooks/channel.deleted/sqs_body_uncompressed.txt create mode 100644 test/fixtures/webhooks/channel.updated/body.gz create mode 100644 test/fixtures/webhooks/channel.updated/body.json create mode 100644 test/fixtures/webhooks/channel.updated/expected.json create mode 100644 test/fixtures/webhooks/channel.updated/signature.txt create mode 100644 test/fixtures/webhooks/channel.updated/sns_notification.txt create mode 100644 test/fixtures/webhooks/channel.updated/sqs_body.txt create mode 100644 test/fixtures/webhooks/channel.updated/sqs_body_uncompressed.txt create mode 100644 test/fixtures/webhooks/feeds.activity.added/body.gz create mode 100644 test/fixtures/webhooks/feeds.activity.added/body.json create mode 100644 test/fixtures/webhooks/feeds.activity.added/expected.json create mode 100644 test/fixtures/webhooks/feeds.activity.added/signature.txt create mode 100644 test/fixtures/webhooks/feeds.activity.added/sns_notification.txt create mode 100644 test/fixtures/webhooks/feeds.activity.added/sqs_body.txt create mode 100644 test/fixtures/webhooks/feeds.activity.added/sqs_body_uncompressed.txt create mode 100644 test/fixtures/webhooks/message.deleted/body.gz create mode 100644 test/fixtures/webhooks/message.deleted/body.json create mode 100644 test/fixtures/webhooks/message.deleted/expected.json create mode 100644 test/fixtures/webhooks/message.deleted/signature.txt create mode 100644 test/fixtures/webhooks/message.deleted/sns_notification.txt create mode 100644 test/fixtures/webhooks/message.deleted/sqs_body.txt create mode 100644 test/fixtures/webhooks/message.deleted/sqs_body_uncompressed.txt create mode 100644 test/fixtures/webhooks/message.new/body.gz create mode 100644 test/fixtures/webhooks/message.new/body.json create mode 100644 test/fixtures/webhooks/message.new/expected.json create mode 100644 test/fixtures/webhooks/message.new/signature.txt create mode 100644 test/fixtures/webhooks/message.new/sns_notification.txt create mode 100644 test/fixtures/webhooks/message.new/sqs_body.txt create mode 100644 test/fixtures/webhooks/message.new/sqs_body_uncompressed.txt create mode 100644 test/fixtures/webhooks/message.updated/body.gz create mode 100644 test/fixtures/webhooks/message.updated/body.json create mode 100644 test/fixtures/webhooks/message.updated/expected.json create mode 100644 test/fixtures/webhooks/message.updated/signature.txt create mode 100644 test/fixtures/webhooks/message.updated/sns_notification.txt create mode 100644 test/fixtures/webhooks/message.updated/sqs_body.txt create mode 100644 test/fixtures/webhooks/message.updated/sqs_body_uncompressed.txt create mode 100644 test/fixtures/webhooks/moderation.flagged/body.gz create mode 100644 test/fixtures/webhooks/moderation.flagged/body.json create mode 100644 test/fixtures/webhooks/moderation.flagged/expected.json create mode 100644 test/fixtures/webhooks/moderation.flagged/signature.txt create mode 100644 test/fixtures/webhooks/moderation.flagged/sns_notification.txt create mode 100644 test/fixtures/webhooks/moderation.flagged/sqs_body.txt create mode 100644 test/fixtures/webhooks/moderation.flagged/sqs_body_uncompressed.txt create mode 100644 test/fixtures/webhooks/reaction.new/body.gz create mode 100644 test/fixtures/webhooks/reaction.new/body.json create mode 100644 test/fixtures/webhooks/reaction.new/expected.json create mode 100644 test/fixtures/webhooks/reaction.new/signature.txt create mode 100644 test/fixtures/webhooks/reaction.new/sns_notification.txt create mode 100644 test/fixtures/webhooks/reaction.new/sqs_body.txt create mode 100644 test/fixtures/webhooks/reaction.new/sqs_body_uncompressed.txt create mode 100644 test/fixtures/webhooks/user.banned/body.gz create mode 100644 test/fixtures/webhooks/user.banned/body.json create mode 100644 test/fixtures/webhooks/user.banned/expected.json create mode 100644 test/fixtures/webhooks/user.banned/signature.txt create mode 100644 test/fixtures/webhooks/user.banned/sns_notification.txt create mode 100644 test/fixtures/webhooks/user.banned/sqs_body.txt create mode 100644 test/fixtures/webhooks/user.banned/sqs_body_uncompressed.txt create mode 100644 test/fixtures/webhooks/user.unbanned/body.gz create mode 100644 test/fixtures/webhooks/user.unbanned/body.json create mode 100644 test/fixtures/webhooks/user.unbanned/expected.json create mode 100644 test/fixtures/webhooks/user.unbanned/signature.txt create mode 100644 test/fixtures/webhooks/user.unbanned/sns_notification.txt create mode 100644 test/fixtures/webhooks/user.unbanned/sqs_body.txt create mode 100644 test/fixtures/webhooks/user.unbanned/sqs_body_uncompressed.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a13c6f..21114e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,30 @@ +## [Unreleased] + +### Added + +- Webhook handling spec helpers (CHA-2961): `UnknownEvent` class for forward-compat; + `gunzip_payload`, `decode_sqs_payload`, `decode_sns_payload` primitives; + `parse_event` (returns typed event or `UnknownEvent` for unrecognized discriminators); + `verify_and_parse_webhook` HTTP composite; `parse_sqs_payload` / `parse_sns_payload` + queue composites (no signature — backend emits no HMAC for queue messages today). +- New `Stream::Webhook` module alias (preferred). `StreamChat::Webhook` retained as + backward-compat alias for one minor-version cycle. +- New unified error class: `StreamChat::Webhook::InvalidWebhookError` covering signature + mismatch, invalid JSON, missing/non-string `type` field, gzip decompression failure, + invalid base64 in a queue body, and malformed SNS envelopes. Distinguish failure modes + via the message substring or `cause` chain rather than the class. +- New instance methods on `GetStreamRuby::Client`: `verify_signature(body, signature)` and + `verify_and_parse_webhook(body, signature)` — drop the `api_secret` parameter in favor + of the client's stored secret. Dual API: module-level methods remain available. +- Conformance fixture suite under `test/fixtures/webhooks/` (14 event-type buckets plus + `_invalid/` negative cases). + +### Changed + +- No breaking changes. + +[Spec](https://www.notion.so/stream-wiki/Server-Side-SDK-Webhook-Handling-Spec-34b6a5d7f9f681e78003c443f227493c) + ## [6.0.0] - 2026-04-17 ### major^2 changes diff --git a/generate.sh b/generate.sh index 30e61a6..a5fc073 100755 --- a/generate.sh +++ b/generate.sh @@ -21,6 +21,9 @@ set -ex # cd in API repo, generate new spec and then generate code from it ( cd $SOURCE_PATH ; make openapi ; go run ./cmd/chat-manager openapi generate-client --language ruby --spec ./releases/v2/serverside-api.yaml --output $DST_PATH ) +# Generate webhook conformance fixtures (CHA-2961) +( cd $SOURCE_PATH ; go run ./cmd/chat-manager openapi generate-webhook-fixtures --output $DST_PATH/test/fixtures/webhooks ) + # Fix any potential issues in generated code echo "Applying Ruby-specific fixes..." diff --git a/lib/getstream_ruby/client.rb b/lib/getstream_ruby/client.rb index 7604225..aa2ceb4 100644 --- a/lib/getstream_ruby/client.rb +++ b/lib/getstream_ruby/client.rb @@ -13,6 +13,7 @@ require_relative 'generated/video_client' require_relative 'extensions/moderation_extensions' require_relative 'generated/feed' +require_relative 'generated/webhook' require_relative 'stream_response' module GetStreamRuby @@ -76,6 +77,35 @@ def feed(feed_group_id, feed_id) GetStream::Generated::Feed.new(self, feed_group_id, feed_id) end + # Verify a webhook signature using this client's API secret (CHA-2961). + # + # Convenience wrapper around StreamChat::Webhook.verify_signature that + # supplies the secret automatically. The module-level method is still + # available for callers that need to verify with an arbitrary secret. + # + # @param body [String] The raw request body (already-decompressed) + # @param signature [String] The signature from the X-Signature header + # @return [Boolean] true if the signature is valid, false otherwise + def verify_signature(body, signature) + StreamChat::Webhook.verify_signature(body, signature, @configuration.api_secret) + end + + # Verify and parse a webhook payload in one call, using this client's API + # secret (CHA-2961). + # + # Handles gzip-compressed bodies transparently. Raises + # StreamChat::Webhook::InvalidWebhookError on signature mismatch or parse + # failures; distinguish failure modes via the message substring. + # + # @param body [String] raw request body (possibly gzip-compressed) + # @param signature [String] X-Signature header value + # @return [Object] the typed event class instance or + # StreamChat::Webhook::UnknownEvent + # @raise [StreamChat::Webhook::InvalidWebhookError] + def verify_and_parse_webhook(body, signature) + StreamChat::Webhook.verify_and_parse_webhook(body, signature, @configuration.api_secret) + end + # @param path [String] The API path # @param body [Hash] The request body # @return [GetStreamRuby::StreamResponse] The API response diff --git a/lib/getstream_ruby/generated/models/async_export_error_event.rb b/lib/getstream_ruby/generated/models/async_export_error_event.rb index 1e922a8..85cf709 100644 --- a/lib/getstream_ruby/generated/models/async_export_error_event.rb +++ b/lib/getstream_ruby/generated/models/async_export_error_event.rb @@ -43,7 +43,7 @@ def initialize(attributes = {}) @started_at = attributes[:started_at] || attributes['started_at'] @task_id = attributes[:task_id] || attributes['task_id'] @custom = attributes[:custom] || attributes['custom'] - @type = attributes[:type] || attributes['type'] || "export.users.error" + @type = attributes[:type] || attributes['type'] || "export.moderation_logs.error" @received_at = attributes[:received_at] || attributes['received_at'] || nil end diff --git a/lib/getstream_ruby/generated/models/query_bookmarks_request.rb b/lib/getstream_ruby/generated/models/query_bookmarks_request.rb index 01f0086..f70d356 100644 --- a/lib/getstream_ruby/generated/models/query_bookmarks_request.rb +++ b/lib/getstream_ruby/generated/models/query_bookmarks_request.rb @@ -21,12 +21,18 @@ class QueryBookmarksRequest < GetStream::BaseModel # @!attribute prev # @return [String] attr_accessor :prev + # @!attribute user_id + # @return [String] + attr_accessor :user_id # @!attribute sort # @return [Array] Sorting parameters for the query attr_accessor :sort # @!attribute filter # @return [Object] Filters to apply to the query attr_accessor :filter + # @!attribute user + # @return [UserRequest] + attr_accessor :user # Initialize with attributes def initialize(attributes = {}) @@ -35,8 +41,10 @@ def initialize(attributes = {}) @limit = attributes[:limit] || attributes['limit'] || nil @next = attributes[:next] || attributes['next'] || nil @prev = attributes[:prev] || attributes['prev'] || nil + @user_id = attributes[:user_id] || attributes['user_id'] || nil @sort = attributes[:sort] || attributes['sort'] || nil @filter = attributes[:filter] || attributes['filter'] || nil + @user = attributes[:user] || attributes['user'] || nil end # Override field mappings for JSON serialization @@ -46,8 +54,10 @@ def self.json_field_mappings limit: 'limit', next: 'next', prev: 'prev', + user_id: 'user_id', sort: 'sort', - filter: 'filter' + filter: 'filter', + user: 'user' } end end diff --git a/lib/getstream_ruby/generated/webhook.rb b/lib/getstream_ruby/generated/webhook.rb index 9d51101..beff35f 100644 --- a/lib/getstream_ruby/generated/webhook.rb +++ b/lib/getstream_ruby/generated/webhook.rb @@ -1,7 +1,10 @@ # Code generated by GetStream internal OpenAPI code generator. DO NOT EDIT. -require 'openssl' +require 'base64' require 'json' +require 'openssl' +require 'time' +require 'zlib' require_relative 'models/activity_added_event' require_relative 'models/activity_deleted_event' require_relative 'models/activity_feedback_event' @@ -169,6 +172,43 @@ module StreamChat module Webhook + # Raised for every webhook handling failure: signature mismatch, invalid + # JSON, missing/non-string +type+ field, gzip decompression failure, + # invalid base64 in a queue body, or a malformed SNS envelope. + # + # The unified class replaces the earlier split (separate signature vs. + # malformed errors): customers distinguish failure modes via the message + # substring or +cause+ chain rather than the class. + class InvalidWebhookError < StandardError; end + + # Returned by parse_event when the type discriminator is well-formed but + # unknown to this SDK version. + # + # Stable forward-compat surface: backend may add new event types before this + # SDK is regenerated. Switch on the returned object's class and include + # UnknownEvent as a fallback case. + # + # @!attribute [r] type + # @return [String] the unrecognized discriminator value + # @!attribute [r] created_at + # @return [Time, nil] parsed timestamp from the envelope, or nil if missing/unparseable + # @!attribute [r] raw + # @return [Hash] the full parsed JSON hash for inspection + class UnknownEvent + attr_reader :type, :created_at, :raw + + def initialize(type:, created_at: nil, raw: {}) + @type = type + @created_at = created_at + @raw = raw + end + end + + # gzip magic prefix (RFC 1952 §2.3.1). JSON cannot start with these bytes, + # so this gives unambiguous detection for Stream's always-JSON payloads. + GZIP_MAGIC = "\x1F\x8B".b.freeze + private_constant :GZIP_MAGIC + # Webhook event type constants EVENT_TYPE_WILDCARD = '*' EVENT_TYPE_APPEAL_ACCEPTED = 'appeal.accepted' @@ -349,6 +389,9 @@ def self.get_event_type(raw_event) # Deserialize a raw webhook payload into a typed event object. # + # Throws on unknown event types. For forward-compatible parsing that returns + # an {UnknownEvent} on unrecognized discriminators, use {parse_event}. + # # @param raw_event [String, Hash] The raw webhook payload # @return [Object] A typed event object corresponding to the event type # @raise [ArgumentError] if the event type is unknown or deserialization fails @@ -725,17 +768,180 @@ def self.parse_webhook_event(raw_event) # Verify the HMAC-SHA256 signature of a webhook payload. # - # @param body [String] The raw request body + # @param body [String] The raw request body (already-decompressed) # @param signature [String] The signature from the X-Signature header # @param secret [String] Your webhook secret (found in the Stream Dashboard) # @return [Boolean] true if the signature is valid, false otherwise def self.verify_signature(body, signature, secret) return false if signature.nil? || signature.bytesize != 64 - + expected = OpenSSL::HMAC.hexdigest('SHA256', secret, body) OpenSSL.fixed_length_secure_compare(signature, expected) rescue ArgumentError false end + + # Decompress the body if it is gzip-prefixed (first two bytes 0x1F 0x8B), + # else return the body bytes unchanged. + # + # Magic-byte detection is reliable for Stream payloads because Stream webhook + # bodies are always JSON, and JSON cannot start with 0x1F. + # + # @param body [String] raw body (binary-safe) + # @return [String] decompressed body, or the original body if not gzip-prefixed + # @raise [InvalidWebhookError] if body has the gzip magic prefix but + # isn't a valid gzip stream + def self.gunzip_payload(body) + raise InvalidWebhookError, 'body must be a String' unless body.is_a?(String) + + bytes = body.b + return bytes if bytes.bytesize < 2 || bytes.byteslice(0, 2) != GZIP_MAGIC + + Zlib.gunzip(bytes) + rescue Zlib::Error => e + raise InvalidWebhookError, "gzip decompression failed: #{e.message}" + end + + # base64-decode an SQS Message Body, then gunzip if gzip-prefixed. + # + # Forward-compat: today the backend emits plain JSON to SQS; once compression + # is extended to queue transports, bodies will be base64(gzip(json)). This + # helper handles both cases via the magic-byte detection in {gunzip_payload}. + # + # Note: if the input is plain JSON (not base64), strict base64 decoding will + # fail and raise InvalidWebhookError. Callers receiving today's plain-JSON + # SQS messages should call {parse_event} directly with the body string. + # + # @param message_body [String] + # @return [String] + # @raise [InvalidWebhookError] + def self.decode_sqs_payload(message_body) + raise InvalidWebhookError, 'message_body must be a String' unless message_body.is_a?(String) + + decoded = Base64.strict_decode64(message_body) + gunzip_payload(decoded) + rescue ArgumentError => e + raise InvalidWebhookError, "invalid base64: #{e.message}" + end + + # Extract the +Message+ field from a standard AWS SNS notification envelope, + # then base64-decode and gunzip. Envelope shape: + # {"Type":"Notification","Message":"","MessageId":"...","Timestamp":"...","TopicArn":"..."} + # + # @param notification_body [String] + # @return [String] + # @raise [InvalidWebhookError] + def self.decode_sns_payload(notification_body) + raise InvalidWebhookError, 'notification_body must be a String' unless notification_body.is_a?(String) + + env = JSON.parse(notification_body) + raise InvalidWebhookError, 'SNS envelope must be a JSON object' unless env.is_a?(Hash) + + msg = env['Message'] + raise InvalidWebhookError, "SNS envelope missing 'Message' string field" unless msg.is_a?(String) + + decode_sqs_payload(msg) + rescue JSON::ParserError => e + raise InvalidWebhookError, "invalid SNS envelope JSON: #{e.message}" + end + + # Parse a webhook payload and return the typed event for known discriminators + # or {UnknownEvent} for well-formed-but-unknown ones. + # + # Distinct from {parse_webhook_event}: parse_event returns an UnknownEvent on + # unrecognized discriminators (forward-compat); parse_webhook_event throws. + # + # @param payload [String] + # @return [Object] the typed event class instance or {UnknownEvent} + # @raise [InvalidWebhookError] for invalid JSON, missing/non-string type field, + # or known-type deserialization failure + def self.parse_event(payload) + raise InvalidWebhookError, 'payload must be a String' unless payload.is_a?(String) + raise InvalidWebhookError, 'payload must not be empty' if payload.empty? + + data = JSON.parse(payload) + raise InvalidWebhookError, 'webhook payload must be a JSON object' unless data.is_a?(Hash) + + event_type = data['type'] + unless event_type.is_a?(String) && !event_type.empty? + raise InvalidWebhookError, "webhook payload missing 'type' string field" + end + + event_class = event_class_for_type(event_type) + return build_unknown_event(event_type, data) if event_class.nil? + + begin + event_class.new(data) + rescue StandardError => e + raise InvalidWebhookError, "failed to deserialize event: #{e.message}" + end + rescue JSON::ParserError => e + raise InvalidWebhookError, "failed to parse webhook payload: #{e.message}" + end + + private_class_method def self.build_unknown_event(event_type, data) + created_raw = data['created_at'] + created_at = nil + if created_raw.is_a?(String) + begin + created_at = Time.iso8601(created_raw) + rescue ArgumentError + created_at = nil + end + end + UnknownEvent.new(type: event_type, created_at: created_at, raw: data) + end + + public + + # HTTP composite: gunzip (if gzip-prefixed) -> verify HMAC-SHA256 -> parse. + # + # The signature header is X-Signature. The signature is HMAC-SHA256 of the + # *uncompressed* JSON body, hex-encoded. Magic-byte detection means callers + # can pass either the raw HTTP body or already-decompressed bytes; both work. + # + # @param body [String] raw request body (possibly gzip-compressed) + # @param signature [String] X-Signature header value + # @param secret [String] webhook secret + # @return [Object] the typed event class instance or {UnknownEvent} + # @raise [InvalidWebhookError] for signature mismatches as well as + # parse/decompression failures; distinguish modes via the message + # substring + def self.verify_and_parse_webhook(body, signature, secret) + payload = gunzip_payload(body) + raise InvalidWebhookError, 'webhook signature mismatch' unless verify_signature(payload, signature, secret) + + parse_event(payload) + end + + # SQS composite: base64-decode -> gunzip (if gzip-prefixed) -> parse. + # + # The backend emits no signature attribute on SQS messages today; this helper + # therefore performs no signature verification. If a signed variant is added + # later, it will be a separate function rather than retrofitting this signature. + # + # @param message_body [String] + # @return [Object] the typed event class instance or {UnknownEvent} + # @raise [InvalidWebhookError] + def self.parse_sqs_payload(message_body) + parse_event(decode_sqs_payload(message_body)) + end + + # SNS composite: parse SNS envelope -> base64-decode -> gunzip -> parse. + # Same no-signature posture as {parse_sqs_payload}. + # + # @param notification_body [String] + # @return [Object] the typed event class instance or {UnknownEvent} + # @raise [InvalidWebhookError] + def self.parse_sns_payload(notification_body) + parse_event(decode_sns_payload(notification_body)) + end end end + +# Spec §7 alias: canonical name +Stream::Webhook+ aliases the existing +# +StreamChat::Webhook+ for one minor-version cycle. New callers should use +# the canonical name; existing callers continue to work unchanged. +module Stream + Webhook = StreamChat::Webhook +end diff --git a/test/fixtures/webhooks/_invalid/bad_base64/body.gz b/test/fixtures/webhooks/_invalid/bad_base64/body.gz new file mode 100644 index 0000000000000000000000000000000000000000..3c472d2a7e363be9cda340b934eee91576d5eeac GIT binary patch literal 79 zcmb2|=3oGW|Et2ZR_b_!ZVEc(tE=bdF|t-xKo$T3uo=Jr literal 0 HcmV?d00001 diff --git a/test/fixtures/webhooks/_invalid/bad_base64/body.json b/test/fixtures/webhooks/_invalid/bad_base64/body.json new file mode 100644 index 0000000..3c5e004 --- /dev/null +++ b/test/fixtures/webhooks/_invalid/bad_base64/body.json @@ -0,0 +1 @@ +{"type":"message.new","created_at":"2026-05-08T00:00:00Z"} \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/bad_base64/expected.json b/test/fixtures/webhooks/_invalid/bad_base64/expected.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/test/fixtures/webhooks/_invalid/bad_base64/expected.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/bad_base64/signature.txt b/test/fixtures/webhooks/_invalid/bad_base64/signature.txt new file mode 100644 index 0000000..f463836 --- /dev/null +++ b/test/fixtures/webhooks/_invalid/bad_base64/signature.txt @@ -0,0 +1 @@ +9e43985889d9d37c51c4bb92f6b814331edf8aa1b86ed7d3a8189acc93baabc8 \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/bad_base64/sns_notification.txt b/test/fixtures/webhooks/_invalid/bad_base64/sns_notification.txt new file mode 100644 index 0000000..d73f5e1 --- /dev/null +++ b/test/fixtures/webhooks/_invalid/bad_base64/sns_notification.txt @@ -0,0 +1 @@ +{"Message":"!!!not base64!!!","MessageId":"00000000-0000-0000-0000-000000000000","Timestamp":"2026-05-08T00:00:00.000Z","TopicArn":"arn:aws:sns:us-east-1:000000000000:fixture-topic","Type":"Notification"} \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/bad_base64/sqs_body.txt b/test/fixtures/webhooks/_invalid/bad_base64/sqs_body.txt new file mode 100644 index 0000000..36fbc6d --- /dev/null +++ b/test/fixtures/webhooks/_invalid/bad_base64/sqs_body.txt @@ -0,0 +1 @@ +!!!not base64!!! \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/bad_base64/sqs_body_uncompressed.txt b/test/fixtures/webhooks/_invalid/bad_base64/sqs_body_uncompressed.txt new file mode 100644 index 0000000..66f5ff0 --- /dev/null +++ b/test/fixtures/webhooks/_invalid/bad_base64/sqs_body_uncompressed.txt @@ -0,0 +1 @@ +eyJ0eXBlIjoibWVzc2FnZS5uZXciLCJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wOFQwMDowMDowMFoifQ== \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/bad_compression/body.gz b/test/fixtures/webhooks/_invalid/bad_compression/body.gz new file mode 100644 index 0000000000000000000000000000000000000000..ff795056e7c0a92043712f6e6bebbc091e1ce5fb GIT binary patch literal 16 Pcmb2|=3sz;2rvKu5zPX_ literal 0 HcmV?d00001 diff --git a/test/fixtures/webhooks/_invalid/bad_compression/body.json b/test/fixtures/webhooks/_invalid/bad_compression/body.json new file mode 100644 index 0000000..3c5e004 --- /dev/null +++ b/test/fixtures/webhooks/_invalid/bad_compression/body.json @@ -0,0 +1 @@ +{"type":"message.new","created_at":"2026-05-08T00:00:00Z"} \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/bad_compression/expected.json b/test/fixtures/webhooks/_invalid/bad_compression/expected.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/test/fixtures/webhooks/_invalid/bad_compression/expected.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/bad_compression/signature.txt b/test/fixtures/webhooks/_invalid/bad_compression/signature.txt new file mode 100644 index 0000000..bc2cba8 --- /dev/null +++ b/test/fixtures/webhooks/_invalid/bad_compression/signature.txt @@ -0,0 +1 @@ +fdd94f434124de48ef9f6237234ce28fd965fcb0f20b3f739f47d42aa17eb7b2 \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/bad_compression/sns_notification.txt b/test/fixtures/webhooks/_invalid/bad_compression/sns_notification.txt new file mode 100644 index 0000000..7d10e3a --- /dev/null +++ b/test/fixtures/webhooks/_invalid/bad_compression/sns_notification.txt @@ -0,0 +1 @@ +{"Message":"H4sIAAAAAAAAAFhYWFhYWA==","MessageId":"00000000-0000-0000-0000-000000000000","Timestamp":"2026-05-08T00:00:00.000Z","TopicArn":"arn:aws:sns:us-east-1:000000000000:fixture-topic","Type":"Notification"} \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/bad_compression/sqs_body.txt b/test/fixtures/webhooks/_invalid/bad_compression/sqs_body.txt new file mode 100644 index 0000000..1ac8964 --- /dev/null +++ b/test/fixtures/webhooks/_invalid/bad_compression/sqs_body.txt @@ -0,0 +1 @@ +H4sIAAAAAAAAAFhYWFhYWA== \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/bad_compression/sqs_body_uncompressed.txt b/test/fixtures/webhooks/_invalid/bad_compression/sqs_body_uncompressed.txt new file mode 100644 index 0000000..66f5ff0 --- /dev/null +++ b/test/fixtures/webhooks/_invalid/bad_compression/sqs_body_uncompressed.txt @@ -0,0 +1 @@ +eyJ0eXBlIjoibWVzc2FnZS5uZXciLCJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wOFQwMDowMDowMFoifQ== \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/bad_sns_envelope/body.gz b/test/fixtures/webhooks/_invalid/bad_sns_envelope/body.gz new file mode 100644 index 0000000000000000000000000000000000000000..3c472d2a7e363be9cda340b934eee91576d5eeac GIT binary patch literal 79 zcmb2|=3oGW|Et2ZR_b_!ZVEc(tE=bdF|t-xKo$T3uo=Jr literal 0 HcmV?d00001 diff --git a/test/fixtures/webhooks/_invalid/bad_sns_envelope/body.json b/test/fixtures/webhooks/_invalid/bad_sns_envelope/body.json new file mode 100644 index 0000000..3c5e004 --- /dev/null +++ b/test/fixtures/webhooks/_invalid/bad_sns_envelope/body.json @@ -0,0 +1 @@ +{"type":"message.new","created_at":"2026-05-08T00:00:00Z"} \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/bad_sns_envelope/expected.json b/test/fixtures/webhooks/_invalid/bad_sns_envelope/expected.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/test/fixtures/webhooks/_invalid/bad_sns_envelope/expected.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/bad_sns_envelope/signature.txt b/test/fixtures/webhooks/_invalid/bad_sns_envelope/signature.txt new file mode 100644 index 0000000..f463836 --- /dev/null +++ b/test/fixtures/webhooks/_invalid/bad_sns_envelope/signature.txt @@ -0,0 +1 @@ +9e43985889d9d37c51c4bb92f6b814331edf8aa1b86ed7d3a8189acc93baabc8 \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/bad_sns_envelope/sns_notification.txt b/test/fixtures/webhooks/_invalid/bad_sns_envelope/sns_notification.txt new file mode 100644 index 0000000..f2aa208 --- /dev/null +++ b/test/fixtures/webhooks/_invalid/bad_sns_envelope/sns_notification.txt @@ -0,0 +1 @@ +{"Type":"Notification"} \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/bad_sns_envelope/sqs_body.txt b/test/fixtures/webhooks/_invalid/bad_sns_envelope/sqs_body.txt new file mode 100644 index 0000000..9f3042b --- /dev/null +++ b/test/fixtures/webhooks/_invalid/bad_sns_envelope/sqs_body.txt @@ -0,0 +1 @@ +H4sIAAAAAAAA/6pWKqksSFWyUspNLS5OTE/Vy0stV9JRSi5KTSxJTYlPLFGyUjIyMDLTNTDVNbAIMTCwAqMopVpAAAAA//+PXB06OgAAAA== \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/bad_sns_envelope/sqs_body_uncompressed.txt b/test/fixtures/webhooks/_invalid/bad_sns_envelope/sqs_body_uncompressed.txt new file mode 100644 index 0000000..66f5ff0 --- /dev/null +++ b/test/fixtures/webhooks/_invalid/bad_sns_envelope/sqs_body_uncompressed.txt @@ -0,0 +1 @@ +eyJ0eXBlIjoibWVzc2FnZS5uZXciLCJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wOFQwMDowMDowMFoifQ== \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/empty_body/body.gz b/test/fixtures/webhooks/_invalid/empty_body/body.gz new file mode 100644 index 0000000000000000000000000000000000000000..1f0aab1d2342c4a03cc29a0a4632534dd8c2f1ee GIT binary patch literal 23 Wcmb2|=3oGW|BMU_|NleS3=9A;W&^PR literal 0 HcmV?d00001 diff --git a/test/fixtures/webhooks/_invalid/empty_body/body.json b/test/fixtures/webhooks/_invalid/empty_body/body.json new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/webhooks/_invalid/empty_body/expected.json b/test/fixtures/webhooks/_invalid/empty_body/expected.json new file mode 100644 index 0000000..e6b1496 --- /dev/null +++ b/test/fixtures/webhooks/_invalid/empty_body/expected.json @@ -0,0 +1,6 @@ +{ + "fields": { + "created_at": "2026-05-08T00:00:00Z" + }, + "type": "" +} \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/empty_body/signature.txt b/test/fixtures/webhooks/_invalid/empty_body/signature.txt new file mode 100644 index 0000000..dc24308 --- /dev/null +++ b/test/fixtures/webhooks/_invalid/empty_body/signature.txt @@ -0,0 +1 @@ +ff0affd4e69c01904d58894b1d0ffac80fb093b36fa32ee2879ded7801a29578 \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/empty_body/sns_notification.txt b/test/fixtures/webhooks/_invalid/empty_body/sns_notification.txt new file mode 100644 index 0000000..39da5fd --- /dev/null +++ b/test/fixtures/webhooks/_invalid/empty_body/sns_notification.txt @@ -0,0 +1 @@ +{"Message":"H4sIAAAAAAAA/wEAAP//AAAAAAAAAAA=","MessageId":"00000000-0000-0000-0000-000000000000","Timestamp":"2026-05-08T00:00:00.000Z","TopicArn":"arn:aws:sns:us-east-1:000000000000:fixture-topic","Type":"Notification"} \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/empty_body/sqs_body.txt b/test/fixtures/webhooks/_invalid/empty_body/sqs_body.txt new file mode 100644 index 0000000..cc33db9 --- /dev/null +++ b/test/fixtures/webhooks/_invalid/empty_body/sqs_body.txt @@ -0,0 +1 @@ +H4sIAAAAAAAA/wEAAP//AAAAAAAAAAA= \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/empty_body/sqs_body_uncompressed.txt b/test/fixtures/webhooks/_invalid/empty_body/sqs_body_uncompressed.txt new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/webhooks/_invalid/malformed_json/body.gz b/test/fixtures/webhooks/_invalid/malformed_json/body.gz new file mode 100644 index 0000000000000000000000000000000000000000..a2798f7b0e3229b983840bfd4e418134b0f9b0cc GIT binary patch literal 39 rcmb2|=3oGW|EtcO)(_P1K6BD1@PyX6^UN#^4FCThxV`2hKTsY34-O9X literal 0 HcmV?d00001 diff --git a/test/fixtures/webhooks/_invalid/malformed_json/body.json b/test/fixtures/webhooks/_invalid/malformed_json/body.json new file mode 100644 index 0000000..8dc5858 --- /dev/null +++ b/test/fixtures/webhooks/_invalid/malformed_json/body.json @@ -0,0 +1 @@ +{not valid json \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/malformed_json/expected.json b/test/fixtures/webhooks/_invalid/malformed_json/expected.json new file mode 100644 index 0000000..e6b1496 --- /dev/null +++ b/test/fixtures/webhooks/_invalid/malformed_json/expected.json @@ -0,0 +1,6 @@ +{ + "fields": { + "created_at": "2026-05-08T00:00:00Z" + }, + "type": "" +} \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/malformed_json/signature.txt b/test/fixtures/webhooks/_invalid/malformed_json/signature.txt new file mode 100644 index 0000000..da3ad90 --- /dev/null +++ b/test/fixtures/webhooks/_invalid/malformed_json/signature.txt @@ -0,0 +1 @@ +ab763ae1d822a7035b0c97d61cebd33ed83ad6011cc77bcc8b9873d32df86854 \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/malformed_json/sns_notification.txt b/test/fixtures/webhooks/_invalid/malformed_json/sns_notification.txt new file mode 100644 index 0000000..72cee88 --- /dev/null +++ b/test/fixtures/webhooks/_invalid/malformed_json/sns_notification.txt @@ -0,0 +1 @@ +{"Message":"H4sIAAAAAAAA/6rOyy9RKEvMyUxRyCrOzwMEAAD//8DbrPEPAAAA","MessageId":"00000000-0000-0000-0000-000000000000","Timestamp":"2026-05-08T00:00:00.000Z","TopicArn":"arn:aws:sns:us-east-1:000000000000:fixture-topic","Type":"Notification"} \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/malformed_json/sqs_body.txt b/test/fixtures/webhooks/_invalid/malformed_json/sqs_body.txt new file mode 100644 index 0000000..99bad10 --- /dev/null +++ b/test/fixtures/webhooks/_invalid/malformed_json/sqs_body.txt @@ -0,0 +1 @@ +H4sIAAAAAAAA/6rOyy9RKEvMyUxRyCrOzwMEAAD//8DbrPEPAAAA \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/malformed_json/sqs_body_uncompressed.txt b/test/fixtures/webhooks/_invalid/malformed_json/sqs_body_uncompressed.txt new file mode 100644 index 0000000..80c0d30 --- /dev/null +++ b/test/fixtures/webhooks/_invalid/malformed_json/sqs_body_uncompressed.txt @@ -0,0 +1 @@ +e25vdCB2YWxpZCBqc29u \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/missing_type/body.gz b/test/fixtures/webhooks/_invalid/missing_type/body.gz new file mode 100644 index 0000000000000000000000000000000000000000..accd9f1fb8920ce9948b0ece041ad053e2bfb6da GIT binary patch literal 58 zcmb2|=3oGW|Et2h^t^m^JbgR;bpkg985tQET{bniYPx~L&|m}8VvVIy4nT$f|2O?R JyIK{<0s!$C64U?y literal 0 HcmV?d00001 diff --git a/test/fixtures/webhooks/_invalid/missing_type/body.json b/test/fixtures/webhooks/_invalid/missing_type/body.json new file mode 100644 index 0000000..6dd72c7 --- /dev/null +++ b/test/fixtures/webhooks/_invalid/missing_type/body.json @@ -0,0 +1 @@ +{"created_at":"2026-05-08T00:00:00Z"} \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/missing_type/expected.json b/test/fixtures/webhooks/_invalid/missing_type/expected.json new file mode 100644 index 0000000..e6b1496 --- /dev/null +++ b/test/fixtures/webhooks/_invalid/missing_type/expected.json @@ -0,0 +1,6 @@ +{ + "fields": { + "created_at": "2026-05-08T00:00:00Z" + }, + "type": "" +} \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/missing_type/signature.txt b/test/fixtures/webhooks/_invalid/missing_type/signature.txt new file mode 100644 index 0000000..c899669 --- /dev/null +++ b/test/fixtures/webhooks/_invalid/missing_type/signature.txt @@ -0,0 +1 @@ +0af44ad112e7a090278f4eacbf342504c13f3bf7368544f3c029b6a2e82cf7ac \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/missing_type/sns_notification.txt b/test/fixtures/webhooks/_invalid/missing_type/sns_notification.txt new file mode 100644 index 0000000..6cba666 --- /dev/null +++ b/test/fixtures/webhooks/_invalid/missing_type/sns_notification.txt @@ -0,0 +1 @@ +{"Message":"H4sIAAAAAAAA/6pWSi5KTSxJTYlPLFGyUjIyMDLTNTDVNbAIMTCwAqMopVpAAAAA//+C/s2rJQAAAA==","MessageId":"00000000-0000-0000-0000-000000000000","Timestamp":"2026-05-08T00:00:00.000Z","TopicArn":"arn:aws:sns:us-east-1:000000000000:fixture-topic","Type":"Notification"} \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/missing_type/sqs_body.txt b/test/fixtures/webhooks/_invalid/missing_type/sqs_body.txt new file mode 100644 index 0000000..2a7491b --- /dev/null +++ b/test/fixtures/webhooks/_invalid/missing_type/sqs_body.txt @@ -0,0 +1 @@ +H4sIAAAAAAAA/6pWSi5KTSxJTYlPLFGyUjIyMDLTNTDVNbAIMTCwAqMopVpAAAAA//+C/s2rJQAAAA== \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/missing_type/sqs_body_uncompressed.txt b/test/fixtures/webhooks/_invalid/missing_type/sqs_body_uncompressed.txt new file mode 100644 index 0000000..414ed50 --- /dev/null +++ b/test/fixtures/webhooks/_invalid/missing_type/sqs_body_uncompressed.txt @@ -0,0 +1 @@ +eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wOFQwMDowMDowMFoifQ== \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/tampered_body/body.gz b/test/fixtures/webhooks/_invalid/tampered_body/body.gz new file mode 100644 index 0000000000000000000000000000000000000000..aa3a9001ad94b4d28654d3513731b0ab94230783 GIT binary patch literal 95 zcmb2|=3oGW|Et2ZR_b_!ZVEc(tE=bd7E3iQ(R^40P5?ey0P+!SPF jWMFjJ)ZnV=1`b1m4NQwQmPR=MP5A$RO-JbkJ0J@H&O9C1 literal 0 HcmV?d00001 diff --git a/test/fixtures/webhooks/_invalid/unknown_type/body.json b/test/fixtures/webhooks/_invalid/unknown_type/body.json new file mode 100644 index 0000000..612a640 --- /dev/null +++ b/test/fixtures/webhooks/_invalid/unknown_type/body.json @@ -0,0 +1 @@ +{"type":"totally.made.up","created_at":"2026-05-08T00:00:00Z"} \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/unknown_type/expected.json b/test/fixtures/webhooks/_invalid/unknown_type/expected.json new file mode 100644 index 0000000..0bdda8c --- /dev/null +++ b/test/fixtures/webhooks/_invalid/unknown_type/expected.json @@ -0,0 +1,6 @@ +{ + "fields": { + "created_at": "2026-05-08T00:00:00Z" + }, + "type": "totally.made.up" +} \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/unknown_type/signature.txt b/test/fixtures/webhooks/_invalid/unknown_type/signature.txt new file mode 100644 index 0000000..6c0d872 --- /dev/null +++ b/test/fixtures/webhooks/_invalid/unknown_type/signature.txt @@ -0,0 +1 @@ +fec459361cdb0d6c0c19ebe323dc37bf0b47c23172e6840260fe66b9b6fdb876 \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/unknown_type/sns_notification.txt b/test/fixtures/webhooks/_invalid/unknown_type/sns_notification.txt new file mode 100644 index 0000000..e0a95ca --- /dev/null +++ b/test/fixtures/webhooks/_invalid/unknown_type/sns_notification.txt @@ -0,0 +1 @@ +{"Message":"H4sIAAAAAAAA/6pWKqksSFWyUirJL0nMyanUy01MSdUrLVDSUUouSk0sSU2JTyxRslIyMjAy0zUw1TWwCDEwsAKjKKVaQAAAAP//rIh10D4AAAA=","MessageId":"00000000-0000-0000-0000-000000000000","Timestamp":"2026-05-08T00:00:00.000Z","TopicArn":"arn:aws:sns:us-east-1:000000000000:fixture-topic","Type":"Notification"} \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/unknown_type/sqs_body.txt b/test/fixtures/webhooks/_invalid/unknown_type/sqs_body.txt new file mode 100644 index 0000000..e06298c --- /dev/null +++ b/test/fixtures/webhooks/_invalid/unknown_type/sqs_body.txt @@ -0,0 +1 @@ +H4sIAAAAAAAA/6pWKqksSFWyUirJL0nMyanUy01MSdUrLVDSUUouSk0sSU2JTyxRslIyMjAy0zUw1TWwCDEwsAKjKKVaQAAAAP//rIh10D4AAAA= \ No newline at end of file diff --git a/test/fixtures/webhooks/_invalid/unknown_type/sqs_body_uncompressed.txt b/test/fixtures/webhooks/_invalid/unknown_type/sqs_body_uncompressed.txt new file mode 100644 index 0000000..ba70b33 --- /dev/null +++ b/test/fixtures/webhooks/_invalid/unknown_type/sqs_body_uncompressed.txt @@ -0,0 +1 @@ +eyJ0eXBlIjoidG90YWxseS5tYWRlLnVwIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDUtMDhUMDA6MDA6MDBaIn0= \ No newline at end of file diff --git a/test/fixtures/webhooks/call.session_ended/body.gz b/test/fixtures/webhooks/call.session_ended/body.gz new file mode 100644 index 0000000000000000000000000000000000000000..416a2ca55be0ac388ee9524323b963738939c5fa GIT binary patch literal 86 zcmb2|=3oGW|Et2ZR_b_!ZVK}9J9F})wx6!vxijax{m*)P`UYMK^wRV4)$#Q0^w$a8 m6l7#%V078k;Hv2c4nu!3L(q8cU-bfX4j)|JpsXz!}H_06fYbaR2}S literal 0 HcmV?d00001 diff --git a/test/fixtures/webhooks/call.session_started/body.json b/test/fixtures/webhooks/call.session_started/body.json new file mode 100644 index 0000000..890ce83 --- /dev/null +++ b/test/fixtures/webhooks/call.session_started/body.json @@ -0,0 +1 @@ +{"type":"call.session_started","created_at":"2026-05-08T00:00:00Z"} \ No newline at end of file diff --git a/test/fixtures/webhooks/call.session_started/expected.json b/test/fixtures/webhooks/call.session_started/expected.json new file mode 100644 index 0000000..19ab9a9 --- /dev/null +++ b/test/fixtures/webhooks/call.session_started/expected.json @@ -0,0 +1,6 @@ +{ + "fields": { + "created_at": "2026-05-08T00:00:00Z" + }, + "type": "call.session_started" +} \ No newline at end of file diff --git a/test/fixtures/webhooks/call.session_started/signature.txt b/test/fixtures/webhooks/call.session_started/signature.txt new file mode 100644 index 0000000..78bf403 --- /dev/null +++ b/test/fixtures/webhooks/call.session_started/signature.txt @@ -0,0 +1 @@ +fb227f610f1c65f9e8da88a29215dc88c20c8b8906ff9efe0dac2dd018fc3288 \ No newline at end of file diff --git a/test/fixtures/webhooks/call.session_started/sns_notification.txt b/test/fixtures/webhooks/call.session_started/sns_notification.txt new file mode 100644 index 0000000..646af21 --- /dev/null +++ b/test/fixtures/webhooks/call.session_started/sns_notification.txt @@ -0,0 +1 @@ +{"Message":"H4sIAAAAAAAA/6pWKqksSFWyUkpOzMnRK04tLs7Mz4svLkksKklNUdJRSi5KTSxJTYlPLFGyUjIyMDLTNTDVNbAIMTCwAqMopVpAAAAA///rR2lwQwAAAA==","MessageId":"00000000-0000-0000-0000-000000000000","Timestamp":"2026-05-08T00:00:00.000Z","TopicArn":"arn:aws:sns:us-east-1:000000000000:fixture-topic","Type":"Notification"} \ No newline at end of file diff --git a/test/fixtures/webhooks/call.session_started/sqs_body.txt b/test/fixtures/webhooks/call.session_started/sqs_body.txt new file mode 100644 index 0000000..289e271 --- /dev/null +++ b/test/fixtures/webhooks/call.session_started/sqs_body.txt @@ -0,0 +1 @@ +H4sIAAAAAAAA/6pWKqksSFWyUkpOzMnRK04tLs7Mz4svLkksKklNUdJRSi5KTSxJTYlPLFGyUjIyMDLTNTDVNbAIMTCwAqMopVpAAAAA///rR2lwQwAAAA== \ No newline at end of file diff --git a/test/fixtures/webhooks/call.session_started/sqs_body_uncompressed.txt b/test/fixtures/webhooks/call.session_started/sqs_body_uncompressed.txt new file mode 100644 index 0000000..0f4bfcb --- /dev/null +++ b/test/fixtures/webhooks/call.session_started/sqs_body_uncompressed.txt @@ -0,0 +1 @@ +eyJ0eXBlIjoiY2FsbC5zZXNzaW9uX3N0YXJ0ZWQiLCJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wOFQwMDowMDowMFoifQ== \ No newline at end of file diff --git a/test/fixtures/webhooks/channel.created/body.gz b/test/fixtures/webhooks/channel.created/body.gz new file mode 100644 index 0000000000000000000000000000000000000000..17b436745b5fd730f44e723d70667c98cd7454ee GIT binary patch literal 77 zcmb2|=3oGW|Et2ZR_b_!ZVK``=W*t=_t}fydS1Rdp1y&X0-H8H684_5W=aoF&zTZl epHf~OCmx!K&=dem>>Pyv literal 0 HcmV?d00001 diff --git a/test/fixtures/webhooks/channel.created/body.json b/test/fixtures/webhooks/channel.created/body.json new file mode 100644 index 0000000..29f7752 --- /dev/null +++ b/test/fixtures/webhooks/channel.created/body.json @@ -0,0 +1 @@ +{"type":"channel.created","created_at":"2026-05-08T00:00:00Z"} \ No newline at end of file diff --git a/test/fixtures/webhooks/channel.created/expected.json b/test/fixtures/webhooks/channel.created/expected.json new file mode 100644 index 0000000..bd277f5 --- /dev/null +++ b/test/fixtures/webhooks/channel.created/expected.json @@ -0,0 +1,6 @@ +{ + "fields": { + "created_at": "2026-05-08T00:00:00Z" + }, + "type": "channel.created" +} \ No newline at end of file diff --git a/test/fixtures/webhooks/channel.created/signature.txt b/test/fixtures/webhooks/channel.created/signature.txt new file mode 100644 index 0000000..fd0348d --- /dev/null +++ b/test/fixtures/webhooks/channel.created/signature.txt @@ -0,0 +1 @@ +55aba7ebf3429a1353b7280a217e8e8572502551affe8e5e4eca64071f6a45f3 \ No newline at end of file diff --git a/test/fixtures/webhooks/channel.created/sns_notification.txt b/test/fixtures/webhooks/channel.created/sns_notification.txt new file mode 100644 index 0000000..0f7173b --- /dev/null +++ b/test/fixtures/webhooks/channel.created/sns_notification.txt @@ -0,0 +1 @@ +{"Message":"H4sIAAAAAAAA/6pWKqksSFWyUkrOSMzLS83RSy5KTSxJTVHSUYKy4hNLlKyUjAyMzHQNTHUNLEIMDKzAKEqpFhAAAP//beAAyj4AAAA=","MessageId":"00000000-0000-0000-0000-000000000000","Timestamp":"2026-05-08T00:00:00.000Z","TopicArn":"arn:aws:sns:us-east-1:000000000000:fixture-topic","Type":"Notification"} \ No newline at end of file diff --git a/test/fixtures/webhooks/channel.created/sqs_body.txt b/test/fixtures/webhooks/channel.created/sqs_body.txt new file mode 100644 index 0000000..c8edaff --- /dev/null +++ b/test/fixtures/webhooks/channel.created/sqs_body.txt @@ -0,0 +1 @@ +H4sIAAAAAAAA/6pWKqksSFWyUkrOSMzLS83RSy5KTSxJTVHSUYKy4hNLlKyUjAyMzHQNTHUNLEIMDKzAKEqpFhAAAP//beAAyj4AAAA= \ No newline at end of file diff --git a/test/fixtures/webhooks/channel.created/sqs_body_uncompressed.txt b/test/fixtures/webhooks/channel.created/sqs_body_uncompressed.txt new file mode 100644 index 0000000..e37ba62 --- /dev/null +++ b/test/fixtures/webhooks/channel.created/sqs_body_uncompressed.txt @@ -0,0 +1 @@ +eyJ0eXBlIjoiY2hhbm5lbC5jcmVhdGVkIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDUtMDhUMDA6MDA6MDBaIn0= \ No newline at end of file diff --git a/test/fixtures/webhooks/channel.deleted/body.gz b/test/fixtures/webhooks/channel.deleted/body.gz new file mode 100644 index 0000000000000000000000000000000000000000..3626f5240fdac62cf8e7ed79398d22866e7e76b7 GIT binary patch literal 83 zcmb2|=3oGW|Et2ZR_b_!ZVK``=W*t=_t}fyo@YIEJ$(Z&1$yau`RaK3cKYiCZVECo jGBCPqYH-zb1Bao(2ByUtOQRfsCj9>&>$K>L9gqb8%=jIP literal 0 HcmV?d00001 diff --git a/test/fixtures/webhooks/channel.deleted/body.json b/test/fixtures/webhooks/channel.deleted/body.json new file mode 100644 index 0000000..c828830 --- /dev/null +++ b/test/fixtures/webhooks/channel.deleted/body.json @@ -0,0 +1 @@ +{"type":"channel.deleted","created_at":"2026-05-08T00:00:00Z"} \ No newline at end of file diff --git a/test/fixtures/webhooks/channel.deleted/expected.json b/test/fixtures/webhooks/channel.deleted/expected.json new file mode 100644 index 0000000..4bbe53d --- /dev/null +++ b/test/fixtures/webhooks/channel.deleted/expected.json @@ -0,0 +1,6 @@ +{ + "fields": { + "created_at": "2026-05-08T00:00:00Z" + }, + "type": "channel.deleted" +} \ No newline at end of file diff --git a/test/fixtures/webhooks/channel.deleted/signature.txt b/test/fixtures/webhooks/channel.deleted/signature.txt new file mode 100644 index 0000000..ecfb466 --- /dev/null +++ b/test/fixtures/webhooks/channel.deleted/signature.txt @@ -0,0 +1 @@ +392ccc998b554a812c0a88888842dae42a788e59af1aaef8f13a9a62f1b05f6e \ No newline at end of file diff --git a/test/fixtures/webhooks/channel.deleted/sns_notification.txt b/test/fixtures/webhooks/channel.deleted/sns_notification.txt new file mode 100644 index 0000000..4991f20 --- /dev/null +++ b/test/fixtures/webhooks/channel.deleted/sns_notification.txt @@ -0,0 +1 @@ +{"Message":"H4sIAAAAAAAA/6pWKqksSFWyUkrOSMzLS83RS0nNSS1JTVHSUUouSk0sSU2JTyxRslIyMjAy0zUw1TWwCDEwsAKjKKVaQAAAAP//XUKi9D4AAAA=","MessageId":"00000000-0000-0000-0000-000000000000","Timestamp":"2026-05-08T00:00:00.000Z","TopicArn":"arn:aws:sns:us-east-1:000000000000:fixture-topic","Type":"Notification"} \ No newline at end of file diff --git a/test/fixtures/webhooks/channel.deleted/sqs_body.txt b/test/fixtures/webhooks/channel.deleted/sqs_body.txt new file mode 100644 index 0000000..df8a408 --- /dev/null +++ b/test/fixtures/webhooks/channel.deleted/sqs_body.txt @@ -0,0 +1 @@ +H4sIAAAAAAAA/6pWKqksSFWyUkrOSMzLS83RS0nNSS1JTVHSUUouSk0sSU2JTyxRslIyMjAy0zUw1TWwCDEwsAKjKKVaQAAAAP//XUKi9D4AAAA= \ No newline at end of file diff --git a/test/fixtures/webhooks/channel.deleted/sqs_body_uncompressed.txt b/test/fixtures/webhooks/channel.deleted/sqs_body_uncompressed.txt new file mode 100644 index 0000000..523d431 --- /dev/null +++ b/test/fixtures/webhooks/channel.deleted/sqs_body_uncompressed.txt @@ -0,0 +1 @@ +eyJ0eXBlIjoiY2hhbm5lbC5kZWxldGVkIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDUtMDhUMDA6MDA6MDBaIn0= \ No newline at end of file diff --git a/test/fixtures/webhooks/channel.updated/body.gz b/test/fixtures/webhooks/channel.updated/body.gz new file mode 100644 index 0000000000000000000000000000000000000000..602cddc5f25ee9bc0d5e995215ea131483d705b3 GIT binary patch literal 80 zcmb2|=3oGW|Et2ZR_b_!ZVK``=W*t=_t}fux*nc7p1y&X0=@LSST{Zr_MWn4N)J!Z hnG#-~QeGV=9-cJ^G`v=d2{17H|8Hg?Io}Rw5CC!J8?gWY literal 0 HcmV?d00001 diff --git a/test/fixtures/webhooks/channel.updated/body.json b/test/fixtures/webhooks/channel.updated/body.json new file mode 100644 index 0000000..dbe1764 --- /dev/null +++ b/test/fixtures/webhooks/channel.updated/body.json @@ -0,0 +1 @@ +{"type":"channel.updated","created_at":"2026-05-08T00:00:00Z"} \ No newline at end of file diff --git a/test/fixtures/webhooks/channel.updated/expected.json b/test/fixtures/webhooks/channel.updated/expected.json new file mode 100644 index 0000000..d864720 --- /dev/null +++ b/test/fixtures/webhooks/channel.updated/expected.json @@ -0,0 +1,6 @@ +{ + "fields": { + "created_at": "2026-05-08T00:00:00Z" + }, + "type": "channel.updated" +} \ No newline at end of file diff --git a/test/fixtures/webhooks/channel.updated/signature.txt b/test/fixtures/webhooks/channel.updated/signature.txt new file mode 100644 index 0000000..f03fc7a --- /dev/null +++ b/test/fixtures/webhooks/channel.updated/signature.txt @@ -0,0 +1 @@ +6203925bfdbe21df850d9b2cd3ffde3962cfbbfaa8812d5f81cdfafdf4a53afd \ No newline at end of file diff --git a/test/fixtures/webhooks/channel.updated/sns_notification.txt b/test/fixtures/webhooks/channel.updated/sns_notification.txt new file mode 100644 index 0000000..3a8a15d --- /dev/null +++ b/test/fixtures/webhooks/channel.updated/sns_notification.txt @@ -0,0 +1 @@ +{"Message":"H4sIAAAAAAAA/6pWKqksSFWyUkrOSMzLS83RKy1ISSxJTVHSUUouSgWx4hNLlKyUjAyMzHQNTHUNLEIMDKzAKEqpFhAAAP//NjgZnz4AAAA=","MessageId":"00000000-0000-0000-0000-000000000000","Timestamp":"2026-05-08T00:00:00.000Z","TopicArn":"arn:aws:sns:us-east-1:000000000000:fixture-topic","Type":"Notification"} \ No newline at end of file diff --git a/test/fixtures/webhooks/channel.updated/sqs_body.txt b/test/fixtures/webhooks/channel.updated/sqs_body.txt new file mode 100644 index 0000000..34d9b4c --- /dev/null +++ b/test/fixtures/webhooks/channel.updated/sqs_body.txt @@ -0,0 +1 @@ +H4sIAAAAAAAA/6pWKqksSFWyUkrOSMzLS83RKy1ISSxJTVHSUUouSgWx4hNLlKyUjAyMzHQNTHUNLEIMDKzAKEqpFhAAAP//NjgZnz4AAAA= \ No newline at end of file diff --git a/test/fixtures/webhooks/channel.updated/sqs_body_uncompressed.txt b/test/fixtures/webhooks/channel.updated/sqs_body_uncompressed.txt new file mode 100644 index 0000000..6d53b80 --- /dev/null +++ b/test/fixtures/webhooks/channel.updated/sqs_body_uncompressed.txt @@ -0,0 +1 @@ +eyJ0eXBlIjoiY2hhbm5lbC51cGRhdGVkIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDUtMDhUMDA6MDA6MDBaIn0= \ No newline at end of file diff --git a/test/fixtures/webhooks/feeds.activity.added/body.gz b/test/fixtures/webhooks/feeds.activity.added/body.gz new file mode 100644 index 0000000000000000000000000000000000000000..79db36444e15573d8526473060d1cb1cee619aa2 GIT binary patch literal 88 zcmb2|=3oGW|Et2ZR_b_!ZVK}9_Vv}g=Ix_*Qs=bJ$}8SJo}Rvemjb=?ynJ;$eLMYi o0yhO285tN|HZ{0vx`D&cU<1=)jipfzKx6*@|Fm_&D`y}J02jj_0RR91 literal 0 HcmV?d00001 diff --git a/test/fixtures/webhooks/feeds.activity.added/body.json b/test/fixtures/webhooks/feeds.activity.added/body.json new file mode 100644 index 0000000..653ca14 --- /dev/null +++ b/test/fixtures/webhooks/feeds.activity.added/body.json @@ -0,0 +1 @@ +{"type":"feeds.activity.added","created_at":"2026-05-08T00:00:00Z"} \ No newline at end of file diff --git a/test/fixtures/webhooks/feeds.activity.added/expected.json b/test/fixtures/webhooks/feeds.activity.added/expected.json new file mode 100644 index 0000000..f5ffece --- /dev/null +++ b/test/fixtures/webhooks/feeds.activity.added/expected.json @@ -0,0 +1,6 @@ +{ + "fields": { + "created_at": "2026-05-08T00:00:00Z" + }, + "type": "feeds.activity.added" +} \ No newline at end of file diff --git a/test/fixtures/webhooks/feeds.activity.added/signature.txt b/test/fixtures/webhooks/feeds.activity.added/signature.txt new file mode 100644 index 0000000..81ea6f4 --- /dev/null +++ b/test/fixtures/webhooks/feeds.activity.added/signature.txt @@ -0,0 +1 @@ +36ac7583d9d0bada766e9831912b848345bb2f38eaa718a82b48604733b88f7e \ No newline at end of file diff --git a/test/fixtures/webhooks/feeds.activity.added/sns_notification.txt b/test/fixtures/webhooks/feeds.activity.added/sns_notification.txt new file mode 100644 index 0000000..96fc9d9 --- /dev/null +++ b/test/fixtures/webhooks/feeds.activity.added/sns_notification.txt @@ -0,0 +1 @@ +{"Message":"H4sIAAAAAAAA/6pWKqksSFWyUkpLTU0p1ktMLsksyyyp1EtMSUlNUdJRSi5KTSxJTYlPLFGyUjIyMDLTNTDVNbAIMTCwAqMopVpAAAAA///ytZDqQwAAAA==","MessageId":"00000000-0000-0000-0000-000000000000","Timestamp":"2026-05-08T00:00:00.000Z","TopicArn":"arn:aws:sns:us-east-1:000000000000:fixture-topic","Type":"Notification"} \ No newline at end of file diff --git a/test/fixtures/webhooks/feeds.activity.added/sqs_body.txt b/test/fixtures/webhooks/feeds.activity.added/sqs_body.txt new file mode 100644 index 0000000..967c43a --- /dev/null +++ b/test/fixtures/webhooks/feeds.activity.added/sqs_body.txt @@ -0,0 +1 @@ +H4sIAAAAAAAA/6pWKqksSFWyUkpLTU0p1ktMLsksyyyp1EtMSUlNUdJRSi5KTSxJTYlPLFGyUjIyMDLTNTDVNbAIMTCwAqMopVpAAAAA///ytZDqQwAAAA== \ No newline at end of file diff --git a/test/fixtures/webhooks/feeds.activity.added/sqs_body_uncompressed.txt b/test/fixtures/webhooks/feeds.activity.added/sqs_body_uncompressed.txt new file mode 100644 index 0000000..ddec009 --- /dev/null +++ b/test/fixtures/webhooks/feeds.activity.added/sqs_body_uncompressed.txt @@ -0,0 +1 @@ +eyJ0eXBlIjoiZmVlZHMuYWN0aXZpdHkuYWRkZWQiLCJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wOFQwMDowMDowMFoifQ== \ No newline at end of file diff --git a/test/fixtures/webhooks/message.deleted/body.gz b/test/fixtures/webhooks/message.deleted/body.gz new file mode 100644 index 0000000000000000000000000000000000000000..d0e40779eb31628afd02e7d25eb202dcd3f9fabe GIT binary patch literal 83 zcmb2|=3oGW|Et2ZR_b_!ZVEc(tE=bdF|t-xKo$T3uo=Jr literal 0 HcmV?d00001 diff --git a/test/fixtures/webhooks/message.new/body.json b/test/fixtures/webhooks/message.new/body.json new file mode 100644 index 0000000..3c5e004 --- /dev/null +++ b/test/fixtures/webhooks/message.new/body.json @@ -0,0 +1 @@ +{"type":"message.new","created_at":"2026-05-08T00:00:00Z"} \ No newline at end of file diff --git a/test/fixtures/webhooks/message.new/expected.json b/test/fixtures/webhooks/message.new/expected.json new file mode 100644 index 0000000..9ecb641 --- /dev/null +++ b/test/fixtures/webhooks/message.new/expected.json @@ -0,0 +1,6 @@ +{ + "fields": { + "created_at": "2026-05-08T00:00:00Z" + }, + "type": "message.new" +} \ No newline at end of file diff --git a/test/fixtures/webhooks/message.new/signature.txt b/test/fixtures/webhooks/message.new/signature.txt new file mode 100644 index 0000000..f463836 --- /dev/null +++ b/test/fixtures/webhooks/message.new/signature.txt @@ -0,0 +1 @@ +9e43985889d9d37c51c4bb92f6b814331edf8aa1b86ed7d3a8189acc93baabc8 \ No newline at end of file diff --git a/test/fixtures/webhooks/message.new/sns_notification.txt b/test/fixtures/webhooks/message.new/sns_notification.txt new file mode 100644 index 0000000..d33ba7a --- /dev/null +++ b/test/fixtures/webhooks/message.new/sns_notification.txt @@ -0,0 +1 @@ +{"Message":"H4sIAAAAAAAA/6pWKqksSFWyUspNLS5OTE/Vy0stV9JRSi5KTSxJTYlPLFGyUjIyMDLTNTDVNbAIMTCwAqMopVpAAAAA//+PXB06OgAAAA==","MessageId":"00000000-0000-0000-0000-000000000000","Timestamp":"2026-05-08T00:00:00.000Z","TopicArn":"arn:aws:sns:us-east-1:000000000000:fixture-topic","Type":"Notification"} \ No newline at end of file diff --git a/test/fixtures/webhooks/message.new/sqs_body.txt b/test/fixtures/webhooks/message.new/sqs_body.txt new file mode 100644 index 0000000..9f3042b --- /dev/null +++ b/test/fixtures/webhooks/message.new/sqs_body.txt @@ -0,0 +1 @@ +H4sIAAAAAAAA/6pWKqksSFWyUspNLS5OTE/Vy0stV9JRSi5KTSxJTYlPLFGyUjIyMDLTNTDVNbAIMTCwAqMopVpAAAAA//+PXB06OgAAAA== \ No newline at end of file diff --git a/test/fixtures/webhooks/message.new/sqs_body_uncompressed.txt b/test/fixtures/webhooks/message.new/sqs_body_uncompressed.txt new file mode 100644 index 0000000..66f5ff0 --- /dev/null +++ b/test/fixtures/webhooks/message.new/sqs_body_uncompressed.txt @@ -0,0 +1 @@ +eyJ0eXBlIjoibWVzc2FnZS5uZXciLCJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wOFQwMDowMDowMFoifQ== \ No newline at end of file diff --git a/test/fixtures/webhooks/message.updated/body.gz b/test/fixtures/webhooks/message.updated/body.gz new file mode 100644 index 0000000000000000000000000000000000000000..37adb0ad9cb43bee0b292bb020549f961a72f2ef GIT binary patch literal 80 zcmb2|=3oGW|Et2ZR_b_!ZVEc(tE=bd<9}6K*TYlC(>L%^pqHK(>&8dI-c#00>EY=) hQ^M<0%B$nV!?WgqhSy3l0S1Qu|DBY~&e#DB0ss@E8bJU6 literal 0 HcmV?d00001 diff --git a/test/fixtures/webhooks/message.updated/body.json b/test/fixtures/webhooks/message.updated/body.json new file mode 100644 index 0000000..e48d1fc --- /dev/null +++ b/test/fixtures/webhooks/message.updated/body.json @@ -0,0 +1 @@ +{"type":"message.updated","created_at":"2026-05-08T00:00:00Z"} \ No newline at end of file diff --git a/test/fixtures/webhooks/message.updated/expected.json b/test/fixtures/webhooks/message.updated/expected.json new file mode 100644 index 0000000..aaff32c --- /dev/null +++ b/test/fixtures/webhooks/message.updated/expected.json @@ -0,0 +1,6 @@ +{ + "fields": { + "created_at": "2026-05-08T00:00:00Z" + }, + "type": "message.updated" +} \ No newline at end of file diff --git a/test/fixtures/webhooks/message.updated/signature.txt b/test/fixtures/webhooks/message.updated/signature.txt new file mode 100644 index 0000000..123558b --- /dev/null +++ b/test/fixtures/webhooks/message.updated/signature.txt @@ -0,0 +1 @@ +c50590eafba7f62f5bb3c9e1a69c6894527573f8a76111a154d2e647e1f6dc7b \ No newline at end of file diff --git a/test/fixtures/webhooks/message.updated/sns_notification.txt b/test/fixtures/webhooks/message.updated/sns_notification.txt new file mode 100644 index 0000000..29f7821 --- /dev/null +++ b/test/fixtures/webhooks/message.updated/sns_notification.txt @@ -0,0 +1 @@ +{"Message":"H4sIAAAAAAAA/6pWKqksSFWyUspNLS5OTE/VKy1ISSxJTVHSUUouSgWx4hNLlKyUjAyMzHQNTHUNLEIMDKzAKEqpFhAAAP//QiI2zD4AAAA=","MessageId":"00000000-0000-0000-0000-000000000000","Timestamp":"2026-05-08T00:00:00.000Z","TopicArn":"arn:aws:sns:us-east-1:000000000000:fixture-topic","Type":"Notification"} \ No newline at end of file diff --git a/test/fixtures/webhooks/message.updated/sqs_body.txt b/test/fixtures/webhooks/message.updated/sqs_body.txt new file mode 100644 index 0000000..0f74f39 --- /dev/null +++ b/test/fixtures/webhooks/message.updated/sqs_body.txt @@ -0,0 +1 @@ +H4sIAAAAAAAA/6pWKqksSFWyUspNLS5OTE/VKy1ISSxJTVHSUUouSgWx4hNLlKyUjAyMzHQNTHUNLEIMDKzAKEqpFhAAAP//QiI2zD4AAAA= \ No newline at end of file diff --git a/test/fixtures/webhooks/message.updated/sqs_body_uncompressed.txt b/test/fixtures/webhooks/message.updated/sqs_body_uncompressed.txt new file mode 100644 index 0000000..5b37a3b --- /dev/null +++ b/test/fixtures/webhooks/message.updated/sqs_body_uncompressed.txt @@ -0,0 +1 @@ +eyJ0eXBlIjoibWVzc2FnZS51cGRhdGVkIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDUtMDhUMDA6MDA6MDBaIn0= \ No newline at end of file diff --git a/test/fixtures/webhooks/moderation.flagged/body.gz b/test/fixtures/webhooks/moderation.flagged/body.gz new file mode 100644 index 0000000000000000000000000000000000000000..7ee98462eb72f82bc10739e27178cc36af4151fb GIT binary patch literal 86 zcmb2|=3oGW|Et2ZR_b_!ZVEbe*56atOXuX7^OwC(d;0kM`vzVL^wRV4)$#Q0^w$a8 m6l7#%V078k;Hv2c4nu$N~T{bsj|k literal 0 HcmV?d00001 diff --git a/test/fixtures/webhooks/moderation.flagged/body.json b/test/fixtures/webhooks/moderation.flagged/body.json new file mode 100644 index 0000000..ad79f15 --- /dev/null +++ b/test/fixtures/webhooks/moderation.flagged/body.json @@ -0,0 +1 @@ +{"type":"moderation.flagged","created_at":"2026-05-08T00:00:00Z"} \ No newline at end of file diff --git a/test/fixtures/webhooks/moderation.flagged/expected.json b/test/fixtures/webhooks/moderation.flagged/expected.json new file mode 100644 index 0000000..ebbc54f --- /dev/null +++ b/test/fixtures/webhooks/moderation.flagged/expected.json @@ -0,0 +1,6 @@ +{ + "fields": { + "created_at": "2026-05-08T00:00:00Z" + }, + "type": "moderation.flagged" +} \ No newline at end of file diff --git a/test/fixtures/webhooks/moderation.flagged/signature.txt b/test/fixtures/webhooks/moderation.flagged/signature.txt new file mode 100644 index 0000000..1c41c5b --- /dev/null +++ b/test/fixtures/webhooks/moderation.flagged/signature.txt @@ -0,0 +1 @@ +7851b65fe601aba1e37f3bbc6f0c3f115376475301e0d62ac561d941fc009490 \ No newline at end of file diff --git a/test/fixtures/webhooks/moderation.flagged/sns_notification.txt b/test/fixtures/webhooks/moderation.flagged/sns_notification.txt new file mode 100644 index 0000000..587ecde --- /dev/null +++ b/test/fixtures/webhooks/moderation.flagged/sns_notification.txt @@ -0,0 +1 @@ +{"Message":"H4sIAAAAAAAA/6pWKqksSFWyUsrNT0ktSizJzM/TS8tJTE9PTVHSUUouSk0sSU2JTyxRslIyMjAy0zUw1TWwCDEwsAKjKKVaQAAAAP//YHVqW0EAAAA=","MessageId":"00000000-0000-0000-0000-000000000000","Timestamp":"2026-05-08T00:00:00.000Z","TopicArn":"arn:aws:sns:us-east-1:000000000000:fixture-topic","Type":"Notification"} \ No newline at end of file diff --git a/test/fixtures/webhooks/moderation.flagged/sqs_body.txt b/test/fixtures/webhooks/moderation.flagged/sqs_body.txt new file mode 100644 index 0000000..6eae82f --- /dev/null +++ b/test/fixtures/webhooks/moderation.flagged/sqs_body.txt @@ -0,0 +1 @@ +H4sIAAAAAAAA/6pWKqksSFWyUsrNT0ktSizJzM/TS8tJTE9PTVHSUUouSk0sSU2JTyxRslIyMjAy0zUw1TWwCDEwsAKjKKVaQAAAAP//YHVqW0EAAAA= \ No newline at end of file diff --git a/test/fixtures/webhooks/moderation.flagged/sqs_body_uncompressed.txt b/test/fixtures/webhooks/moderation.flagged/sqs_body_uncompressed.txt new file mode 100644 index 0000000..3b0ffa1 --- /dev/null +++ b/test/fixtures/webhooks/moderation.flagged/sqs_body_uncompressed.txt @@ -0,0 +1 @@ +eyJ0eXBlIjoibW9kZXJhdGlvbi5mbGFnZ2VkIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDUtMDhUMDA6MDA6MDBaIn0= \ No newline at end of file diff --git a/test/fixtures/webhooks/reaction.new/body.gz b/test/fixtures/webhooks/reaction.new/body.gz new file mode 100644 index 0000000000000000000000000000000000000000..faf0eca6d841617ea7c05b6b7183830e3b6ef18e GIT binary patch literal 80 zcmb2|=3oGW|Et2ZR_b_!ZVJ-!^7YX>dFK4()84w_mjb=?ynJ;$eLMYi0yhO285tN| gHZ{0vx`D&cU<1=)jipfzK<)qkU+7tM&l<=A09Cdeod5s; literal 0 HcmV?d00001 diff --git a/test/fixtures/webhooks/reaction.new/body.json b/test/fixtures/webhooks/reaction.new/body.json new file mode 100644 index 0000000..58f8d84 --- /dev/null +++ b/test/fixtures/webhooks/reaction.new/body.json @@ -0,0 +1 @@ +{"type":"reaction.new","created_at":"2026-05-08T00:00:00Z"} \ No newline at end of file diff --git a/test/fixtures/webhooks/reaction.new/expected.json b/test/fixtures/webhooks/reaction.new/expected.json new file mode 100644 index 0000000..2eaeb7b --- /dev/null +++ b/test/fixtures/webhooks/reaction.new/expected.json @@ -0,0 +1,6 @@ +{ + "fields": { + "created_at": "2026-05-08T00:00:00Z" + }, + "type": "reaction.new" +} \ No newline at end of file diff --git a/test/fixtures/webhooks/reaction.new/signature.txt b/test/fixtures/webhooks/reaction.new/signature.txt new file mode 100644 index 0000000..0ba7a98 --- /dev/null +++ b/test/fixtures/webhooks/reaction.new/signature.txt @@ -0,0 +1 @@ +a998b37083d4293d28164f49aa2331464517650713814e601f78bd658a5fde7d \ No newline at end of file diff --git a/test/fixtures/webhooks/reaction.new/sns_notification.txt b/test/fixtures/webhooks/reaction.new/sns_notification.txt new file mode 100644 index 0000000..5e0bb65 --- /dev/null +++ b/test/fixtures/webhooks/reaction.new/sns_notification.txt @@ -0,0 +1 @@ +{"Message":"H4sIAAAAAAAA/6pWKqksSFWyUipKTUwuyczP08tLLVfSUUouSk0sSU2JTyxRslIyMjAy0zUw1TWwCDEwsAKjKKVaQAAAAP//0Iyi3jsAAAA=","MessageId":"00000000-0000-0000-0000-000000000000","Timestamp":"2026-05-08T00:00:00.000Z","TopicArn":"arn:aws:sns:us-east-1:000000000000:fixture-topic","Type":"Notification"} \ No newline at end of file diff --git a/test/fixtures/webhooks/reaction.new/sqs_body.txt b/test/fixtures/webhooks/reaction.new/sqs_body.txt new file mode 100644 index 0000000..5aedf9c --- /dev/null +++ b/test/fixtures/webhooks/reaction.new/sqs_body.txt @@ -0,0 +1 @@ +H4sIAAAAAAAA/6pWKqksSFWyUipKTUwuyczP08tLLVfSUUouSk0sSU2JTyxRslIyMjAy0zUw1TWwCDEwsAKjKKVaQAAAAP//0Iyi3jsAAAA= \ No newline at end of file diff --git a/test/fixtures/webhooks/reaction.new/sqs_body_uncompressed.txt b/test/fixtures/webhooks/reaction.new/sqs_body_uncompressed.txt new file mode 100644 index 0000000..521552b --- /dev/null +++ b/test/fixtures/webhooks/reaction.new/sqs_body_uncompressed.txt @@ -0,0 +1 @@ +eyJ0eXBlIjoicmVhY3Rpb24ubmV3IiwiY3JlYXRlZF9hdCI6IjIwMjYtMDUtMDhUMDA6MDA6MDBaIn0= \ No newline at end of file diff --git a/test/fixtures/webhooks/user.banned/body.gz b/test/fixtures/webhooks/user.banned/body.gz new file mode 100644 index 0000000000000000000000000000000000000000..7f236bbbe1456dcdcd9a670a05efe6c631813aa4 GIT binary patch literal 79 zcmb2|=3oGW|Et2ZR_b_!ZVJ-U_0zrN?RDm~w{PI3KrcNnUmZ{1PJf-iO+iLR21b`n e4X&DQ;4n1Uz_eInX_Ny{_y7MQK@9h;fGhy|M;WpJ literal 0 HcmV?d00001 diff --git a/test/fixtures/webhooks/user.banned/body.json b/test/fixtures/webhooks/user.banned/body.json new file mode 100644 index 0000000..0fb8ab5 --- /dev/null +++ b/test/fixtures/webhooks/user.banned/body.json @@ -0,0 +1 @@ +{"type":"user.banned","created_at":"2026-05-08T00:00:00Z"} \ No newline at end of file diff --git a/test/fixtures/webhooks/user.banned/expected.json b/test/fixtures/webhooks/user.banned/expected.json new file mode 100644 index 0000000..42aa4c9 --- /dev/null +++ b/test/fixtures/webhooks/user.banned/expected.json @@ -0,0 +1,6 @@ +{ + "fields": { + "created_at": "2026-05-08T00:00:00Z" + }, + "type": "user.banned" +} \ No newline at end of file diff --git a/test/fixtures/webhooks/user.banned/signature.txt b/test/fixtures/webhooks/user.banned/signature.txt new file mode 100644 index 0000000..a88f140 --- /dev/null +++ b/test/fixtures/webhooks/user.banned/signature.txt @@ -0,0 +1 @@ +35504e4551320c86f8166f96df7d193677fd779599e01760ba5653a275970be7 \ No newline at end of file diff --git a/test/fixtures/webhooks/user.banned/sns_notification.txt b/test/fixtures/webhooks/user.banned/sns_notification.txt new file mode 100644 index 0000000..6cecfb6 --- /dev/null +++ b/test/fixtures/webhooks/user.banned/sns_notification.txt @@ -0,0 +1 @@ +{"Message":"H4sIAAAAAAAA/6pWKqksSFWyUiotTi3SS0rMy0tNUdJRSi5KTSxJTYlPLFGyUjIyMDLTNTDVNbAIMTCwAqMopVpAAAAA//8UUgDfOgAAAA==","MessageId":"00000000-0000-0000-0000-000000000000","Timestamp":"2026-05-08T00:00:00.000Z","TopicArn":"arn:aws:sns:us-east-1:000000000000:fixture-topic","Type":"Notification"} \ No newline at end of file diff --git a/test/fixtures/webhooks/user.banned/sqs_body.txt b/test/fixtures/webhooks/user.banned/sqs_body.txt new file mode 100644 index 0000000..8d1959c --- /dev/null +++ b/test/fixtures/webhooks/user.banned/sqs_body.txt @@ -0,0 +1 @@ +H4sIAAAAAAAA/6pWKqksSFWyUiotTi3SS0rMy0tNUdJRSi5KTSxJTYlPLFGyUjIyMDLTNTDVNbAIMTCwAqMopVpAAAAA//8UUgDfOgAAAA== \ No newline at end of file diff --git a/test/fixtures/webhooks/user.banned/sqs_body_uncompressed.txt b/test/fixtures/webhooks/user.banned/sqs_body_uncompressed.txt new file mode 100644 index 0000000..956dbec --- /dev/null +++ b/test/fixtures/webhooks/user.banned/sqs_body_uncompressed.txt @@ -0,0 +1 @@ +eyJ0eXBlIjoidXNlci5iYW5uZWQiLCJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wOFQwMDowMDowMFoifQ== \ No newline at end of file diff --git a/test/fixtures/webhooks/user.unbanned/body.gz b/test/fixtures/webhooks/user.unbanned/body.gz new file mode 100644 index 0000000000000000000000000000000000000000..dd65c1232b4606a64e14d1b12d29c91f3be5c929 GIT binary patch literal 81 zcmb2|=3oGW|Et2ZR_b_!ZVJ-U_0zqieb(FS%xQ1mz)OK%dS1Rdp1z&_I)R&ljEoG7 gE}I%$HQm5rXt054vBuIU2cZ7{|EF4>VzdFW077&d00000 literal 0 HcmV?d00001 diff --git a/test/fixtures/webhooks/user.unbanned/body.json b/test/fixtures/webhooks/user.unbanned/body.json new file mode 100644 index 0000000..fcb0281 --- /dev/null +++ b/test/fixtures/webhooks/user.unbanned/body.json @@ -0,0 +1 @@ +{"type":"user.unbanned","created_at":"2026-05-08T00:00:00Z"} \ No newline at end of file diff --git a/test/fixtures/webhooks/user.unbanned/expected.json b/test/fixtures/webhooks/user.unbanned/expected.json new file mode 100644 index 0000000..f60a330 --- /dev/null +++ b/test/fixtures/webhooks/user.unbanned/expected.json @@ -0,0 +1,6 @@ +{ + "fields": { + "created_at": "2026-05-08T00:00:00Z" + }, + "type": "user.unbanned" +} \ No newline at end of file diff --git a/test/fixtures/webhooks/user.unbanned/signature.txt b/test/fixtures/webhooks/user.unbanned/signature.txt new file mode 100644 index 0000000..ec75779 --- /dev/null +++ b/test/fixtures/webhooks/user.unbanned/signature.txt @@ -0,0 +1 @@ +04b33a93f8ea848fbca682c487d871120b726493d3c25083d694ee7a59fb1cfa \ No newline at end of file diff --git a/test/fixtures/webhooks/user.unbanned/sns_notification.txt b/test/fixtures/webhooks/user.unbanned/sns_notification.txt new file mode 100644 index 0000000..7797255 --- /dev/null +++ b/test/fixtures/webhooks/user.unbanned/sns_notification.txt @@ -0,0 +1 @@ +{"Message":"H4sIAAAAAAAA/6pWKqksSFWyUiotTi3SK81LSszLS01R0lFKLkpNLElNiU8sUbJSMjIwMtM1MNU1sAgxMLACoyilWkAAAAD//5U5ygE8AAAA","MessageId":"00000000-0000-0000-0000-000000000000","Timestamp":"2026-05-08T00:00:00.000Z","TopicArn":"arn:aws:sns:us-east-1:000000000000:fixture-topic","Type":"Notification"} \ No newline at end of file diff --git a/test/fixtures/webhooks/user.unbanned/sqs_body.txt b/test/fixtures/webhooks/user.unbanned/sqs_body.txt new file mode 100644 index 0000000..d099a17 --- /dev/null +++ b/test/fixtures/webhooks/user.unbanned/sqs_body.txt @@ -0,0 +1 @@ +H4sIAAAAAAAA/6pWKqksSFWyUiotTi3SK81LSszLS01R0lFKLkpNLElNiU8sUbJSMjIwMtM1MNU1sAgxMLACoyilWkAAAAD//5U5ygE8AAAA \ No newline at end of file diff --git a/test/fixtures/webhooks/user.unbanned/sqs_body_uncompressed.txt b/test/fixtures/webhooks/user.unbanned/sqs_body_uncompressed.txt new file mode 100644 index 0000000..284de09 --- /dev/null +++ b/test/fixtures/webhooks/user.unbanned/sqs_body_uncompressed.txt @@ -0,0 +1 @@ +eyJ0eXBlIjoidXNlci51bmJhbm5lZCIsImNyZWF0ZWRfYXQiOiIyMDI2LTA1LTA4VDAwOjAwOjAwWiJ9 \ No newline at end of file diff --git a/test/webhook_test.rb b/test/webhook_test.rb index ddd851b..b809e42 100644 --- a/test/webhook_test.rb +++ b/test/webhook_test.rb @@ -1,8 +1,10 @@ # Code generated by GetStream internal OpenAPI code generator. DO NOT EDIT. require 'minitest/autorun' -require 'openssl' +require 'base64' require 'json' +require 'openssl' +require 'zlib' require_relative '../lib/getstream_ruby/generated/webhook' class WebhookTest < Minitest::Test @@ -903,4 +905,281 @@ def test_parse_webhook_event_invalid_json StreamChat::Webhook.parse_webhook_event('not json') end end + + # --------------------------------------------------------------------------- + # Spec §6 helpers + composites: parse_event, gunzip_payload, decode_sqs_payload, + # decode_sns_payload, verify_and_parse_webhook, parse_sqs_payload, parse_sns_payload. + # --------------------------------------------------------------------------- + + def test_stream_webhook_canonical_alias_resolves + # Spec §7: Stream::Webhook is the canonical name; should alias StreamChat::Webhook. + assert_equal StreamChat::Webhook, Stream::Webhook + end + + def test_parse_event_returns_unknown_event_for_unknown_discriminator + body = '{"type":"totally.made.up","created_at":"2026-05-08T00:00:00Z"}' + event = StreamChat::Webhook.parse_event(body) + assert_kind_of StreamChat::Webhook::UnknownEvent, event + assert_equal 'totally.made.up', event.type + refute_nil event.created_at + assert_equal 'totally.made.up', event.raw['type'] + end + + def test_parse_event_empty_body + assert_raises(StreamChat::Webhook::InvalidWebhookError) do + StreamChat::Webhook.parse_event('') + end + end + + def test_parse_event_invalid_json + assert_raises(StreamChat::Webhook::InvalidWebhookError) do + StreamChat::Webhook.parse_event('not json') + end + end + + def test_parse_event_non_object_json + assert_raises(StreamChat::Webhook::InvalidWebhookError) do + StreamChat::Webhook.parse_event('[1,2,3]') + end + end + + def test_parse_event_missing_type + assert_raises(StreamChat::Webhook::InvalidWebhookError) do + StreamChat::Webhook.parse_event('{"foo":"bar"}') + end + end + + def test_gunzip_payload_passes_through_plain_body + plain = '{"type":"message.new"}' + assert_equal plain.b, StreamChat::Webhook.gunzip_payload(plain) + end + + def test_gunzip_payload_decompresses_gzip_body + plain = '{"type":"message.new"}' + gz = Zlib.gzip(plain) + assert_equal plain, StreamChat::Webhook.gunzip_payload(gz) + end + + def test_gunzip_payload_raises_on_corrupt_gzip + corrupt = "\x1F\x8Bnot-actually-a-gzip-stream".b + assert_raises(StreamChat::Webhook::InvalidWebhookError) do + StreamChat::Webhook.gunzip_payload(corrupt) + end + end + + def test_decode_sqs_payload_decodes_plain_base64 + plain = '{"type":"message.new"}' + encoded = Base64.strict_encode64(plain) + assert_equal plain.b, StreamChat::Webhook.decode_sqs_payload(encoded) + end + + def test_decode_sqs_payload_decodes_base64_gzip_body + plain = '{"type":"message.new"}' + gz = Zlib.gzip(plain) + encoded = Base64.strict_encode64(gz) + assert_equal plain, StreamChat::Webhook.decode_sqs_payload(encoded) + end + + def test_decode_sqs_payload_raises_on_invalid_base64 + assert_raises(StreamChat::Webhook::InvalidWebhookError) do + StreamChat::Webhook.decode_sqs_payload('!!!not base64!!!') + end + end + + def test_decode_sns_payload_extracts_and_decodes_message + plain = '{"type":"message.new"}' + envelope = JSON.generate( + 'Type' => 'Notification', + 'Message' => Base64.strict_encode64(plain), + 'MessageId' => 'abc-123', + 'Timestamp' => '2026-05-08T00:00:00Z', + 'TopicArn' => 'arn:aws:sns:us-east-1:123:test' + ) + assert_equal plain.b, StreamChat::Webhook.decode_sns_payload(envelope) + end + + def test_decode_sns_payload_raises_on_invalid_envelope + assert_raises(StreamChat::Webhook::InvalidWebhookError) do + StreamChat::Webhook.decode_sns_payload('not json') + end + end + + def test_decode_sns_payload_raises_on_missing_message_field + assert_raises(StreamChat::Webhook::InvalidWebhookError) do + StreamChat::Webhook.decode_sns_payload('{"Type":"Notification"}') + end + end + + def test_verify_and_parse_webhook_happy_path + body = '{"type":"message.new"}' + sig = compute_signature(body, SECRET) + refute_nil StreamChat::Webhook.verify_and_parse_webhook(body, sig, SECRET) + end + + def test_verify_and_parse_webhook_gzip_body + body = '{"type":"message.new"}' + sig = compute_signature(body, SECRET) + gz = Zlib.gzip(body) + refute_nil StreamChat::Webhook.verify_and_parse_webhook(gz, sig, SECRET) + end + + def test_verify_and_parse_webhook_raises_on_tampered_body + body = '{"type":"message.new"}' + sig = compute_signature(body, SECRET) + assert_raises(StreamChat::Webhook::InvalidWebhookError) do + StreamChat::Webhook.verify_and_parse_webhook('{"type":"message.deleted"}', sig, SECRET) + end + end + + def test_parse_sqs_payload_happy_path + plain = '{"type":"message.new"}' + refute_nil StreamChat::Webhook.parse_sqs_payload(Base64.strict_encode64(plain)) + end + + def test_parse_sns_payload_happy_path + plain = '{"type":"message.new"}' + envelope = JSON.generate( + 'Type' => 'Notification', + 'Message' => Base64.strict_encode64(plain) + ) + refute_nil StreamChat::Webhook.parse_sns_payload(envelope) + end +end + +# --------------------------------------------------------------------------- +# Fixture-driven conformance tests. Fixtures live at test/fixtures/webhooks/ +# (one subdirectory per case for happy path; _invalid/ for negative cases). +# --------------------------------------------------------------------------- +class WebhookConformanceTest < Minitest::Test + CANONICAL_TEST_SECRET = 'test_secret_do_not_use_in_production' + FIXTURE_ROOT = File.expand_path('fixtures/webhooks', __dir__).freeze + + def fixtures_present? + File.directory?(FIXTURE_ROOT) + end + + def each_happy_dir + return unless fixtures_present? + + Dir.children(FIXTURE_ROOT).sort.each do |name| + next if name == '_invalid' + + dir = File.join(FIXTURE_ROOT, name) + next unless File.directory?(dir) + + yield name, dir + end + end + + def test_happy_fixtures + skip 'webhook conformance fixtures not present' unless fixtures_present? + + each_happy_dir do |name, dir| + body = File.binread(File.join(dir, 'body.json')) + body_gz = File.binread(File.join(dir, 'body.gz')) + sqs_compressed = File.read(File.join(dir, 'sqs_body.txt')).strip + sqs_raw = File.read(File.join(dir, 'sqs_body_uncompressed.txt')).strip + sns = File.read(File.join(dir, 'sns_notification.txt')).strip + sig = File.read(File.join(dir, 'signature.txt')).strip + + assert StreamChat::Webhook.verify_signature(body, sig, CANONICAL_TEST_SECRET), + "verify_signature failed for #{name}" + refute_nil StreamChat::Webhook.parse_event(body), + "parse_event nil for #{name}" + refute_nil StreamChat::Webhook.verify_and_parse_webhook(body, sig, CANONICAL_TEST_SECRET), + "verify_and_parse_webhook (identity) failed for #{name}" + refute_nil StreamChat::Webhook.verify_and_parse_webhook(body_gz, sig, CANONICAL_TEST_SECRET), + "verify_and_parse_webhook (gzip) failed for #{name}" + refute_nil StreamChat::Webhook.parse_sqs_payload(sqs_compressed), + "parse_sqs_payload (compressed) failed for #{name}" + refute_nil StreamChat::Webhook.parse_sqs_payload(sqs_raw), + "parse_sqs_payload (uncompressed) failed for #{name}" + refute_nil StreamChat::Webhook.parse_sns_payload(sns), + "parse_sns_payload failed for #{name}" + end + end + + def neg_dir(name) + File.join(FIXTURE_ROOT, '_invalid', name) + end + + def test_tampered_body + skip 'fixtures not present' unless fixtures_present? + + body = File.binread(File.join(neg_dir('tampered_body'), 'body.json')) + sig = File.read(File.join(neg_dir('tampered_body'), 'signature.txt')).strip + err = assert_raises(StreamChat::Webhook::InvalidWebhookError) do + StreamChat::Webhook.verify_and_parse_webhook(body, sig, CANONICAL_TEST_SECRET) + end + assert_includes err.message, 'signature mismatch' + end + + def test_unknown_type_returns_unknown_event + skip 'fixtures not present' unless fixtures_present? + + body = File.binread(File.join(neg_dir('unknown_type'), 'body.json')) + result = StreamChat::Webhook.parse_event(body) + assert_kind_of StreamChat::Webhook::UnknownEvent, result + assert_equal 'totally.made.up', result.type + end + + def test_missing_type + skip 'fixtures not present' unless fixtures_present? + + body = File.binread(File.join(neg_dir('missing_type'), 'body.json')) + err = assert_raises(StreamChat::Webhook::InvalidWebhookError) do + StreamChat::Webhook.parse_event(body) + end + assert_includes err.message, "missing 'type'" + end + + def test_malformed_json + skip 'fixtures not present' unless fixtures_present? + + body = File.binread(File.join(neg_dir('malformed_json'), 'body.json')) + err = assert_raises(StreamChat::Webhook::InvalidWebhookError) do + StreamChat::Webhook.parse_event(body) + end + assert_includes err.message, 'failed to parse webhook payload' + end + + def test_empty_body + skip 'fixtures not present' unless fixtures_present? + + body = File.binread(File.join(neg_dir('empty_body'), 'body.json')) + err = assert_raises(StreamChat::Webhook::InvalidWebhookError) do + StreamChat::Webhook.parse_event(body) + end + assert_includes err.message, 'must not be empty' + end + + def test_bad_compression + skip 'fixtures not present' unless fixtures_present? + + body = File.binread(File.join(neg_dir('bad_compression'), 'body.gz')) + err = assert_raises(StreamChat::Webhook::InvalidWebhookError) do + StreamChat::Webhook.gunzip_payload(body) + end + assert_includes err.message, 'gzip decompression failed' + end + + def test_bad_base64 + skip 'fixtures not present' unless fixtures_present? + + msg = File.read(File.join(neg_dir('bad_base64'), 'sqs_body.txt')).strip + err = assert_raises(StreamChat::Webhook::InvalidWebhookError) do + StreamChat::Webhook.parse_sqs_payload(msg) + end + assert_includes err.message, 'base64' + end + + def test_bad_sns_envelope + skip 'fixtures not present' unless fixtures_present? + + notif = File.read(File.join(neg_dir('bad_sns_envelope'), 'sns_notification.txt')).strip + err = assert_raises(StreamChat::Webhook::InvalidWebhookError) do + StreamChat::Webhook.parse_sns_payload(notif) + end + assert_includes err.message, 'SNS envelope' + end end From 28af5404d9392a7891df5726cacba23c0aec50a7 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Tue, 12 May 2026 17:11:59 +0200 Subject: [PATCH 2/8] chore: re-regenerate against updated chat/ templates (parse_sqs rename + namespace fix) --- CHANGELOG.md | 12 +- .../models/async_export_error_event.rb | 2 +- lib/getstream_ruby/generated/webhook.rb | 340 +++++++++--------- test/webhook_test.rb | 26 +- 4 files changed, 194 insertions(+), 186 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21114e8..53d16f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,10 @@ - Webhook handling spec helpers (CHA-2961): `UnknownEvent` class for forward-compat; `gunzip_payload`, `decode_sqs_payload`, `decode_sns_payload` primitives; `parse_event` (returns typed event or `UnknownEvent` for unrecognized discriminators); - `verify_and_parse_webhook` HTTP composite; `parse_sqs_payload` / `parse_sns_payload` - queue composites (no signature — backend emits no HMAC for queue messages today). + `verify_and_parse_webhook` HTTP composite; `parse_sqs` / `parse_sns` + queue composites (no signature; backend emits no HMAC for queue messages today). + Security for queue-delivered payloads is enforced via AWS IAM on the SQS/SNS + subscription, not in-SDK. - New `Stream::Webhook` module alias (preferred). `StreamChat::Webhook` retained as backward-compat alias for one minor-version cycle. - New unified error class: `StreamChat::Webhook::InvalidWebhookError` covering signature @@ -23,6 +25,12 @@ - No breaking changes. +### Fixed + +- `event_class_for_type` now references `GetStream::Generated::Models::*Event` + (was `StreamChat::*Event`, which raised `NameError` at runtime). `parse_event` + resolves known event types correctly. + [Spec](https://www.notion.so/stream-wiki/Server-Side-SDK-Webhook-Handling-Spec-34b6a5d7f9f681e78003c443f227493c) ## [6.0.0] - 2026-04-17 diff --git a/lib/getstream_ruby/generated/models/async_export_error_event.rb b/lib/getstream_ruby/generated/models/async_export_error_event.rb index 85cf709..f785b8c 100644 --- a/lib/getstream_ruby/generated/models/async_export_error_event.rb +++ b/lib/getstream_ruby/generated/models/async_export_error_event.rb @@ -43,7 +43,7 @@ def initialize(attributes = {}) @started_at = attributes[:started_at] || attributes['started_at'] @task_id = attributes[:task_id] || attributes['task_id'] @custom = attributes[:custom] || attributes['custom'] - @type = attributes[:type] || attributes['type'] || "export.moderation_logs.error" + @type = attributes[:type] || attributes['type'] || "export.bulk_image_moderation.error" @received_at = attributes[:received_at] || attributes['received_at'] || nil end diff --git a/lib/getstream_ruby/generated/webhook.rb b/lib/getstream_ruby/generated/webhook.rb index beff35f..16616b5 100644 --- a/lib/getstream_ruby/generated/webhook.rb +++ b/lib/getstream_ruby/generated/webhook.rb @@ -426,339 +426,339 @@ def self.parse_webhook_event(raw_event) private_class_method def self.event_class_for_type(event_type) case event_type when '*' - StreamChat::CustomEvent + GetStream::Generated::Models::CustomEvent when 'appeal.accepted' - StreamChat::AppealAcceptedEvent + GetStream::Generated::Models::AppealAcceptedEvent when 'appeal.created' - StreamChat::AppealCreatedEvent + GetStream::Generated::Models::AppealCreatedEvent when 'appeal.rejected' - StreamChat::AppealRejectedEvent + GetStream::Generated::Models::AppealRejectedEvent when 'call.accepted' - StreamChat::CallAcceptedEvent + GetStream::Generated::Models::CallAcceptedEvent when 'call.blocked_user' - StreamChat::BlockedUserEvent + GetStream::Generated::Models::BlockedUserEvent when 'call.closed_caption' - StreamChat::ClosedCaptionEvent + GetStream::Generated::Models::ClosedCaptionEvent when 'call.closed_captions_failed' - StreamChat::CallClosedCaptionsFailedEvent + GetStream::Generated::Models::CallClosedCaptionsFailedEvent when 'call.closed_captions_started' - StreamChat::CallClosedCaptionsStartedEvent + GetStream::Generated::Models::CallClosedCaptionsStartedEvent when 'call.closed_captions_stopped' - StreamChat::CallClosedCaptionsStoppedEvent + GetStream::Generated::Models::CallClosedCaptionsStoppedEvent when 'call.created' - StreamChat::CallCreatedEvent + GetStream::Generated::Models::CallCreatedEvent when 'call.deleted' - StreamChat::CallDeletedEvent + GetStream::Generated::Models::CallDeletedEvent when 'call.dtmf' - StreamChat::CallDTMFEvent + GetStream::Generated::Models::CallDTMFEvent when 'call.ended' - StreamChat::CallEndedEvent + GetStream::Generated::Models::CallEndedEvent when 'call.frame_recording_failed' - StreamChat::CallFrameRecordingFailedEvent + GetStream::Generated::Models::CallFrameRecordingFailedEvent when 'call.frame_recording_ready' - StreamChat::CallFrameRecordingFrameReadyEvent + GetStream::Generated::Models::CallFrameRecordingFrameReadyEvent when 'call.frame_recording_started' - StreamChat::CallFrameRecordingStartedEvent + GetStream::Generated::Models::CallFrameRecordingStartedEvent when 'call.frame_recording_stopped' - StreamChat::CallFrameRecordingStoppedEvent + GetStream::Generated::Models::CallFrameRecordingStoppedEvent when 'call.hls_broadcasting_failed' - StreamChat::CallHLSBroadcastingFailedEvent + GetStream::Generated::Models::CallHLSBroadcastingFailedEvent when 'call.hls_broadcasting_started' - StreamChat::CallHLSBroadcastingStartedEvent + GetStream::Generated::Models::CallHLSBroadcastingStartedEvent when 'call.hls_broadcasting_stopped' - StreamChat::CallHLSBroadcastingStoppedEvent + GetStream::Generated::Models::CallHLSBroadcastingStoppedEvent when 'call.kicked_user' - StreamChat::KickedUserEvent + GetStream::Generated::Models::KickedUserEvent when 'call.live_started' - StreamChat::CallLiveStartedEvent + GetStream::Generated::Models::CallLiveStartedEvent when 'call.member_added' - StreamChat::CallMemberAddedEvent + GetStream::Generated::Models::CallMemberAddedEvent when 'call.member_removed' - StreamChat::CallMemberRemovedEvent + GetStream::Generated::Models::CallMemberRemovedEvent when 'call.member_updated' - StreamChat::CallMemberUpdatedEvent + GetStream::Generated::Models::CallMemberUpdatedEvent when 'call.member_updated_permission' - StreamChat::CallMemberUpdatedPermissionEvent + GetStream::Generated::Models::CallMemberUpdatedPermissionEvent when 'call.missed' - StreamChat::CallMissedEvent + GetStream::Generated::Models::CallMissedEvent when 'call.moderation_blur' - StreamChat::CallModerationBlurEvent + GetStream::Generated::Models::CallModerationBlurEvent when 'call.moderation_warning' - StreamChat::CallModerationWarningEvent + GetStream::Generated::Models::CallModerationWarningEvent when 'call.notification' - StreamChat::CallNotificationEvent + GetStream::Generated::Models::CallNotificationEvent when 'call.permission_request' - StreamChat::PermissionRequestEvent + GetStream::Generated::Models::PermissionRequestEvent when 'call.permissions_updated' - StreamChat::UpdatedCallPermissionsEvent + GetStream::Generated::Models::UpdatedCallPermissionsEvent when 'call.reaction_new' - StreamChat::CallReactionEvent + GetStream::Generated::Models::CallReactionEvent when 'call.recording_failed' - StreamChat::CallRecordingFailedEvent + GetStream::Generated::Models::CallRecordingFailedEvent when 'call.recording_ready' - StreamChat::CallRecordingReadyEvent + GetStream::Generated::Models::CallRecordingReadyEvent when 'call.recording_started' - StreamChat::CallRecordingStartedEvent + GetStream::Generated::Models::CallRecordingStartedEvent when 'call.recording_stopped' - StreamChat::CallRecordingStoppedEvent + GetStream::Generated::Models::CallRecordingStoppedEvent when 'call.rejected' - StreamChat::CallRejectedEvent + GetStream::Generated::Models::CallRejectedEvent when 'call.ring' - StreamChat::CallRingEvent + GetStream::Generated::Models::CallRingEvent when 'call.rtmp_broadcast_failed' - StreamChat::CallRtmpBroadcastFailedEvent + GetStream::Generated::Models::CallRtmpBroadcastFailedEvent when 'call.rtmp_broadcast_started' - StreamChat::CallRtmpBroadcastStartedEvent + GetStream::Generated::Models::CallRtmpBroadcastStartedEvent when 'call.rtmp_broadcast_stopped' - StreamChat::CallRtmpBroadcastStoppedEvent + GetStream::Generated::Models::CallRtmpBroadcastStoppedEvent when 'call.session_ended' - StreamChat::CallSessionEndedEvent + GetStream::Generated::Models::CallSessionEndedEvent when 'call.session_participant_count_updated' - StreamChat::CallSessionParticipantCountsUpdatedEvent + GetStream::Generated::Models::CallSessionParticipantCountsUpdatedEvent when 'call.session_participant_joined' - StreamChat::CallSessionParticipantJoinedEvent + GetStream::Generated::Models::CallSessionParticipantJoinedEvent when 'call.session_participant_left' - StreamChat::CallSessionParticipantLeftEvent + GetStream::Generated::Models::CallSessionParticipantLeftEvent when 'call.session_started' - StreamChat::CallSessionStartedEvent + GetStream::Generated::Models::CallSessionStartedEvent when 'call.stats_report_ready' - StreamChat::CallStatsReportReadyEvent + GetStream::Generated::Models::CallStatsReportReadyEvent when 'call.transcription_failed' - StreamChat::CallTranscriptionFailedEvent + GetStream::Generated::Models::CallTranscriptionFailedEvent when 'call.transcription_ready' - StreamChat::CallTranscriptionReadyEvent + GetStream::Generated::Models::CallTranscriptionReadyEvent when 'call.transcription_started' - StreamChat::CallTranscriptionStartedEvent + GetStream::Generated::Models::CallTranscriptionStartedEvent when 'call.transcription_stopped' - StreamChat::CallTranscriptionStoppedEvent + GetStream::Generated::Models::CallTranscriptionStoppedEvent when 'call.unblocked_user' - StreamChat::UnblockedUserEvent + GetStream::Generated::Models::UnblockedUserEvent when 'call.updated' - StreamChat::CallUpdatedEvent + GetStream::Generated::Models::CallUpdatedEvent when 'call.user_feedback_submitted' - StreamChat::CallUserFeedbackSubmittedEvent + GetStream::Generated::Models::CallUserFeedbackSubmittedEvent when 'call.user_muted' - StreamChat::CallUserMutedEvent + GetStream::Generated::Models::CallUserMutedEvent when 'campaign.completed' - StreamChat::CampaignCompletedEvent + GetStream::Generated::Models::CampaignCompletedEvent when 'campaign.started' - StreamChat::CampaignStartedEvent + GetStream::Generated::Models::CampaignStartedEvent when 'channel.created' - StreamChat::ChannelCreatedEvent + GetStream::Generated::Models::ChannelCreatedEvent when 'channel.deleted' - StreamChat::ChannelDeletedEvent + GetStream::Generated::Models::ChannelDeletedEvent when 'channel.frozen' - StreamChat::ChannelFrozenEvent + GetStream::Generated::Models::ChannelFrozenEvent when 'channel.hidden' - StreamChat::ChannelHiddenEvent + GetStream::Generated::Models::ChannelHiddenEvent when 'channel.max_streak_changed' - StreamChat::MaxStreakChangedEvent + GetStream::Generated::Models::MaxStreakChangedEvent when 'channel.muted' - StreamChat::ChannelMutedEvent + GetStream::Generated::Models::ChannelMutedEvent when 'channel.truncated' - StreamChat::ChannelTruncatedEvent + GetStream::Generated::Models::ChannelTruncatedEvent when 'channel.unfrozen' - StreamChat::ChannelUnFrozenEvent + GetStream::Generated::Models::ChannelUnFrozenEvent when 'channel.unmuted' - StreamChat::ChannelUnmutedEvent + GetStream::Generated::Models::ChannelUnmutedEvent when 'channel.updated' - StreamChat::ChannelUpdatedEvent + GetStream::Generated::Models::ChannelUpdatedEvent when 'channel.visible' - StreamChat::ChannelVisibleEvent + GetStream::Generated::Models::ChannelVisibleEvent when 'channel_batch_update.completed' - StreamChat::ChannelBatchCompletedEvent + GetStream::Generated::Models::ChannelBatchCompletedEvent when 'channel_batch_update.started' - StreamChat::ChannelBatchStartedEvent + GetStream::Generated::Models::ChannelBatchStartedEvent when 'custom' - StreamChat::CustomVideoEvent + GetStream::Generated::Models::CustomVideoEvent when 'export.bulk_image_moderation.error' - StreamChat::AsyncExportErrorEvent + GetStream::Generated::Models::AsyncExportErrorEvent when 'export.bulk_image_moderation.success' - StreamChat::AsyncBulkImageModerationEvent + GetStream::Generated::Models::AsyncBulkImageModerationEvent when 'export.channels.error' - StreamChat::AsyncExportErrorEvent + GetStream::Generated::Models::AsyncExportErrorEvent when 'export.channels.success' - StreamChat::AsyncExportChannelsEvent + GetStream::Generated::Models::AsyncExportChannelsEvent when 'export.moderation_logs.error' - StreamChat::AsyncExportErrorEvent + GetStream::Generated::Models::AsyncExportErrorEvent when 'export.moderation_logs.success' - StreamChat::AsyncExportModerationLogsEvent + GetStream::Generated::Models::AsyncExportModerationLogsEvent when 'export.users.error' - StreamChat::AsyncExportErrorEvent + GetStream::Generated::Models::AsyncExportErrorEvent when 'export.users.success' - StreamChat::AsyncExportUsersEvent + GetStream::Generated::Models::AsyncExportUsersEvent when 'feeds.activity.added' - StreamChat::ActivityAddedEvent + GetStream::Generated::Models::ActivityAddedEvent when 'feeds.activity.deleted' - StreamChat::ActivityDeletedEvent + GetStream::Generated::Models::ActivityDeletedEvent when 'feeds.activity.feedback' - StreamChat::ActivityFeedbackEvent + GetStream::Generated::Models::ActivityFeedbackEvent when 'feeds.activity.marked' - StreamChat::ActivityMarkEvent + GetStream::Generated::Models::ActivityMarkEvent when 'feeds.activity.pinned' - StreamChat::ActivityPinnedEvent + GetStream::Generated::Models::ActivityPinnedEvent when 'feeds.activity.reaction.added' - StreamChat::ActivityReactionAddedEvent + GetStream::Generated::Models::ActivityReactionAddedEvent when 'feeds.activity.reaction.deleted' - StreamChat::ActivityReactionDeletedEvent + GetStream::Generated::Models::ActivityReactionDeletedEvent when 'feeds.activity.reaction.updated' - StreamChat::ActivityReactionUpdatedEvent + GetStream::Generated::Models::ActivityReactionUpdatedEvent when 'feeds.activity.removed_from_feed' - StreamChat::ActivityRemovedFromFeedEvent + GetStream::Generated::Models::ActivityRemovedFromFeedEvent when 'feeds.activity.restored' - StreamChat::ActivityRestoredEvent + GetStream::Generated::Models::ActivityRestoredEvent when 'feeds.activity.unpinned' - StreamChat::ActivityUnpinnedEvent + GetStream::Generated::Models::ActivityUnpinnedEvent when 'feeds.activity.updated' - StreamChat::ActivityUpdatedEvent + GetStream::Generated::Models::ActivityUpdatedEvent when 'feeds.bookmark.added' - StreamChat::BookmarkAddedEvent + GetStream::Generated::Models::BookmarkAddedEvent when 'feeds.bookmark.deleted' - StreamChat::BookmarkDeletedEvent + GetStream::Generated::Models::BookmarkDeletedEvent when 'feeds.bookmark.updated' - StreamChat::BookmarkUpdatedEvent + GetStream::Generated::Models::BookmarkUpdatedEvent when 'feeds.bookmark_folder.deleted' - StreamChat::BookmarkFolderDeletedEvent + GetStream::Generated::Models::BookmarkFolderDeletedEvent when 'feeds.bookmark_folder.updated' - StreamChat::BookmarkFolderUpdatedEvent + GetStream::Generated::Models::BookmarkFolderUpdatedEvent when 'feeds.comment.added' - StreamChat::CommentAddedEvent + GetStream::Generated::Models::CommentAddedEvent when 'feeds.comment.deleted' - StreamChat::CommentDeletedEvent + GetStream::Generated::Models::CommentDeletedEvent when 'feeds.comment.reaction.added' - StreamChat::CommentReactionAddedEvent + GetStream::Generated::Models::CommentReactionAddedEvent when 'feeds.comment.reaction.deleted' - StreamChat::CommentReactionDeletedEvent + GetStream::Generated::Models::CommentReactionDeletedEvent when 'feeds.comment.reaction.updated' - StreamChat::CommentReactionUpdatedEvent + GetStream::Generated::Models::CommentReactionUpdatedEvent when 'feeds.comment.restored' - StreamChat::CommentRestoredEvent + GetStream::Generated::Models::CommentRestoredEvent when 'feeds.comment.updated' - StreamChat::CommentUpdatedEvent + GetStream::Generated::Models::CommentUpdatedEvent when 'feeds.feed.created' - StreamChat::FeedCreatedEvent + GetStream::Generated::Models::FeedCreatedEvent when 'feeds.feed.deleted' - StreamChat::FeedDeletedEvent + GetStream::Generated::Models::FeedDeletedEvent when 'feeds.feed.updated' - StreamChat::FeedUpdatedEvent + GetStream::Generated::Models::FeedUpdatedEvent when 'feeds.feed_group.changed' - StreamChat::FeedGroupChangedEvent + GetStream::Generated::Models::FeedGroupChangedEvent when 'feeds.feed_group.deleted' - StreamChat::FeedGroupDeletedEvent + GetStream::Generated::Models::FeedGroupDeletedEvent when 'feeds.feed_group.restored' - StreamChat::FeedGroupRestoredEvent + GetStream::Generated::Models::FeedGroupRestoredEvent when 'feeds.feed_member.added' - StreamChat::FeedMemberAddedEvent + GetStream::Generated::Models::FeedMemberAddedEvent when 'feeds.feed_member.removed' - StreamChat::FeedMemberRemovedEvent + GetStream::Generated::Models::FeedMemberRemovedEvent when 'feeds.feed_member.updated' - StreamChat::FeedMemberUpdatedEvent + GetStream::Generated::Models::FeedMemberUpdatedEvent when 'feeds.follow.created' - StreamChat::FollowCreatedEvent + GetStream::Generated::Models::FollowCreatedEvent when 'feeds.follow.deleted' - StreamChat::FollowDeletedEvent + GetStream::Generated::Models::FollowDeletedEvent when 'feeds.follow.updated' - StreamChat::FollowUpdatedEvent + GetStream::Generated::Models::FollowUpdatedEvent when 'feeds.notification_feed.updated' - StreamChat::NotificationFeedUpdatedEvent + GetStream::Generated::Models::NotificationFeedUpdatedEvent when 'feeds.stories_feed.updated' - StreamChat::StoriesFeedUpdatedEvent + GetStream::Generated::Models::StoriesFeedUpdatedEvent when 'flag.updated' - StreamChat::FlagUpdatedEvent + GetStream::Generated::Models::FlagUpdatedEvent when 'ingress.error' - StreamChat::IngressErrorEvent + GetStream::Generated::Models::IngressErrorEvent when 'ingress.started' - StreamChat::IngressStartedEvent + GetStream::Generated::Models::IngressStartedEvent when 'ingress.stopped' - StreamChat::IngressStoppedEvent + GetStream::Generated::Models::IngressStoppedEvent when 'member.added' - StreamChat::MemberAddedEvent + GetStream::Generated::Models::MemberAddedEvent when 'member.removed' - StreamChat::MemberRemovedEvent + GetStream::Generated::Models::MemberRemovedEvent when 'member.updated' - StreamChat::MemberUpdatedEvent + GetStream::Generated::Models::MemberUpdatedEvent when 'message.deleted' - StreamChat::MessageDeletedEvent + GetStream::Generated::Models::MessageDeletedEvent when 'message.flagged' - StreamChat::MessageFlaggedEvent + GetStream::Generated::Models::MessageFlaggedEvent when 'message.new' - StreamChat::MessageNewEvent + GetStream::Generated::Models::MessageNewEvent when 'message.pending' - StreamChat::PendingMessageEvent + GetStream::Generated::Models::PendingMessageEvent when 'message.read' - StreamChat::MessageReadEvent + GetStream::Generated::Models::MessageReadEvent when 'message.unblocked' - StreamChat::MessageUnblockedEvent + GetStream::Generated::Models::MessageUnblockedEvent when 'message.undeleted' - StreamChat::MessageUndeletedEvent + GetStream::Generated::Models::MessageUndeletedEvent when 'message.updated' - StreamChat::MessageUpdatedEvent + GetStream::Generated::Models::MessageUpdatedEvent when 'moderation.custom_action' - StreamChat::ModerationCustomActionEvent + GetStream::Generated::Models::ModerationCustomActionEvent when 'moderation.flagged' - StreamChat::ModerationFlaggedEvent + GetStream::Generated::Models::ModerationFlaggedEvent when 'moderation.mark_reviewed' - StreamChat::ModerationMarkReviewedEvent + GetStream::Generated::Models::ModerationMarkReviewedEvent when 'moderation_check.completed' - StreamChat::ModerationCheckCompletedEvent + GetStream::Generated::Models::ModerationCheckCompletedEvent when 'moderation_rule.triggered' - StreamChat::ModerationRulesTriggeredEvent + GetStream::Generated::Models::ModerationRulesTriggeredEvent when 'notification.mark_unread' - StreamChat::NotificationMarkUnreadEvent + GetStream::Generated::Models::NotificationMarkUnreadEvent when 'notification.reminder_due' - StreamChat::ReminderNotificationEvent + GetStream::Generated::Models::ReminderNotificationEvent when 'notification.thread_message_new' - StreamChat::NotificationThreadMessageNewEvent + GetStream::Generated::Models::NotificationThreadMessageNewEvent when 'reaction.deleted' - StreamChat::ReactionDeletedEvent + GetStream::Generated::Models::ReactionDeletedEvent when 'reaction.new' - StreamChat::ReactionNewEvent + GetStream::Generated::Models::ReactionNewEvent when 'reaction.updated' - StreamChat::ReactionUpdatedEvent + GetStream::Generated::Models::ReactionUpdatedEvent when 'reminder.created' - StreamChat::ReminderCreatedEvent + GetStream::Generated::Models::ReminderCreatedEvent when 'reminder.deleted' - StreamChat::ReminderDeletedEvent + GetStream::Generated::Models::ReminderDeletedEvent when 'reminder.updated' - StreamChat::ReminderUpdatedEvent + GetStream::Generated::Models::ReminderUpdatedEvent when 'review_queue_item.new' - StreamChat::ReviewQueueItemNewEvent + GetStream::Generated::Models::ReviewQueueItemNewEvent when 'review_queue_item.updated' - StreamChat::ReviewQueueItemUpdatedEvent + GetStream::Generated::Models::ReviewQueueItemUpdatedEvent when 'thread.updated' - StreamChat::ThreadUpdatedEvent + GetStream::Generated::Models::ThreadUpdatedEvent when 'user.banned' - StreamChat::UserBannedEvent + GetStream::Generated::Models::UserBannedEvent when 'user.deactivated' - StreamChat::UserDeactivatedEvent + GetStream::Generated::Models::UserDeactivatedEvent when 'user.deleted' - StreamChat::UserDeletedEvent + GetStream::Generated::Models::UserDeletedEvent when 'user.flagged' - StreamChat::UserFlaggedEvent + GetStream::Generated::Models::UserFlaggedEvent when 'user.messages.deleted' - StreamChat::UserMessagesDeletedEvent + GetStream::Generated::Models::UserMessagesDeletedEvent when 'user.muted' - StreamChat::UserMutedEvent + GetStream::Generated::Models::UserMutedEvent when 'user.reactivated' - StreamChat::UserReactivatedEvent + GetStream::Generated::Models::UserReactivatedEvent when 'user.unbanned' - StreamChat::UserUnbannedEvent + GetStream::Generated::Models::UserUnbannedEvent when 'user.unmuted' - StreamChat::UserUnmutedEvent + GetStream::Generated::Models::UserUnmutedEvent when 'user.unread_message_reminder' - StreamChat::UserUnreadReminderEvent + GetStream::Generated::Models::UserUnreadReminderEvent when 'user.updated' - StreamChat::UserUpdatedEvent + GetStream::Generated::Models::UserUpdatedEvent when 'user_group.created' - StreamChat::UserGroupCreatedEvent + GetStream::Generated::Models::UserGroupCreatedEvent when 'user_group.deleted' - StreamChat::UserGroupDeletedEvent + GetStream::Generated::Models::UserGroupDeletedEvent when 'user_group.member_added' - StreamChat::UserGroupMemberAddedEvent + GetStream::Generated::Models::UserGroupMemberAddedEvent when 'user_group.member_removed' - StreamChat::UserGroupMemberRemovedEvent + GetStream::Generated::Models::UserGroupMemberRemovedEvent when 'user_group.updated' - StreamChat::UserGroupUpdatedEvent + GetStream::Generated::Models::UserGroupUpdatedEvent else nil end @@ -923,17 +923,17 @@ def self.verify_and_parse_webhook(body, signature, secret) # @param message_body [String] # @return [Object] the typed event class instance or {UnknownEvent} # @raise [InvalidWebhookError] - def self.parse_sqs_payload(message_body) + def self.parse_sqs(message_body) parse_event(decode_sqs_payload(message_body)) end # SNS composite: parse SNS envelope -> base64-decode -> gunzip -> parse. - # Same no-signature posture as {parse_sqs_payload}. + # Same no-signature posture as {parse_sqs}. # # @param notification_body [String] # @return [Object] the typed event class instance or {UnknownEvent} # @raise [InvalidWebhookError] - def self.parse_sns_payload(notification_body) + def self.parse_sns(notification_body) parse_event(decode_sns_payload(notification_body)) end end diff --git a/test/webhook_test.rb b/test/webhook_test.rb index b809e42..ad134f5 100644 --- a/test/webhook_test.rb +++ b/test/webhook_test.rb @@ -908,7 +908,7 @@ def test_parse_webhook_event_invalid_json # --------------------------------------------------------------------------- # Spec §6 helpers + composites: parse_event, gunzip_payload, decode_sqs_payload, - # decode_sns_payload, verify_and_parse_webhook, parse_sqs_payload, parse_sns_payload. + # decode_sns_payload, verify_and_parse_webhook, parse_sqs, parse_sns. # --------------------------------------------------------------------------- def test_stream_webhook_canonical_alias_resolves @@ -1031,18 +1031,18 @@ def test_verify_and_parse_webhook_raises_on_tampered_body end end - def test_parse_sqs_payload_happy_path + def test_parse_sqs_happy_path plain = '{"type":"message.new"}' - refute_nil StreamChat::Webhook.parse_sqs_payload(Base64.strict_encode64(plain)) + refute_nil StreamChat::Webhook.parse_sqs(Base64.strict_encode64(plain)) end - def test_parse_sns_payload_happy_path + def test_parse_sns_happy_path plain = '{"type":"message.new"}' envelope = JSON.generate( 'Type' => 'Notification', 'Message' => Base64.strict_encode64(plain) ) - refute_nil StreamChat::Webhook.parse_sns_payload(envelope) + refute_nil StreamChat::Webhook.parse_sns(envelope) end end @@ -1090,12 +1090,12 @@ def test_happy_fixtures "verify_and_parse_webhook (identity) failed for #{name}" refute_nil StreamChat::Webhook.verify_and_parse_webhook(body_gz, sig, CANONICAL_TEST_SECRET), "verify_and_parse_webhook (gzip) failed for #{name}" - refute_nil StreamChat::Webhook.parse_sqs_payload(sqs_compressed), - "parse_sqs_payload (compressed) failed for #{name}" - refute_nil StreamChat::Webhook.parse_sqs_payload(sqs_raw), - "parse_sqs_payload (uncompressed) failed for #{name}" - refute_nil StreamChat::Webhook.parse_sns_payload(sns), - "parse_sns_payload failed for #{name}" + refute_nil StreamChat::Webhook.parse_sqs(sqs_compressed), + "parse_sqs (compressed) failed for #{name}" + refute_nil StreamChat::Webhook.parse_sqs(sqs_raw), + "parse_sqs (uncompressed) failed for #{name}" + refute_nil StreamChat::Webhook.parse_sns(sns), + "parse_sns failed for #{name}" end end @@ -1168,7 +1168,7 @@ def test_bad_base64 msg = File.read(File.join(neg_dir('bad_base64'), 'sqs_body.txt')).strip err = assert_raises(StreamChat::Webhook::InvalidWebhookError) do - StreamChat::Webhook.parse_sqs_payload(msg) + StreamChat::Webhook.parse_sqs(msg) end assert_includes err.message, 'base64' end @@ -1178,7 +1178,7 @@ def test_bad_sns_envelope notif = File.read(File.join(neg_dir('bad_sns_envelope'), 'sns_notification.txt')).strip err = assert_raises(StreamChat::Webhook::InvalidWebhookError) do - StreamChat::Webhook.parse_sns_payload(notif) + StreamChat::Webhook.parse_sns(notif) end assert_includes err.message, 'SNS envelope' end From bc3718fe309cde899ea62748cf2f023acad4affe Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Tue, 12 May 2026 19:03:32 +0200 Subject: [PATCH 3/8] feat(webhook): add parse_sqs/parse_sns instance methods on Client --- CHANGELOG.md | 2 ++ lib/getstream_ruby/client.rb | 17 +++++++++++++++++ lib/getstream_ruby/generated/feed.rb | 4 +++- lib/getstream_ruby/generated/feeds_client.rb | 4 +++- .../models/delete_feeds_batch_request.rb | 7 ++++++- 5 files changed, 31 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53d16f7..9805772 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ - New instance methods on `GetStreamRuby::Client`: `verify_signature(body, signature)` and `verify_and_parse_webhook(body, signature)` — drop the `api_secret` parameter in favor of the client's stored secret. Dual API: module-level methods remain available. +- New instance methods on `GetStreamRuby::Client`: `parse_sqs(message_body)` and + `parse_sns(notification_body)` (no signature; AWS IAM). - Conformance fixture suite under `test/fixtures/webhooks/` (14 event-type buckets plus `_invalid/` negative cases). diff --git a/lib/getstream_ruby/client.rb b/lib/getstream_ruby/client.rb index aa2ceb4..68a7398 100644 --- a/lib/getstream_ruby/client.rb +++ b/lib/getstream_ruby/client.rb @@ -106,6 +106,23 @@ def verify_and_parse_webhook(body, signature) StreamChat::Webhook.verify_and_parse_webhook(body, signature, @configuration.api_secret) end + # Decode + parse a Stream-delivered SQS message body. + # + # Convenience wrapper around StreamChat::Webhook.parse_sqs. No signature is + # required; SQS deliveries are authenticated via AWS IAM. + def parse_sqs(message_body) + StreamChat::Webhook.parse_sqs(message_body) + end + + # Decode + parse a Stream-delivered SNS notification body. + # + # Accepts either the raw SNS HTTP envelope JSON or the pre-extracted Message + # string. Convenience wrapper around StreamChat::Webhook.parse_sns. No signature + # is required; SNS deliveries are authenticated via AWS IAM. + def parse_sns(notification_body) + StreamChat::Webhook.parse_sns(notification_body) + end + # @param path [String] The API path # @param body [Hash] The request body # @return [GetStreamRuby::StreamResponse] The API response diff --git a/lib/getstream_ruby/generated/feed.rb b/lib/getstream_ruby/generated/feed.rb index 82ea989..dc39cb6 100644 --- a/lib/getstream_ruby/generated/feed.rb +++ b/lib/getstream_ruby/generated/feed.rb @@ -17,11 +17,13 @@ def initialize(client, feed_group_id, feed_id) # Delete a single feed by its ID # # @param hard_delete [Boolean] + # @param purge_user_activities [Boolean] # @return [Models::DeleteFeedResponse] - def delete_feed(hard_delete = nil) + def delete_feed(hard_delete = nil, purge_user_activities = nil) # Build query parameters query_params = {} query_params['hard_delete'] = hard_delete unless hard_delete.nil? + query_params['purge_user_activities'] = purge_user_activities unless purge_user_activities.nil? # Delegate to the FeedsClient @client.feeds.delete_feed(@feed_group_id, @feed_id, query_params) diff --git a/lib/getstream_ruby/generated/feeds_client.rb b/lib/getstream_ruby/generated/feeds_client.rb index 5649841..fd461b9 100644 --- a/lib/getstream_ruby/generated/feeds_client.rb +++ b/lib/getstream_ruby/generated/feeds_client.rb @@ -992,8 +992,9 @@ def create_feed_group(create_feed_group_request) # @param feed_group_id [String] # @param feed_id [String] # @param hard_delete [Boolean] + # @param purge_user_activities [Boolean] # @return [Models::DeleteFeedResponse] - def delete_feed(feed_group_id, feed_id, hard_delete = nil) + def delete_feed(feed_group_id, feed_id, hard_delete = nil, purge_user_activities = nil) path = '/api/v2/feeds/feed_groups/{feed_group_id}/feeds/{feed_id}' # Replace path parameters path = path.gsub('{feed_group_id}', feed_group_id.to_s) @@ -1001,6 +1002,7 @@ def delete_feed(feed_group_id, feed_id, hard_delete = nil) # Build query parameters query_params = {} query_params['hard_delete'] = hard_delete unless hard_delete.nil? + query_params['purge_user_activities'] = purge_user_activities unless purge_user_activities.nil? # Make the API request @client.make_request( diff --git a/lib/getstream_ruby/generated/models/delete_feeds_batch_request.rb b/lib/getstream_ruby/generated/models/delete_feeds_batch_request.rb index 15461f8..7c97343 100644 --- a/lib/getstream_ruby/generated/models/delete_feeds_batch_request.rb +++ b/lib/getstream_ruby/generated/models/delete_feeds_batch_request.rb @@ -15,19 +15,24 @@ class DeleteFeedsBatchRequest < GetStream::BaseModel # @!attribute hard_delete # @return [Boolean] Whether to permanently delete the feeds instead of soft delete attr_accessor :hard_delete + # @!attribute purge_user_activities + # @return [Boolean] When hard-deleting, also fully delete activities authored by each feed's owner from every other feed those activities were fanned out to. Default false preserves existing fan-out. Requires 'hard_delete' to be true; the request is rejected otherwise. Feeds with no recorded owner (created_by_id is empty) are silently skipped for the purge step — owner-matching against an empty string is a safety guard, not a wildcard. + attr_accessor :purge_user_activities # Initialize with attributes def initialize(attributes = {}) super(attributes) @feeds = attributes[:feeds] || attributes['feeds'] @hard_delete = attributes[:hard_delete] || attributes['hard_delete'] || nil + @purge_user_activities = attributes[:purge_user_activities] || attributes['purge_user_activities'] || nil end # Override field mappings for JSON serialization def self.json_field_mappings { feeds: 'feeds', - hard_delete: 'hard_delete' + hard_delete: 'hard_delete', + purge_user_activities: 'purge_user_activities' } end end From 36dd33422c5bd33707285dd704c072b298b86b33 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Wed, 13 May 2026 12:34:11 +0200 Subject: [PATCH 4/8] chore: regenerate webhook helpers with base64 fallback for plain-JSON SQS --- .../generated/models/labels_request.rb | 5 ++++ lib/getstream_ruby/generated/webhook.rb | 29 ++++++++++++------- test/webhook_test.rb | 17 +++++++---- 3 files changed, 34 insertions(+), 17 deletions(-) diff --git a/lib/getstream_ruby/generated/models/labels_request.rb b/lib/getstream_ruby/generated/models/labels_request.rb index 710c73c..25a403f 100644 --- a/lib/getstream_ruby/generated/models/labels_request.rb +++ b/lib/getstream_ruby/generated/models/labels_request.rb @@ -21,6 +21,9 @@ class LabelsRequest < GetStream::BaseModel # @!attribute content_type # @return [String] Type of content: 'text' (default), 'message', or 'username'. Stored as-sent; only 'username' routes to the username moderation API. attr_accessor :content_type + # @!attribute dry_run + # @return [Boolean] When true, run moderation and return labels without persisting the result. Useful for one-off checks (e.g. UI testers) that should not be recorded in the stored history. + attr_accessor :dry_run # @!attribute policy # @return [String] Optional moderation policy key (max 128 chars) attr_accessor :policy @@ -35,6 +38,7 @@ def initialize(attributes = {}) @category = attributes[:category] || attributes['category'] || nil @content_id = attributes[:content_id] || attributes['content_id'] || nil @content_type = attributes[:content_type] || attributes['content_type'] || nil + @dry_run = attributes[:dry_run] || attributes['dry_run'] || nil @policy = attributes[:policy] || attributes['policy'] || nil @user_id = attributes[:user_id] || attributes['user_id'] || nil end @@ -46,6 +50,7 @@ def self.json_field_mappings category: 'category', content_id: 'content_id', content_type: 'content_type', + dry_run: 'dry_run', policy: 'policy', user_id: 'user_id' } diff --git a/lib/getstream_ruby/generated/webhook.rb b/lib/getstream_ruby/generated/webhook.rb index 16616b5..a5448f0 100644 --- a/lib/getstream_ruby/generated/webhook.rb +++ b/lib/getstream_ruby/generated/webhook.rb @@ -802,26 +802,33 @@ def self.gunzip_payload(body) raise InvalidWebhookError, "gzip decompression failed: #{e.message}" end - # base64-decode an SQS Message Body, then gunzip if gzip-prefixed. + # Decode an SQS Message Body: try base64 first, fall back to raw bytes if + # base64 fails, then gunzip if gzip-prefixed. # - # Forward-compat: today the backend emits plain JSON to SQS; once compression - # is extended to queue transports, bodies will be base64(gzip(json)). This - # helper handles both cases via the magic-byte detection in {gunzip_payload}. + # Wire format (per CHA-3071): SQS bodies are raw JSON when + # enable_hook_payload_compression is off (today's default for all existing + # apps), and base64(gzip(json)) when it's on. This helper handles both: + # raw JSON starts with '{' which is not valid base64, so the base64 decode + # fails and we fall through to raw bytes, then {gunzip_payload}'s magic-byte + # detection decides whether to decompress. # - # Note: if the input is plain JSON (not base64), strict base64 decoding will - # fail and raise InvalidWebhookError. Callers receiving today's plain-JSON - # SQS messages should call {parse_event} directly with the body string. + # {parse_sqs} sits on top of this and works transparently for both wire + # formats — no caller code change, no flag, no header. # # @param message_body [String] # @return [String] - # @raise [InvalidWebhookError] + # @raise [InvalidWebhookError] only if gzip decompression fails (input had gzip magic prefix) def self.decode_sqs_payload(message_body) raise InvalidWebhookError, 'message_body must be a String' unless message_body.is_a?(String) - decoded = Base64.strict_decode64(message_body) + decoded = + begin + Base64.strict_decode64(message_body) + rescue ArgumentError + # Not base64 — treat input as raw bytes (uncompressed wire format). + message_body.dup.force_encoding(Encoding::ASCII_8BIT) + end gunzip_payload(decoded) - rescue ArgumentError => e - raise InvalidWebhookError, "invalid base64: #{e.message}" end # Extract the +Message+ field from a standard AWS SNS notification envelope, diff --git a/test/webhook_test.rb b/test/webhook_test.rb index ad134f5..13ccf27 100644 --- a/test/webhook_test.rb +++ b/test/webhook_test.rb @@ -980,10 +980,12 @@ def test_decode_sqs_payload_decodes_base64_gzip_body assert_equal plain, StreamChat::Webhook.decode_sqs_payload(encoded) end - def test_decode_sqs_payload_raises_on_invalid_base64 - assert_raises(StreamChat::Webhook::InvalidWebhookError) do - StreamChat::Webhook.decode_sqs_payload('!!!not base64!!!') - end + def test_decode_sqs_payload_passes_through_non_base64 + # Per chat#13392 wire format: SQS bodies are raw JSON when + # hook_payload_compression is off. decode_sqs_payload must fall back to + # raw bytes on non-base64 input rather than raise. + plain = '{"type":"message.new"}' + assert_equal plain, StreamChat::Webhook.decode_sqs_payload(plain) end def test_decode_sns_payload_extracts_and_decodes_message @@ -1164,13 +1166,16 @@ def test_bad_compression end def test_bad_base64 + # Per CHA-3071 wire format: decode_sqs_payload falls back to raw bytes when + # base64 decoding fails (uncompressed wire format). For input that is + # neither valid base64 nor valid JSON nor gzip-prefixed, parse_sqs still + # raises InvalidWebhookError — just down the chain at JSON parsing. skip 'fixtures not present' unless fixtures_present? msg = File.read(File.join(neg_dir('bad_base64'), 'sqs_body.txt')).strip - err = assert_raises(StreamChat::Webhook::InvalidWebhookError) do + assert_raises(StreamChat::Webhook::InvalidWebhookError) do StreamChat::Webhook.parse_sqs(msg) end - assert_includes err.message, 'base64' end def test_bad_sns_envelope From 26016ab20378bafd3345ac7436581e7247b58546 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Wed, 13 May 2026 14:14:23 +0200 Subject: [PATCH 5/8] chore: regenerate with P7 chat/ template fixes (error class split, SNS unwrap) --- lib/getstream_ruby/generated/webhook.rb | 101 +++++++++++------- .../bad_sns_envelope/sns_notification.txt | 2 +- test/webhook_test.rb | 54 ++++++---- 3 files changed, 93 insertions(+), 64 deletions(-) diff --git a/lib/getstream_ruby/generated/webhook.rb b/lib/getstream_ruby/generated/webhook.rb index a5448f0..d70efdc 100644 --- a/lib/getstream_ruby/generated/webhook.rb +++ b/lib/getstream_ruby/generated/webhook.rb @@ -172,14 +172,21 @@ module StreamChat module Webhook - # Raised for every webhook handling failure: signature mismatch, invalid - # JSON, missing/non-string +type+ field, gzip decompression failure, - # invalid base64 in a queue body, or a malformed SNS envelope. - # - # The unified class replaces the earlier split (separate signature vs. - # malformed errors): customers distinguish failure modes via the message - # substring or +cause+ chain rather than the class. - class InvalidWebhookError < StandardError; end + # Base class for every webhook handling failure. Rescue +Error+ for a + # single arm that covers all modes, or one of its subclasses + # ({InvalidSignatureError}, {MalformedWebhookError}) to distinguish + # security failures from parse failures. + class Error < StandardError; end + + # Raised when the X-Signature header does not match the HMAC-SHA256 of + # the body under the configured webhook secret. Treat as a security + # failure: log the source and reject the request. + class InvalidSignatureError < Error; end + + # Raised when the body could not be parsed: invalid JSON, missing/non- + # string +type+ field, gzip decompression failure, base64 failure, or + # malformed SNS envelope. Treat as a client/format problem. + class MalformedWebhookError < Error; end # Returned by parse_event when the type discriminator is well-formed but # unknown to this SDK version. @@ -789,17 +796,17 @@ def self.verify_signature(body, signature, secret) # # @param body [String] raw body (binary-safe) # @return [String] decompressed body, or the original body if not gzip-prefixed - # @raise [InvalidWebhookError] if body has the gzip magic prefix but + # @raise [MalformedWebhookError] if body has the gzip magic prefix but # isn't a valid gzip stream def self.gunzip_payload(body) - raise InvalidWebhookError, 'body must be a String' unless body.is_a?(String) + raise MalformedWebhookError, 'body must be a String' unless body.is_a?(String) bytes = body.b return bytes if bytes.bytesize < 2 || bytes.byteslice(0, 2) != GZIP_MAGIC Zlib.gunzip(bytes) rescue Zlib::Error => e - raise InvalidWebhookError, "gzip decompression failed: #{e.message}" + raise MalformedWebhookError, "gzip decompression failed: #{e.message}" end # Decode an SQS Message Body: try base64 first, fall back to raw bytes if @@ -817,9 +824,9 @@ def self.gunzip_payload(body) # # @param message_body [String] # @return [String] - # @raise [InvalidWebhookError] only if gzip decompression fails (input had gzip magic prefix) + # @raise [MalformedWebhookError] only if gzip decompression fails (input had gzip magic prefix) def self.decode_sqs_payload(message_body) - raise InvalidWebhookError, 'message_body must be a String' unless message_body.is_a?(String) + raise MalformedWebhookError, 'message_body must be a String' unless message_body.is_a?(String) decoded = begin @@ -831,25 +838,38 @@ def self.decode_sqs_payload(message_body) gunzip_payload(decoded) end - # Extract the +Message+ field from a standard AWS SNS notification envelope, - # then base64-decode and gunzip. Envelope shape: - # {"Type":"Notification","Message":"","MessageId":"...","Timestamp":"...","TopicArn":"..."} + # Return the inner +Message+ field when +body+ is a standard SNS + # notification envelope JSON; otherwise return +body+ unchanged so a + # pre-extracted Message string flows through. + # + # Heuristic: try to JSON-parse the input. If it yields a Hash with a + # String +Message+ field, that's the envelope shape — return the Message. + # Otherwise the input is presumed to BE the pre-extracted Message + # (base64-encoded bytes are not valid JSON, so this falls through cleanly). + def self.unwrap_sns_notification_body(body) + env = JSON.parse(body) + return env['Message'] if env.is_a?(Hash) && env['Message'].is_a?(String) + + body + rescue JSON::ParserError + body + end + private_class_method :unwrap_sns_notification_body + + # Decode an SNS notification body. Accepts either: + # * a full SNS HTTP notification envelope JSON + # ({"Type":"Notification","Message":"",...}), or + # * a pre-extracted Message string (forwarded-through-SQS path). + # The inner payload is then base64-decoded and gunzipped via + # {decode_sqs_payload}. # # @param notification_body [String] # @return [String] - # @raise [InvalidWebhookError] + # @raise [MalformedWebhookError] def self.decode_sns_payload(notification_body) - raise InvalidWebhookError, 'notification_body must be a String' unless notification_body.is_a?(String) + raise MalformedWebhookError, 'notification_body must be a String' unless notification_body.is_a?(String) - env = JSON.parse(notification_body) - raise InvalidWebhookError, 'SNS envelope must be a JSON object' unless env.is_a?(Hash) - - msg = env['Message'] - raise InvalidWebhookError, "SNS envelope missing 'Message' string field" unless msg.is_a?(String) - - decode_sqs_payload(msg) - rescue JSON::ParserError => e - raise InvalidWebhookError, "invalid SNS envelope JSON: #{e.message}" + decode_sqs_payload(unwrap_sns_notification_body(notification_body)) end # Parse a webhook payload and return the typed event for known discriminators @@ -860,18 +880,18 @@ def self.decode_sns_payload(notification_body) # # @param payload [String] # @return [Object] the typed event class instance or {UnknownEvent} - # @raise [InvalidWebhookError] for invalid JSON, missing/non-string type field, + # @raise [MalformedWebhookError] for invalid JSON, missing/non-string type field, # or known-type deserialization failure def self.parse_event(payload) - raise InvalidWebhookError, 'payload must be a String' unless payload.is_a?(String) - raise InvalidWebhookError, 'payload must not be empty' if payload.empty? + raise MalformedWebhookError, 'payload must be a String' unless payload.is_a?(String) + raise MalformedWebhookError, 'payload must not be empty' if payload.empty? data = JSON.parse(payload) - raise InvalidWebhookError, 'webhook payload must be a JSON object' unless data.is_a?(Hash) + raise MalformedWebhookError, 'webhook payload must be a JSON object' unless data.is_a?(Hash) event_type = data['type'] unless event_type.is_a?(String) && !event_type.empty? - raise InvalidWebhookError, "webhook payload missing 'type' string field" + raise MalformedWebhookError, "webhook payload missing 'type' string field" end event_class = event_class_for_type(event_type) @@ -880,10 +900,10 @@ def self.parse_event(payload) begin event_class.new(data) rescue StandardError => e - raise InvalidWebhookError, "failed to deserialize event: #{e.message}" + raise MalformedWebhookError, "failed to deserialize event: #{e.message}" end rescue JSON::ParserError => e - raise InvalidWebhookError, "failed to parse webhook payload: #{e.message}" + raise MalformedWebhookError, "failed to parse webhook payload: #{e.message}" end private_class_method def self.build_unknown_event(event_type, data) @@ -911,12 +931,13 @@ def self.parse_event(payload) # @param signature [String] X-Signature header value # @param secret [String] webhook secret # @return [Object] the typed event class instance or {UnknownEvent} - # @raise [InvalidWebhookError] for signature mismatches as well as - # parse/decompression failures; distinguish modes via the message - # substring + # @raise [InvalidSignatureError] if the X-Signature header does not match + # the HMAC-SHA256 of the body under the configured secret. + # @raise [MalformedWebhookError] if gunzip, JSON parse, type dispatch, or + # event deserialization fails. def self.verify_and_parse_webhook(body, signature, secret) payload = gunzip_payload(body) - raise InvalidWebhookError, 'webhook signature mismatch' unless verify_signature(payload, signature, secret) + raise InvalidSignatureError, 'webhook signature mismatch' unless verify_signature(payload, signature, secret) parse_event(payload) end @@ -929,7 +950,7 @@ def self.verify_and_parse_webhook(body, signature, secret) # # @param message_body [String] # @return [Object] the typed event class instance or {UnknownEvent} - # @raise [InvalidWebhookError] + # @raise [MalformedWebhookError] def self.parse_sqs(message_body) parse_event(decode_sqs_payload(message_body)) end @@ -939,7 +960,7 @@ def self.parse_sqs(message_body) # # @param notification_body [String] # @return [Object] the typed event class instance or {UnknownEvent} - # @raise [InvalidWebhookError] + # @raise [MalformedWebhookError] def self.parse_sns(notification_body) parse_event(decode_sns_payload(notification_body)) end diff --git a/test/fixtures/webhooks/_invalid/bad_sns_envelope/sns_notification.txt b/test/fixtures/webhooks/_invalid/bad_sns_envelope/sns_notification.txt index f2aa208..d98c2de 100644 --- a/test/fixtures/webhooks/_invalid/bad_sns_envelope/sns_notification.txt +++ b/test/fixtures/webhooks/_invalid/bad_sns_envelope/sns_notification.txt @@ -1 +1 @@ -{"Type":"Notification"} \ No newline at end of file +{"Type":"Notification","Message":"@@@not-valid-anything@@@"} \ No newline at end of file diff --git a/test/webhook_test.rb b/test/webhook_test.rb index 13ccf27..ed85255 100644 --- a/test/webhook_test.rb +++ b/test/webhook_test.rb @@ -926,25 +926,25 @@ def test_parse_event_returns_unknown_event_for_unknown_discriminator end def test_parse_event_empty_body - assert_raises(StreamChat::Webhook::InvalidWebhookError) do + assert_raises(StreamChat::Webhook::MalformedWebhookError) do StreamChat::Webhook.parse_event('') end end def test_parse_event_invalid_json - assert_raises(StreamChat::Webhook::InvalidWebhookError) do + assert_raises(StreamChat::Webhook::MalformedWebhookError) do StreamChat::Webhook.parse_event('not json') end end def test_parse_event_non_object_json - assert_raises(StreamChat::Webhook::InvalidWebhookError) do + assert_raises(StreamChat::Webhook::MalformedWebhookError) do StreamChat::Webhook.parse_event('[1,2,3]') end end def test_parse_event_missing_type - assert_raises(StreamChat::Webhook::InvalidWebhookError) do + assert_raises(StreamChat::Webhook::MalformedWebhookError) do StreamChat::Webhook.parse_event('{"foo":"bar"}') end end @@ -962,7 +962,7 @@ def test_gunzip_payload_decompresses_gzip_body def test_gunzip_payload_raises_on_corrupt_gzip corrupt = "\x1F\x8Bnot-actually-a-gzip-stream".b - assert_raises(StreamChat::Webhook::InvalidWebhookError) do + assert_raises(StreamChat::Webhook::MalformedWebhookError) do StreamChat::Webhook.gunzip_payload(corrupt) end end @@ -1000,16 +1000,16 @@ def test_decode_sns_payload_extracts_and_decodes_message assert_equal plain.b, StreamChat::Webhook.decode_sns_payload(envelope) end - def test_decode_sns_payload_raises_on_invalid_envelope - assert_raises(StreamChat::Webhook::InvalidWebhookError) do - StreamChat::Webhook.decode_sns_payload('not json') - end + def test_decode_sns_payload_treats_non_envelope_as_raw_message + # Fix #4: decode_sns_payload accepts either a full SNS envelope or a + # pre-extracted Message string. Non-envelope input flows through as bytes. + refute_nil StreamChat::Webhook.decode_sns_payload('not json') end - def test_decode_sns_payload_raises_on_missing_message_field - assert_raises(StreamChat::Webhook::InvalidWebhookError) do - StreamChat::Webhook.decode_sns_payload('{"Type":"Notification"}') - end + def test_decode_sns_payload_treats_missing_message_field_as_raw_message + # Fix #4: envelope-shaped JSON without a string Message field is treated + # as a pre-extracted Message string and flows through. + refute_nil StreamChat::Webhook.decode_sns_payload('{"Type":"Notification"}') end def test_verify_and_parse_webhook_happy_path @@ -1028,7 +1028,12 @@ def test_verify_and_parse_webhook_gzip_body def test_verify_and_parse_webhook_raises_on_tampered_body body = '{"type":"message.new"}' sig = compute_signature(body, SECRET) - assert_raises(StreamChat::Webhook::InvalidWebhookError) do + # Signature-mismatch path must raise InvalidSignatureError. Base class + # Webhook::Error must also match for single-arm rescuers. + assert_raises(StreamChat::Webhook::InvalidSignatureError) do + StreamChat::Webhook.verify_and_parse_webhook('{"type":"message.deleted"}', sig, SECRET) + end + assert_raises(StreamChat::Webhook::Error) do StreamChat::Webhook.verify_and_parse_webhook('{"type":"message.deleted"}', sig, SECRET) end end @@ -1110,7 +1115,7 @@ def test_tampered_body body = File.binread(File.join(neg_dir('tampered_body'), 'body.json')) sig = File.read(File.join(neg_dir('tampered_body'), 'signature.txt')).strip - err = assert_raises(StreamChat::Webhook::InvalidWebhookError) do + err = assert_raises(StreamChat::Webhook::MalformedWebhookError) do StreamChat::Webhook.verify_and_parse_webhook(body, sig, CANONICAL_TEST_SECRET) end assert_includes err.message, 'signature mismatch' @@ -1129,7 +1134,7 @@ def test_missing_type skip 'fixtures not present' unless fixtures_present? body = File.binread(File.join(neg_dir('missing_type'), 'body.json')) - err = assert_raises(StreamChat::Webhook::InvalidWebhookError) do + err = assert_raises(StreamChat::Webhook::MalformedWebhookError) do StreamChat::Webhook.parse_event(body) end assert_includes err.message, "missing 'type'" @@ -1139,7 +1144,7 @@ def test_malformed_json skip 'fixtures not present' unless fixtures_present? body = File.binread(File.join(neg_dir('malformed_json'), 'body.json')) - err = assert_raises(StreamChat::Webhook::InvalidWebhookError) do + err = assert_raises(StreamChat::Webhook::MalformedWebhookError) do StreamChat::Webhook.parse_event(body) end assert_includes err.message, 'failed to parse webhook payload' @@ -1149,7 +1154,7 @@ def test_empty_body skip 'fixtures not present' unless fixtures_present? body = File.binread(File.join(neg_dir('empty_body'), 'body.json')) - err = assert_raises(StreamChat::Webhook::InvalidWebhookError) do + err = assert_raises(StreamChat::Webhook::MalformedWebhookError) do StreamChat::Webhook.parse_event(body) end assert_includes err.message, 'must not be empty' @@ -1159,7 +1164,7 @@ def test_bad_compression skip 'fixtures not present' unless fixtures_present? body = File.binread(File.join(neg_dir('bad_compression'), 'body.gz')) - err = assert_raises(StreamChat::Webhook::InvalidWebhookError) do + err = assert_raises(StreamChat::Webhook::MalformedWebhookError) do StreamChat::Webhook.gunzip_payload(body) end assert_includes err.message, 'gzip decompression failed' @@ -1169,22 +1174,25 @@ def test_bad_base64 # Per CHA-3071 wire format: decode_sqs_payload falls back to raw bytes when # base64 decoding fails (uncompressed wire format). For input that is # neither valid base64 nor valid JSON nor gzip-prefixed, parse_sqs still - # raises InvalidWebhookError — just down the chain at JSON parsing. + # raises MalformedWebhookError — just down the chain at JSON parsing. skip 'fixtures not present' unless fixtures_present? msg = File.read(File.join(neg_dir('bad_base64'), 'sqs_body.txt')).strip - assert_raises(StreamChat::Webhook::InvalidWebhookError) do + assert_raises(StreamChat::Webhook::MalformedWebhookError) do StreamChat::Webhook.parse_sqs(msg) end end def test_bad_sns_envelope + # Fix #4: bad_sns_envelope (non-envelope JSON) is now treated as a + # pre-extracted Message string and flows through the SQS path, surfacing + # as a downstream parse failure rather than SNS-specific. Still + # MalformedWebhookError. skip 'fixtures not present' unless fixtures_present? notif = File.read(File.join(neg_dir('bad_sns_envelope'), 'sns_notification.txt')).strip - err = assert_raises(StreamChat::Webhook::InvalidWebhookError) do + assert_raises(StreamChat::Webhook::MalformedWebhookError) do StreamChat::Webhook.parse_sns(notif) end - assert_includes err.message, 'SNS envelope' end end From 0fbf3c8b54993dc058bf34ea770e5e8fa74b02d7 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Wed, 13 May 2026 14:53:57 +0200 Subject: [PATCH 6/8] chore: regenerate with unified InvalidWebhookError (revert error class split) --- .../models/async_export_error_event.rb | 2 +- lib/getstream_ruby/generated/webhook.rb | 69 +++++++++---------- test/webhook_test.rb | 39 +++++------ 3 files changed, 52 insertions(+), 58 deletions(-) diff --git a/lib/getstream_ruby/generated/models/async_export_error_event.rb b/lib/getstream_ruby/generated/models/async_export_error_event.rb index f785b8c..1e922a8 100644 --- a/lib/getstream_ruby/generated/models/async_export_error_event.rb +++ b/lib/getstream_ruby/generated/models/async_export_error_event.rb @@ -43,7 +43,7 @@ def initialize(attributes = {}) @started_at = attributes[:started_at] || attributes['started_at'] @task_id = attributes[:task_id] || attributes['task_id'] @custom = attributes[:custom] || attributes['custom'] - @type = attributes[:type] || attributes['type'] || "export.bulk_image_moderation.error" + @type = attributes[:type] || attributes['type'] || "export.users.error" @received_at = attributes[:received_at] || attributes['received_at'] || nil end diff --git a/lib/getstream_ruby/generated/webhook.rb b/lib/getstream_ruby/generated/webhook.rb index d70efdc..f3cd965 100644 --- a/lib/getstream_ruby/generated/webhook.rb +++ b/lib/getstream_ruby/generated/webhook.rb @@ -172,21 +172,18 @@ module StreamChat module Webhook - # Base class for every webhook handling failure. Rescue +Error+ for a - # single arm that covers all modes, or one of its subclasses - # ({InvalidSignatureError}, {MalformedWebhookError}) to distinguish - # security failures from parse failures. - class Error < StandardError; end - - # Raised when the X-Signature header does not match the HMAC-SHA256 of - # the body under the configured webhook secret. Treat as a security - # failure: log the source and reject the request. - class InvalidSignatureError < Error; end - - # Raised when the body could not be parsed: invalid JSON, missing/non- - # string +type+ field, gzip decompression failure, base64 failure, or - # malformed SNS envelope. Treat as a client/format problem. - class MalformedWebhookError < Error; end + # Raised for every webhook handling failure: signature mismatch, invalid + # JSON, missing/non-string +type+ field, gzip-prefixed body that fails to + # decompress, invalid base64 in a queue body, or a malformed SNS envelope. + # + # Rescue this single class for any webhook problem; filter on the message + # text or against the failure-mode constants below to differentiate. + class InvalidWebhookError < StandardError + SIGNATURE_MISMATCH = 'signature mismatch' + INVALID_BASE64 = 'invalid base64 encoding' + GZIP_FAILED = 'gzip decompression failed' + INVALID_JSON = 'invalid JSON payload' + end # Returned by parse_event when the type discriminator is well-formed but # unknown to this SDK version. @@ -796,17 +793,17 @@ def self.verify_signature(body, signature, secret) # # @param body [String] raw body (binary-safe) # @return [String] decompressed body, or the original body if not gzip-prefixed - # @raise [MalformedWebhookError] if body has the gzip magic prefix but + # @raise [InvalidWebhookError] if body has the gzip magic prefix but # isn't a valid gzip stream def self.gunzip_payload(body) - raise MalformedWebhookError, 'body must be a String' unless body.is_a?(String) + raise InvalidWebhookError, "#{InvalidWebhookError::INVALID_JSON}: body must be a String" unless body.is_a?(String) bytes = body.b return bytes if bytes.bytesize < 2 || bytes.byteslice(0, 2) != GZIP_MAGIC Zlib.gunzip(bytes) rescue Zlib::Error => e - raise MalformedWebhookError, "gzip decompression failed: #{e.message}" + raise InvalidWebhookError, "#{InvalidWebhookError::GZIP_FAILED}: #{e.message}" end # Decode an SQS Message Body: try base64 first, fall back to raw bytes if @@ -824,9 +821,9 @@ def self.gunzip_payload(body) # # @param message_body [String] # @return [String] - # @raise [MalformedWebhookError] only if gzip decompression fails (input had gzip magic prefix) + # @raise [InvalidWebhookError] only if gzip decompression fails (input had gzip magic prefix) def self.decode_sqs_payload(message_body) - raise MalformedWebhookError, 'message_body must be a String' unless message_body.is_a?(String) + raise InvalidWebhookError, "#{InvalidWebhookError::INVALID_JSON}: message_body must be a String" unless message_body.is_a?(String) decoded = begin @@ -865,9 +862,9 @@ def self.unwrap_sns_notification_body(body) # # @param notification_body [String] # @return [String] - # @raise [MalformedWebhookError] + # @raise [InvalidWebhookError] def self.decode_sns_payload(notification_body) - raise MalformedWebhookError, 'notification_body must be a String' unless notification_body.is_a?(String) + raise InvalidWebhookError, "#{InvalidWebhookError::INVALID_JSON}: notification_body must be a String" unless notification_body.is_a?(String) decode_sqs_payload(unwrap_sns_notification_body(notification_body)) end @@ -880,18 +877,18 @@ def self.decode_sns_payload(notification_body) # # @param payload [String] # @return [Object] the typed event class instance or {UnknownEvent} - # @raise [MalformedWebhookError] for invalid JSON, missing/non-string type field, + # @raise [InvalidWebhookError] for invalid JSON, missing/non-string type field, # or known-type deserialization failure def self.parse_event(payload) - raise MalformedWebhookError, 'payload must be a String' unless payload.is_a?(String) - raise MalformedWebhookError, 'payload must not be empty' if payload.empty? + raise InvalidWebhookError, "#{InvalidWebhookError::INVALID_JSON}: payload must be a String" unless payload.is_a?(String) + raise InvalidWebhookError, "#{InvalidWebhookError::INVALID_JSON}: payload must not be empty" if payload.empty? data = JSON.parse(payload) - raise MalformedWebhookError, 'webhook payload must be a JSON object' unless data.is_a?(Hash) + raise InvalidWebhookError, "#{InvalidWebhookError::INVALID_JSON}: webhook payload must be a JSON object" unless data.is_a?(Hash) event_type = data['type'] unless event_type.is_a?(String) && !event_type.empty? - raise MalformedWebhookError, "webhook payload missing 'type' string field" + raise InvalidWebhookError, "#{InvalidWebhookError::INVALID_JSON}: webhook payload missing 'type' string field" end event_class = event_class_for_type(event_type) @@ -900,10 +897,10 @@ def self.parse_event(payload) begin event_class.new(data) rescue StandardError => e - raise MalformedWebhookError, "failed to deserialize event: #{e.message}" + raise InvalidWebhookError, "#{InvalidWebhookError::INVALID_JSON}: failed to deserialize event: #{e.message}" end rescue JSON::ParserError => e - raise MalformedWebhookError, "failed to parse webhook payload: #{e.message}" + raise InvalidWebhookError, "#{InvalidWebhookError::INVALID_JSON}: failed to parse webhook payload: #{e.message}" end private_class_method def self.build_unknown_event(event_type, data) @@ -931,13 +928,13 @@ def self.parse_event(payload) # @param signature [String] X-Signature header value # @param secret [String] webhook secret # @return [Object] the typed event class instance or {UnknownEvent} - # @raise [InvalidSignatureError] if the X-Signature header does not match - # the HMAC-SHA256 of the body under the configured secret. - # @raise [MalformedWebhookError] if gunzip, JSON parse, type dispatch, or - # event deserialization fails. + # @raise [InvalidWebhookError] for every failure mode: signature mismatch, + # gunzip failure, JSON parse, type dispatch, or event deserialization + # failure. Filter on the message text (or the failure-mode constants + # on InvalidWebhookError) to differentiate. def self.verify_and_parse_webhook(body, signature, secret) payload = gunzip_payload(body) - raise InvalidSignatureError, 'webhook signature mismatch' unless verify_signature(payload, signature, secret) + raise InvalidWebhookError, InvalidWebhookError::SIGNATURE_MISMATCH unless verify_signature(payload, signature, secret) parse_event(payload) end @@ -950,7 +947,7 @@ def self.verify_and_parse_webhook(body, signature, secret) # # @param message_body [String] # @return [Object] the typed event class instance or {UnknownEvent} - # @raise [MalformedWebhookError] + # @raise [InvalidWebhookError] def self.parse_sqs(message_body) parse_event(decode_sqs_payload(message_body)) end @@ -960,7 +957,7 @@ def self.parse_sqs(message_body) # # @param notification_body [String] # @return [Object] the typed event class instance or {UnknownEvent} - # @raise [MalformedWebhookError] + # @raise [InvalidWebhookError] def self.parse_sns(notification_body) parse_event(decode_sns_payload(notification_body)) end diff --git a/test/webhook_test.rb b/test/webhook_test.rb index ed85255..262b926 100644 --- a/test/webhook_test.rb +++ b/test/webhook_test.rb @@ -926,25 +926,25 @@ def test_parse_event_returns_unknown_event_for_unknown_discriminator end def test_parse_event_empty_body - assert_raises(StreamChat::Webhook::MalformedWebhookError) do + assert_raises(StreamChat::Webhook::InvalidWebhookError) do StreamChat::Webhook.parse_event('') end end def test_parse_event_invalid_json - assert_raises(StreamChat::Webhook::MalformedWebhookError) do + assert_raises(StreamChat::Webhook::InvalidWebhookError) do StreamChat::Webhook.parse_event('not json') end end def test_parse_event_non_object_json - assert_raises(StreamChat::Webhook::MalformedWebhookError) do + assert_raises(StreamChat::Webhook::InvalidWebhookError) do StreamChat::Webhook.parse_event('[1,2,3]') end end def test_parse_event_missing_type - assert_raises(StreamChat::Webhook::MalformedWebhookError) do + assert_raises(StreamChat::Webhook::InvalidWebhookError) do StreamChat::Webhook.parse_event('{"foo":"bar"}') end end @@ -962,7 +962,7 @@ def test_gunzip_payload_decompresses_gzip_body def test_gunzip_payload_raises_on_corrupt_gzip corrupt = "\x1F\x8Bnot-actually-a-gzip-stream".b - assert_raises(StreamChat::Webhook::MalformedWebhookError) do + assert_raises(StreamChat::Webhook::InvalidWebhookError) do StreamChat::Webhook.gunzip_payload(corrupt) end end @@ -1028,14 +1028,11 @@ def test_verify_and_parse_webhook_gzip_body def test_verify_and_parse_webhook_raises_on_tampered_body body = '{"type":"message.new"}' sig = compute_signature(body, SECRET) - # Signature-mismatch path must raise InvalidSignatureError. Base class - # Webhook::Error must also match for single-arm rescuers. - assert_raises(StreamChat::Webhook::InvalidSignatureError) do - StreamChat::Webhook.verify_and_parse_webhook('{"type":"message.deleted"}', sig, SECRET) - end - assert_raises(StreamChat::Webhook::Error) do + # Single-arm rescue on the unified InvalidWebhookError; message identifies the mode. + err = assert_raises(StreamChat::Webhook::InvalidWebhookError) do StreamChat::Webhook.verify_and_parse_webhook('{"type":"message.deleted"}', sig, SECRET) end + assert_includes err.message, StreamChat::Webhook::InvalidWebhookError::SIGNATURE_MISMATCH end def test_parse_sqs_happy_path @@ -1115,10 +1112,10 @@ def test_tampered_body body = File.binread(File.join(neg_dir('tampered_body'), 'body.json')) sig = File.read(File.join(neg_dir('tampered_body'), 'signature.txt')).strip - err = assert_raises(StreamChat::Webhook::MalformedWebhookError) do + err = assert_raises(StreamChat::Webhook::InvalidWebhookError) do StreamChat::Webhook.verify_and_parse_webhook(body, sig, CANONICAL_TEST_SECRET) end - assert_includes err.message, 'signature mismatch' + assert_includes err.message, StreamChat::Webhook::InvalidWebhookError::SIGNATURE_MISMATCH end def test_unknown_type_returns_unknown_event @@ -1134,7 +1131,7 @@ def test_missing_type skip 'fixtures not present' unless fixtures_present? body = File.binread(File.join(neg_dir('missing_type'), 'body.json')) - err = assert_raises(StreamChat::Webhook::MalformedWebhookError) do + err = assert_raises(StreamChat::Webhook::InvalidWebhookError) do StreamChat::Webhook.parse_event(body) end assert_includes err.message, "missing 'type'" @@ -1144,7 +1141,7 @@ def test_malformed_json skip 'fixtures not present' unless fixtures_present? body = File.binread(File.join(neg_dir('malformed_json'), 'body.json')) - err = assert_raises(StreamChat::Webhook::MalformedWebhookError) do + err = assert_raises(StreamChat::Webhook::InvalidWebhookError) do StreamChat::Webhook.parse_event(body) end assert_includes err.message, 'failed to parse webhook payload' @@ -1154,7 +1151,7 @@ def test_empty_body skip 'fixtures not present' unless fixtures_present? body = File.binread(File.join(neg_dir('empty_body'), 'body.json')) - err = assert_raises(StreamChat::Webhook::MalformedWebhookError) do + err = assert_raises(StreamChat::Webhook::InvalidWebhookError) do StreamChat::Webhook.parse_event(body) end assert_includes err.message, 'must not be empty' @@ -1164,7 +1161,7 @@ def test_bad_compression skip 'fixtures not present' unless fixtures_present? body = File.binread(File.join(neg_dir('bad_compression'), 'body.gz')) - err = assert_raises(StreamChat::Webhook::MalformedWebhookError) do + err = assert_raises(StreamChat::Webhook::InvalidWebhookError) do StreamChat::Webhook.gunzip_payload(body) end assert_includes err.message, 'gzip decompression failed' @@ -1174,11 +1171,11 @@ def test_bad_base64 # Per CHA-3071 wire format: decode_sqs_payload falls back to raw bytes when # base64 decoding fails (uncompressed wire format). For input that is # neither valid base64 nor valid JSON nor gzip-prefixed, parse_sqs still - # raises MalformedWebhookError — just down the chain at JSON parsing. + # raises InvalidWebhookError — just down the chain at JSON parsing. skip 'fixtures not present' unless fixtures_present? msg = File.read(File.join(neg_dir('bad_base64'), 'sqs_body.txt')).strip - assert_raises(StreamChat::Webhook::MalformedWebhookError) do + assert_raises(StreamChat::Webhook::InvalidWebhookError) do StreamChat::Webhook.parse_sqs(msg) end end @@ -1187,11 +1184,11 @@ def test_bad_sns_envelope # Fix #4: bad_sns_envelope (non-envelope JSON) is now treated as a # pre-extracted Message string and flows through the SQS path, surfacing # as a downstream parse failure rather than SNS-specific. Still - # MalformedWebhookError. + # InvalidWebhookError. skip 'fixtures not present' unless fixtures_present? notif = File.read(File.join(neg_dir('bad_sns_envelope'), 'sns_notification.txt')).strip - assert_raises(StreamChat::Webhook::MalformedWebhookError) do + assert_raises(StreamChat::Webhook::InvalidWebhookError) do StreamChat::Webhook.parse_sns(notif) end end From ce9807e5e0d66a085bf98c7a8f01dae62c486c69 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Fri, 15 May 2026 14:03:30 +0200 Subject: [PATCH 7/8] chore: regenerate with P9 chat/ template fixes (RSpec emission, namespace fix) --- .../models/async_export_error_event.rb | 2 +- spec/webhook_spec.rb | 1186 ++++++++++++++++ test/webhook_test.rb | 1195 ----------------- 3 files changed, 1187 insertions(+), 1196 deletions(-) create mode 100644 spec/webhook_spec.rb delete mode 100644 test/webhook_test.rb diff --git a/lib/getstream_ruby/generated/models/async_export_error_event.rb b/lib/getstream_ruby/generated/models/async_export_error_event.rb index 1e922a8..e297b0e 100644 --- a/lib/getstream_ruby/generated/models/async_export_error_event.rb +++ b/lib/getstream_ruby/generated/models/async_export_error_event.rb @@ -43,7 +43,7 @@ def initialize(attributes = {}) @started_at = attributes[:started_at] || attributes['started_at'] @task_id = attributes[:task_id] || attributes['task_id'] @custom = attributes[:custom] || attributes['custom'] - @type = attributes[:type] || attributes['type'] || "export.users.error" + @type = attributes[:type] || attributes['type'] || "export.channels.error" @received_at = attributes[:received_at] || attributes['received_at'] || nil end diff --git a/spec/webhook_spec.rb b/spec/webhook_spec.rb new file mode 100644 index 0000000..16e57f6 --- /dev/null +++ b/spec/webhook_spec.rb @@ -0,0 +1,1186 @@ +# Code generated by GetStream internal OpenAPI code generator. DO NOT EDIT. + +require 'spec_helper' + +require 'base64' +require 'json' +require 'openssl' +require 'zlib' + +require_relative '../lib/getstream_ruby/generated/webhook' + +RSpec.describe 'Webhook' do + SECRET = 'test-webhook-secret' + BODY = '{"type":"test.event"}' + + def compute_signature(body, secret) + OpenSSL::HMAC.hexdigest('SHA256', secret, body) + end + + describe 'verify_signature' do + it 'accepts a valid signature' do + signature = compute_signature(BODY, SECRET) + expect(StreamChat::Webhook.verify_signature(BODY, signature, SECRET)).to be true + end + + it 'rejects a wrong signature' do + expect(StreamChat::Webhook.verify_signature(BODY, 'invalidsignature', SECRET)).to be false + end + + it 'rejects a tampered body' do + signature = compute_signature(BODY, SECRET) + expect(StreamChat::Webhook.verify_signature('{"type":"tampered"}', signature, SECRET)).to be false + end + + it 'rejects a wrong secret' do + signature = compute_signature(BODY, SECRET) + expect(StreamChat::Webhook.verify_signature(BODY, signature, 'wrong-secret')).to be false + end + + it 'rejects an empty signature' do + expect(StreamChat::Webhook.verify_signature(BODY, '', SECRET)).to be false + end + end + + describe 'get_event_type' do + it 'reads the type from a string payload' do + expect(StreamChat::Webhook.get_event_type('{"type":"message.new"}')).to eq('message.new') + end + + it 'reads the type from a hash payload' do + expect(StreamChat::Webhook.get_event_type({ 'type' => 'message.new' })).to eq('message.new') + end + + it 'returns nil when the type field is missing' do + expect(StreamChat::Webhook.get_event_type('{"foo":"bar"}')).to be_nil + end + + it 'returns nil for an empty object' do + expect(StreamChat::Webhook.get_event_type('{}')).to be_nil + end + end + + describe 'parse_webhook_event' do + it 'parses *' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"*"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CustomEvent') + end + + it 'parses appeal.accepted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"appeal.accepted"}') + expect(event.class.name).to eq('GetStream::Generated::Models::AppealAcceptedEvent') + end + + it 'parses appeal.created' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"appeal.created"}') + expect(event.class.name).to eq('GetStream::Generated::Models::AppealCreatedEvent') + end + + it 'parses appeal.rejected' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"appeal.rejected"}') + expect(event.class.name).to eq('GetStream::Generated::Models::AppealRejectedEvent') + end + + it 'parses call.accepted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.accepted"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallAcceptedEvent') + end + + it 'parses call.blocked_user' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.blocked_user"}') + expect(event.class.name).to eq('GetStream::Generated::Models::BlockedUserEvent') + end + + it 'parses call.closed_caption' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.closed_caption"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ClosedCaptionEvent') + end + + it 'parses call.closed_captions_failed' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.closed_captions_failed"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallClosedCaptionsFailedEvent') + end + + it 'parses call.closed_captions_started' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.closed_captions_started"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallClosedCaptionsStartedEvent') + end + + it 'parses call.closed_captions_stopped' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.closed_captions_stopped"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallClosedCaptionsStoppedEvent') + end + + it 'parses call.created' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.created"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallCreatedEvent') + end + + it 'parses call.deleted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.deleted"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallDeletedEvent') + end + + it 'parses call.dtmf' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.dtmf"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallDTMFEvent') + end + + it 'parses call.ended' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.ended"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallEndedEvent') + end + + it 'parses call.frame_recording_failed' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.frame_recording_failed"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallFrameRecordingFailedEvent') + end + + it 'parses call.frame_recording_ready' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.frame_recording_ready"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallFrameRecordingFrameReadyEvent') + end + + it 'parses call.frame_recording_started' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.frame_recording_started"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallFrameRecordingStartedEvent') + end + + it 'parses call.frame_recording_stopped' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.frame_recording_stopped"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallFrameRecordingStoppedEvent') + end + + it 'parses call.hls_broadcasting_failed' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.hls_broadcasting_failed"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallHLSBroadcastingFailedEvent') + end + + it 'parses call.hls_broadcasting_started' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.hls_broadcasting_started"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallHLSBroadcastingStartedEvent') + end + + it 'parses call.hls_broadcasting_stopped' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.hls_broadcasting_stopped"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallHLSBroadcastingStoppedEvent') + end + + it 'parses call.kicked_user' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.kicked_user"}') + expect(event.class.name).to eq('GetStream::Generated::Models::KickedUserEvent') + end + + it 'parses call.live_started' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.live_started"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallLiveStartedEvent') + end + + it 'parses call.member_added' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.member_added"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallMemberAddedEvent') + end + + it 'parses call.member_removed' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.member_removed"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallMemberRemovedEvent') + end + + it 'parses call.member_updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.member_updated"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallMemberUpdatedEvent') + end + + it 'parses call.member_updated_permission' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.member_updated_permission"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallMemberUpdatedPermissionEvent') + end + + it 'parses call.missed' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.missed"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallMissedEvent') + end + + it 'parses call.moderation_blur' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.moderation_blur"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallModerationBlurEvent') + end + + it 'parses call.moderation_warning' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.moderation_warning"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallModerationWarningEvent') + end + + it 'parses call.notification' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.notification"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallNotificationEvent') + end + + it 'parses call.permission_request' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.permission_request"}') + expect(event.class.name).to eq('GetStream::Generated::Models::PermissionRequestEvent') + end + + it 'parses call.permissions_updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.permissions_updated"}') + expect(event.class.name).to eq('GetStream::Generated::Models::UpdatedCallPermissionsEvent') + end + + it 'parses call.reaction_new' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.reaction_new"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallReactionEvent') + end + + it 'parses call.recording_failed' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.recording_failed"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallRecordingFailedEvent') + end + + it 'parses call.recording_ready' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.recording_ready"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallRecordingReadyEvent') + end + + it 'parses call.recording_started' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.recording_started"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallRecordingStartedEvent') + end + + it 'parses call.recording_stopped' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.recording_stopped"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallRecordingStoppedEvent') + end + + it 'parses call.rejected' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.rejected"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallRejectedEvent') + end + + it 'parses call.ring' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.ring"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallRingEvent') + end + + it 'parses call.rtmp_broadcast_failed' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.rtmp_broadcast_failed"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallRtmpBroadcastFailedEvent') + end + + it 'parses call.rtmp_broadcast_started' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.rtmp_broadcast_started"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallRtmpBroadcastStartedEvent') + end + + it 'parses call.rtmp_broadcast_stopped' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.rtmp_broadcast_stopped"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallRtmpBroadcastStoppedEvent') + end + + it 'parses call.session_ended' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.session_ended"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallSessionEndedEvent') + end + + it 'parses call.session_participant_count_updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.session_participant_count_updated"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallSessionParticipantCountsUpdatedEvent') + end + + it 'parses call.session_participant_joined' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.session_participant_joined"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallSessionParticipantJoinedEvent') + end + + it 'parses call.session_participant_left' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.session_participant_left"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallSessionParticipantLeftEvent') + end + + it 'parses call.session_started' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.session_started"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallSessionStartedEvent') + end + + it 'parses call.stats_report_ready' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.stats_report_ready"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallStatsReportReadyEvent') + end + + it 'parses call.transcription_failed' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.transcription_failed"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallTranscriptionFailedEvent') + end + + it 'parses call.transcription_ready' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.transcription_ready"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallTranscriptionReadyEvent') + end + + it 'parses call.transcription_started' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.transcription_started"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallTranscriptionStartedEvent') + end + + it 'parses call.transcription_stopped' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.transcription_stopped"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallTranscriptionStoppedEvent') + end + + it 'parses call.unblocked_user' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.unblocked_user"}') + expect(event.class.name).to eq('GetStream::Generated::Models::UnblockedUserEvent') + end + + it 'parses call.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.updated"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallUpdatedEvent') + end + + it 'parses call.user_feedback_submitted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.user_feedback_submitted"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallUserFeedbackSubmittedEvent') + end + + it 'parses call.user_muted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.user_muted"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CallUserMutedEvent') + end + + it 'parses campaign.completed' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"campaign.completed"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CampaignCompletedEvent') + end + + it 'parses campaign.started' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"campaign.started"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CampaignStartedEvent') + end + + it 'parses channel.created' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"channel.created"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ChannelCreatedEvent') + end + + it 'parses channel.deleted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"channel.deleted"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ChannelDeletedEvent') + end + + it 'parses channel.frozen' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"channel.frozen"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ChannelFrozenEvent') + end + + it 'parses channel.hidden' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"channel.hidden"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ChannelHiddenEvent') + end + + it 'parses channel.max_streak_changed' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"channel.max_streak_changed"}') + expect(event.class.name).to eq('GetStream::Generated::Models::MaxStreakChangedEvent') + end + + it 'parses channel.muted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"channel.muted"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ChannelMutedEvent') + end + + it 'parses channel.truncated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"channel.truncated"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ChannelTruncatedEvent') + end + + it 'parses channel.unfrozen' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"channel.unfrozen"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ChannelUnFrozenEvent') + end + + it 'parses channel.unmuted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"channel.unmuted"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ChannelUnmutedEvent') + end + + it 'parses channel.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"channel.updated"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ChannelUpdatedEvent') + end + + it 'parses channel.visible' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"channel.visible"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ChannelVisibleEvent') + end + + it 'parses channel_batch_update.completed' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"channel_batch_update.completed"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ChannelBatchCompletedEvent') + end + + it 'parses channel_batch_update.started' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"channel_batch_update.started"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ChannelBatchStartedEvent') + end + + it 'parses custom' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"custom"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CustomVideoEvent') + end + + it 'parses export.bulk_image_moderation.error' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"export.bulk_image_moderation.error"}') + expect(event.class.name).to eq('GetStream::Generated::Models::AsyncExportErrorEvent') + end + + it 'parses export.bulk_image_moderation.success' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"export.bulk_image_moderation.success"}') + expect(event.class.name).to eq('GetStream::Generated::Models::AsyncBulkImageModerationEvent') + end + + it 'parses export.channels.error' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"export.channels.error"}') + expect(event.class.name).to eq('GetStream::Generated::Models::AsyncExportErrorEvent') + end + + it 'parses export.channels.success' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"export.channels.success"}') + expect(event.class.name).to eq('GetStream::Generated::Models::AsyncExportChannelsEvent') + end + + it 'parses export.moderation_logs.error' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"export.moderation_logs.error"}') + expect(event.class.name).to eq('GetStream::Generated::Models::AsyncExportErrorEvent') + end + + it 'parses export.moderation_logs.success' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"export.moderation_logs.success"}') + expect(event.class.name).to eq('GetStream::Generated::Models::AsyncExportModerationLogsEvent') + end + + it 'parses export.users.error' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"export.users.error"}') + expect(event.class.name).to eq('GetStream::Generated::Models::AsyncExportErrorEvent') + end + + it 'parses export.users.success' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"export.users.success"}') + expect(event.class.name).to eq('GetStream::Generated::Models::AsyncExportUsersEvent') + end + + it 'parses feeds.activity.added' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.activity.added"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ActivityAddedEvent') + end + + it 'parses feeds.activity.deleted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.activity.deleted"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ActivityDeletedEvent') + end + + it 'parses feeds.activity.feedback' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.activity.feedback"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ActivityFeedbackEvent') + end + + it 'parses feeds.activity.marked' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.activity.marked"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ActivityMarkEvent') + end + + it 'parses feeds.activity.pinned' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.activity.pinned"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ActivityPinnedEvent') + end + + it 'parses feeds.activity.reaction.added' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.activity.reaction.added"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ActivityReactionAddedEvent') + end + + it 'parses feeds.activity.reaction.deleted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.activity.reaction.deleted"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ActivityReactionDeletedEvent') + end + + it 'parses feeds.activity.reaction.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.activity.reaction.updated"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ActivityReactionUpdatedEvent') + end + + it 'parses feeds.activity.removed_from_feed' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.activity.removed_from_feed"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ActivityRemovedFromFeedEvent') + end + + it 'parses feeds.activity.restored' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.activity.restored"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ActivityRestoredEvent') + end + + it 'parses feeds.activity.unpinned' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.activity.unpinned"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ActivityUnpinnedEvent') + end + + it 'parses feeds.activity.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.activity.updated"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ActivityUpdatedEvent') + end + + it 'parses feeds.bookmark.added' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.bookmark.added"}') + expect(event.class.name).to eq('GetStream::Generated::Models::BookmarkAddedEvent') + end + + it 'parses feeds.bookmark.deleted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.bookmark.deleted"}') + expect(event.class.name).to eq('GetStream::Generated::Models::BookmarkDeletedEvent') + end + + it 'parses feeds.bookmark.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.bookmark.updated"}') + expect(event.class.name).to eq('GetStream::Generated::Models::BookmarkUpdatedEvent') + end + + it 'parses feeds.bookmark_folder.deleted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.bookmark_folder.deleted"}') + expect(event.class.name).to eq('GetStream::Generated::Models::BookmarkFolderDeletedEvent') + end + + it 'parses feeds.bookmark_folder.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.bookmark_folder.updated"}') + expect(event.class.name).to eq('GetStream::Generated::Models::BookmarkFolderUpdatedEvent') + end + + it 'parses feeds.comment.added' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.comment.added"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CommentAddedEvent') + end + + it 'parses feeds.comment.deleted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.comment.deleted"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CommentDeletedEvent') + end + + it 'parses feeds.comment.reaction.added' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.comment.reaction.added"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CommentReactionAddedEvent') + end + + it 'parses feeds.comment.reaction.deleted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.comment.reaction.deleted"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CommentReactionDeletedEvent') + end + + it 'parses feeds.comment.reaction.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.comment.reaction.updated"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CommentReactionUpdatedEvent') + end + + it 'parses feeds.comment.restored' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.comment.restored"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CommentRestoredEvent') + end + + it 'parses feeds.comment.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.comment.updated"}') + expect(event.class.name).to eq('GetStream::Generated::Models::CommentUpdatedEvent') + end + + it 'parses feeds.feed.created' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.feed.created"}') + expect(event.class.name).to eq('GetStream::Generated::Models::FeedCreatedEvent') + end + + it 'parses feeds.feed.deleted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.feed.deleted"}') + expect(event.class.name).to eq('GetStream::Generated::Models::FeedDeletedEvent') + end + + it 'parses feeds.feed.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.feed.updated"}') + expect(event.class.name).to eq('GetStream::Generated::Models::FeedUpdatedEvent') + end + + it 'parses feeds.feed_group.changed' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.feed_group.changed"}') + expect(event.class.name).to eq('GetStream::Generated::Models::FeedGroupChangedEvent') + end + + it 'parses feeds.feed_group.deleted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.feed_group.deleted"}') + expect(event.class.name).to eq('GetStream::Generated::Models::FeedGroupDeletedEvent') + end + + it 'parses feeds.feed_group.restored' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.feed_group.restored"}') + expect(event.class.name).to eq('GetStream::Generated::Models::FeedGroupRestoredEvent') + end + + it 'parses feeds.feed_member.added' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.feed_member.added"}') + expect(event.class.name).to eq('GetStream::Generated::Models::FeedMemberAddedEvent') + end + + it 'parses feeds.feed_member.removed' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.feed_member.removed"}') + expect(event.class.name).to eq('GetStream::Generated::Models::FeedMemberRemovedEvent') + end + + it 'parses feeds.feed_member.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.feed_member.updated"}') + expect(event.class.name).to eq('GetStream::Generated::Models::FeedMemberUpdatedEvent') + end + + it 'parses feeds.follow.created' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.follow.created"}') + expect(event.class.name).to eq('GetStream::Generated::Models::FollowCreatedEvent') + end + + it 'parses feeds.follow.deleted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.follow.deleted"}') + expect(event.class.name).to eq('GetStream::Generated::Models::FollowDeletedEvent') + end + + it 'parses feeds.follow.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.follow.updated"}') + expect(event.class.name).to eq('GetStream::Generated::Models::FollowUpdatedEvent') + end + + it 'parses feeds.notification_feed.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.notification_feed.updated"}') + expect(event.class.name).to eq('GetStream::Generated::Models::NotificationFeedUpdatedEvent') + end + + it 'parses feeds.stories_feed.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.stories_feed.updated"}') + expect(event.class.name).to eq('GetStream::Generated::Models::StoriesFeedUpdatedEvent') + end + + it 'parses flag.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"flag.updated"}') + expect(event.class.name).to eq('GetStream::Generated::Models::FlagUpdatedEvent') + end + + it 'parses ingress.error' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"ingress.error"}') + expect(event.class.name).to eq('GetStream::Generated::Models::IngressErrorEvent') + end + + it 'parses ingress.started' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"ingress.started"}') + expect(event.class.name).to eq('GetStream::Generated::Models::IngressStartedEvent') + end + + it 'parses ingress.stopped' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"ingress.stopped"}') + expect(event.class.name).to eq('GetStream::Generated::Models::IngressStoppedEvent') + end + + it 'parses member.added' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"member.added"}') + expect(event.class.name).to eq('GetStream::Generated::Models::MemberAddedEvent') + end + + it 'parses member.removed' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"member.removed"}') + expect(event.class.name).to eq('GetStream::Generated::Models::MemberRemovedEvent') + end + + it 'parses member.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"member.updated"}') + expect(event.class.name).to eq('GetStream::Generated::Models::MemberUpdatedEvent') + end + + it 'parses message.deleted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"message.deleted"}') + expect(event.class.name).to eq('GetStream::Generated::Models::MessageDeletedEvent') + end + + it 'parses message.flagged' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"message.flagged"}') + expect(event.class.name).to eq('GetStream::Generated::Models::MessageFlaggedEvent') + end + + it 'parses message.new' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"message.new"}') + expect(event.class.name).to eq('GetStream::Generated::Models::MessageNewEvent') + end + + it 'parses message.pending' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"message.pending"}') + expect(event.class.name).to eq('GetStream::Generated::Models::PendingMessageEvent') + end + + it 'parses message.read' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"message.read"}') + expect(event.class.name).to eq('GetStream::Generated::Models::MessageReadEvent') + end + + it 'parses message.unblocked' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"message.unblocked"}') + expect(event.class.name).to eq('GetStream::Generated::Models::MessageUnblockedEvent') + end + + it 'parses message.undeleted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"message.undeleted"}') + expect(event.class.name).to eq('GetStream::Generated::Models::MessageUndeletedEvent') + end + + it 'parses message.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"message.updated"}') + expect(event.class.name).to eq('GetStream::Generated::Models::MessageUpdatedEvent') + end + + it 'parses moderation.custom_action' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"moderation.custom_action"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ModerationCustomActionEvent') + end + + it 'parses moderation.flagged' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"moderation.flagged"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ModerationFlaggedEvent') + end + + it 'parses moderation.mark_reviewed' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"moderation.mark_reviewed"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ModerationMarkReviewedEvent') + end + + it 'parses moderation_check.completed' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"moderation_check.completed"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ModerationCheckCompletedEvent') + end + + it 'parses moderation_rule.triggered' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"moderation_rule.triggered"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ModerationRulesTriggeredEvent') + end + + it 'parses notification.mark_unread' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"notification.mark_unread"}') + expect(event.class.name).to eq('GetStream::Generated::Models::NotificationMarkUnreadEvent') + end + + it 'parses notification.reminder_due' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"notification.reminder_due"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ReminderNotificationEvent') + end + + it 'parses notification.thread_message_new' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"notification.thread_message_new"}') + expect(event.class.name).to eq('GetStream::Generated::Models::NotificationThreadMessageNewEvent') + end + + it 'parses reaction.deleted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"reaction.deleted"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ReactionDeletedEvent') + end + + it 'parses reaction.new' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"reaction.new"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ReactionNewEvent') + end + + it 'parses reaction.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"reaction.updated"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ReactionUpdatedEvent') + end + + it 'parses reminder.created' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"reminder.created"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ReminderCreatedEvent') + end + + it 'parses reminder.deleted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"reminder.deleted"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ReminderDeletedEvent') + end + + it 'parses reminder.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"reminder.updated"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ReminderUpdatedEvent') + end + + it 'parses review_queue_item.new' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"review_queue_item.new"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ReviewQueueItemNewEvent') + end + + it 'parses review_queue_item.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"review_queue_item.updated"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ReviewQueueItemUpdatedEvent') + end + + it 'parses thread.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"thread.updated"}') + expect(event.class.name).to eq('GetStream::Generated::Models::ThreadUpdatedEvent') + end + + it 'parses user.banned' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"user.banned"}') + expect(event.class.name).to eq('GetStream::Generated::Models::UserBannedEvent') + end + + it 'parses user.deactivated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"user.deactivated"}') + expect(event.class.name).to eq('GetStream::Generated::Models::UserDeactivatedEvent') + end + + it 'parses user.deleted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"user.deleted"}') + expect(event.class.name).to eq('GetStream::Generated::Models::UserDeletedEvent') + end + + it 'parses user.flagged' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"user.flagged"}') + expect(event.class.name).to eq('GetStream::Generated::Models::UserFlaggedEvent') + end + + it 'parses user.messages.deleted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"user.messages.deleted"}') + expect(event.class.name).to eq('GetStream::Generated::Models::UserMessagesDeletedEvent') + end + + it 'parses user.muted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"user.muted"}') + expect(event.class.name).to eq('GetStream::Generated::Models::UserMutedEvent') + end + + it 'parses user.reactivated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"user.reactivated"}') + expect(event.class.name).to eq('GetStream::Generated::Models::UserReactivatedEvent') + end + + it 'parses user.unbanned' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"user.unbanned"}') + expect(event.class.name).to eq('GetStream::Generated::Models::UserUnbannedEvent') + end + + it 'parses user.unmuted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"user.unmuted"}') + expect(event.class.name).to eq('GetStream::Generated::Models::UserUnmutedEvent') + end + + it 'parses user.unread_message_reminder' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"user.unread_message_reminder"}') + expect(event.class.name).to eq('GetStream::Generated::Models::UserUnreadReminderEvent') + end + + it 'parses user.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"user.updated"}') + expect(event.class.name).to eq('GetStream::Generated::Models::UserUpdatedEvent') + end + + it 'parses user_group.created' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"user_group.created"}') + expect(event.class.name).to eq('GetStream::Generated::Models::UserGroupCreatedEvent') + end + + it 'parses user_group.deleted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"user_group.deleted"}') + expect(event.class.name).to eq('GetStream::Generated::Models::UserGroupDeletedEvent') + end + + it 'parses user_group.member_added' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"user_group.member_added"}') + expect(event.class.name).to eq('GetStream::Generated::Models::UserGroupMemberAddedEvent') + end + + it 'parses user_group.member_removed' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"user_group.member_removed"}') + expect(event.class.name).to eq('GetStream::Generated::Models::UserGroupMemberRemovedEvent') + end + + it 'parses user_group.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"user_group.updated"}') + expect(event.class.name).to eq('GetStream::Generated::Models::UserGroupUpdatedEvent') + end + + it 'raises on unknown event type' do + expect { StreamChat::Webhook.parse_webhook_event('{"type":"unknown.event"}') }.to raise_error(ArgumentError) + end + + it 'raises when type field is missing' do + expect { StreamChat::Webhook.parse_webhook_event('{"foo":"bar"}') }.to raise_error(ArgumentError) + end + + it 'raises on invalid JSON' do + expect { StreamChat::Webhook.parse_webhook_event('not json') }.to raise_error(ArgumentError) + end + end + + # --------------------------------------------------------------------------- + # Spec §6 helpers + composites: parse_event, gunzip_payload, decode_sqs_payload, + # decode_sns_payload, verify_and_parse_webhook, parse_sqs, parse_sns. + # --------------------------------------------------------------------------- + + describe 'canonical alias' do + it 'resolves Stream::Webhook to StreamChat::Webhook' do + expect(Stream::Webhook).to eq(StreamChat::Webhook) + end + end + + describe 'parse_event' do + it 'returns an UnknownEvent for unknown discriminator' do + body = '{"type":"totally.made.up","created_at":"2026-05-08T00:00:00Z"}' + event = StreamChat::Webhook.parse_event(body) + expect(event).to be_a(StreamChat::Webhook::UnknownEvent) + expect(event.type).to eq('totally.made.up') + expect(event.created_at).not_to be_nil + expect(event.raw['type']).to eq('totally.made.up') + end + + it 'raises on empty body' do + expect { StreamChat::Webhook.parse_event('') }.to raise_error(StreamChat::Webhook::InvalidWebhookError) + end + + it 'raises on invalid JSON' do + expect { StreamChat::Webhook.parse_event('not json') }.to raise_error(StreamChat::Webhook::InvalidWebhookError) + end + + it 'raises on non-object JSON' do + expect { StreamChat::Webhook.parse_event('[1,2,3]') }.to raise_error(StreamChat::Webhook::InvalidWebhookError) + end + + it 'raises when type field is missing' do + expect { StreamChat::Webhook.parse_event('{"foo":"bar"}') }.to raise_error(StreamChat::Webhook::InvalidWebhookError) + end + end + + describe 'gunzip_payload' do + it 'passes through a plain body' do + plain = '{"type":"message.new"}' + expect(StreamChat::Webhook.gunzip_payload(plain)).to eq(plain.b) + end + + it 'decompresses a gzip body' do + plain = '{"type":"message.new"}' + gz = Zlib.gzip(plain) + expect(StreamChat::Webhook.gunzip_payload(gz)).to eq(plain) + end + + it 'raises on a corrupt gzip stream' do + corrupt = "\x1F\x8Bnot-actually-a-gzip-stream".b + expect { StreamChat::Webhook.gunzip_payload(corrupt) }.to raise_error(StreamChat::Webhook::InvalidWebhookError) + end + end + + describe 'decode_sqs_payload' do + it 'decodes plain base64' do + plain = '{"type":"message.new"}' + encoded = Base64.strict_encode64(plain) + expect(StreamChat::Webhook.decode_sqs_payload(encoded)).to eq(plain.b) + end + + it 'decodes base64-then-gzip payload' do + plain = '{"type":"message.new"}' + gz = Zlib.gzip(plain) + encoded = Base64.strict_encode64(gz) + expect(StreamChat::Webhook.decode_sqs_payload(encoded)).to eq(plain) + end + + it 'passes through non-base64 raw JSON' do + # Per chat#13392 wire format: SQS bodies are raw JSON when + # hook_payload_compression is off. decode_sqs_payload must fall back to + # raw bytes on non-base64 input rather than raise. + plain = '{"type":"message.new"}' + expect(StreamChat::Webhook.decode_sqs_payload(plain)).to eq(plain) + end + end + + describe 'decode_sns_payload' do + it 'extracts and decodes the inner Message' do + plain = '{"type":"message.new"}' + envelope = JSON.generate( + 'Type' => 'Notification', + 'Message' => Base64.strict_encode64(plain), + 'MessageId' => 'abc-123', + 'Timestamp' => '2026-05-08T00:00:00Z', + 'TopicArn' => 'arn:aws:sns:us-east-1:123:test' + ) + expect(StreamChat::Webhook.decode_sns_payload(envelope)).to eq(plain.b) + end + + it 'treats non-envelope input as a raw Message string' do + # Fix #4: decode_sns_payload accepts either a full SNS envelope or a + # pre-extracted Message string. Non-envelope input flows through as bytes. + expect(StreamChat::Webhook.decode_sns_payload('not json')).not_to be_nil + end + + it 'treats envelope-shape without Message as raw' do + # Fix #4: envelope-shaped JSON without a string Message field is treated + # as a pre-extracted Message string and flows through. + expect(StreamChat::Webhook.decode_sns_payload('{"Type":"Notification"}')).not_to be_nil + end + end + + describe 'verify_and_parse_webhook' do + it 'parses a valid happy-path body' do + body = '{"type":"message.new"}' + sig = compute_signature(body, SECRET) + expect(StreamChat::Webhook.verify_and_parse_webhook(body, sig, SECRET)).not_to be_nil + end + + it 'parses a valid gzip body' do + body = '{"type":"message.new"}' + sig = compute_signature(body, SECRET) + gz = Zlib.gzip(body) + expect(StreamChat::Webhook.verify_and_parse_webhook(gz, sig, SECRET)).not_to be_nil + end + + it 'raises on tampered body' do + body = '{"type":"message.new"}' + sig = compute_signature(body, SECRET) + # Single-arm rescue on the unified InvalidWebhookError; message identifies the mode. + expect do + StreamChat::Webhook.verify_and_parse_webhook('{"type":"message.deleted"}', sig, SECRET) + end.to raise_error(StreamChat::Webhook::InvalidWebhookError, /#{StreamChat::Webhook::InvalidWebhookError::SIGNATURE_MISMATCH}/) + end + end + + describe 'parse_sqs' do + it 'parses a happy-path SQS body' do + plain = '{"type":"message.new"}' + expect(StreamChat::Webhook.parse_sqs(Base64.strict_encode64(plain))).not_to be_nil + end + end + + describe 'parse_sns' do + it 'parses a happy-path SNS envelope' do + plain = '{"type":"message.new"}' + envelope = JSON.generate( + 'Type' => 'Notification', + 'Message' => Base64.strict_encode64(plain) + ) + expect(StreamChat::Webhook.parse_sns(envelope)).not_to be_nil + end + end +end + +# --------------------------------------------------------------------------- +# Fixture-driven conformance tests. Fixtures live at test/fixtures/webhooks/ +# (one subdirectory per case for happy path; _invalid/ for negative cases). +# --------------------------------------------------------------------------- +RSpec.describe 'Webhook conformance', type: :integration do + CANONICAL_TEST_SECRET = 'test_secret_do_not_use_in_production'.freeze + FIXTURE_ROOT = File.expand_path('../test/fixtures/webhooks', __dir__).freeze + + it 'has conformance fixtures present' do + expect(File).to be_directory(FIXTURE_ROOT) + end + + if File.directory?(FIXTURE_ROOT) + Dir.children(FIXTURE_ROOT).sort.each do |name| + next if name == '_invalid' + + dir = File.join(FIXTURE_ROOT, name) + next unless File.directory?(dir) + + context "happy fixture #{name}" do + it 'verifies signature, parses event, and round-trips all transports' do + body = File.binread(File.join(dir, 'body.json')) + body_gz = File.binread(File.join(dir, 'body.gz')) + sqs_compressed = File.read(File.join(dir, 'sqs_body.txt')).strip + sqs_raw = File.read(File.join(dir, 'sqs_body_uncompressed.txt')).strip + sns = File.read(File.join(dir, 'sns_notification.txt')).strip + sig = File.read(File.join(dir, 'signature.txt')).strip + + expect(StreamChat::Webhook.verify_signature(body, sig, CANONICAL_TEST_SECRET)).to be true + expect(StreamChat::Webhook.parse_event(body)).not_to be_nil + expect(StreamChat::Webhook.verify_and_parse_webhook(body, sig, CANONICAL_TEST_SECRET)).not_to be_nil + expect(StreamChat::Webhook.verify_and_parse_webhook(body_gz, sig, CANONICAL_TEST_SECRET)).not_to be_nil + expect(StreamChat::Webhook.parse_sqs(sqs_compressed)).not_to be_nil + expect(StreamChat::Webhook.parse_sqs(sqs_raw)).not_to be_nil + expect(StreamChat::Webhook.parse_sns(sns)).not_to be_nil + end + end + end + end + + context 'negative fixtures' do + def neg_dir(name) + File.join(FIXTURE_ROOT, '_invalid', name) + end + + it 'rejects tampered body' do + skip 'fixtures not present' unless File.directory?(FIXTURE_ROOT) + + body = File.binread(File.join(neg_dir('tampered_body'), 'body.json')) + sig = File.read(File.join(neg_dir('tampered_body'), 'signature.txt')).strip + expect do + StreamChat::Webhook.verify_and_parse_webhook(body, sig, CANONICAL_TEST_SECRET) + end.to raise_error(StreamChat::Webhook::InvalidWebhookError, /#{StreamChat::Webhook::InvalidWebhookError::SIGNATURE_MISMATCH}/) + end + + it 'returns UnknownEvent for unknown type' do + skip 'fixtures not present' unless File.directory?(FIXTURE_ROOT) + + body = File.binread(File.join(neg_dir('unknown_type'), 'body.json')) + result = StreamChat::Webhook.parse_event(body) + expect(result).to be_a(StreamChat::Webhook::UnknownEvent) + expect(result.type).to eq('totally.made.up') + end + + it 'raises when type field is missing' do + skip 'fixtures not present' unless File.directory?(FIXTURE_ROOT) + + body = File.binread(File.join(neg_dir('missing_type'), 'body.json')) + expect do + StreamChat::Webhook.parse_event(body) + end.to raise_error(StreamChat::Webhook::InvalidWebhookError, /missing 'type'/) + end + + it 'raises on malformed JSON' do + skip 'fixtures not present' unless File.directory?(FIXTURE_ROOT) + + body = File.binread(File.join(neg_dir('malformed_json'), 'body.json')) + expect do + StreamChat::Webhook.parse_event(body) + end.to raise_error(StreamChat::Webhook::InvalidWebhookError, /failed to parse webhook payload/) + end + + it 'raises on empty body' do + skip 'fixtures not present' unless File.directory?(FIXTURE_ROOT) + + body = File.binread(File.join(neg_dir('empty_body'), 'body.json')) + expect do + StreamChat::Webhook.parse_event(body) + end.to raise_error(StreamChat::Webhook::InvalidWebhookError, /must not be empty/) + end + + it 'raises on bad gzip compression' do + skip 'fixtures not present' unless File.directory?(FIXTURE_ROOT) + + body = File.binread(File.join(neg_dir('bad_compression'), 'body.gz')) + expect do + StreamChat::Webhook.gunzip_payload(body) + end.to raise_error(StreamChat::Webhook::InvalidWebhookError, /gzip decompression failed/) + end + + it 'raises on bad base64 (downstream parse failure)' do + # Per CHA-3071 wire format: decode_sqs_payload falls back to raw bytes + # when base64 decoding fails (uncompressed wire format). For input that + # is neither valid base64 nor valid JSON nor gzip-prefixed, parse_sqs + # still raises InvalidWebhookError — just down the chain at JSON parsing. + skip 'fixtures not present' unless File.directory?(FIXTURE_ROOT) + + msg = File.read(File.join(neg_dir('bad_base64'), 'sqs_body.txt')).strip + expect do + StreamChat::Webhook.parse_sqs(msg) + end.to raise_error(StreamChat::Webhook::InvalidWebhookError) + end + + it 'raises on bad SNS envelope (downstream parse failure)' do + # Fix #4: bad_sns_envelope (non-envelope JSON) is now treated as a + # pre-extracted Message string and flows through the SQS path, surfacing + # as a downstream parse failure rather than SNS-specific. Still + # InvalidWebhookError. + skip 'fixtures not present' unless File.directory?(FIXTURE_ROOT) + + notif = File.read(File.join(neg_dir('bad_sns_envelope'), 'sns_notification.txt')).strip + expect do + StreamChat::Webhook.parse_sns(notif) + end.to raise_error(StreamChat::Webhook::InvalidWebhookError) + end + end +end diff --git a/test/webhook_test.rb b/test/webhook_test.rb deleted file mode 100644 index 262b926..0000000 --- a/test/webhook_test.rb +++ /dev/null @@ -1,1195 +0,0 @@ -# Code generated by GetStream internal OpenAPI code generator. DO NOT EDIT. - -require 'minitest/autorun' -require 'base64' -require 'json' -require 'openssl' -require 'zlib' -require_relative '../lib/getstream_ruby/generated/webhook' - -class WebhookTest < Minitest::Test - SECRET = 'test-webhook-secret' - BODY = '{"type":"test.event"}' - - def compute_signature(body, secret) - OpenSSL::HMAC.hexdigest('SHA256', secret, body) - end - - def test_verify_signature_valid - signature = compute_signature(BODY, SECRET) - assert StreamChat::Webhook.verify_signature(BODY, signature, SECRET) - end - - def test_verify_signature_wrong_signature - refute StreamChat::Webhook.verify_signature(BODY, 'invalidsignature', SECRET) - end - - def test_verify_signature_tampered_body - signature = compute_signature(BODY, SECRET) - refute StreamChat::Webhook.verify_signature('{"type":"tampered"}', signature, SECRET) - end - - def test_verify_signature_wrong_secret - signature = compute_signature(BODY, SECRET) - refute StreamChat::Webhook.verify_signature(BODY, signature, 'wrong-secret') - end - - def test_verify_signature_empty_signature - refute StreamChat::Webhook.verify_signature(BODY, '', SECRET) - end - - def test_get_event_type_from_string - assert_equal 'message.new', StreamChat::Webhook.get_event_type('{"type":"message.new"}') - end - - def test_get_event_type_from_hash - assert_equal 'message.new', StreamChat::Webhook.get_event_type({ 'type' => 'message.new' }) - end - - def test_get_event_type_missing_field - assert_nil StreamChat::Webhook.get_event_type('{"foo":"bar"}') - end - - def test_get_event_type_empty_object - assert_nil StreamChat::Webhook.get_event_type('{}') - end - def test_parse_ - event = StreamChat::Webhook.parse_webhook_event('{"type":"*"}') - assert_equal 'StreamChat::CustomEvent', event.class.name - end - - def test_parse_appeal_accepted - event = StreamChat::Webhook.parse_webhook_event('{"type":"appeal.accepted"}') - assert_equal 'StreamChat::AppealAcceptedEvent', event.class.name - end - - def test_parse_appeal_created - event = StreamChat::Webhook.parse_webhook_event('{"type":"appeal.created"}') - assert_equal 'StreamChat::AppealCreatedEvent', event.class.name - end - - def test_parse_appeal_rejected - event = StreamChat::Webhook.parse_webhook_event('{"type":"appeal.rejected"}') - assert_equal 'StreamChat::AppealRejectedEvent', event.class.name - end - - def test_parse_call_accepted - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.accepted"}') - assert_equal 'StreamChat::CallAcceptedEvent', event.class.name - end - - def test_parse_call_blocked_user - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.blocked_user"}') - assert_equal 'StreamChat::BlockedUserEvent', event.class.name - end - - def test_parse_call_closed_caption - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.closed_caption"}') - assert_equal 'StreamChat::ClosedCaptionEvent', event.class.name - end - - def test_parse_call_closed_captions_failed - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.closed_captions_failed"}') - assert_equal 'StreamChat::CallClosedCaptionsFailedEvent', event.class.name - end - - def test_parse_call_closed_captions_started - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.closed_captions_started"}') - assert_equal 'StreamChat::CallClosedCaptionsStartedEvent', event.class.name - end - - def test_parse_call_closed_captions_stopped - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.closed_captions_stopped"}') - assert_equal 'StreamChat::CallClosedCaptionsStoppedEvent', event.class.name - end - - def test_parse_call_created - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.created"}') - assert_equal 'StreamChat::CallCreatedEvent', event.class.name - end - - def test_parse_call_deleted - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.deleted"}') - assert_equal 'StreamChat::CallDeletedEvent', event.class.name - end - - def test_parse_call_dtmf - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.dtmf"}') - assert_equal 'StreamChat::CallDTMFEvent', event.class.name - end - - def test_parse_call_ended - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.ended"}') - assert_equal 'StreamChat::CallEndedEvent', event.class.name - end - - def test_parse_call_frame_recording_failed - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.frame_recording_failed"}') - assert_equal 'StreamChat::CallFrameRecordingFailedEvent', event.class.name - end - - def test_parse_call_frame_recording_ready - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.frame_recording_ready"}') - assert_equal 'StreamChat::CallFrameRecordingFrameReadyEvent', event.class.name - end - - def test_parse_call_frame_recording_started - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.frame_recording_started"}') - assert_equal 'StreamChat::CallFrameRecordingStartedEvent', event.class.name - end - - def test_parse_call_frame_recording_stopped - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.frame_recording_stopped"}') - assert_equal 'StreamChat::CallFrameRecordingStoppedEvent', event.class.name - end - - def test_parse_call_hls_broadcasting_failed - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.hls_broadcasting_failed"}') - assert_equal 'StreamChat::CallHLSBroadcastingFailedEvent', event.class.name - end - - def test_parse_call_hls_broadcasting_started - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.hls_broadcasting_started"}') - assert_equal 'StreamChat::CallHLSBroadcastingStartedEvent', event.class.name - end - - def test_parse_call_hls_broadcasting_stopped - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.hls_broadcasting_stopped"}') - assert_equal 'StreamChat::CallHLSBroadcastingStoppedEvent', event.class.name - end - - def test_parse_call_kicked_user - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.kicked_user"}') - assert_equal 'StreamChat::KickedUserEvent', event.class.name - end - - def test_parse_call_live_started - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.live_started"}') - assert_equal 'StreamChat::CallLiveStartedEvent', event.class.name - end - - def test_parse_call_member_added - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.member_added"}') - assert_equal 'StreamChat::CallMemberAddedEvent', event.class.name - end - - def test_parse_call_member_removed - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.member_removed"}') - assert_equal 'StreamChat::CallMemberRemovedEvent', event.class.name - end - - def test_parse_call_member_updated - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.member_updated"}') - assert_equal 'StreamChat::CallMemberUpdatedEvent', event.class.name - end - - def test_parse_call_member_updated_permission - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.member_updated_permission"}') - assert_equal 'StreamChat::CallMemberUpdatedPermissionEvent', event.class.name - end - - def test_parse_call_missed - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.missed"}') - assert_equal 'StreamChat::CallMissedEvent', event.class.name - end - - def test_parse_call_moderation_blur - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.moderation_blur"}') - assert_equal 'StreamChat::CallModerationBlurEvent', event.class.name - end - - def test_parse_call_moderation_warning - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.moderation_warning"}') - assert_equal 'StreamChat::CallModerationWarningEvent', event.class.name - end - - def test_parse_call_notification - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.notification"}') - assert_equal 'StreamChat::CallNotificationEvent', event.class.name - end - - def test_parse_call_permission_request - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.permission_request"}') - assert_equal 'StreamChat::PermissionRequestEvent', event.class.name - end - - def test_parse_call_permissions_updated - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.permissions_updated"}') - assert_equal 'StreamChat::UpdatedCallPermissionsEvent', event.class.name - end - - def test_parse_call_reaction_new - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.reaction_new"}') - assert_equal 'StreamChat::CallReactionEvent', event.class.name - end - - def test_parse_call_recording_failed - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.recording_failed"}') - assert_equal 'StreamChat::CallRecordingFailedEvent', event.class.name - end - - def test_parse_call_recording_ready - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.recording_ready"}') - assert_equal 'StreamChat::CallRecordingReadyEvent', event.class.name - end - - def test_parse_call_recording_started - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.recording_started"}') - assert_equal 'StreamChat::CallRecordingStartedEvent', event.class.name - end - - def test_parse_call_recording_stopped - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.recording_stopped"}') - assert_equal 'StreamChat::CallRecordingStoppedEvent', event.class.name - end - - def test_parse_call_rejected - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.rejected"}') - assert_equal 'StreamChat::CallRejectedEvent', event.class.name - end - - def test_parse_call_ring - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.ring"}') - assert_equal 'StreamChat::CallRingEvent', event.class.name - end - - def test_parse_call_rtmp_broadcast_failed - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.rtmp_broadcast_failed"}') - assert_equal 'StreamChat::CallRtmpBroadcastFailedEvent', event.class.name - end - - def test_parse_call_rtmp_broadcast_started - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.rtmp_broadcast_started"}') - assert_equal 'StreamChat::CallRtmpBroadcastStartedEvent', event.class.name - end - - def test_parse_call_rtmp_broadcast_stopped - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.rtmp_broadcast_stopped"}') - assert_equal 'StreamChat::CallRtmpBroadcastStoppedEvent', event.class.name - end - - def test_parse_call_session_ended - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.session_ended"}') - assert_equal 'StreamChat::CallSessionEndedEvent', event.class.name - end - - def test_parse_call_session_participant_count_updated - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.session_participant_count_updated"}') - assert_equal 'StreamChat::CallSessionParticipantCountsUpdatedEvent', event.class.name - end - - def test_parse_call_session_participant_joined - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.session_participant_joined"}') - assert_equal 'StreamChat::CallSessionParticipantJoinedEvent', event.class.name - end - - def test_parse_call_session_participant_left - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.session_participant_left"}') - assert_equal 'StreamChat::CallSessionParticipantLeftEvent', event.class.name - end - - def test_parse_call_session_started - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.session_started"}') - assert_equal 'StreamChat::CallSessionStartedEvent', event.class.name - end - - def test_parse_call_stats_report_ready - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.stats_report_ready"}') - assert_equal 'StreamChat::CallStatsReportReadyEvent', event.class.name - end - - def test_parse_call_transcription_failed - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.transcription_failed"}') - assert_equal 'StreamChat::CallTranscriptionFailedEvent', event.class.name - end - - def test_parse_call_transcription_ready - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.transcription_ready"}') - assert_equal 'StreamChat::CallTranscriptionReadyEvent', event.class.name - end - - def test_parse_call_transcription_started - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.transcription_started"}') - assert_equal 'StreamChat::CallTranscriptionStartedEvent', event.class.name - end - - def test_parse_call_transcription_stopped - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.transcription_stopped"}') - assert_equal 'StreamChat::CallTranscriptionStoppedEvent', event.class.name - end - - def test_parse_call_unblocked_user - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.unblocked_user"}') - assert_equal 'StreamChat::UnblockedUserEvent', event.class.name - end - - def test_parse_call_updated - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.updated"}') - assert_equal 'StreamChat::CallUpdatedEvent', event.class.name - end - - def test_parse_call_user_feedback_submitted - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.user_feedback_submitted"}') - assert_equal 'StreamChat::CallUserFeedbackSubmittedEvent', event.class.name - end - - def test_parse_call_user_muted - event = StreamChat::Webhook.parse_webhook_event('{"type":"call.user_muted"}') - assert_equal 'StreamChat::CallUserMutedEvent', event.class.name - end - - def test_parse_campaign_completed - event = StreamChat::Webhook.parse_webhook_event('{"type":"campaign.completed"}') - assert_equal 'StreamChat::CampaignCompletedEvent', event.class.name - end - - def test_parse_campaign_started - event = StreamChat::Webhook.parse_webhook_event('{"type":"campaign.started"}') - assert_equal 'StreamChat::CampaignStartedEvent', event.class.name - end - - def test_parse_channel_created - event = StreamChat::Webhook.parse_webhook_event('{"type":"channel.created"}') - assert_equal 'StreamChat::ChannelCreatedEvent', event.class.name - end - - def test_parse_channel_deleted - event = StreamChat::Webhook.parse_webhook_event('{"type":"channel.deleted"}') - assert_equal 'StreamChat::ChannelDeletedEvent', event.class.name - end - - def test_parse_channel_frozen - event = StreamChat::Webhook.parse_webhook_event('{"type":"channel.frozen"}') - assert_equal 'StreamChat::ChannelFrozenEvent', event.class.name - end - - def test_parse_channel_hidden - event = StreamChat::Webhook.parse_webhook_event('{"type":"channel.hidden"}') - assert_equal 'StreamChat::ChannelHiddenEvent', event.class.name - end - - def test_parse_channel_max_streak_changed - event = StreamChat::Webhook.parse_webhook_event('{"type":"channel.max_streak_changed"}') - assert_equal 'StreamChat::MaxStreakChangedEvent', event.class.name - end - - def test_parse_channel_muted - event = StreamChat::Webhook.parse_webhook_event('{"type":"channel.muted"}') - assert_equal 'StreamChat::ChannelMutedEvent', event.class.name - end - - def test_parse_channel_truncated - event = StreamChat::Webhook.parse_webhook_event('{"type":"channel.truncated"}') - assert_equal 'StreamChat::ChannelTruncatedEvent', event.class.name - end - - def test_parse_channel_unfrozen - event = StreamChat::Webhook.parse_webhook_event('{"type":"channel.unfrozen"}') - assert_equal 'StreamChat::ChannelUnFrozenEvent', event.class.name - end - - def test_parse_channel_unmuted - event = StreamChat::Webhook.parse_webhook_event('{"type":"channel.unmuted"}') - assert_equal 'StreamChat::ChannelUnmutedEvent', event.class.name - end - - def test_parse_channel_updated - event = StreamChat::Webhook.parse_webhook_event('{"type":"channel.updated"}') - assert_equal 'StreamChat::ChannelUpdatedEvent', event.class.name - end - - def test_parse_channel_visible - event = StreamChat::Webhook.parse_webhook_event('{"type":"channel.visible"}') - assert_equal 'StreamChat::ChannelVisibleEvent', event.class.name - end - - def test_parse_channel_batch_update_completed - event = StreamChat::Webhook.parse_webhook_event('{"type":"channel_batch_update.completed"}') - assert_equal 'StreamChat::ChannelBatchCompletedEvent', event.class.name - end - - def test_parse_channel_batch_update_started - event = StreamChat::Webhook.parse_webhook_event('{"type":"channel_batch_update.started"}') - assert_equal 'StreamChat::ChannelBatchStartedEvent', event.class.name - end - - def test_parse_custom - event = StreamChat::Webhook.parse_webhook_event('{"type":"custom"}') - assert_equal 'StreamChat::CustomVideoEvent', event.class.name - end - - def test_parse_export_bulk_image_moderation_error - event = StreamChat::Webhook.parse_webhook_event('{"type":"export.bulk_image_moderation.error"}') - assert_equal 'StreamChat::AsyncExportErrorEvent', event.class.name - end - - def test_parse_export_bulk_image_moderation_success - event = StreamChat::Webhook.parse_webhook_event('{"type":"export.bulk_image_moderation.success"}') - assert_equal 'StreamChat::AsyncBulkImageModerationEvent', event.class.name - end - - def test_parse_export_channels_error - event = StreamChat::Webhook.parse_webhook_event('{"type":"export.channels.error"}') - assert_equal 'StreamChat::AsyncExportErrorEvent', event.class.name - end - - def test_parse_export_channels_success - event = StreamChat::Webhook.parse_webhook_event('{"type":"export.channels.success"}') - assert_equal 'StreamChat::AsyncExportChannelsEvent', event.class.name - end - - def test_parse_export_moderation_logs_error - event = StreamChat::Webhook.parse_webhook_event('{"type":"export.moderation_logs.error"}') - assert_equal 'StreamChat::AsyncExportErrorEvent', event.class.name - end - - def test_parse_export_moderation_logs_success - event = StreamChat::Webhook.parse_webhook_event('{"type":"export.moderation_logs.success"}') - assert_equal 'StreamChat::AsyncExportModerationLogsEvent', event.class.name - end - - def test_parse_export_users_error - event = StreamChat::Webhook.parse_webhook_event('{"type":"export.users.error"}') - assert_equal 'StreamChat::AsyncExportErrorEvent', event.class.name - end - - def test_parse_export_users_success - event = StreamChat::Webhook.parse_webhook_event('{"type":"export.users.success"}') - assert_equal 'StreamChat::AsyncExportUsersEvent', event.class.name - end - - def test_parse_feeds_activity_added - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.activity.added"}') - assert_equal 'StreamChat::ActivityAddedEvent', event.class.name - end - - def test_parse_feeds_activity_deleted - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.activity.deleted"}') - assert_equal 'StreamChat::ActivityDeletedEvent', event.class.name - end - - def test_parse_feeds_activity_feedback - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.activity.feedback"}') - assert_equal 'StreamChat::ActivityFeedbackEvent', event.class.name - end - - def test_parse_feeds_activity_marked - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.activity.marked"}') - assert_equal 'StreamChat::ActivityMarkEvent', event.class.name - end - - def test_parse_feeds_activity_pinned - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.activity.pinned"}') - assert_equal 'StreamChat::ActivityPinnedEvent', event.class.name - end - - def test_parse_feeds_activity_reaction_added - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.activity.reaction.added"}') - assert_equal 'StreamChat::ActivityReactionAddedEvent', event.class.name - end - - def test_parse_feeds_activity_reaction_deleted - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.activity.reaction.deleted"}') - assert_equal 'StreamChat::ActivityReactionDeletedEvent', event.class.name - end - - def test_parse_feeds_activity_reaction_updated - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.activity.reaction.updated"}') - assert_equal 'StreamChat::ActivityReactionUpdatedEvent', event.class.name - end - - def test_parse_feeds_activity_removed_from_feed - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.activity.removed_from_feed"}') - assert_equal 'StreamChat::ActivityRemovedFromFeedEvent', event.class.name - end - - def test_parse_feeds_activity_restored - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.activity.restored"}') - assert_equal 'StreamChat::ActivityRestoredEvent', event.class.name - end - - def test_parse_feeds_activity_unpinned - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.activity.unpinned"}') - assert_equal 'StreamChat::ActivityUnpinnedEvent', event.class.name - end - - def test_parse_feeds_activity_updated - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.activity.updated"}') - assert_equal 'StreamChat::ActivityUpdatedEvent', event.class.name - end - - def test_parse_feeds_bookmark_added - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.bookmark.added"}') - assert_equal 'StreamChat::BookmarkAddedEvent', event.class.name - end - - def test_parse_feeds_bookmark_deleted - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.bookmark.deleted"}') - assert_equal 'StreamChat::BookmarkDeletedEvent', event.class.name - end - - def test_parse_feeds_bookmark_updated - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.bookmark.updated"}') - assert_equal 'StreamChat::BookmarkUpdatedEvent', event.class.name - end - - def test_parse_feeds_bookmark_folder_deleted - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.bookmark_folder.deleted"}') - assert_equal 'StreamChat::BookmarkFolderDeletedEvent', event.class.name - end - - def test_parse_feeds_bookmark_folder_updated - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.bookmark_folder.updated"}') - assert_equal 'StreamChat::BookmarkFolderUpdatedEvent', event.class.name - end - - def test_parse_feeds_comment_added - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.comment.added"}') - assert_equal 'StreamChat::CommentAddedEvent', event.class.name - end - - def test_parse_feeds_comment_deleted - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.comment.deleted"}') - assert_equal 'StreamChat::CommentDeletedEvent', event.class.name - end - - def test_parse_feeds_comment_reaction_added - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.comment.reaction.added"}') - assert_equal 'StreamChat::CommentReactionAddedEvent', event.class.name - end - - def test_parse_feeds_comment_reaction_deleted - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.comment.reaction.deleted"}') - assert_equal 'StreamChat::CommentReactionDeletedEvent', event.class.name - end - - def test_parse_feeds_comment_reaction_updated - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.comment.reaction.updated"}') - assert_equal 'StreamChat::CommentReactionUpdatedEvent', event.class.name - end - - def test_parse_feeds_comment_restored - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.comment.restored"}') - assert_equal 'StreamChat::CommentRestoredEvent', event.class.name - end - - def test_parse_feeds_comment_updated - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.comment.updated"}') - assert_equal 'StreamChat::CommentUpdatedEvent', event.class.name - end - - def test_parse_feeds_feed_created - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.feed.created"}') - assert_equal 'StreamChat::FeedCreatedEvent', event.class.name - end - - def test_parse_feeds_feed_deleted - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.feed.deleted"}') - assert_equal 'StreamChat::FeedDeletedEvent', event.class.name - end - - def test_parse_feeds_feed_updated - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.feed.updated"}') - assert_equal 'StreamChat::FeedUpdatedEvent', event.class.name - end - - def test_parse_feeds_feed_group_changed - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.feed_group.changed"}') - assert_equal 'StreamChat::FeedGroupChangedEvent', event.class.name - end - - def test_parse_feeds_feed_group_deleted - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.feed_group.deleted"}') - assert_equal 'StreamChat::FeedGroupDeletedEvent', event.class.name - end - - def test_parse_feeds_feed_group_restored - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.feed_group.restored"}') - assert_equal 'StreamChat::FeedGroupRestoredEvent', event.class.name - end - - def test_parse_feeds_feed_member_added - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.feed_member.added"}') - assert_equal 'StreamChat::FeedMemberAddedEvent', event.class.name - end - - def test_parse_feeds_feed_member_removed - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.feed_member.removed"}') - assert_equal 'StreamChat::FeedMemberRemovedEvent', event.class.name - end - - def test_parse_feeds_feed_member_updated - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.feed_member.updated"}') - assert_equal 'StreamChat::FeedMemberUpdatedEvent', event.class.name - end - - def test_parse_feeds_follow_created - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.follow.created"}') - assert_equal 'StreamChat::FollowCreatedEvent', event.class.name - end - - def test_parse_feeds_follow_deleted - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.follow.deleted"}') - assert_equal 'StreamChat::FollowDeletedEvent', event.class.name - end - - def test_parse_feeds_follow_updated - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.follow.updated"}') - assert_equal 'StreamChat::FollowUpdatedEvent', event.class.name - end - - def test_parse_feeds_notification_feed_updated - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.notification_feed.updated"}') - assert_equal 'StreamChat::NotificationFeedUpdatedEvent', event.class.name - end - - def test_parse_feeds_stories_feed_updated - event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.stories_feed.updated"}') - assert_equal 'StreamChat::StoriesFeedUpdatedEvent', event.class.name - end - - def test_parse_flag_updated - event = StreamChat::Webhook.parse_webhook_event('{"type":"flag.updated"}') - assert_equal 'StreamChat::FlagUpdatedEvent', event.class.name - end - - def test_parse_ingress_error - event = StreamChat::Webhook.parse_webhook_event('{"type":"ingress.error"}') - assert_equal 'StreamChat::IngressErrorEvent', event.class.name - end - - def test_parse_ingress_started - event = StreamChat::Webhook.parse_webhook_event('{"type":"ingress.started"}') - assert_equal 'StreamChat::IngressStartedEvent', event.class.name - end - - def test_parse_ingress_stopped - event = StreamChat::Webhook.parse_webhook_event('{"type":"ingress.stopped"}') - assert_equal 'StreamChat::IngressStoppedEvent', event.class.name - end - - def test_parse_member_added - event = StreamChat::Webhook.parse_webhook_event('{"type":"member.added"}') - assert_equal 'StreamChat::MemberAddedEvent', event.class.name - end - - def test_parse_member_removed - event = StreamChat::Webhook.parse_webhook_event('{"type":"member.removed"}') - assert_equal 'StreamChat::MemberRemovedEvent', event.class.name - end - - def test_parse_member_updated - event = StreamChat::Webhook.parse_webhook_event('{"type":"member.updated"}') - assert_equal 'StreamChat::MemberUpdatedEvent', event.class.name - end - - def test_parse_message_deleted - event = StreamChat::Webhook.parse_webhook_event('{"type":"message.deleted"}') - assert_equal 'StreamChat::MessageDeletedEvent', event.class.name - end - - def test_parse_message_flagged - event = StreamChat::Webhook.parse_webhook_event('{"type":"message.flagged"}') - assert_equal 'StreamChat::MessageFlaggedEvent', event.class.name - end - - def test_parse_message_new - event = StreamChat::Webhook.parse_webhook_event('{"type":"message.new"}') - assert_equal 'StreamChat::MessageNewEvent', event.class.name - end - - def test_parse_message_pending - event = StreamChat::Webhook.parse_webhook_event('{"type":"message.pending"}') - assert_equal 'StreamChat::PendingMessageEvent', event.class.name - end - - def test_parse_message_read - event = StreamChat::Webhook.parse_webhook_event('{"type":"message.read"}') - assert_equal 'StreamChat::MessageReadEvent', event.class.name - end - - def test_parse_message_unblocked - event = StreamChat::Webhook.parse_webhook_event('{"type":"message.unblocked"}') - assert_equal 'StreamChat::MessageUnblockedEvent', event.class.name - end - - def test_parse_message_undeleted - event = StreamChat::Webhook.parse_webhook_event('{"type":"message.undeleted"}') - assert_equal 'StreamChat::MessageUndeletedEvent', event.class.name - end - - def test_parse_message_updated - event = StreamChat::Webhook.parse_webhook_event('{"type":"message.updated"}') - assert_equal 'StreamChat::MessageUpdatedEvent', event.class.name - end - - def test_parse_moderation_custom_action - event = StreamChat::Webhook.parse_webhook_event('{"type":"moderation.custom_action"}') - assert_equal 'StreamChat::ModerationCustomActionEvent', event.class.name - end - - def test_parse_moderation_flagged - event = StreamChat::Webhook.parse_webhook_event('{"type":"moderation.flagged"}') - assert_equal 'StreamChat::ModerationFlaggedEvent', event.class.name - end - - def test_parse_moderation_mark_reviewed - event = StreamChat::Webhook.parse_webhook_event('{"type":"moderation.mark_reviewed"}') - assert_equal 'StreamChat::ModerationMarkReviewedEvent', event.class.name - end - - def test_parse_moderation_check_completed - event = StreamChat::Webhook.parse_webhook_event('{"type":"moderation_check.completed"}') - assert_equal 'StreamChat::ModerationCheckCompletedEvent', event.class.name - end - - def test_parse_moderation_rule_triggered - event = StreamChat::Webhook.parse_webhook_event('{"type":"moderation_rule.triggered"}') - assert_equal 'StreamChat::ModerationRulesTriggeredEvent', event.class.name - end - - def test_parse_notification_mark_unread - event = StreamChat::Webhook.parse_webhook_event('{"type":"notification.mark_unread"}') - assert_equal 'StreamChat::NotificationMarkUnreadEvent', event.class.name - end - - def test_parse_notification_reminder_due - event = StreamChat::Webhook.parse_webhook_event('{"type":"notification.reminder_due"}') - assert_equal 'StreamChat::ReminderNotificationEvent', event.class.name - end - - def test_parse_notification_thread_message_new - event = StreamChat::Webhook.parse_webhook_event('{"type":"notification.thread_message_new"}') - assert_equal 'StreamChat::NotificationThreadMessageNewEvent', event.class.name - end - - def test_parse_reaction_deleted - event = StreamChat::Webhook.parse_webhook_event('{"type":"reaction.deleted"}') - assert_equal 'StreamChat::ReactionDeletedEvent', event.class.name - end - - def test_parse_reaction_new - event = StreamChat::Webhook.parse_webhook_event('{"type":"reaction.new"}') - assert_equal 'StreamChat::ReactionNewEvent', event.class.name - end - - def test_parse_reaction_updated - event = StreamChat::Webhook.parse_webhook_event('{"type":"reaction.updated"}') - assert_equal 'StreamChat::ReactionUpdatedEvent', event.class.name - end - - def test_parse_reminder_created - event = StreamChat::Webhook.parse_webhook_event('{"type":"reminder.created"}') - assert_equal 'StreamChat::ReminderCreatedEvent', event.class.name - end - - def test_parse_reminder_deleted - event = StreamChat::Webhook.parse_webhook_event('{"type":"reminder.deleted"}') - assert_equal 'StreamChat::ReminderDeletedEvent', event.class.name - end - - def test_parse_reminder_updated - event = StreamChat::Webhook.parse_webhook_event('{"type":"reminder.updated"}') - assert_equal 'StreamChat::ReminderUpdatedEvent', event.class.name - end - - def test_parse_review_queue_item_new - event = StreamChat::Webhook.parse_webhook_event('{"type":"review_queue_item.new"}') - assert_equal 'StreamChat::ReviewQueueItemNewEvent', event.class.name - end - - def test_parse_review_queue_item_updated - event = StreamChat::Webhook.parse_webhook_event('{"type":"review_queue_item.updated"}') - assert_equal 'StreamChat::ReviewQueueItemUpdatedEvent', event.class.name - end - - def test_parse_thread_updated - event = StreamChat::Webhook.parse_webhook_event('{"type":"thread.updated"}') - assert_equal 'StreamChat::ThreadUpdatedEvent', event.class.name - end - - def test_parse_user_banned - event = StreamChat::Webhook.parse_webhook_event('{"type":"user.banned"}') - assert_equal 'StreamChat::UserBannedEvent', event.class.name - end - - def test_parse_user_deactivated - event = StreamChat::Webhook.parse_webhook_event('{"type":"user.deactivated"}') - assert_equal 'StreamChat::UserDeactivatedEvent', event.class.name - end - - def test_parse_user_deleted - event = StreamChat::Webhook.parse_webhook_event('{"type":"user.deleted"}') - assert_equal 'StreamChat::UserDeletedEvent', event.class.name - end - - def test_parse_user_flagged - event = StreamChat::Webhook.parse_webhook_event('{"type":"user.flagged"}') - assert_equal 'StreamChat::UserFlaggedEvent', event.class.name - end - - def test_parse_user_messages_deleted - event = StreamChat::Webhook.parse_webhook_event('{"type":"user.messages.deleted"}') - assert_equal 'StreamChat::UserMessagesDeletedEvent', event.class.name - end - - def test_parse_user_muted - event = StreamChat::Webhook.parse_webhook_event('{"type":"user.muted"}') - assert_equal 'StreamChat::UserMutedEvent', event.class.name - end - - def test_parse_user_reactivated - event = StreamChat::Webhook.parse_webhook_event('{"type":"user.reactivated"}') - assert_equal 'StreamChat::UserReactivatedEvent', event.class.name - end - - def test_parse_user_unbanned - event = StreamChat::Webhook.parse_webhook_event('{"type":"user.unbanned"}') - assert_equal 'StreamChat::UserUnbannedEvent', event.class.name - end - - def test_parse_user_unmuted - event = StreamChat::Webhook.parse_webhook_event('{"type":"user.unmuted"}') - assert_equal 'StreamChat::UserUnmutedEvent', event.class.name - end - - def test_parse_user_unread_message_reminder - event = StreamChat::Webhook.parse_webhook_event('{"type":"user.unread_message_reminder"}') - assert_equal 'StreamChat::UserUnreadReminderEvent', event.class.name - end - - def test_parse_user_updated - event = StreamChat::Webhook.parse_webhook_event('{"type":"user.updated"}') - assert_equal 'StreamChat::UserUpdatedEvent', event.class.name - end - - def test_parse_user_group_created - event = StreamChat::Webhook.parse_webhook_event('{"type":"user_group.created"}') - assert_equal 'StreamChat::UserGroupCreatedEvent', event.class.name - end - - def test_parse_user_group_deleted - event = StreamChat::Webhook.parse_webhook_event('{"type":"user_group.deleted"}') - assert_equal 'StreamChat::UserGroupDeletedEvent', event.class.name - end - - def test_parse_user_group_member_added - event = StreamChat::Webhook.parse_webhook_event('{"type":"user_group.member_added"}') - assert_equal 'StreamChat::UserGroupMemberAddedEvent', event.class.name - end - - def test_parse_user_group_member_removed - event = StreamChat::Webhook.parse_webhook_event('{"type":"user_group.member_removed"}') - assert_equal 'StreamChat::UserGroupMemberRemovedEvent', event.class.name - end - - def test_parse_user_group_updated - event = StreamChat::Webhook.parse_webhook_event('{"type":"user_group.updated"}') - assert_equal 'StreamChat::UserGroupUpdatedEvent', event.class.name - end - - def test_parse_webhook_event_unknown_type - assert_raises(ArgumentError) do - StreamChat::Webhook.parse_webhook_event('{"type":"unknown.event"}') - end - end - - def test_parse_webhook_event_missing_type - assert_raises(ArgumentError) do - StreamChat::Webhook.parse_webhook_event('{"foo":"bar"}') - end - end - - def test_parse_webhook_event_invalid_json - assert_raises(ArgumentError) do - StreamChat::Webhook.parse_webhook_event('not json') - end - end - - # --------------------------------------------------------------------------- - # Spec §6 helpers + composites: parse_event, gunzip_payload, decode_sqs_payload, - # decode_sns_payload, verify_and_parse_webhook, parse_sqs, parse_sns. - # --------------------------------------------------------------------------- - - def test_stream_webhook_canonical_alias_resolves - # Spec §7: Stream::Webhook is the canonical name; should alias StreamChat::Webhook. - assert_equal StreamChat::Webhook, Stream::Webhook - end - - def test_parse_event_returns_unknown_event_for_unknown_discriminator - body = '{"type":"totally.made.up","created_at":"2026-05-08T00:00:00Z"}' - event = StreamChat::Webhook.parse_event(body) - assert_kind_of StreamChat::Webhook::UnknownEvent, event - assert_equal 'totally.made.up', event.type - refute_nil event.created_at - assert_equal 'totally.made.up', event.raw['type'] - end - - def test_parse_event_empty_body - assert_raises(StreamChat::Webhook::InvalidWebhookError) do - StreamChat::Webhook.parse_event('') - end - end - - def test_parse_event_invalid_json - assert_raises(StreamChat::Webhook::InvalidWebhookError) do - StreamChat::Webhook.parse_event('not json') - end - end - - def test_parse_event_non_object_json - assert_raises(StreamChat::Webhook::InvalidWebhookError) do - StreamChat::Webhook.parse_event('[1,2,3]') - end - end - - def test_parse_event_missing_type - assert_raises(StreamChat::Webhook::InvalidWebhookError) do - StreamChat::Webhook.parse_event('{"foo":"bar"}') - end - end - - def test_gunzip_payload_passes_through_plain_body - plain = '{"type":"message.new"}' - assert_equal plain.b, StreamChat::Webhook.gunzip_payload(plain) - end - - def test_gunzip_payload_decompresses_gzip_body - plain = '{"type":"message.new"}' - gz = Zlib.gzip(plain) - assert_equal plain, StreamChat::Webhook.gunzip_payload(gz) - end - - def test_gunzip_payload_raises_on_corrupt_gzip - corrupt = "\x1F\x8Bnot-actually-a-gzip-stream".b - assert_raises(StreamChat::Webhook::InvalidWebhookError) do - StreamChat::Webhook.gunzip_payload(corrupt) - end - end - - def test_decode_sqs_payload_decodes_plain_base64 - plain = '{"type":"message.new"}' - encoded = Base64.strict_encode64(plain) - assert_equal plain.b, StreamChat::Webhook.decode_sqs_payload(encoded) - end - - def test_decode_sqs_payload_decodes_base64_gzip_body - plain = '{"type":"message.new"}' - gz = Zlib.gzip(plain) - encoded = Base64.strict_encode64(gz) - assert_equal plain, StreamChat::Webhook.decode_sqs_payload(encoded) - end - - def test_decode_sqs_payload_passes_through_non_base64 - # Per chat#13392 wire format: SQS bodies are raw JSON when - # hook_payload_compression is off. decode_sqs_payload must fall back to - # raw bytes on non-base64 input rather than raise. - plain = '{"type":"message.new"}' - assert_equal plain, StreamChat::Webhook.decode_sqs_payload(plain) - end - - def test_decode_sns_payload_extracts_and_decodes_message - plain = '{"type":"message.new"}' - envelope = JSON.generate( - 'Type' => 'Notification', - 'Message' => Base64.strict_encode64(plain), - 'MessageId' => 'abc-123', - 'Timestamp' => '2026-05-08T00:00:00Z', - 'TopicArn' => 'arn:aws:sns:us-east-1:123:test' - ) - assert_equal plain.b, StreamChat::Webhook.decode_sns_payload(envelope) - end - - def test_decode_sns_payload_treats_non_envelope_as_raw_message - # Fix #4: decode_sns_payload accepts either a full SNS envelope or a - # pre-extracted Message string. Non-envelope input flows through as bytes. - refute_nil StreamChat::Webhook.decode_sns_payload('not json') - end - - def test_decode_sns_payload_treats_missing_message_field_as_raw_message - # Fix #4: envelope-shaped JSON without a string Message field is treated - # as a pre-extracted Message string and flows through. - refute_nil StreamChat::Webhook.decode_sns_payload('{"Type":"Notification"}') - end - - def test_verify_and_parse_webhook_happy_path - body = '{"type":"message.new"}' - sig = compute_signature(body, SECRET) - refute_nil StreamChat::Webhook.verify_and_parse_webhook(body, sig, SECRET) - end - - def test_verify_and_parse_webhook_gzip_body - body = '{"type":"message.new"}' - sig = compute_signature(body, SECRET) - gz = Zlib.gzip(body) - refute_nil StreamChat::Webhook.verify_and_parse_webhook(gz, sig, SECRET) - end - - def test_verify_and_parse_webhook_raises_on_tampered_body - body = '{"type":"message.new"}' - sig = compute_signature(body, SECRET) - # Single-arm rescue on the unified InvalidWebhookError; message identifies the mode. - err = assert_raises(StreamChat::Webhook::InvalidWebhookError) do - StreamChat::Webhook.verify_and_parse_webhook('{"type":"message.deleted"}', sig, SECRET) - end - assert_includes err.message, StreamChat::Webhook::InvalidWebhookError::SIGNATURE_MISMATCH - end - - def test_parse_sqs_happy_path - plain = '{"type":"message.new"}' - refute_nil StreamChat::Webhook.parse_sqs(Base64.strict_encode64(plain)) - end - - def test_parse_sns_happy_path - plain = '{"type":"message.new"}' - envelope = JSON.generate( - 'Type' => 'Notification', - 'Message' => Base64.strict_encode64(plain) - ) - refute_nil StreamChat::Webhook.parse_sns(envelope) - end -end - -# --------------------------------------------------------------------------- -# Fixture-driven conformance tests. Fixtures live at test/fixtures/webhooks/ -# (one subdirectory per case for happy path; _invalid/ for negative cases). -# --------------------------------------------------------------------------- -class WebhookConformanceTest < Minitest::Test - CANONICAL_TEST_SECRET = 'test_secret_do_not_use_in_production' - FIXTURE_ROOT = File.expand_path('fixtures/webhooks', __dir__).freeze - - def fixtures_present? - File.directory?(FIXTURE_ROOT) - end - - def each_happy_dir - return unless fixtures_present? - - Dir.children(FIXTURE_ROOT).sort.each do |name| - next if name == '_invalid' - - dir = File.join(FIXTURE_ROOT, name) - next unless File.directory?(dir) - - yield name, dir - end - end - - def test_happy_fixtures - skip 'webhook conformance fixtures not present' unless fixtures_present? - - each_happy_dir do |name, dir| - body = File.binread(File.join(dir, 'body.json')) - body_gz = File.binread(File.join(dir, 'body.gz')) - sqs_compressed = File.read(File.join(dir, 'sqs_body.txt')).strip - sqs_raw = File.read(File.join(dir, 'sqs_body_uncompressed.txt')).strip - sns = File.read(File.join(dir, 'sns_notification.txt')).strip - sig = File.read(File.join(dir, 'signature.txt')).strip - - assert StreamChat::Webhook.verify_signature(body, sig, CANONICAL_TEST_SECRET), - "verify_signature failed for #{name}" - refute_nil StreamChat::Webhook.parse_event(body), - "parse_event nil for #{name}" - refute_nil StreamChat::Webhook.verify_and_parse_webhook(body, sig, CANONICAL_TEST_SECRET), - "verify_and_parse_webhook (identity) failed for #{name}" - refute_nil StreamChat::Webhook.verify_and_parse_webhook(body_gz, sig, CANONICAL_TEST_SECRET), - "verify_and_parse_webhook (gzip) failed for #{name}" - refute_nil StreamChat::Webhook.parse_sqs(sqs_compressed), - "parse_sqs (compressed) failed for #{name}" - refute_nil StreamChat::Webhook.parse_sqs(sqs_raw), - "parse_sqs (uncompressed) failed for #{name}" - refute_nil StreamChat::Webhook.parse_sns(sns), - "parse_sns failed for #{name}" - end - end - - def neg_dir(name) - File.join(FIXTURE_ROOT, '_invalid', name) - end - - def test_tampered_body - skip 'fixtures not present' unless fixtures_present? - - body = File.binread(File.join(neg_dir('tampered_body'), 'body.json')) - sig = File.read(File.join(neg_dir('tampered_body'), 'signature.txt')).strip - err = assert_raises(StreamChat::Webhook::InvalidWebhookError) do - StreamChat::Webhook.verify_and_parse_webhook(body, sig, CANONICAL_TEST_SECRET) - end - assert_includes err.message, StreamChat::Webhook::InvalidWebhookError::SIGNATURE_MISMATCH - end - - def test_unknown_type_returns_unknown_event - skip 'fixtures not present' unless fixtures_present? - - body = File.binread(File.join(neg_dir('unknown_type'), 'body.json')) - result = StreamChat::Webhook.parse_event(body) - assert_kind_of StreamChat::Webhook::UnknownEvent, result - assert_equal 'totally.made.up', result.type - end - - def test_missing_type - skip 'fixtures not present' unless fixtures_present? - - body = File.binread(File.join(neg_dir('missing_type'), 'body.json')) - err = assert_raises(StreamChat::Webhook::InvalidWebhookError) do - StreamChat::Webhook.parse_event(body) - end - assert_includes err.message, "missing 'type'" - end - - def test_malformed_json - skip 'fixtures not present' unless fixtures_present? - - body = File.binread(File.join(neg_dir('malformed_json'), 'body.json')) - err = assert_raises(StreamChat::Webhook::InvalidWebhookError) do - StreamChat::Webhook.parse_event(body) - end - assert_includes err.message, 'failed to parse webhook payload' - end - - def test_empty_body - skip 'fixtures not present' unless fixtures_present? - - body = File.binread(File.join(neg_dir('empty_body'), 'body.json')) - err = assert_raises(StreamChat::Webhook::InvalidWebhookError) do - StreamChat::Webhook.parse_event(body) - end - assert_includes err.message, 'must not be empty' - end - - def test_bad_compression - skip 'fixtures not present' unless fixtures_present? - - body = File.binread(File.join(neg_dir('bad_compression'), 'body.gz')) - err = assert_raises(StreamChat::Webhook::InvalidWebhookError) do - StreamChat::Webhook.gunzip_payload(body) - end - assert_includes err.message, 'gzip decompression failed' - end - - def test_bad_base64 - # Per CHA-3071 wire format: decode_sqs_payload falls back to raw bytes when - # base64 decoding fails (uncompressed wire format). For input that is - # neither valid base64 nor valid JSON nor gzip-prefixed, parse_sqs still - # raises InvalidWebhookError — just down the chain at JSON parsing. - skip 'fixtures not present' unless fixtures_present? - - msg = File.read(File.join(neg_dir('bad_base64'), 'sqs_body.txt')).strip - assert_raises(StreamChat::Webhook::InvalidWebhookError) do - StreamChat::Webhook.parse_sqs(msg) - end - end - - def test_bad_sns_envelope - # Fix #4: bad_sns_envelope (non-envelope JSON) is now treated as a - # pre-extracted Message string and flows through the SQS path, surfacing - # as a downstream parse failure rather than SNS-specific. Still - # InvalidWebhookError. - skip 'fixtures not present' unless fixtures_present? - - notif = File.read(File.join(neg_dir('bad_sns_envelope'), 'sns_notification.txt')).strip - assert_raises(StreamChat::Webhook::InvalidWebhookError) do - StreamChat::Webhook.parse_sns(notif) - end - end -end From d9ec0937a32d5bb2b40b62190f94a7006d62d958 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Fri, 15 May 2026 15:05:37 +0200 Subject: [PATCH 8/8] chore: regenerate with RuboCop-clean spec template --- .../models/async_export_error_event.rb | 2 +- spec/webhook_spec.rb | 590 +++++++++++++++--- 2 files changed, 521 insertions(+), 71 deletions(-) diff --git a/lib/getstream_ruby/generated/models/async_export_error_event.rb b/lib/getstream_ruby/generated/models/async_export_error_event.rb index e297b0e..f785b8c 100644 --- a/lib/getstream_ruby/generated/models/async_export_error_event.rb +++ b/lib/getstream_ruby/generated/models/async_export_error_event.rb @@ -43,7 +43,7 @@ def initialize(attributes = {}) @started_at = attributes[:started_at] || attributes['started_at'] @task_id = attributes[:task_id] || attributes['task_id'] @custom = attributes[:custom] || attributes['custom'] - @type = attributes[:type] || attributes['type'] || "export.channels.error" + @type = attributes[:type] || attributes['type'] || "export.bulk_image_moderation.error" @received_at = attributes[:received_at] || attributes['received_at'] || nil end diff --git a/spec/webhook_spec.rb b/spec/webhook_spec.rb index 16e57f6..096be57 100644 --- a/spec/webhook_spec.rb +++ b/spec/webhook_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Code generated by GetStream internal OpenAPI code generator. DO NOT EDIT. require 'spec_helper' @@ -9,904 +11,1275 @@ require_relative '../lib/getstream_ruby/generated/webhook' -RSpec.describe 'Webhook' do - SECRET = 'test-webhook-secret' - BODY = '{"type":"test.event"}' +# Top-level constants — pulled out of the RSpec.describe blocks below to avoid +# Lint/ConstantDefinitionInBlock. WEBHOOK_FIXTURE_ROOT is evaluated at file-load +# time so the `Dir.children` iteration below can build the per-fixture contexts. +WEBHOOK_TEST_SECRET = 'test-webhook-secret' +WEBHOOK_TEST_BODY = '{"type":"test.event"}' +WEBHOOK_CONFORMANCE_SECRET = 'test_secret_do_not_use_in_production' +WEBHOOK_FIXTURE_ROOT = File.expand_path('../test/fixtures/webhooks', __dir__) - def compute_signature(body, secret) - OpenSSL::HMAC.hexdigest('SHA256', secret, body) - end +def compute_signature(body, secret) + OpenSSL::HMAC.hexdigest('SHA256', secret, body) +end + +RSpec.describe 'Webhook' do describe 'verify_signature' do + it 'accepts a valid signature' do - signature = compute_signature(BODY, SECRET) - expect(StreamChat::Webhook.verify_signature(BODY, signature, SECRET)).to be true + + signature = compute_signature(WEBHOOK_TEST_BODY, WEBHOOK_TEST_SECRET) + expect(StreamChat::Webhook.verify_signature(WEBHOOK_TEST_BODY, signature, WEBHOOK_TEST_SECRET)).to be true + end it 'rejects a wrong signature' do - expect(StreamChat::Webhook.verify_signature(BODY, 'invalidsignature', SECRET)).to be false + + result = StreamChat::Webhook.verify_signature(WEBHOOK_TEST_BODY, 'invalidsignature', WEBHOOK_TEST_SECRET) + expect(result).to be false + end it 'rejects a tampered body' do - signature = compute_signature(BODY, SECRET) - expect(StreamChat::Webhook.verify_signature('{"type":"tampered"}', signature, SECRET)).to be false + + signature = compute_signature(WEBHOOK_TEST_BODY, WEBHOOK_TEST_SECRET) + expect(StreamChat::Webhook.verify_signature('{"type":"tampered"}', signature, WEBHOOK_TEST_SECRET)).to be false + end it 'rejects a wrong secret' do - signature = compute_signature(BODY, SECRET) - expect(StreamChat::Webhook.verify_signature(BODY, signature, 'wrong-secret')).to be false + + signature = compute_signature(WEBHOOK_TEST_BODY, WEBHOOK_TEST_SECRET) + expect(StreamChat::Webhook.verify_signature(WEBHOOK_TEST_BODY, signature, 'wrong-secret')).to be false + end it 'rejects an empty signature' do - expect(StreamChat::Webhook.verify_signature(BODY, '', SECRET)).to be false + + expect(StreamChat::Webhook.verify_signature(WEBHOOK_TEST_BODY, '', WEBHOOK_TEST_SECRET)).to be false + end + end describe 'get_event_type' do + it 'reads the type from a string payload' do + expect(StreamChat::Webhook.get_event_type('{"type":"message.new"}')).to eq('message.new') + end it 'reads the type from a hash payload' do + expect(StreamChat::Webhook.get_event_type({ 'type' => 'message.new' })).to eq('message.new') + end it 'returns nil when the type field is missing' do + expect(StreamChat::Webhook.get_event_type('{"foo":"bar"}')).to be_nil + end it 'returns nil for an empty object' do + expect(StreamChat::Webhook.get_event_type('{}')).to be_nil + end + end describe 'parse_webhook_event' do + it 'parses *' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"*"}') expect(event.class.name).to eq('GetStream::Generated::Models::CustomEvent') + end it 'parses appeal.accepted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"appeal.accepted"}') expect(event.class.name).to eq('GetStream::Generated::Models::AppealAcceptedEvent') + end it 'parses appeal.created' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"appeal.created"}') expect(event.class.name).to eq('GetStream::Generated::Models::AppealCreatedEvent') + end it 'parses appeal.rejected' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"appeal.rejected"}') expect(event.class.name).to eq('GetStream::Generated::Models::AppealRejectedEvent') + end it 'parses call.accepted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.accepted"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallAcceptedEvent') + end it 'parses call.blocked_user' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.blocked_user"}') expect(event.class.name).to eq('GetStream::Generated::Models::BlockedUserEvent') + end it 'parses call.closed_caption' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.closed_caption"}') expect(event.class.name).to eq('GetStream::Generated::Models::ClosedCaptionEvent') + end it 'parses call.closed_captions_failed' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.closed_captions_failed"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallClosedCaptionsFailedEvent') + end it 'parses call.closed_captions_started' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.closed_captions_started"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallClosedCaptionsStartedEvent') + end it 'parses call.closed_captions_stopped' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.closed_captions_stopped"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallClosedCaptionsStoppedEvent') + end it 'parses call.created' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.created"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallCreatedEvent') + end it 'parses call.deleted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.deleted"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallDeletedEvent') + end it 'parses call.dtmf' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.dtmf"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallDTMFEvent') + end it 'parses call.ended' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.ended"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallEndedEvent') + end it 'parses call.frame_recording_failed' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.frame_recording_failed"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallFrameRecordingFailedEvent') + end it 'parses call.frame_recording_ready' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.frame_recording_ready"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallFrameRecordingFrameReadyEvent') + end it 'parses call.frame_recording_started' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.frame_recording_started"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallFrameRecordingStartedEvent') + end it 'parses call.frame_recording_stopped' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.frame_recording_stopped"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallFrameRecordingStoppedEvent') + end it 'parses call.hls_broadcasting_failed' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.hls_broadcasting_failed"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallHLSBroadcastingFailedEvent') + end it 'parses call.hls_broadcasting_started' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.hls_broadcasting_started"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallHLSBroadcastingStartedEvent') + end it 'parses call.hls_broadcasting_stopped' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.hls_broadcasting_stopped"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallHLSBroadcastingStoppedEvent') + end it 'parses call.kicked_user' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.kicked_user"}') expect(event.class.name).to eq('GetStream::Generated::Models::KickedUserEvent') + end it 'parses call.live_started' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.live_started"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallLiveStartedEvent') + end it 'parses call.member_added' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.member_added"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallMemberAddedEvent') + end it 'parses call.member_removed' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.member_removed"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallMemberRemovedEvent') + end it 'parses call.member_updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.member_updated"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallMemberUpdatedEvent') + end it 'parses call.member_updated_permission' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.member_updated_permission"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallMemberUpdatedPermissionEvent') + end it 'parses call.missed' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.missed"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallMissedEvent') + end it 'parses call.moderation_blur' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.moderation_blur"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallModerationBlurEvent') + end it 'parses call.moderation_warning' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.moderation_warning"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallModerationWarningEvent') + end it 'parses call.notification' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.notification"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallNotificationEvent') + end it 'parses call.permission_request' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.permission_request"}') expect(event.class.name).to eq('GetStream::Generated::Models::PermissionRequestEvent') + end it 'parses call.permissions_updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.permissions_updated"}') expect(event.class.name).to eq('GetStream::Generated::Models::UpdatedCallPermissionsEvent') + end it 'parses call.reaction_new' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.reaction_new"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallReactionEvent') + end it 'parses call.recording_failed' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.recording_failed"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallRecordingFailedEvent') + end it 'parses call.recording_ready' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.recording_ready"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallRecordingReadyEvent') + end it 'parses call.recording_started' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.recording_started"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallRecordingStartedEvent') + end it 'parses call.recording_stopped' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.recording_stopped"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallRecordingStoppedEvent') + end it 'parses call.rejected' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.rejected"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallRejectedEvent') + end it 'parses call.ring' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.ring"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallRingEvent') + end it 'parses call.rtmp_broadcast_failed' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.rtmp_broadcast_failed"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallRtmpBroadcastFailedEvent') + end it 'parses call.rtmp_broadcast_started' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.rtmp_broadcast_started"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallRtmpBroadcastStartedEvent') + end it 'parses call.rtmp_broadcast_stopped' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.rtmp_broadcast_stopped"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallRtmpBroadcastStoppedEvent') + end it 'parses call.session_ended' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.session_ended"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallSessionEndedEvent') + end it 'parses call.session_participant_count_updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.session_participant_count_updated"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallSessionParticipantCountsUpdatedEvent') + end it 'parses call.session_participant_joined' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.session_participant_joined"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallSessionParticipantJoinedEvent') + end it 'parses call.session_participant_left' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.session_participant_left"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallSessionParticipantLeftEvent') + end it 'parses call.session_started' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.session_started"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallSessionStartedEvent') + end it 'parses call.stats_report_ready' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.stats_report_ready"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallStatsReportReadyEvent') + end it 'parses call.transcription_failed' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.transcription_failed"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallTranscriptionFailedEvent') + end it 'parses call.transcription_ready' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.transcription_ready"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallTranscriptionReadyEvent') + end it 'parses call.transcription_started' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.transcription_started"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallTranscriptionStartedEvent') + end it 'parses call.transcription_stopped' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.transcription_stopped"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallTranscriptionStoppedEvent') + end it 'parses call.unblocked_user' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.unblocked_user"}') expect(event.class.name).to eq('GetStream::Generated::Models::UnblockedUserEvent') + end it 'parses call.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.updated"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallUpdatedEvent') + end it 'parses call.user_feedback_submitted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.user_feedback_submitted"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallUserFeedbackSubmittedEvent') + end it 'parses call.user_muted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"call.user_muted"}') expect(event.class.name).to eq('GetStream::Generated::Models::CallUserMutedEvent') + end it 'parses campaign.completed' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"campaign.completed"}') expect(event.class.name).to eq('GetStream::Generated::Models::CampaignCompletedEvent') + end it 'parses campaign.started' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"campaign.started"}') expect(event.class.name).to eq('GetStream::Generated::Models::CampaignStartedEvent') + end it 'parses channel.created' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"channel.created"}') expect(event.class.name).to eq('GetStream::Generated::Models::ChannelCreatedEvent') + end it 'parses channel.deleted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"channel.deleted"}') expect(event.class.name).to eq('GetStream::Generated::Models::ChannelDeletedEvent') + end it 'parses channel.frozen' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"channel.frozen"}') expect(event.class.name).to eq('GetStream::Generated::Models::ChannelFrozenEvent') + end it 'parses channel.hidden' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"channel.hidden"}') expect(event.class.name).to eq('GetStream::Generated::Models::ChannelHiddenEvent') + end it 'parses channel.max_streak_changed' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"channel.max_streak_changed"}') expect(event.class.name).to eq('GetStream::Generated::Models::MaxStreakChangedEvent') + end it 'parses channel.muted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"channel.muted"}') expect(event.class.name).to eq('GetStream::Generated::Models::ChannelMutedEvent') + end it 'parses channel.truncated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"channel.truncated"}') expect(event.class.name).to eq('GetStream::Generated::Models::ChannelTruncatedEvent') + end it 'parses channel.unfrozen' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"channel.unfrozen"}') expect(event.class.name).to eq('GetStream::Generated::Models::ChannelUnFrozenEvent') + end it 'parses channel.unmuted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"channel.unmuted"}') expect(event.class.name).to eq('GetStream::Generated::Models::ChannelUnmutedEvent') + end it 'parses channel.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"channel.updated"}') expect(event.class.name).to eq('GetStream::Generated::Models::ChannelUpdatedEvent') + end it 'parses channel.visible' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"channel.visible"}') expect(event.class.name).to eq('GetStream::Generated::Models::ChannelVisibleEvent') + end it 'parses channel_batch_update.completed' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"channel_batch_update.completed"}') expect(event.class.name).to eq('GetStream::Generated::Models::ChannelBatchCompletedEvent') + end it 'parses channel_batch_update.started' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"channel_batch_update.started"}') expect(event.class.name).to eq('GetStream::Generated::Models::ChannelBatchStartedEvent') + end it 'parses custom' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"custom"}') expect(event.class.name).to eq('GetStream::Generated::Models::CustomVideoEvent') + end it 'parses export.bulk_image_moderation.error' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"export.bulk_image_moderation.error"}') expect(event.class.name).to eq('GetStream::Generated::Models::AsyncExportErrorEvent') + end it 'parses export.bulk_image_moderation.success' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"export.bulk_image_moderation.success"}') expect(event.class.name).to eq('GetStream::Generated::Models::AsyncBulkImageModerationEvent') + end it 'parses export.channels.error' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"export.channels.error"}') expect(event.class.name).to eq('GetStream::Generated::Models::AsyncExportErrorEvent') + end it 'parses export.channels.success' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"export.channels.success"}') expect(event.class.name).to eq('GetStream::Generated::Models::AsyncExportChannelsEvent') + end it 'parses export.moderation_logs.error' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"export.moderation_logs.error"}') expect(event.class.name).to eq('GetStream::Generated::Models::AsyncExportErrorEvent') + end it 'parses export.moderation_logs.success' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"export.moderation_logs.success"}') expect(event.class.name).to eq('GetStream::Generated::Models::AsyncExportModerationLogsEvent') + end it 'parses export.users.error' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"export.users.error"}') expect(event.class.name).to eq('GetStream::Generated::Models::AsyncExportErrorEvent') + end it 'parses export.users.success' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"export.users.success"}') expect(event.class.name).to eq('GetStream::Generated::Models::AsyncExportUsersEvent') + end it 'parses feeds.activity.added' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.activity.added"}') expect(event.class.name).to eq('GetStream::Generated::Models::ActivityAddedEvent') + end it 'parses feeds.activity.deleted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.activity.deleted"}') expect(event.class.name).to eq('GetStream::Generated::Models::ActivityDeletedEvent') + end it 'parses feeds.activity.feedback' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.activity.feedback"}') expect(event.class.name).to eq('GetStream::Generated::Models::ActivityFeedbackEvent') + end it 'parses feeds.activity.marked' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.activity.marked"}') expect(event.class.name).to eq('GetStream::Generated::Models::ActivityMarkEvent') + end it 'parses feeds.activity.pinned' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.activity.pinned"}') expect(event.class.name).to eq('GetStream::Generated::Models::ActivityPinnedEvent') + end it 'parses feeds.activity.reaction.added' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.activity.reaction.added"}') expect(event.class.name).to eq('GetStream::Generated::Models::ActivityReactionAddedEvent') + end it 'parses feeds.activity.reaction.deleted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.activity.reaction.deleted"}') expect(event.class.name).to eq('GetStream::Generated::Models::ActivityReactionDeletedEvent') + end it 'parses feeds.activity.reaction.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.activity.reaction.updated"}') expect(event.class.name).to eq('GetStream::Generated::Models::ActivityReactionUpdatedEvent') + end it 'parses feeds.activity.removed_from_feed' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.activity.removed_from_feed"}') expect(event.class.name).to eq('GetStream::Generated::Models::ActivityRemovedFromFeedEvent') + end it 'parses feeds.activity.restored' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.activity.restored"}') expect(event.class.name).to eq('GetStream::Generated::Models::ActivityRestoredEvent') + end it 'parses feeds.activity.unpinned' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.activity.unpinned"}') expect(event.class.name).to eq('GetStream::Generated::Models::ActivityUnpinnedEvent') + end it 'parses feeds.activity.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.activity.updated"}') expect(event.class.name).to eq('GetStream::Generated::Models::ActivityUpdatedEvent') + end it 'parses feeds.bookmark.added' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.bookmark.added"}') expect(event.class.name).to eq('GetStream::Generated::Models::BookmarkAddedEvent') + end it 'parses feeds.bookmark.deleted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.bookmark.deleted"}') expect(event.class.name).to eq('GetStream::Generated::Models::BookmarkDeletedEvent') + end it 'parses feeds.bookmark.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.bookmark.updated"}') expect(event.class.name).to eq('GetStream::Generated::Models::BookmarkUpdatedEvent') + end it 'parses feeds.bookmark_folder.deleted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.bookmark_folder.deleted"}') expect(event.class.name).to eq('GetStream::Generated::Models::BookmarkFolderDeletedEvent') + end it 'parses feeds.bookmark_folder.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.bookmark_folder.updated"}') expect(event.class.name).to eq('GetStream::Generated::Models::BookmarkFolderUpdatedEvent') + end it 'parses feeds.comment.added' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.comment.added"}') expect(event.class.name).to eq('GetStream::Generated::Models::CommentAddedEvent') + end it 'parses feeds.comment.deleted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.comment.deleted"}') expect(event.class.name).to eq('GetStream::Generated::Models::CommentDeletedEvent') + end it 'parses feeds.comment.reaction.added' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.comment.reaction.added"}') expect(event.class.name).to eq('GetStream::Generated::Models::CommentReactionAddedEvent') + end it 'parses feeds.comment.reaction.deleted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.comment.reaction.deleted"}') expect(event.class.name).to eq('GetStream::Generated::Models::CommentReactionDeletedEvent') + end it 'parses feeds.comment.reaction.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.comment.reaction.updated"}') expect(event.class.name).to eq('GetStream::Generated::Models::CommentReactionUpdatedEvent') + end it 'parses feeds.comment.restored' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.comment.restored"}') expect(event.class.name).to eq('GetStream::Generated::Models::CommentRestoredEvent') + end it 'parses feeds.comment.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.comment.updated"}') expect(event.class.name).to eq('GetStream::Generated::Models::CommentUpdatedEvent') + end it 'parses feeds.feed.created' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.feed.created"}') expect(event.class.name).to eq('GetStream::Generated::Models::FeedCreatedEvent') + end it 'parses feeds.feed.deleted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.feed.deleted"}') expect(event.class.name).to eq('GetStream::Generated::Models::FeedDeletedEvent') + end it 'parses feeds.feed.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.feed.updated"}') expect(event.class.name).to eq('GetStream::Generated::Models::FeedUpdatedEvent') + end it 'parses feeds.feed_group.changed' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.feed_group.changed"}') expect(event.class.name).to eq('GetStream::Generated::Models::FeedGroupChangedEvent') + end it 'parses feeds.feed_group.deleted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.feed_group.deleted"}') expect(event.class.name).to eq('GetStream::Generated::Models::FeedGroupDeletedEvent') + end it 'parses feeds.feed_group.restored' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.feed_group.restored"}') expect(event.class.name).to eq('GetStream::Generated::Models::FeedGroupRestoredEvent') + end it 'parses feeds.feed_member.added' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.feed_member.added"}') expect(event.class.name).to eq('GetStream::Generated::Models::FeedMemberAddedEvent') + end it 'parses feeds.feed_member.removed' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.feed_member.removed"}') expect(event.class.name).to eq('GetStream::Generated::Models::FeedMemberRemovedEvent') + end it 'parses feeds.feed_member.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.feed_member.updated"}') expect(event.class.name).to eq('GetStream::Generated::Models::FeedMemberUpdatedEvent') + end it 'parses feeds.follow.created' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.follow.created"}') expect(event.class.name).to eq('GetStream::Generated::Models::FollowCreatedEvent') + end it 'parses feeds.follow.deleted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.follow.deleted"}') expect(event.class.name).to eq('GetStream::Generated::Models::FollowDeletedEvent') + end it 'parses feeds.follow.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.follow.updated"}') expect(event.class.name).to eq('GetStream::Generated::Models::FollowUpdatedEvent') + end it 'parses feeds.notification_feed.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.notification_feed.updated"}') expect(event.class.name).to eq('GetStream::Generated::Models::NotificationFeedUpdatedEvent') + end it 'parses feeds.stories_feed.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"feeds.stories_feed.updated"}') expect(event.class.name).to eq('GetStream::Generated::Models::StoriesFeedUpdatedEvent') + end it 'parses flag.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"flag.updated"}') expect(event.class.name).to eq('GetStream::Generated::Models::FlagUpdatedEvent') + end it 'parses ingress.error' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"ingress.error"}') expect(event.class.name).to eq('GetStream::Generated::Models::IngressErrorEvent') + end it 'parses ingress.started' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"ingress.started"}') expect(event.class.name).to eq('GetStream::Generated::Models::IngressStartedEvent') + end it 'parses ingress.stopped' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"ingress.stopped"}') expect(event.class.name).to eq('GetStream::Generated::Models::IngressStoppedEvent') + end it 'parses member.added' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"member.added"}') expect(event.class.name).to eq('GetStream::Generated::Models::MemberAddedEvent') + end it 'parses member.removed' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"member.removed"}') expect(event.class.name).to eq('GetStream::Generated::Models::MemberRemovedEvent') + end it 'parses member.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"member.updated"}') expect(event.class.name).to eq('GetStream::Generated::Models::MemberUpdatedEvent') + end it 'parses message.deleted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"message.deleted"}') expect(event.class.name).to eq('GetStream::Generated::Models::MessageDeletedEvent') + end it 'parses message.flagged' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"message.flagged"}') expect(event.class.name).to eq('GetStream::Generated::Models::MessageFlaggedEvent') + end it 'parses message.new' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"message.new"}') expect(event.class.name).to eq('GetStream::Generated::Models::MessageNewEvent') + end it 'parses message.pending' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"message.pending"}') expect(event.class.name).to eq('GetStream::Generated::Models::PendingMessageEvent') + end it 'parses message.read' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"message.read"}') expect(event.class.name).to eq('GetStream::Generated::Models::MessageReadEvent') + end it 'parses message.unblocked' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"message.unblocked"}') expect(event.class.name).to eq('GetStream::Generated::Models::MessageUnblockedEvent') + end it 'parses message.undeleted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"message.undeleted"}') expect(event.class.name).to eq('GetStream::Generated::Models::MessageUndeletedEvent') + end it 'parses message.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"message.updated"}') expect(event.class.name).to eq('GetStream::Generated::Models::MessageUpdatedEvent') + end it 'parses moderation.custom_action' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"moderation.custom_action"}') expect(event.class.name).to eq('GetStream::Generated::Models::ModerationCustomActionEvent') + end it 'parses moderation.flagged' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"moderation.flagged"}') expect(event.class.name).to eq('GetStream::Generated::Models::ModerationFlaggedEvent') + end it 'parses moderation.mark_reviewed' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"moderation.mark_reviewed"}') expect(event.class.name).to eq('GetStream::Generated::Models::ModerationMarkReviewedEvent') + end it 'parses moderation_check.completed' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"moderation_check.completed"}') expect(event.class.name).to eq('GetStream::Generated::Models::ModerationCheckCompletedEvent') + end it 'parses moderation_rule.triggered' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"moderation_rule.triggered"}') expect(event.class.name).to eq('GetStream::Generated::Models::ModerationRulesTriggeredEvent') + end it 'parses notification.mark_unread' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"notification.mark_unread"}') expect(event.class.name).to eq('GetStream::Generated::Models::NotificationMarkUnreadEvent') + end it 'parses notification.reminder_due' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"notification.reminder_due"}') expect(event.class.name).to eq('GetStream::Generated::Models::ReminderNotificationEvent') + end it 'parses notification.thread_message_new' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"notification.thread_message_new"}') expect(event.class.name).to eq('GetStream::Generated::Models::NotificationThreadMessageNewEvent') + end it 'parses reaction.deleted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"reaction.deleted"}') expect(event.class.name).to eq('GetStream::Generated::Models::ReactionDeletedEvent') + end it 'parses reaction.new' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"reaction.new"}') expect(event.class.name).to eq('GetStream::Generated::Models::ReactionNewEvent') + end it 'parses reaction.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"reaction.updated"}') expect(event.class.name).to eq('GetStream::Generated::Models::ReactionUpdatedEvent') + end it 'parses reminder.created' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"reminder.created"}') expect(event.class.name).to eq('GetStream::Generated::Models::ReminderCreatedEvent') + end it 'parses reminder.deleted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"reminder.deleted"}') expect(event.class.name).to eq('GetStream::Generated::Models::ReminderDeletedEvent') + end it 'parses reminder.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"reminder.updated"}') expect(event.class.name).to eq('GetStream::Generated::Models::ReminderUpdatedEvent') + end it 'parses review_queue_item.new' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"review_queue_item.new"}') expect(event.class.name).to eq('GetStream::Generated::Models::ReviewQueueItemNewEvent') + end it 'parses review_queue_item.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"review_queue_item.updated"}') expect(event.class.name).to eq('GetStream::Generated::Models::ReviewQueueItemUpdatedEvent') + end it 'parses thread.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"thread.updated"}') expect(event.class.name).to eq('GetStream::Generated::Models::ThreadUpdatedEvent') + end it 'parses user.banned' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"user.banned"}') expect(event.class.name).to eq('GetStream::Generated::Models::UserBannedEvent') + end it 'parses user.deactivated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"user.deactivated"}') expect(event.class.name).to eq('GetStream::Generated::Models::UserDeactivatedEvent') + end it 'parses user.deleted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"user.deleted"}') expect(event.class.name).to eq('GetStream::Generated::Models::UserDeletedEvent') + end it 'parses user.flagged' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"user.flagged"}') expect(event.class.name).to eq('GetStream::Generated::Models::UserFlaggedEvent') + end it 'parses user.messages.deleted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"user.messages.deleted"}') expect(event.class.name).to eq('GetStream::Generated::Models::UserMessagesDeletedEvent') + end it 'parses user.muted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"user.muted"}') expect(event.class.name).to eq('GetStream::Generated::Models::UserMutedEvent') + end it 'parses user.reactivated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"user.reactivated"}') expect(event.class.name).to eq('GetStream::Generated::Models::UserReactivatedEvent') + end it 'parses user.unbanned' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"user.unbanned"}') expect(event.class.name).to eq('GetStream::Generated::Models::UserUnbannedEvent') + end it 'parses user.unmuted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"user.unmuted"}') expect(event.class.name).to eq('GetStream::Generated::Models::UserUnmutedEvent') + end it 'parses user.unread_message_reminder' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"user.unread_message_reminder"}') expect(event.class.name).to eq('GetStream::Generated::Models::UserUnreadReminderEvent') + end it 'parses user.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"user.updated"}') expect(event.class.name).to eq('GetStream::Generated::Models::UserUpdatedEvent') + end it 'parses user_group.created' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"user_group.created"}') expect(event.class.name).to eq('GetStream::Generated::Models::UserGroupCreatedEvent') + end it 'parses user_group.deleted' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"user_group.deleted"}') expect(event.class.name).to eq('GetStream::Generated::Models::UserGroupDeletedEvent') + end it 'parses user_group.member_added' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"user_group.member_added"}') expect(event.class.name).to eq('GetStream::Generated::Models::UserGroupMemberAddedEvent') + end it 'parses user_group.member_removed' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"user_group.member_removed"}') expect(event.class.name).to eq('GetStream::Generated::Models::UserGroupMemberRemovedEvent') + end it 'parses user_group.updated' do + event = StreamChat::Webhook.parse_webhook_event('{"type":"user_group.updated"}') expect(event.class.name).to eq('GetStream::Generated::Models::UserGroupUpdatedEvent') + end it 'raises on unknown event type' do + expect { StreamChat::Webhook.parse_webhook_event('{"type":"unknown.event"}') }.to raise_error(ArgumentError) + end it 'raises when type field is missing' do + expect { StreamChat::Webhook.parse_webhook_event('{"foo":"bar"}') }.to raise_error(ArgumentError) + end it 'raises on invalid JSON' do + expect { StreamChat::Webhook.parse_webhook_event('not json') }.to raise_error(ArgumentError) + end + end # --------------------------------------------------------------------------- @@ -915,146 +1288,205 @@ def compute_signature(body, secret) # --------------------------------------------------------------------------- describe 'canonical alias' do + it 'resolves Stream::Webhook to StreamChat::Webhook' do + expect(Stream::Webhook).to eq(StreamChat::Webhook) + end + end describe 'parse_event' do + it 'returns an UnknownEvent for unknown discriminator' do + body = '{"type":"totally.made.up","created_at":"2026-05-08T00:00:00Z"}' event = StreamChat::Webhook.parse_event(body) expect(event).to be_a(StreamChat::Webhook::UnknownEvent) expect(event.type).to eq('totally.made.up') expect(event.created_at).not_to be_nil expect(event.raw['type']).to eq('totally.made.up') + end it 'raises on empty body' do + expect { StreamChat::Webhook.parse_event('') }.to raise_error(StreamChat::Webhook::InvalidWebhookError) + end it 'raises on invalid JSON' do + expect { StreamChat::Webhook.parse_event('not json') }.to raise_error(StreamChat::Webhook::InvalidWebhookError) + end it 'raises on non-object JSON' do + expect { StreamChat::Webhook.parse_event('[1,2,3]') }.to raise_error(StreamChat::Webhook::InvalidWebhookError) + end it 'raises when type field is missing' do - expect { StreamChat::Webhook.parse_event('{"foo":"bar"}') }.to raise_error(StreamChat::Webhook::InvalidWebhookError) + + err_class = StreamChat::Webhook::InvalidWebhookError + expect { StreamChat::Webhook.parse_event('{"foo":"bar"}') }.to raise_error(err_class) + end + end describe 'gunzip_payload' do + it 'passes through a plain body' do + plain = '{"type":"message.new"}' expect(StreamChat::Webhook.gunzip_payload(plain)).to eq(plain.b) + end it 'decompresses a gzip body' do + plain = '{"type":"message.new"}' gz = Zlib.gzip(plain) expect(StreamChat::Webhook.gunzip_payload(gz)).to eq(plain) + end it 'raises on a corrupt gzip stream' do + corrupt = "\x1F\x8Bnot-actually-a-gzip-stream".b expect { StreamChat::Webhook.gunzip_payload(corrupt) }.to raise_error(StreamChat::Webhook::InvalidWebhookError) + end + end describe 'decode_sqs_payload' do + it 'decodes plain base64' do + plain = '{"type":"message.new"}' encoded = Base64.strict_encode64(plain) expect(StreamChat::Webhook.decode_sqs_payload(encoded)).to eq(plain.b) + end it 'decodes base64-then-gzip payload' do + plain = '{"type":"message.new"}' gz = Zlib.gzip(plain) encoded = Base64.strict_encode64(gz) expect(StreamChat::Webhook.decode_sqs_payload(encoded)).to eq(plain) + end it 'passes through non-base64 raw JSON' do + # Per chat#13392 wire format: SQS bodies are raw JSON when # hook_payload_compression is off. decode_sqs_payload must fall back to # raw bytes on non-base64 input rather than raise. plain = '{"type":"message.new"}' expect(StreamChat::Webhook.decode_sqs_payload(plain)).to eq(plain) + end + end describe 'decode_sns_payload' do + it 'extracts and decodes the inner Message' do + plain = '{"type":"message.new"}' envelope = JSON.generate( 'Type' => 'Notification', 'Message' => Base64.strict_encode64(plain), 'MessageId' => 'abc-123', 'Timestamp' => '2026-05-08T00:00:00Z', - 'TopicArn' => 'arn:aws:sns:us-east-1:123:test' + 'TopicArn' => 'arn:aws:sns:us-east-1:123:test', ) expect(StreamChat::Webhook.decode_sns_payload(envelope)).to eq(plain.b) + end it 'treats non-envelope input as a raw Message string' do - # Fix #4: decode_sns_payload accepts either a full SNS envelope or a + + # decode_sns_payload accepts either a full SNS envelope or a # pre-extracted Message string. Non-envelope input flows through as bytes. expect(StreamChat::Webhook.decode_sns_payload('not json')).not_to be_nil + end it 'treats envelope-shape without Message as raw' do - # Fix #4: envelope-shaped JSON without a string Message field is treated + + # Envelope-shaped JSON without a string Message field is treated # as a pre-extracted Message string and flows through. expect(StreamChat::Webhook.decode_sns_payload('{"Type":"Notification"}')).not_to be_nil + end + end describe 'verify_and_parse_webhook' do + it 'parses a valid happy-path body' do + body = '{"type":"message.new"}' - sig = compute_signature(body, SECRET) - expect(StreamChat::Webhook.verify_and_parse_webhook(body, sig, SECRET)).not_to be_nil + sig = compute_signature(body, WEBHOOK_TEST_SECRET) + expect(StreamChat::Webhook.verify_and_parse_webhook(body, sig, WEBHOOK_TEST_SECRET)).not_to be_nil + end it 'parses a valid gzip body' do + body = '{"type":"message.new"}' - sig = compute_signature(body, SECRET) + sig = compute_signature(body, WEBHOOK_TEST_SECRET) gz = Zlib.gzip(body) - expect(StreamChat::Webhook.verify_and_parse_webhook(gz, sig, SECRET)).not_to be_nil + expect(StreamChat::Webhook.verify_and_parse_webhook(gz, sig, WEBHOOK_TEST_SECRET)).not_to be_nil + end it 'raises on tampered body' do + body = '{"type":"message.new"}' - sig = compute_signature(body, SECRET) + sig = compute_signature(body, WEBHOOK_TEST_SECRET) + tampered = '{"type":"message.deleted"}' + err_class = StreamChat::Webhook::InvalidWebhookError # Single-arm rescue on the unified InvalidWebhookError; message identifies the mode. - expect do - StreamChat::Webhook.verify_and_parse_webhook('{"type":"message.deleted"}', sig, SECRET) - end.to raise_error(StreamChat::Webhook::InvalidWebhookError, /#{StreamChat::Webhook::InvalidWebhookError::SIGNATURE_MISMATCH}/) + expect { StreamChat::Webhook.verify_and_parse_webhook(tampered, sig, WEBHOOK_TEST_SECRET) } + .to raise_error(err_class, /#{err_class::SIGNATURE_MISMATCH}/) + end + end describe 'parse_sqs' do + it 'parses a happy-path SQS body' do + plain = '{"type":"message.new"}' expect(StreamChat::Webhook.parse_sqs(Base64.strict_encode64(plain))).not_to be_nil + end + end describe 'parse_sns' do + it 'parses a happy-path SNS envelope' do + plain = '{"type":"message.new"}' envelope = JSON.generate( 'Type' => 'Notification', - 'Message' => Base64.strict_encode64(plain) + 'Message' => Base64.strict_encode64(plain), ) expect(StreamChat::Webhook.parse_sns(envelope)).not_to be_nil + end + end + end # --------------------------------------------------------------------------- @@ -1062,22 +1494,26 @@ def compute_signature(body, secret) # (one subdirectory per case for happy path; _invalid/ for negative cases). # --------------------------------------------------------------------------- RSpec.describe 'Webhook conformance', type: :integration do - CANONICAL_TEST_SECRET = 'test_secret_do_not_use_in_production'.freeze - FIXTURE_ROOT = File.expand_path('../test/fixtures/webhooks', __dir__).freeze it 'has conformance fixtures present' do - expect(File).to be_directory(FIXTURE_ROOT) + + expect(File).to be_directory(WEBHOOK_FIXTURE_ROOT) + end - if File.directory?(FIXTURE_ROOT) - Dir.children(FIXTURE_ROOT).sort.each do |name| + if File.directory?(WEBHOOK_FIXTURE_ROOT) + + Dir.children(WEBHOOK_FIXTURE_ROOT).sort.each do |name| + next if name == '_invalid' - dir = File.join(FIXTURE_ROOT, name) + dir = File.join(WEBHOOK_FIXTURE_ROOT, name) next unless File.directory?(dir) context "happy fixture #{name}" do + it 'verifies signature, parses event, and round-trips all transports' do + body = File.binread(File.join(dir, 'body.json')) body_gz = File.binread(File.join(dir, 'body.gz')) sqs_compressed = File.read(File.join(dir, 'sqs_body.txt')).strip @@ -1085,102 +1521,116 @@ def compute_signature(body, secret) sns = File.read(File.join(dir, 'sns_notification.txt')).strip sig = File.read(File.join(dir, 'signature.txt')).strip - expect(StreamChat::Webhook.verify_signature(body, sig, CANONICAL_TEST_SECRET)).to be true + expect(StreamChat::Webhook.verify_signature(body, sig, WEBHOOK_CONFORMANCE_SECRET)).to be true expect(StreamChat::Webhook.parse_event(body)).not_to be_nil - expect(StreamChat::Webhook.verify_and_parse_webhook(body, sig, CANONICAL_TEST_SECRET)).not_to be_nil - expect(StreamChat::Webhook.verify_and_parse_webhook(body_gz, sig, CANONICAL_TEST_SECRET)).not_to be_nil + expect(StreamChat::Webhook.verify_and_parse_webhook(body, sig, WEBHOOK_CONFORMANCE_SECRET)).not_to be_nil + expect(StreamChat::Webhook.verify_and_parse_webhook(body_gz, sig, WEBHOOK_CONFORMANCE_SECRET)).not_to be_nil expect(StreamChat::Webhook.parse_sqs(sqs_compressed)).not_to be_nil expect(StreamChat::Webhook.parse_sqs(sqs_raw)).not_to be_nil expect(StreamChat::Webhook.parse_sns(sns)).not_to be_nil + end + end + end + end context 'negative fixtures' do + def neg_dir(name) - File.join(FIXTURE_ROOT, '_invalid', name) + File.join(WEBHOOK_FIXTURE_ROOT, '_invalid', name) end it 'rejects tampered body' do - skip 'fixtures not present' unless File.directory?(FIXTURE_ROOT) + + skip 'fixtures not present' unless File.directory?(WEBHOOK_FIXTURE_ROOT) body = File.binread(File.join(neg_dir('tampered_body'), 'body.json')) sig = File.read(File.join(neg_dir('tampered_body'), 'signature.txt')).strip - expect do - StreamChat::Webhook.verify_and_parse_webhook(body, sig, CANONICAL_TEST_SECRET) - end.to raise_error(StreamChat::Webhook::InvalidWebhookError, /#{StreamChat::Webhook::InvalidWebhookError::SIGNATURE_MISMATCH}/) + err_class = StreamChat::Webhook::InvalidWebhookError + expect { StreamChat::Webhook.verify_and_parse_webhook(body, sig, WEBHOOK_CONFORMANCE_SECRET) } + .to raise_error(err_class, /#{err_class::SIGNATURE_MISMATCH}/) + end it 'returns UnknownEvent for unknown type' do - skip 'fixtures not present' unless File.directory?(FIXTURE_ROOT) + + skip 'fixtures not present' unless File.directory?(WEBHOOK_FIXTURE_ROOT) body = File.binread(File.join(neg_dir('unknown_type'), 'body.json')) result = StreamChat::Webhook.parse_event(body) expect(result).to be_a(StreamChat::Webhook::UnknownEvent) expect(result.type).to eq('totally.made.up') + end it 'raises when type field is missing' do - skip 'fixtures not present' unless File.directory?(FIXTURE_ROOT) + + skip 'fixtures not present' unless File.directory?(WEBHOOK_FIXTURE_ROOT) body = File.binread(File.join(neg_dir('missing_type'), 'body.json')) - expect do - StreamChat::Webhook.parse_event(body) - end.to raise_error(StreamChat::Webhook::InvalidWebhookError, /missing 'type'/) + err_class = StreamChat::Webhook::InvalidWebhookError + expect { StreamChat::Webhook.parse_event(body) }.to raise_error(err_class, /missing 'type'/) + end it 'raises on malformed JSON' do - skip 'fixtures not present' unless File.directory?(FIXTURE_ROOT) + + skip 'fixtures not present' unless File.directory?(WEBHOOK_FIXTURE_ROOT) body = File.binread(File.join(neg_dir('malformed_json'), 'body.json')) - expect do - StreamChat::Webhook.parse_event(body) - end.to raise_error(StreamChat::Webhook::InvalidWebhookError, /failed to parse webhook payload/) + err_class = StreamChat::Webhook::InvalidWebhookError + expect { StreamChat::Webhook.parse_event(body) }.to raise_error(err_class, /failed to parse webhook payload/) + end it 'raises on empty body' do - skip 'fixtures not present' unless File.directory?(FIXTURE_ROOT) + + skip 'fixtures not present' unless File.directory?(WEBHOOK_FIXTURE_ROOT) body = File.binread(File.join(neg_dir('empty_body'), 'body.json')) - expect do - StreamChat::Webhook.parse_event(body) - end.to raise_error(StreamChat::Webhook::InvalidWebhookError, /must not be empty/) + err_class = StreamChat::Webhook::InvalidWebhookError + expect { StreamChat::Webhook.parse_event(body) }.to raise_error(err_class, /must not be empty/) + end it 'raises on bad gzip compression' do - skip 'fixtures not present' unless File.directory?(FIXTURE_ROOT) + + skip 'fixtures not present' unless File.directory?(WEBHOOK_FIXTURE_ROOT) body = File.binread(File.join(neg_dir('bad_compression'), 'body.gz')) - expect do - StreamChat::Webhook.gunzip_payload(body) - end.to raise_error(StreamChat::Webhook::InvalidWebhookError, /gzip decompression failed/) + err_class = StreamChat::Webhook::InvalidWebhookError + expect { StreamChat::Webhook.gunzip_payload(body) }.to raise_error(err_class, /gzip decompression failed/) + end it 'raises on bad base64 (downstream parse failure)' do + # Per CHA-3071 wire format: decode_sqs_payload falls back to raw bytes # when base64 decoding fails (uncompressed wire format). For input that # is neither valid base64 nor valid JSON nor gzip-prefixed, parse_sqs # still raises InvalidWebhookError — just down the chain at JSON parsing. - skip 'fixtures not present' unless File.directory?(FIXTURE_ROOT) + skip 'fixtures not present' unless File.directory?(WEBHOOK_FIXTURE_ROOT) msg = File.read(File.join(neg_dir('bad_base64'), 'sqs_body.txt')).strip - expect do - StreamChat::Webhook.parse_sqs(msg) - end.to raise_error(StreamChat::Webhook::InvalidWebhookError) + expect { StreamChat::Webhook.parse_sqs(msg) }.to raise_error(StreamChat::Webhook::InvalidWebhookError) + end it 'raises on bad SNS envelope (downstream parse failure)' do - # Fix #4: bad_sns_envelope (non-envelope JSON) is now treated as a - # pre-extracted Message string and flows through the SQS path, surfacing - # as a downstream parse failure rather than SNS-specific. Still - # InvalidWebhookError. - skip 'fixtures not present' unless File.directory?(FIXTURE_ROOT) + + # bad_sns_envelope (non-envelope JSON) is now treated as a pre-extracted + # Message string and flows through the SQS path, surfacing as a downstream + # parse failure rather than SNS-specific. Still InvalidWebhookError. + skip 'fixtures not present' unless File.directory?(WEBHOOK_FIXTURE_ROOT) notif = File.read(File.join(neg_dir('bad_sns_envelope'), 'sns_notification.txt')).strip - expect do - StreamChat::Webhook.parse_sns(notif) - end.to raise_error(StreamChat::Webhook::InvalidWebhookError) + expect { StreamChat::Webhook.parse_sns(notif) }.to raise_error(StreamChat::Webhook::InvalidWebhookError) + end + end + end