hooksniff 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,13 @@
1
1
  # frozen_string_literal: true
2
+ require "base64"
3
+ require "json"
2
4
 
3
5
  module HookSniff
6
+ # Error raised when webhook signature verification fails
7
+ class WebhookVerificationError < StandardError; end
8
+ # Error raised when webhook signing fails
9
+ class WebhookSigningError < StandardError; end
10
+
4
11
  class Webhook
5
12
 
6
13
  def self.new_using_raw_bytes(secret)
@@ -15,6 +22,15 @@ module HookSniff
15
22
  @secret = Base64.decode64(secret)
16
23
  end
17
24
 
25
+ # Verify and parse a webhook payload.
26
+ #
27
+ # Verifies the HMAC-SHA256 signature, then parses the payload
28
+ # into a typed WebhookEvent with +event+, +data+, and +timestamp+.
29
+ #
30
+ # @param payload [String] raw request body
31
+ # @param headers [Hash] request headers containing hooksniff-id, hooksniff-timestamp, hooksniff-signature
32
+ # @return [WebhookEvent] parsed webhook event
33
+ # @raise [WebhookVerificationError] if signature is invalid or timestamp is outside tolerance
18
34
  def verify(payload, headers)
19
35
  msgId = headers["hooksniff-id"]
20
36
  msgSignature = headers["hooksniff-signature"]
@@ -32,6 +48,45 @@ module HookSniff
32
48
 
33
49
  _, signature = sign(msgId, msgTimestamp, payload).split(",", 2)
34
50
 
51
+ passedSignatures = msgSignature.split(" ")
52
+ passedSignatures.each do |versionedSignature|
53
+ version, expectedSignature = versionedSignature.split(",", 2)
54
+ if version != "v1"
55
+ next
56
+ end
57
+
58
+ if ::HookSniff::secure_compare(signature, expectedSignature)
59
+ return parse_payload(payload)
60
+ end
61
+ end
62
+
63
+ raise WebhookVerificationError, "No matching signature found"
64
+ end
65
+
66
+ # Verify and return raw payload without parsing.
67
+ # Use this when you need the raw hash instead of a typed event.
68
+ #
69
+ # @param payload [String] raw request body
70
+ # @param headers [Hash] request headers
71
+ # @return [Hash, nil] parsed JSON hash
72
+ # @raise [WebhookVerificationError] if signature is invalid
73
+ def verify_raw(payload, headers)
74
+ msgId = headers["hooksniff-id"]
75
+ msgSignature = headers["hooksniff-signature"]
76
+ msgTimestamp = headers["hooksniff-timestamp"]
77
+ if !msgSignature || !msgId || !msgTimestamp
78
+ msgId = headers["webhook-id"]
79
+ msgSignature = headers["webhook-signature"]
80
+ msgTimestamp = headers["webhook-timestamp"]
81
+ if !msgSignature || !msgId || !msgTimestamp
82
+ raise WebhookVerificationError, "Missing required headers"
83
+ end
84
+ end
85
+
86
+ verify_timestamp(msgTimestamp)
87
+
88
+ _, signature = sign(msgId, msgTimestamp, payload).split(",", 2)
89
+
35
90
  passedSignatures = msgSignature.split(" ")
36
91
  passedSignatures.each do |versionedSignature|
37
92
  version, expectedSignature = versionedSignature.split(",", 2)
@@ -80,5 +135,12 @@ module HookSniff
80
135
  raise WebhookVerificationError, "Message timestamp too new"
81
136
  end
82
137
  end
138
+
139
+ def parse_payload(payload)
140
+ return WebhookEvent.new(event: "", data: {}, timestamp: "") if payload.empty?
141
+
142
+ parsed = JSON.parse(payload, symbolize_names: true)
143
+ WebhookEvent.parse(parsed)
144
+ end
83
145
  end
84
146
  end
@@ -0,0 +1,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HookSniff
4
+ # ─── Event Data Classes ─────────────────────────────────────────
5
+
6
+ # Data payload for endpoint.created events.
7
+ class EndpointCreatedEventData
8
+ attr_reader :app_id, :endpoint_id, :app_uid
9
+
10
+ def initialize(app_id:, endpoint_id:, app_uid: nil)
11
+ @app_id = app_id
12
+ @endpoint_id = endpoint_id
13
+ @app_uid = app_uid
14
+ end
15
+ end
16
+
17
+ # Data payload for endpoint.updated events.
18
+ class EndpointUpdatedEventData
19
+ attr_reader :app_id, :endpoint_id, :app_uid
20
+
21
+ def initialize(app_id:, endpoint_id:, app_uid: nil)
22
+ @app_id = app_id
23
+ @endpoint_id = endpoint_id
24
+ @app_uid = app_uid
25
+ end
26
+ end
27
+
28
+ # Data payload for endpoint.deleted events.
29
+ class EndpointDeletedEventData
30
+ attr_reader :app_id, :endpoint_id, :app_uid
31
+
32
+ def initialize(app_id:, endpoint_id:, app_uid: nil)
33
+ @app_id = app_id
34
+ @endpoint_id = endpoint_id
35
+ @app_uid = app_uid
36
+ end
37
+ end
38
+
39
+ # Data payload for endpoint.enabled events.
40
+ class EndpointEnabledEventData
41
+ attr_reader :app_id, :endpoint_id, :app_uid
42
+
43
+ def initialize(app_id:, endpoint_id:, app_uid: nil)
44
+ @app_id = app_id
45
+ @endpoint_id = endpoint_id
46
+ @app_uid = app_uid
47
+ end
48
+ end
49
+
50
+ # Data payload for endpoint.disabled events.
51
+ class EndpointDisabledEventData
52
+ attr_reader :app_id, :endpoint_id, :app_uid, :fail_since, :trigger
53
+
54
+ def initialize(app_id:, endpoint_id:, app_uid: nil, fail_since: nil, trigger: nil)
55
+ @app_id = app_id
56
+ @endpoint_id = endpoint_id
57
+ @app_uid = app_uid
58
+ @fail_since = fail_since
59
+ @trigger = trigger
60
+ end
61
+ end
62
+
63
+ # Info about the last delivery attempt.
64
+ class LastAttemptInfo
65
+ attr_reader :id, :timestamp, :response_status_code
66
+
67
+ def initialize(id:, timestamp:, response_status_code:)
68
+ @id = id
69
+ @timestamp = timestamp
70
+ @response_status_code = response_status_code
71
+ end
72
+ end
73
+
74
+ # Info about a delivery attempt.
75
+ class AttemptInfo
76
+ attr_reader :id, :timestamp, :response_status_code
77
+
78
+ def initialize(id:, timestamp:, response_status_code:)
79
+ @id = id
80
+ @timestamp = timestamp
81
+ @response_status_code = response_status_code
82
+ end
83
+ end
84
+
85
+ # Data payload for message.attempt.exhausted events.
86
+ class MessageAttemptExhaustedEventData
87
+ attr_reader :app_id, :msg_id, :last_attempt, :app_uid
88
+
89
+ def initialize(app_id:, msg_id:, last_attempt:, app_uid: nil)
90
+ @app_id = app_id
91
+ @msg_id = msg_id
92
+ @last_attempt = last_attempt
93
+ @app_uid = app_uid
94
+ end
95
+ end
96
+
97
+ # Data payload for message.attempt.failing events.
98
+ class MessageAttemptFailingEventData
99
+ attr_reader :app_id, :msg_id, :attempt, :app_uid
100
+
101
+ def initialize(app_id:, msg_id:, attempt:, app_uid: nil)
102
+ @app_id = app_id
103
+ @msg_id = msg_id
104
+ @attempt = attempt
105
+ @app_uid = app_uid
106
+ end
107
+ end
108
+
109
+ # Data payload for message.attempt.recovered events.
110
+ class MessageAttemptRecoveredEventData
111
+ attr_reader :app_id, :msg_id, :attempt, :app_uid
112
+
113
+ def initialize(app_id:, msg_id:, attempt:, app_uid: nil)
114
+ @app_id = app_id
115
+ @msg_id = msg_id
116
+ @attempt = attempt
117
+ @app_uid = app_uid
118
+ end
119
+ end
120
+
121
+ # ─── WebhookEvent (base class — must come before subclasses) ────
122
+
123
+ # Represents a parsed webhook event from HookSniff.
124
+ class WebhookEvent
125
+ # @return [String] event type name (e.g., "endpoint.created")
126
+ attr_reader :event
127
+
128
+ # @return [Object] event payload data (typed data class or Hash)
129
+ attr_reader :data
130
+
131
+ # @return [String] ISO 8601 timestamp string
132
+ attr_reader :timestamp
133
+
134
+ def initialize(event:, data:, timestamp:)
135
+ @event = event
136
+ @data = data
137
+ @timestamp = timestamp
138
+ end
139
+
140
+ # Alias for +event+ — the event type name.
141
+ def event_type
142
+ @event
143
+ end
144
+
145
+ # Get a value from the data (Hash or typed object).
146
+ def get(key)
147
+ if @data.is_a?(Hash)
148
+ @data[key.to_s] || @data[key.to_sym]
149
+ else
150
+ @data.respond_to?(key.to_sym) ? @data.send(key.to_sym) : nil
151
+ end
152
+ end
153
+
154
+ # Access data values with bracket notation (backward compat).
155
+ def [](key)
156
+ get(key)
157
+ end
158
+
159
+ # Check if key exists in data.
160
+ def key?(key)
161
+ if @data.is_a?(Hash)
162
+ @data.key?(key.to_s) || @data.key?(key.to_sym)
163
+ else
164
+ @data.respond_to?(key.to_sym)
165
+ end
166
+ end
167
+
168
+ def to_s
169
+ "#<#{self.class.name} event=#{@event} timestamp=#{@timestamp}>"
170
+ end
171
+
172
+ def inspect
173
+ to_s
174
+ end
175
+
176
+ # Map of known event types to their classes
177
+ EVENT_TYPE_MAP = {
178
+ "endpoint.created" => "EndpointCreatedEvent",
179
+ "endpoint.updated" => "EndpointUpdatedEvent",
180
+ "endpoint.deleted" => "EndpointDeletedEvent",
181
+ "endpoint.enabled" => "EndpointEnabledEvent",
182
+ "endpoint.disabled" => "EndpointDisabledEvent",
183
+ "message.attempt.exhausted" => "MessageAttemptExhaustedEvent",
184
+ "message.attempt.failing" => "MessageAttemptFailingEvent",
185
+ "message.atattempt.failing" => "MessageAttemptFailingEvent",
186
+ "message.attempt.recovered" => "MessageAttemptRecoveredEvent",
187
+ "message.atattempt.recovered" => "MessageAttemptRecoveredEvent",
188
+ }.freeze
189
+
190
+ # Parse a webhook payload hash into a typed WebhookEvent.
191
+ def self.parse(data)
192
+ event_type = data[:event] || data["event"] || data[:eventType] || data["eventType"] || ""
193
+ raw_data = data[:data] || data["data"] || {}
194
+ timestamp = data[:timestamp] || data["timestamp"] || ""
195
+
196
+ parsed_data = parse_event_data(event_type, raw_data)
197
+ class_name = EVENT_TYPE_MAP[event_type]
198
+ event_class = class_name ? const_get(class_name) : WebhookEvent
199
+
200
+ event_class.new(event: event_type, data: parsed_data, timestamp: timestamp)
201
+ end
202
+
203
+ private
204
+
205
+ def self.parse_event_data(event_type, raw)
206
+ case event_type
207
+ when "endpoint.created"
208
+ EndpointCreatedEventData.new(
209
+ app_id: raw[:appId] || raw["appId"] || raw[:app_id] || raw["app_id"] || "",
210
+ endpoint_id: raw[:endpointId] || raw["endpointId"] || raw[:endpoint_id] || raw["endpoint_id"] || "",
211
+ app_uid: raw[:appUid] || raw["appUid"] || raw[:app_uid] || raw["app_uid"]
212
+ )
213
+ when "endpoint.updated"
214
+ EndpointUpdatedEventData.new(
215
+ app_id: raw[:appId] || raw["appId"] || raw[:app_id] || raw["app_id"] || "",
216
+ endpoint_id: raw[:endpointId] || raw["endpointId"] || raw[:endpoint_id] || raw["endpoint_id"] || "",
217
+ app_uid: raw[:appUid] || raw["appUid"] || raw[:app_uid] || raw["app_uid"]
218
+ )
219
+ when "endpoint.deleted"
220
+ EndpointDeletedEventData.new(
221
+ app_id: raw[:appId] || raw["appId"] || raw[:app_id] || raw["app_id"] || "",
222
+ endpoint_id: raw[:endpointId] || raw["endpointId"] || raw[:endpoint_id] || raw["endpoint_id"] || "",
223
+ app_uid: raw[:appUid] || raw["appUid"] || raw[:app_uid] || raw["app_uid"]
224
+ )
225
+ when "endpoint.enabled"
226
+ EndpointEnabledEventData.new(
227
+ app_id: raw[:appId] || raw["appId"] || raw[:app_id] || raw["app_id"] || "",
228
+ endpoint_id: raw[:endpointId] || raw["endpointId"] || raw[:endpoint_id] || raw["endpoint_id"] || "",
229
+ app_uid: raw[:appUid] || raw["appUid"] || raw[:app_uid] || raw["app_uid"]
230
+ )
231
+ when "endpoint.disabled"
232
+ EndpointDisabledEventData.new(
233
+ app_id: raw[:appId] || raw["appId"] || raw[:app_id] || raw["app_id"] || "",
234
+ endpoint_id: raw[:endpointId] || raw["endpointId"] || raw[:endpoint_id] || raw["endpoint_id"] || "",
235
+ app_uid: raw[:appUid] || raw["appUid"] || raw[:app_uid] || raw["app_uid"],
236
+ fail_since: raw[:failSince] || raw["failSince"] || raw[:fail_since] || raw["fail_since"],
237
+ trigger: raw[:trigger] || raw["trigger"]
238
+ )
239
+ when "message.attempt.exhausted"
240
+ last_raw = raw[:lastAttempt] || raw["lastAttempt"] || raw[:last_attempt] || raw["last_attempt"] || {}
241
+ MessageAttemptExhaustedEventData.new(
242
+ app_id: raw[:appId] || raw["appId"] || raw[:app_id] || raw["app_id"] || "",
243
+ msg_id: raw[:msgId] || raw["msgId"] || raw[:msg_id] || raw["msg_id"] || "",
244
+ last_attempt: parse_last_attempt(last_raw),
245
+ app_uid: raw[:appUid] || raw["appUid"] || raw[:app_uid] || raw["app_uid"]
246
+ )
247
+ when "message.attempt.failing", "message.atattempt.failing"
248
+ attempt_raw = raw[:attempt] || raw["attempt"] || {}
249
+ MessageAttemptFailingEventData.new(
250
+ app_id: raw[:appId] || raw["appId"] || raw[:app_id] || raw["app_id"] || "",
251
+ msg_id: raw[:msgId] || raw["msgId"] || raw[:msg_id] || raw["msg_id"] || "",
252
+ attempt: parse_attempt(attempt_raw),
253
+ app_uid: raw[:appUid] || raw["appUid"] || raw[:app_uid] || raw["app_uid"]
254
+ )
255
+ when "message.atattempt.recovered", "message.attempt.recovered"
256
+ attempt_raw = raw[:attempt] || raw["attempt"] || {}
257
+ MessageAttemptRecoveredEventData.new(
258
+ app_id: raw[:appId] || raw["appId"] || raw[:app_id] || raw["app_id"] || "",
259
+ msg_id: raw[:msgId] || raw["msgId"] || raw[:msg_id] || raw["msg_id"] || "",
260
+ attempt: parse_attempt(attempt_raw),
261
+ app_uid: raw[:appUid] || raw["appUid"] || raw[:app_uid] || raw["app_uid"]
262
+ )
263
+ else
264
+ raw
265
+ end
266
+ end
267
+
268
+ def self.parse_last_attempt(raw)
269
+ LastAttemptInfo.new(
270
+ id: raw[:id] || raw["id"] || "",
271
+ timestamp: raw[:timestamp] || raw["timestamp"] || "",
272
+ response_status_code: raw[:responseStatusCode] || raw["responseStatusCode"] || raw[:response_status_code] || raw["response_status_code"] || 0
273
+ )
274
+ end
275
+
276
+ def self.parse_attempt(raw)
277
+ AttemptInfo.new(
278
+ id: raw[:id] || raw["id"] || "",
279
+ timestamp: raw[:timestamp] || raw["timestamp"] || "",
280
+ response_status_code: raw[:responseStatusCode] || raw["responseStatusCode"] || raw[:response_status_code] || raw["response_status_code"] || 0
281
+ )
282
+ end
283
+ end
284
+
285
+ # ─── Typed Event Subclasses ─────────────────────────────────────
286
+
287
+ class EndpointCreatedEvent < WebhookEvent; end
288
+ class EndpointUpdatedEvent < WebhookEvent; end
289
+ class EndpointDeletedEvent < WebhookEvent; end
290
+ class EndpointEnabledEvent < WebhookEvent; end
291
+ class EndpointDisabledEvent < WebhookEvent; end
292
+ class MessageAttemptExhaustedEvent < WebhookEvent; end
293
+ class MessageAttemptFailingEvent < WebhookEvent; end
294
+ class MessageAttemptRecoveredEvent < WebhookEvent; end
295
+ end
data/lib/hooksniff.rb CHANGED
@@ -5,6 +5,9 @@ require "hooksniff/hooksniff_http_client"
5
5
  require "hooksniff/api_error"
6
6
  require "hooksniff/errors"
7
7
  require "hooksniff/util"
8
+ require "hooksniff/webhook_event"
9
+ require "hooksniff/response_metadata"
10
+ require "hooksniff/options"
8
11
  require "hooksniff/webhook"
9
12
 
10
13
  # Original resources
@@ -56,17 +56,6 @@ class WebhookTest < Minitest::Test
56
56
  end
57
57
  end
58
58
 
59
- def test_svix_branded_headers
60
- wh = HookSniff::Webhook.new(SECRET)
61
- sig = sign(SECRET, MSG_ID, TIMESTAMP, PAYLOAD)
62
- headers = {
63
- "svix-id" => MSG_ID,
64
- "svix-timestamp" => TIMESTAMP.to_s,
65
- "svix-signature" => sig,
66
- }
67
- result = wh.verify(PAYLOAD, headers)
68
- assert_equal({"event" => "test"}, result)
69
- end
70
59
  end
71
60
 
72
61
  class ErrorTest < Minitest::Test
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require_relative "../lib/hooksniff/webhook_event"
5
+
6
+ class TypedWebhookEventTest < Minitest::Test
7
+ def test_endpoint_created
8
+ event = HookSniff::WebhookEvent.parse(
9
+ event: "endpoint.created",
10
+ data: { appId: "a1", endpointId: "e1", appUid: "u1" },
11
+ timestamp: "2026-05-19"
12
+ )
13
+ assert_instance_of HookSniff::EndpointCreatedEvent, event
14
+ assert_instance_of HookSniff::EndpointCreatedEventData, event.data
15
+ assert_equal "a1", event.data.app_id
16
+ assert_equal "e1", event.data.endpoint_id
17
+ assert_equal "u1", event.data.app_uid
18
+ end
19
+
20
+ def test_endpoint_disabled
21
+ event = HookSniff::WebhookEvent.parse(
22
+ event: "endpoint.disabled",
23
+ data: { appId: "a1", endpointId: "e1", failSince: "2026-01", trigger: "repeated-failure" },
24
+ timestamp: ""
25
+ )
26
+ assert_instance_of HookSniff::EndpointDisabledEvent, event
27
+ assert_equal "2026-01", event.data.fail_since
28
+ assert_equal "repeated-failure", event.data.trigger
29
+ end
30
+
31
+ def test_message_attempt_exhausted
32
+ event = HookSniff::WebhookEvent.parse(
33
+ event: "message.attempt.exhausted",
34
+ data: { appId: "a1", msgId: "m1", lastAttempt: { id: "att", timestamp: "t", responseStatusCode: 500 } },
35
+ timestamp: ""
36
+ )
37
+ assert_instance_of HookSniff::MessageAttemptExhaustedEvent, event
38
+ assert_equal "m1", event.data.msg_id
39
+ assert_equal 500, event.data.last_attempt.response_status_code
40
+ end
41
+
42
+ def test_message_attempt_failing
43
+ event = HookSniff::WebhookEvent.parse(
44
+ event: "message.attempt.failing",
45
+ data: { appId: "a1", msgId: "m1", attempt: { id: "att", timestamp: "t", responseStatusCode: 429 } },
46
+ timestamp: ""
47
+ )
48
+ assert_instance_of HookSniff::MessageAttemptFailingEvent, event
49
+ assert_equal 429, event.data.attempt.response_status_code
50
+ end
51
+
52
+ def test_message_attempt_recovered
53
+ event = HookSniff::WebhookEvent.parse(
54
+ event: "message.attempt.recovered",
55
+ data: { appId: "a1", msgId: "m1", attempt: { id: "att", timestamp: "t", responseStatusCode: 200 } },
56
+ timestamp: ""
57
+ )
58
+ assert_instance_of HookSniff::MessageAttemptRecoveredEvent, event
59
+ end
60
+
61
+ def test_unknown_event_fallback
62
+ event = HookSniff::WebhookEvent.parse(
63
+ event: "custom.unknown",
64
+ data: { x: 1 },
65
+ timestamp: ""
66
+ )
67
+ assert_instance_of HookSniff::WebhookEvent, event
68
+ assert_equal 1, event.data[:x]
69
+ end
70
+
71
+ def test_backward_compat_get
72
+ event = HookSniff::WebhookEvent.parse(
73
+ event: "endpoint.created",
74
+ data: { appId: "a1", endpointId: "e1" },
75
+ timestamp: "t"
76
+ )
77
+ assert_equal "a1", event.get("app_id")
78
+ assert_equal "a1", event["app_id"]
79
+ assert event.key?("app_id")
80
+ assert_equal "endpoint.created", event.event_type
81
+ end
82
+
83
+ def test_snake_case_fields
84
+ event = HookSniff::WebhookEvent.parse(
85
+ event: "endpoint.created",
86
+ data: { app_id: "a1", endpoint_id: "e1" },
87
+ timestamp: ""
88
+ )
89
+ assert_equal "a1", event.data.app_id
90
+ assert_equal "e1", event.data.endpoint_id
91
+ end
92
+
93
+ def test_empty_data
94
+ event = HookSniff::WebhookEvent.parse(
95
+ event: "endpoint.created",
96
+ data: {},
97
+ timestamp: ""
98
+ )
99
+ assert_equal "", event.data.app_id
100
+ end
101
+
102
+ def test_missing_data
103
+ event = HookSniff::WebhookEvent.parse(
104
+ event: "endpoint.created",
105
+ timestamp: ""
106
+ )
107
+ assert_equal "", event.data.app_id
108
+ end
109
+
110
+ def test_unknown_event_keeps_raw_data
111
+ event = HookSniff::WebhookEvent.parse(
112
+ event: "custom.unknown",
113
+ data: { "x" => 1 },
114
+ timestamp: ""
115
+ )
116
+ assert_instance_of HookSniff::WebhookEvent, event
117
+ assert_equal 1, event.get("x")
118
+ end
119
+
120
+ def test_unicode_data
121
+ event = HookSniff::WebhookEvent.parse(
122
+ event: "endpoint.created",
123
+ data: { appId: "ünïcödé", endpointId: "日本語" },
124
+ timestamp: ""
125
+ )
126
+ assert_equal "ünïcödé", event.data.app_id
127
+ assert_equal "日本語", event.data.endpoint_id
128
+ end
129
+
130
+ def test_event_type_map_count
131
+ assert_equal 10, HookSniff::WebhookEvent::EVENT_TYPE_MAP.size
132
+ end
133
+
134
+ def test_endpoint_updated
135
+ event = HookSniff::WebhookEvent.parse(
136
+ event: "endpoint.updated",
137
+ data: { appId: "a1", endpointId: "e1" },
138
+ timestamp: ""
139
+ )
140
+ assert_instance_of HookSniff::EndpointUpdatedEvent, event
141
+ end
142
+
143
+ def test_endpoint_deleted
144
+ event = HookSniff::WebhookEvent.parse(
145
+ event: "endpoint.deleted",
146
+ data: { appId: "a1", endpointId: "e1" },
147
+ timestamp: ""
148
+ )
149
+ assert_instance_of HookSniff::EndpointDeletedEvent, event
150
+ end
151
+
152
+ def test_endpoint_enabled
153
+ event = HookSniff::WebhookEvent.parse(
154
+ event: "endpoint.enabled",
155
+ data: { appId: "a1", endpointId: "e1" },
156
+ timestamp: ""
157
+ )
158
+ assert_instance_of HookSniff::EndpointEnabledEvent, event
159
+ end
160
+
161
+ def test_unicode_data
162
+ event = HookSniff::WebhookEvent.parse(
163
+ event: "endpoint.created",
164
+ data: { appId: "ünïcödé", endpointId: "日本語" },
165
+ timestamp: ""
166
+ )
167
+ assert_equal "ünïcödé", event.data.app_id
168
+ assert_equal "日本語", event.data.endpoint_id
169
+ end
170
+
171
+ def test_large_data
172
+ event = HookSniff::WebhookEvent.parse(
173
+ event: "endpoint.created",
174
+ data: { appId: "a" * 10000, endpointId: "e" * 10000 },
175
+ timestamp: ""
176
+ )
177
+ assert_equal 10000, event.data.app_id.length
178
+ end
179
+
180
+ def test_special_characters
181
+ event = HookSniff::WebhookEvent.parse(
182
+ event: "endpoint.created",
183
+ data: { appId: "a@b.c", endpointId: "e#1" },
184
+ timestamp: ""
185
+ )
186
+ assert_equal "a@b.c", event.data.app_id
187
+ end
188
+
189
+ def test_trigger_none
190
+ event = HookSniff::WebhookEvent.parse(
191
+ event: "endpoint.disabled",
192
+ data: { appId: "a", endpointId: "e", trigger: "none" },
193
+ timestamp: ""
194
+ )
195
+ assert_equal "none", event.data.trigger
196
+ end
197
+
198
+ def test_trigger_first_failure
199
+ event = HookSniff::WebhookEvent.parse(
200
+ event: "endpoint.disabled",
201
+ data: { appId: "a", endpointId: "e", trigger: "first-failure" },
202
+ timestamp: ""
203
+ )
204
+ assert_equal "first-failure", event.data.trigger
205
+ end
206
+
207
+ def test_all_event_types
208
+ %w[endpoint.created endpoint.updated endpoint.deleted endpoint.enabled endpoint.disabled
209
+ message.attempt.exhausted message.attempt.failing message.attempt.recovered].each do |et|
210
+ event = HookSniff::WebhookEvent.parse(event: et, data: { appId: "a" }, timestamp: "")
211
+ assert_equal et, event.event
212
+ end
213
+ end
214
+
215
+ def test_get_with_symbol_key
216
+ event = HookSniff::WebhookEvent.parse(
217
+ event: "endpoint.created",
218
+ data: { appId: "a1" },
219
+ timestamp: ""
220
+ )
221
+ assert_equal "a1", event.get(:appId)
222
+ end
223
+
224
+ def test_bracket_with_symbol
225
+ event = HookSniff::WebhookEvent.parse(
226
+ event: "endpoint.created",
227
+ data: { appId: "a1" },
228
+ timestamp: ""
229
+ )
230
+ assert_equal "a1", event[:appId]
231
+ end
232
+
233
+ def test_key_with_symbol
234
+ event = HookSniff::WebhookEvent.parse(
235
+ event: "endpoint.created",
236
+ data: { appId: "a1" },
237
+ timestamp: ""
238
+ )
239
+ assert event.key?(:appId)
240
+ end
241
+
242
+ def test_to_s
243
+ event = HookSniff::WebhookEvent.parse(
244
+ event: "endpoint.created",
245
+ data: {},
246
+ timestamp: "2026-05-19"
247
+ )
248
+ assert_match(/EndpointCreatedEvent/, event.to_s)
249
+ assert_match(/2026-05-19/, event.to_s)
250
+ end
251
+
252
+ def test_inspect
253
+ event = HookSniff::WebhookEvent.parse(
254
+ event: "endpoint.created",
255
+ data: {},
256
+ timestamp: "2026-05-19"
257
+ )
258
+ assert_equal event.to_s, event.inspect
259
+ end
260
+ end