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.
- checksums.yaml +4 -4
- data/Gemfile.lock +2 -2
- data/README.md +49 -53
- data/hooksniff.gemspec +1 -1
- data/lib/hooksniff/api/authentication.rb +0 -13
- data/lib/hooksniff/api/integration.rb +8 -8
- data/lib/hooksniff/api/statistics.rb +0 -13
- data/lib/hooksniff/api/stream.rb +28 -9
- data/lib/hooksniff/errors.rb +79 -0
- data/lib/hooksniff/hooksniff_http_client.rb +48 -9
- data/lib/hooksniff/options.rb +31 -0
- data/lib/hooksniff/paginator.rb +45 -0
- data/lib/hooksniff/response_metadata.rb +48 -0
- data/lib/hooksniff/version.rb +1 -1
- data/lib/hooksniff/webhook.rb +62 -0
- data/lib/hooksniff/webhook_event.rb +295 -0
- data/lib/hooksniff.rb +3 -0
- data/test/test_hooksniff.rb +0 -11
- data/test/test_typed_events.rb +260 -0
- metadata +9 -7
data/lib/hooksniff/webhook.rb
CHANGED
|
@@ -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
|
data/test/test_hooksniff.rb
CHANGED
|
@@ -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
|