munster 0.4.1 → 0.4.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7d3f84bd06c033ef585c3282701915793b981142351824cc7bba2f41c5bcde4b
4
- data.tar.gz: 9e614a61681b3bed5667905ae378a357fcfa96d85c79b7d85c32473c2b763c4f
3
+ metadata.gz: 1cc63f1db75010013d7ad528bfcc3d4aa52c8d4242ee93a5baff4ecbe6673d01
4
+ data.tar.gz: b8458f7196754f2bde9440b37e4fac46f308e713ed21fca28b700c54aa599168
5
5
  SHA512:
6
- metadata.gz: 616dc8e6585239309106444bb65de90d572e946e08479044459e5f8deb764d0d308d22da895152e3d7250345a81640f627ea72485daf17d6d51b03299d29f5c3
7
- data.tar.gz: 369a9b8ce33c83e876836f652b57fc618a82e3028818531f6bc98d8732fad53cc7a926d6faf2ad8711c5e08ac3e0c9ec23de06a95b597087f86091dfb78e6240
6
+ metadata.gz: 4a8ee7017935cb805c69f7cf0f31b88356b00466159040d5f67b763d19643844246485b3db0d8f4302dfd3ab02b9ff862cabf1fb541a0b338e237897e1327723
7
+ data.tar.gz: 6ac65ba57bebe9fce221ae820369b20eb78c69823c021a9d74b58d38122a7ac6e6918ba84e63d16db4b60330684bf358f4ec72c55590afddccfaa3113ce7d841
data/CHANGELOG.md CHANGED
@@ -3,6 +3,10 @@ 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
+
6
10
  ## 0.4.1
7
11
 
8
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.
@@ -4,23 +4,25 @@ 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
- return unless webhook.received?
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
- e = WebhookPayloadInvalid.new("#{webhook.class} #{webhook.id} did not pass validation and was skipped")
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Munster
4
- VERSION = "0.4.1"
4
+ VERSION = "0.4.2"
5
5
  end
@@ -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
@@ -0,0 +1,5 @@
1
+ class ExtractIdHandler < WebhookTestHandler
2
+ def extract_event_id_from_request(action_dispatch_request)
3
+ JSON.parse(action_dispatch_request.body.read).fetch("event_id")
4
+ end
5
+ end
@@ -0,0 +1,7 @@
1
+ class FailingWithConcealedErrors < Munster::BaseHandler
2
+ def handle(_request)
3
+ raise "oops"
4
+ end
5
+
6
+ def expose_errors_to_sender? = false
7
+ end
@@ -0,0 +1,7 @@
1
+ class FailingWithExposedErrors < Munster::BaseHandler
2
+ def handle(_request)
3
+ raise "oops"
4
+ end
5
+
6
+ def expose_errors_to_sender? = true
7
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class InactiveHandler < WebhookTestHandler
4
+ def active? = false
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class InvalidHandler < WebhookTestHandler
4
+ def valid?(request) = false
5
+ end
@@ -0,0 +1,3 @@
1
+ class PrivateHandler < WebhookTestHandler
2
+ def expose_errors_to_sender? = false
3
+ end
@@ -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
@@ -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.1
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-07-29 00:00:00.000000000 Z
11
+ date: 2024-08-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -186,6 +186,17 @@ files:
186
186
  - lib/munster/templates/munster.rb
187
187
  - lib/munster/version.rb
188
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
189
200
  homepage: https://www.cheddar.me/
190
201
  licenses:
191
202
  - MIT
@@ -208,7 +219,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
208
219
  - !ruby/object:Gem::Version
209
220
  version: '0'
210
221
  requirements: []
211
- rubygems_version: 3.5.9
222
+ rubygems_version: 3.3.7
212
223
  signing_key:
213
224
  specification_version: 4
214
225
  summary: Webhooks processing engine for Rails applications