munster 0.4.0 → 0.4.2
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/CHANGELOG.md +8 -0
- data/README.md +12 -0
- data/handler-examples/customer_io_handler.rb +36 -0
- data/handler-examples/revolut_business_v1_handler.rb +29 -0
- data/handler-examples/revolut_business_v2_handler.rb +42 -0
- data/handler-examples/starling_payments_handler.rb +25 -0
- data/lib/munster/base_handler.rb +17 -1
- data/lib/munster/jobs/processing_job.rb +9 -7
- data/lib/munster/models/received_webhook.rb +5 -0
- data/lib/munster/templates/munster.rb +16 -4
- data/lib/munster/version.rb +1 -1
- data/test/munster_test.rb +214 -0
- data/test/test-webhook-handlers/.DS_Store +0 -0
- data/test/test-webhook-handlers/extract_id_handler.rb +5 -0
- data/test/test-webhook-handlers/failing_with_concealed_errors.rb +7 -0
- data/test/test-webhook-handlers/failing_with_exposed_errors.rb +7 -0
- data/test/test-webhook-handlers/inactive_handler.rb +5 -0
- data/test/test-webhook-handlers/invalid_handler.rb +5 -0
- data/test/test-webhook-handlers/private_handler.rb +3 -0
- data/test/test-webhook-handlers/webhook_test_handler.rb +13 -0
- data/test/test_app.rb +47 -0
- data/test/test_helper.rb +50 -0
- metadata +18 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 1cc63f1db75010013d7ad528bfcc3d4aa52c8d4242ee93a5baff4ecbe6673d01
|
4
|
+
data.tar.gz: b8458f7196754f2bde9440b37e4fac46f308e713ed21fca28b700c54aa599168
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 4a8ee7017935cb805c69f7cf0f31b88356b00466159040d5f67b763d19643844246485b3db0d8f4302dfd3ab02b9ff862cabf1fb541a0b338e237897e1327723
|
7
|
+
data.tar.gz: 6ac65ba57bebe9fce221ae820369b20eb78c69823c021a9d74b58d38122a7ac6e6918ba84e63d16db4b60330684bf358f4ec72c55590afddccfaa3113ce7d841
|
data/CHANGELOG.md
CHANGED
@@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file.
|
|
3
3
|
|
4
4
|
This format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
5
5
|
|
6
|
+
## 0.4.2
|
7
|
+
|
8
|
+
- When processing a webhook, print messages to the ActiveJob logger. This allows for quicker debugging if the app does not have an error tracking service set up
|
9
|
+
|
10
|
+
## 0.4.1
|
11
|
+
|
12
|
+
- Webhook processor now requires `active_job/railtie`, instead of `active_job`. It requires GlobalID to work and that get's required only with railtie.
|
13
|
+
- Adding a state transition from `error` to `received`. Proccessing job will be enqueued automatic after this transition was executed.
|
6
14
|
|
7
15
|
## 0.4.0
|
8
16
|
|
data/README.md
CHANGED
@@ -30,6 +30,18 @@ Mount munster engine in your routes.
|
|
30
30
|
mount Munster::Engine, at: "/webhooks"
|
31
31
|
```
|
32
32
|
|
33
|
+
Define a class for your first handler (let's call it `ExampleHandler`) and inherit it from `Munster::BaseHandler`. Place it somewhere where Rails autoloading can find it, and add it to your `munster.rb` config file:
|
34
|
+
|
35
|
+
```ruby
|
36
|
+
config.active_handlers = {
|
37
|
+
"example" => "ExampleHandler"
|
38
|
+
}
|
39
|
+
```
|
40
|
+
|
41
|
+
## Example handlers
|
42
|
+
|
43
|
+
We provide a number of webhook handlers which demonstrate certain features of Munster. You will find them in `handler-examples`.
|
44
|
+
|
33
45
|
## Requirements
|
34
46
|
|
35
47
|
This project depends on two dependencies:
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# This is an example handler for Customer.io reporting webhooks. You
|
2
|
+
# can find more documentation here https://customer.io/docs/api/webhooks/#operation/reportingWebhook
|
3
|
+
class Webhooks::CustomerIoHandler < Munster::BaseHandler
|
4
|
+
def process(webhook)
|
5
|
+
json = JSON.parse(webhook.body, symbolize_names: true)
|
6
|
+
case json[:metric]
|
7
|
+
when "subscribed"
|
8
|
+
# ...
|
9
|
+
when "unsubscribed"
|
10
|
+
# ...
|
11
|
+
when "cio_subscription_preferences_changed"
|
12
|
+
# ...
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def extract_event_id_from_request(action_dispatch_request)
|
17
|
+
action_dispatch_request.params.fetch(:event_id)
|
18
|
+
end
|
19
|
+
|
20
|
+
# Verify that request is actually comming from customer.io here
|
21
|
+
# @see https://customer.io/docs/api/webhooks/#section/Securely-Verifying-Requests
|
22
|
+
#
|
23
|
+
# - Should have "X-CIO-Signature", "X-CIO-Timestamp" headers.
|
24
|
+
# - Combine the version number, timestamp and body delimited by colons to form a string in the form v0:<timestamp>:<body>
|
25
|
+
# - Using HMAC-SHA256, hash the string using your webhook signing secret as the hash key.
|
26
|
+
# - Compare this value to the value of the X-CIO-Signature header sent with the request to confirm
|
27
|
+
def valid?(action_dispatch_request)
|
28
|
+
signing_key = Rails.application.secrets.customer_io_webhook_signing_key
|
29
|
+
xcio_signature = action_dispatch_request.headers["HTTP_X_CIO_SIGNATURE"]
|
30
|
+
xcio_timestamp = action_dispatch_request.headers["HTTP_X_CIO_TIMESTAMP"]
|
31
|
+
request_body = action_dispatch_request.body.read
|
32
|
+
string_to_sign = "v0:#{xcio_timestamp}:#{request_body}"
|
33
|
+
hmac = OpenSSL::HMAC.hexdigest("SHA256", signing_key, string_to_sign)
|
34
|
+
Rack::Utils.secure_compare(hmac, xcio_signature)
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# This is for Revolut V1 API for webhooks - https://developer.revolut.com/docs/business/webhooks-v-1-deprecated
|
2
|
+
class RevolutBusinessV1Handler < Munster::BaseHandler
|
3
|
+
def valid?(_)
|
4
|
+
# V1 of Revolut webhooks does not support signatures
|
5
|
+
true
|
6
|
+
end
|
7
|
+
|
8
|
+
def self.process(webhook)
|
9
|
+
parsed_payload = JSON.parse(webhook.body)
|
10
|
+
topic = parsed_payload.fetch("Topic")
|
11
|
+
case topic
|
12
|
+
when "tokens" # Account access revocation payload
|
13
|
+
# ...
|
14
|
+
when "draftpayments/transfers" # Draft payment transfer notification payload
|
15
|
+
# ...
|
16
|
+
else
|
17
|
+
# ...
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.extract_event_id_from_request(action_dispatch_request)
|
22
|
+
# Since b-tree indices generally divide from the start of the string, place the highest
|
23
|
+
# entropy component at the start (the EventId)
|
24
|
+
key_components = %w[EventId Topic Version]
|
25
|
+
key_components.map do |key|
|
26
|
+
action_dispatch_request.params.fetch(key)
|
27
|
+
end.join("-")
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# This is for Revolut V2 API for webhooks - https://developer.revolut.com/docs/business/webhooks-v-2
|
2
|
+
class RevolutBusinessV2Handler < Munster::BaseHandler
|
3
|
+
def valid?(request)
|
4
|
+
# 1 - Validate the timestamp of the request. Prevent replay attacks.
|
5
|
+
# "To validate the event, make sure that the Revolut-Request-Timestamp date-time is within a 5-minute time tolerance of the current universal time (UTC)".
|
6
|
+
# Their examples list `timestamp = '1683650202360'` as a sample value, so their timestamp is in millis - not in seconds
|
7
|
+
timestamp_str_from_headers = request.headers["HTTP_REVOLUT_REQUEST_TIMESTAMP"]
|
8
|
+
delta_t_seconds = (timestamp_str_from_headers / 1000) - Time.now.to_i
|
9
|
+
return false unless delta_t_seconds.abs < (5 * 60)
|
10
|
+
|
11
|
+
# 2 - Validate the signature
|
12
|
+
# https://developer.revolut.com/docs/guides/manage-accounts/tutorials/work-with-webhooks/verify-the-payload-signature
|
13
|
+
string_to_sign = [
|
14
|
+
"v1",
|
15
|
+
timestamp_str_from_headers,
|
16
|
+
request.body.read
|
17
|
+
].join(".")
|
18
|
+
computed_signature = "v1=" + OpenSSL::HMAC.hexdigest("SHA256", Rails.application.secrets.revolut_business_webhook_signing_key, string_to_sign)
|
19
|
+
# Note: "This means that in the period when multiple signing secrets remain valid, multiple signatures are sent."
|
20
|
+
# https://developer.revolut.com/docs/guides/manage-accounts/tutorials/work-with-webhooks/manage-webhooks#rotate-a-webhook-signing-secret
|
21
|
+
# https://developer.revolut.com/docs/guides/manage-accounts/tutorials/work-with-webhooks/about-webhooks#security
|
22
|
+
# An HTTP header may contain multiple values if it gets sent multiple times. But it does mean we need to test for multiple provided
|
23
|
+
# signatures in case of rotation.
|
24
|
+
provided_signatures = request.headers["HTTP_REVOLUT_SIGNATURE"].split(",")
|
25
|
+
# Use #select instead of `find` to compare all signatures even if only one matches - this to avoid timing leaks.
|
26
|
+
# Small effort but might be useful.
|
27
|
+
matches = provided_signatures.select do |provided_signature|
|
28
|
+
ActiveSupport::SecurityUtils.secure_compare(provided_signature, computed_signature)
|
29
|
+
end
|
30
|
+
matches.any?
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.process(webhook)
|
34
|
+
Rails.logger.info { "Processing Revolut webhook #{webhook.body.inspect}" }
|
35
|
+
end
|
36
|
+
|
37
|
+
def self.extract_event_id_from_request(action_dispatch_request)
|
38
|
+
# The event ID is only available when you retrieve the failed webhooks, which is sad.
|
39
|
+
# We can divinate a synthetic ID though by taking a hash of the entire payload though.
|
40
|
+
Digest::SHA256.hexdigest(action_dispatch_request.body.read)
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# This handler is an example for Starling Payments API,
|
4
|
+
# you can find the documentation here https://developer.starlingbank.com/payments/docs#account-and-address-structure-1
|
5
|
+
class StarlingPaymentsHandler < Munster::BaseHandler
|
6
|
+
# This method will be used to process webhook by async worker.
|
7
|
+
def process(received_webhook)
|
8
|
+
Rails.logger.info { received_webhook.body }
|
9
|
+
end
|
10
|
+
|
11
|
+
# Starling supplies signatures in the form SHA512(secret + request_body)
|
12
|
+
def valid?(action_dispatch_request)
|
13
|
+
supplied_signature = action_dispatch_request.headers.fetch("X-Hook-Signature")
|
14
|
+
supplied_digest_bytes = Base64.strict_decode64(supplied_signature)
|
15
|
+
sha512 = Digest::SHA2.new(512)
|
16
|
+
signing_secret = Rails.credentials.starling_payments_webhook_signing_secret
|
17
|
+
computed_digest_bytes = sha512.digest(signing_secret.b + action_dispatch_request.body.b)
|
18
|
+
ActiveSupport::SecurityUtils.secure_compare(computed_digest_bytes, supplied_digest_bytes)
|
19
|
+
end
|
20
|
+
|
21
|
+
# Some Starling webhooks do not provide a notification UID, but for those which do we can deduplicate
|
22
|
+
def extract_event_id_from_request(action_dispatch_request)
|
23
|
+
action_dispatch_request.params.fetch("notificationUid", SecureRandom.uuid)
|
24
|
+
end
|
25
|
+
end
|
data/lib/munster/base_handler.rb
CHANGED
@@ -16,11 +16,27 @@ module Munster
|
|
16
16
|
webhook = Munster::ReceivedWebhook.new(request: action_dispatch_request, handler_event_id: handler_event_id, handler_module_name: handler_module_name)
|
17
17
|
webhook.save!
|
18
18
|
|
19
|
-
|
19
|
+
enqueue(webhook)
|
20
20
|
rescue ActiveRecord::RecordNotUnique # Webhook deduplicated
|
21
21
|
Rails.logger.info { "#{inspect} Webhook #{handler_event_id} is a duplicate delivery and will not be stored." }
|
22
22
|
end
|
23
23
|
|
24
|
+
# Enqueues the processing job to process webhook asynchronously. The job class could be configured.
|
25
|
+
#
|
26
|
+
# @param webhook [Munster::ReceivedWebhook]
|
27
|
+
# @return [void]
|
28
|
+
def enqueue(webhook)
|
29
|
+
# The configured job class can be a class name or a module, to support lazy loading
|
30
|
+
job_class_or_module_name = Munster.configuration.processing_job_class
|
31
|
+
job_class = if job_class_or_module_name.respond_to?(:perform_later)
|
32
|
+
job_class_or_module_name
|
33
|
+
else
|
34
|
+
job_class_or_module_name.constantize
|
35
|
+
end
|
36
|
+
|
37
|
+
job_class.perform_later(webhook)
|
38
|
+
end
|
39
|
+
|
24
40
|
# This is the heart of your webhook processing. Override this method and define your processing inside of it.
|
25
41
|
# The `received_webhook` will provide access to the `ReceivedWebhook` model, which contains the received
|
26
42
|
# body of the webhook request, but also the full (as-full-as-possible) clone of the original ActionDispatch::Request
|
@@ -1,26 +1,28 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require "active_job"
|
3
|
+
require "active_job/railtie"
|
4
4
|
|
5
5
|
module Munster
|
6
6
|
class ProcessingJob < ActiveJob::Base
|
7
|
-
class WebhookPayloadInvalid < StandardError
|
8
|
-
end
|
9
|
-
|
10
7
|
def perform(webhook)
|
11
8
|
Rails.error.set_context(munster_handler_module_name: webhook.handler_module_name, **Munster.configuration.error_context)
|
12
9
|
|
10
|
+
webhook_details_for_logs = "Munster::ReceivedWebhook#%s (handler: %s)" % [webhook.id, webhook.handler]
|
13
11
|
webhook.with_lock do
|
14
|
-
|
12
|
+
unless webhook.received?
|
13
|
+
logger.info { "#{webhook_details_for_logs} is being processed in a different job or has been processed already, skipping." }
|
14
|
+
return
|
15
|
+
end
|
15
16
|
webhook.processing!
|
16
17
|
end
|
17
18
|
|
18
19
|
if webhook.handler.valid?(webhook.request)
|
20
|
+
logger.info { "#{webhook_details_for_logs} starting to process" }
|
19
21
|
webhook.handler.process(webhook)
|
20
22
|
webhook.processed! if webhook.processing?
|
23
|
+
logger.info { "#{webhook_details_for_logs} processed" }
|
21
24
|
else
|
22
|
-
|
23
|
-
Rails.error.report(e, handled: true, severity: :error)
|
25
|
+
logger.info { "#{webhook_details_for_logs} did not pass validation by the handler. Marking it `failed_validation`." }
|
24
26
|
webhook.failed_validation!
|
25
27
|
end
|
26
28
|
rescue => e
|
@@ -15,6 +15,11 @@ module Munster
|
|
15
15
|
s.permit_transition(:processing, :skipped)
|
16
16
|
s.permit_transition(:processing, :processed)
|
17
17
|
s.permit_transition(:processing, :error)
|
18
|
+
s.permit_transition(:error, :received)
|
19
|
+
|
20
|
+
s.after_committed_transition_to(:received) do |webhook|
|
21
|
+
webhook.handler.enqueue(webhook)
|
22
|
+
end
|
18
23
|
end
|
19
24
|
|
20
25
|
# Store the pertinent data from an ActionDispatch::Request into the webhook.
|
@@ -1,10 +1,15 @@
|
|
1
1
|
Munster.configure do |config|
|
2
2
|
# Active Handlers are defined as hash with key as a service_id and handler class that would handle webhook request.
|
3
|
+
# A Handler must respond to `.new` and return an object roughly matching `Munster::BaseHandler` in terms of interface.
|
4
|
+
# Use module names (strings) here to allow the handler modules to be lazy-loaded by Rails.
|
5
|
+
#
|
3
6
|
# Example:
|
4
|
-
# {:test => TestHandler, :inactive => InactiveHandler}
|
7
|
+
# {:test => "TestHandler", :inactive => "InactiveHandler"}
|
5
8
|
config.active_handlers = {}
|
6
9
|
|
7
|
-
# It's possible to overwrite default processing job to enahance it. As example if you want to add
|
10
|
+
# It's possible to overwrite default processing job to enahance it. As example if you want to add custom
|
11
|
+
# locking or retry mechanism. You want to inherit that job from Munster::ProcessingJob because the background
|
12
|
+
# job also manages the webhook state.
|
8
13
|
#
|
9
14
|
# Example:
|
10
15
|
#
|
@@ -15,9 +20,9 @@ Munster.configure do |config|
|
|
15
20
|
# end
|
16
21
|
# end
|
17
22
|
#
|
18
|
-
#
|
23
|
+
# In the config a string with your job' class name can be used so that the job can be lazy-loaded by Rails:
|
19
24
|
#
|
20
|
-
# config.processing_job_class = WebhookProcessingJob
|
25
|
+
# config.processing_job_class = "WebhookProcessingJob"
|
21
26
|
|
22
27
|
# We're using a common interface for error reporting provided by Rails, e.g Rails.error.report. In some cases
|
23
28
|
# you want to enhance those errors with additional context. As example to provide a namespace:
|
@@ -25,4 +30,11 @@ Munster.configure do |config|
|
|
25
30
|
# { appsignal: { namespace: "webhooks" } }
|
26
31
|
#
|
27
32
|
# config.error_context = { appsignal: { namespace: "webhooks" } }
|
33
|
+
|
34
|
+
# Incoming webhooks will be written into your DB without any prior validation. By default, Munster limits the
|
35
|
+
# request body size for webhooks to 512 KiB, so that it would not be too easy for an attacker to fill your
|
36
|
+
# database with junk. However, if you are receiving very large webhook payloads you might need to increase
|
37
|
+
# that limit (or make it even smaller for extra security)
|
38
|
+
#
|
39
|
+
# config.request_body_size_limit = 2.megabytes
|
28
40
|
end
|
data/lib/munster/version.rb
CHANGED
@@ -0,0 +1,214 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "test_helper"
|
4
|
+
require_relative "test_app"
|
5
|
+
|
6
|
+
class TestMunster < ActionDispatch::IntegrationTest
|
7
|
+
teardown { Munster::ReceivedWebhook.delete_all }
|
8
|
+
|
9
|
+
def test_that_it_has_a_version_number
|
10
|
+
refute_nil ::Munster::VERSION
|
11
|
+
end
|
12
|
+
|
13
|
+
def webhook_body
|
14
|
+
<<~JSON
|
15
|
+
{
|
16
|
+
"provider_id": "musterbank-flyio",
|
17
|
+
"starts_at": "<%= Time.now.utc %>",
|
18
|
+
"external_source": "The Forge Of Downtime",
|
19
|
+
"external_ticket_title": "DOWN-123",
|
20
|
+
"internal_description_markdown": "A test has failed"
|
21
|
+
}
|
22
|
+
JSON
|
23
|
+
end
|
24
|
+
|
25
|
+
Munster.configure do |config|
|
26
|
+
config.active_handlers = {
|
27
|
+
test: WebhookTestHandler,
|
28
|
+
inactive: "InactiveHandler",
|
29
|
+
invalid: "InvalidHandler",
|
30
|
+
private: "PrivateHandler",
|
31
|
+
"failing-with-exposed-errors": "FailingWithExposedErrors",
|
32
|
+
"failing-with-concealed-errors": "FailingWithConcealedErrors",
|
33
|
+
extract_id: "ExtractIdHandler"
|
34
|
+
}
|
35
|
+
end
|
36
|
+
self.app = MunsterTestApp
|
37
|
+
|
38
|
+
def self.xtest(msg)
|
39
|
+
test(msg) { skip }
|
40
|
+
end
|
41
|
+
|
42
|
+
test "ensure webhook is processed only once during creation" do
|
43
|
+
tf = Tempfile.new
|
44
|
+
body = {isValid: true, outputToFilename: tf.path}
|
45
|
+
body_json = body.to_json
|
46
|
+
|
47
|
+
assert_enqueued_jobs 1, only: Munster::ProcessingJob do
|
48
|
+
post "/munster/test", params: body_json, headers: {"CONTENT_TYPE" => "application/json"}
|
49
|
+
assert_response 200
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
test "accepts a webhook, stores and processes it" do
|
54
|
+
tf = Tempfile.new
|
55
|
+
body = {isValid: true, outputToFilename: tf.path}
|
56
|
+
body_json = body.to_json
|
57
|
+
|
58
|
+
post "/munster/test", params: body_json, headers: {"CONTENT_TYPE" => "application/json"}
|
59
|
+
assert_response 200
|
60
|
+
|
61
|
+
webhook = Munster::ReceivedWebhook.last!
|
62
|
+
|
63
|
+
assert_predicate webhook, :received?
|
64
|
+
assert_equal "WebhookTestHandler", webhook.handler_module_name
|
65
|
+
assert_equal webhook.status, "received"
|
66
|
+
assert_equal webhook.body, body_json
|
67
|
+
|
68
|
+
perform_enqueued_jobs
|
69
|
+
assert_predicate webhook.reload, :processed?
|
70
|
+
tf.rewind
|
71
|
+
assert_equal tf.read, body_json
|
72
|
+
end
|
73
|
+
|
74
|
+
test "accepts a webhook but does not process it if it is invalid" do
|
75
|
+
tf = Tempfile.new
|
76
|
+
body = {isValid: false, outputToFilename: tf.path}
|
77
|
+
body_json = body.to_json
|
78
|
+
|
79
|
+
post "/munster/test", params: body_json, headers: {"CONTENT_TYPE" => "application/json"}
|
80
|
+
assert_response 200
|
81
|
+
|
82
|
+
webhook = Munster::ReceivedWebhook.last!
|
83
|
+
|
84
|
+
assert_predicate webhook, :received?
|
85
|
+
assert_equal "WebhookTestHandler", webhook.handler_module_name
|
86
|
+
assert_equal webhook.status, "received"
|
87
|
+
assert_equal webhook.body, body_json
|
88
|
+
|
89
|
+
perform_enqueued_jobs
|
90
|
+
assert_predicate webhook.reload, :failed_validation?
|
91
|
+
|
92
|
+
tf.rewind
|
93
|
+
assert_predicate tf.read, :empty?
|
94
|
+
end
|
95
|
+
|
96
|
+
test "marks a webhook as errored if it raises during processing" do
|
97
|
+
tf = Tempfile.new
|
98
|
+
body = {isValid: true, raiseDuringProcessing: true, outputToFilename: tf.path}
|
99
|
+
body_json = body.to_json
|
100
|
+
|
101
|
+
post "/munster/test", params: body_json, headers: {"CONTENT_TYPE" => "application/json"}
|
102
|
+
assert_response 200
|
103
|
+
|
104
|
+
webhook = Munster::ReceivedWebhook.last!
|
105
|
+
|
106
|
+
assert_predicate webhook, :received?
|
107
|
+
assert_equal "WebhookTestHandler", webhook.handler_module_name
|
108
|
+
assert_equal webhook.status, "received"
|
109
|
+
assert_equal webhook.body, body_json
|
110
|
+
|
111
|
+
assert_raises(StandardError) { perform_enqueued_jobs }
|
112
|
+
assert_predicate webhook.reload, :error?
|
113
|
+
|
114
|
+
tf.rewind
|
115
|
+
assert_predicate tf.read, :empty?
|
116
|
+
end
|
117
|
+
|
118
|
+
test "does not accept a test payload that is larger than the configured maximum size" do
|
119
|
+
oversize = Munster.configuration.request_body_size_limit + 1
|
120
|
+
utf8_junk = Base64.strict_encode64(Random.bytes(oversize))
|
121
|
+
body = {isValid: true, filler: utf8_junk, raiseDuringProcessing: false, outputToFilename: "/tmp/nothing"}
|
122
|
+
body_json = body.to_json
|
123
|
+
|
124
|
+
post "/munster/test", params: body_json, headers: {"CONTENT_TYPE" => "application/json"}
|
125
|
+
assert_raises(ActiveRecord::RecordNotFound) { Munster::ReceivedWebhook.last! }
|
126
|
+
end
|
127
|
+
|
128
|
+
test "does not try to process a webhook if it is not in `received' state" do
|
129
|
+
tf = Tempfile.new
|
130
|
+
body = {isValid: true, raiseDuringProcessing: true, outputToFilename: tf.path}
|
131
|
+
body_json = body.to_json
|
132
|
+
|
133
|
+
post "/munster/test", params: body_json, headers: {"CONTENT_TYPE" => "application/json"}
|
134
|
+
assert_response 200
|
135
|
+
|
136
|
+
webhook = Munster::ReceivedWebhook.last!
|
137
|
+
webhook.processing!
|
138
|
+
|
139
|
+
perform_enqueued_jobs
|
140
|
+
assert_predicate webhook.reload, :processing?
|
141
|
+
|
142
|
+
tf.rewind
|
143
|
+
assert_predicate tf.read, :empty?
|
144
|
+
end
|
145
|
+
|
146
|
+
test "raises an error if the service_id is not known" do
|
147
|
+
post "/munster/missing_service", params: webhook_body, headers: {"CONTENT_TYPE" => "application/json"}
|
148
|
+
assert_response 404
|
149
|
+
end
|
150
|
+
|
151
|
+
test "returns a 503 when a handler is inactive" do
|
152
|
+
post "/munster/inactive", params: webhook_body, headers: {"CONTENT_TYPE" => "application/json"}
|
153
|
+
|
154
|
+
assert_response 503
|
155
|
+
assert_equal 'Webhook handler "inactive" is inactive', response.parsed_body["error"]
|
156
|
+
end
|
157
|
+
|
158
|
+
test "returns a 200 status and error message if the handler does not expose errors" do
|
159
|
+
post "/munster/failing-with-concealed-errors", params: webhook_body, headers: {"CONTENT_TYPE" => "application/json"}
|
160
|
+
|
161
|
+
assert_response 200
|
162
|
+
assert_equal false, response.parsed_body["ok"]
|
163
|
+
assert response.parsed_body["error"]
|
164
|
+
end
|
165
|
+
|
166
|
+
test "returns a 500 status and error message if the handler does not expose errors" do
|
167
|
+
post "/munster/failing-with-exposed-errors", params: webhook_body, headers: {"CONTENT_TYPE" => "application/json"}
|
168
|
+
|
169
|
+
assert_response 500
|
170
|
+
# The response generation in this case is done by Rails, through the
|
171
|
+
# common Rails error page
|
172
|
+
end
|
173
|
+
|
174
|
+
test "deduplicates received webhooks based on the event ID" do
|
175
|
+
body = {event_id: SecureRandom.uuid, body: "test"}.to_json
|
176
|
+
|
177
|
+
assert_changes_by -> { Munster::ReceivedWebhook.count }, exactly: 1 do
|
178
|
+
3.times do
|
179
|
+
post "/munster/extract_id", params: body, headers: {"CONTENT_TYPE" => "application/json"}
|
180
|
+
assert_response 200
|
181
|
+
end
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
test "preserves the route params and the request params in the serialised request stored with the webhook" do
|
186
|
+
body = {user_name: "John", number_of_dependents: 14}.to_json
|
187
|
+
|
188
|
+
Munster::ReceivedWebhook.delete_all
|
189
|
+
post "/per-user-munster/123/private", params: body, headers: {"CONTENT_TYPE" => "application/json"}
|
190
|
+
assert_response 200
|
191
|
+
|
192
|
+
received_webhook = Munster::ReceivedWebhook.first!
|
193
|
+
assert_predicate received_webhook, :received?
|
194
|
+
assert_equal body, received_webhook.request.body.read
|
195
|
+
assert_equal "John", received_webhook.request.params["user_name"]
|
196
|
+
assert_equal 14, received_webhook.request.params["number_of_dependents"]
|
197
|
+
assert_equal "123", received_webhook.request.params["user_id"]
|
198
|
+
end
|
199
|
+
|
200
|
+
test "erroneous webhook could be processed again" do
|
201
|
+
webhook = Munster::ReceivedWebhook.create(
|
202
|
+
handler_event_id: "test",
|
203
|
+
handler_module_name: "WebhookTestHandler",
|
204
|
+
status: "error",
|
205
|
+
body: {isValid: true}.to_json
|
206
|
+
)
|
207
|
+
|
208
|
+
assert_enqueued_jobs 1, only: Munster::ProcessingJob do
|
209
|
+
webhook.received!
|
210
|
+
|
211
|
+
assert_equal "received", webhook.status
|
212
|
+
end
|
213
|
+
end
|
214
|
+
end
|
Binary file
|
@@ -0,0 +1,13 @@
|
|
1
|
+
class WebhookTestHandler < Munster::BaseHandler
|
2
|
+
def valid?(request)
|
3
|
+
request.params.fetch(:isValid, false)
|
4
|
+
end
|
5
|
+
|
6
|
+
def process(webhook)
|
7
|
+
raise "Oops, failed" if webhook.request.params[:raiseDuringProcessing]
|
8
|
+
filename = webhook.request.params.fetch(:outputToFilename)
|
9
|
+
File.binwrite(filename, webhook.body)
|
10
|
+
end
|
11
|
+
|
12
|
+
def expose_errors_to_sender? = true
|
13
|
+
end
|
data/test/test_app.rb
ADDED
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_record"
|
4
|
+
require "action_pack"
|
5
|
+
require "action_controller"
|
6
|
+
require "rails"
|
7
|
+
|
8
|
+
database = "development.sqlite3"
|
9
|
+
ENV["DATABASE_URL"] = "sqlite3:#{database}"
|
10
|
+
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: database)
|
11
|
+
ActiveRecord::Base.logger = Logger.new(nil)
|
12
|
+
ActiveRecord::Schema.define do
|
13
|
+
create_table "received_webhooks", force: :cascade do |t|
|
14
|
+
t.string "handler_event_id", null: false
|
15
|
+
t.string "handler_module_name", null: false
|
16
|
+
t.string "status", default: "received", null: false
|
17
|
+
t.binary "body", null: false
|
18
|
+
t.json "request_headers", null: true
|
19
|
+
t.datetime "created_at", null: false
|
20
|
+
t.datetime "updated_at", null: false
|
21
|
+
t.index ["handler_module_name", "handler_event_id"], name: "webhook_dedup_idx", unique: true
|
22
|
+
t.index ["status"], name: "index_received_webhooks_on_status"
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
require_relative "../lib/munster"
|
27
|
+
require_relative "test-webhook-handlers/webhook_test_handler"
|
28
|
+
|
29
|
+
class MunsterTestApp < Rails::Application
|
30
|
+
config.logger = Logger.new(nil)
|
31
|
+
config.autoload_paths << File.dirname(__FILE__) + "/test-webhook-handlers"
|
32
|
+
config.root = __dir__
|
33
|
+
config.eager_load = false
|
34
|
+
config.consider_all_requests_local = true
|
35
|
+
config.secret_key_base = "i_am_a_secret"
|
36
|
+
config.active_support.cache_format_version = 7.0
|
37
|
+
config.hosts << ->(host) { true } # Permit all hosts
|
38
|
+
|
39
|
+
routes.append do
|
40
|
+
mount Munster::Engine, at: "/munster"
|
41
|
+
post "/per-user-munster/:user_id/:service_id" => "munster/receive_webhooks#create"
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
MunsterTestApp.initialize!
|
46
|
+
|
47
|
+
# run MunsterTestApp
|
data/test/test_helper.rb
ADDED
@@ -0,0 +1,50 @@
|
|
1
|
+
require_relative "test_app"
|
2
|
+
require "rails/test_help"
|
3
|
+
|
4
|
+
class ActiveSupport::TestCase
|
5
|
+
# Same as "assert_changes" in Rails but for countable entities.
|
6
|
+
# @return [*] return value of the block
|
7
|
+
# @example
|
8
|
+
# assert_changes_by("Notification.count", exactly: 2) do
|
9
|
+
# cause_two_notifications_to_get_delivered
|
10
|
+
# end
|
11
|
+
def assert_changes_by(expression, message = nil, exactly: nil, at_least: nil, at_most: nil, &block)
|
12
|
+
# rubocop:disable Security/Eval
|
13
|
+
exp = expression.respond_to?(:call) ? expression : -> { eval(expression.to_s, block.binding) }
|
14
|
+
# rubocop:enable Security/Eval
|
15
|
+
|
16
|
+
raise "either exactly:, at_least: or at_most: must be specified" unless exactly || at_least || at_most
|
17
|
+
raise "exactly: is mutually exclusive with other options" if exactly && (at_least || at_most)
|
18
|
+
raise "at_most: must be larger than at_least:" if at_least && at_most && at_most < at_least
|
19
|
+
|
20
|
+
before = exp.call
|
21
|
+
retval = assert_nothing_raised(&block)
|
22
|
+
|
23
|
+
after = exp.call
|
24
|
+
delta = after - before
|
25
|
+
|
26
|
+
if exactly
|
27
|
+
at_most = exactly
|
28
|
+
at_least = exactly
|
29
|
+
end
|
30
|
+
|
31
|
+
# We do not make these an if/else since we allow both at_most and at_least
|
32
|
+
if at_most
|
33
|
+
error = "#{expression.inspect} changed by #{delta} which is more than #{at_most}"
|
34
|
+
error = "#{error}. It was #{before} and became #{after}"
|
35
|
+
error = "#{message.call}.\n" if message&.respond_to?(:call)
|
36
|
+
error = "#{message}.\n#{error}" if message && !message.respond_to?(:call)
|
37
|
+
assert delta <= at_most, error
|
38
|
+
end
|
39
|
+
|
40
|
+
if at_least
|
41
|
+
error = "#{expression.inspect} changed by #{delta} which is less than #{at_least}"
|
42
|
+
error = "#{error}. It was #{before} and became #{after}"
|
43
|
+
error = "#{message.call}.\n" if message&.respond_to?(:call)
|
44
|
+
error = "#{message}.\n#{error}" if message && !message.respond_to?(:call)
|
45
|
+
assert delta >= at_least, error
|
46
|
+
end
|
47
|
+
|
48
|
+
retval
|
49
|
+
end
|
50
|
+
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: munster
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.4.
|
4
|
+
version: 0.4.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Stanislav Katkov
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-
|
11
|
+
date: 2024-08-08 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rails
|
@@ -170,6 +170,10 @@ files:
|
|
170
170
|
- example/tmp/.keep
|
171
171
|
- example/tmp/pids/.keep
|
172
172
|
- example/vendor/.keep
|
173
|
+
- handler-examples/customer_io_handler.rb
|
174
|
+
- handler-examples/revolut_business_v1_handler.rb
|
175
|
+
- handler-examples/revolut_business_v2_handler.rb
|
176
|
+
- handler-examples/starling_payments_handler.rb
|
173
177
|
- lib/munster.rb
|
174
178
|
- lib/munster/base_handler.rb
|
175
179
|
- lib/munster/controllers/receive_webhooks_controller.rb
|
@@ -182,6 +186,17 @@ files:
|
|
182
186
|
- lib/munster/templates/munster.rb
|
183
187
|
- lib/munster/version.rb
|
184
188
|
- lib/tasks/munster_tasks.rake
|
189
|
+
- test/munster_test.rb
|
190
|
+
- test/test-webhook-handlers/.DS_Store
|
191
|
+
- test/test-webhook-handlers/extract_id_handler.rb
|
192
|
+
- test/test-webhook-handlers/failing_with_concealed_errors.rb
|
193
|
+
- test/test-webhook-handlers/failing_with_exposed_errors.rb
|
194
|
+
- test/test-webhook-handlers/inactive_handler.rb
|
195
|
+
- test/test-webhook-handlers/invalid_handler.rb
|
196
|
+
- test/test-webhook-handlers/private_handler.rb
|
197
|
+
- test/test-webhook-handlers/webhook_test_handler.rb
|
198
|
+
- test/test_app.rb
|
199
|
+
- test/test_helper.rb
|
185
200
|
homepage: https://www.cheddar.me/
|
186
201
|
licenses:
|
187
202
|
- MIT
|
@@ -204,7 +219,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
204
219
|
- !ruby/object:Gem::Version
|
205
220
|
version: '0'
|
206
221
|
requirements: []
|
207
|
-
rubygems_version: 3.
|
222
|
+
rubygems_version: 3.3.7
|
208
223
|
signing_key:
|
209
224
|
specification_version: 4
|
210
225
|
summary: Webhooks processing engine for Rails applications
|