diff --git a/CHANGELOG.md b/CHANGELOG.md index 4600c0d..7e18ace 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ ## parse-stack-next Changelog +### 5.4.1 + +#### Webhook after_save callback fix + +- **FIXED**: an `afterSave` webhook handler that returns a `Parse::Object` + (instead of `true` or `nil`) no longer suppresses the model's `after_create` + and `after_save` callbacks on client-initiated saves. Parse Server discards + the `afterSave` response body entirely — it resolves the request as a success + even when the handler raises — so the handler's return value must not gate + callback dispatch. The decision now depends only on the request origin: a + trusted Ruby-initiated save still skips the webhook-side callbacks (the local + `run_callbacks :save` fires them, avoiding a double-run), while a + client-initiated save always fires them. Returning the object — the + recommended `beforeSave` pattern, easy to copy into an `afterSave` by mistake + — previously skipped an `after_save :send_email` and similar side effects + silently. The result is also normalized to `true`, so a returned object can + no longer leak into the response body or the debug log. + ### 5.4.0 #### Parse Server 8.x / 9.x compatibility fixes diff --git a/lib/parse/stack/version.rb b/lib/parse/stack/version.rb index dca8b8c..1cb7056 100644 --- a/lib/parse/stack/version.rb +++ b/lib/parse/stack/version.rb @@ -6,6 +6,6 @@ module Parse # The Parse Server SDK for Ruby module Stack # The current version. - VERSION = "5.4.0" + VERSION = "5.4.1" end end diff --git a/lib/parse/webhooks.rb b/lib/parse/webhooks.rb index 4362e9c..c3a75a5 100644 --- a/lib/parse/webhooks.rb +++ b/lib/parse/webhooks.rb @@ -454,7 +454,7 @@ def call_route(type, className, payload = nil) end end - if type == :after_save && (result == true || result.nil?) && payload&.parse_object.present? && payload.parse_object.is_a?(Parse::Object) + if type == :after_save && payload&.parse_object.present? && payload.parse_object.is_a?(Parse::Object) # Handle after_save callbacks intelligently based on request origin. # For trusted-Ruby-initiated saves (both `_RB_` header AND master # key), Parse Stack's local `run_callbacks :save` will fire @@ -464,6 +464,14 @@ def call_route(type, className, payload = nil) # per save). For everything else -- client-initiated saves, or a # spoofed `_RB_` from a non-master client -- Parse Stack never had # a chance to run callbacks, so we fire them here. + # + # The decision depends ONLY on request origin, never on what the + # handler returned. Parse Server discards the afterSave response + # body entirely (it resolves {success} even if the handler throws), + # so a handler that returns the parse_object -- the recommended + # before_save pattern, easy to copy by mistake -- must NOT silently + # suppress these callbacks. We normalize the result to `true` below + # so a returned object never leaks into the response or the log. is_new = payload.original.nil? unless trusted_ruby_initiated payload.parse_object.run_after_create_callbacks if is_new diff --git a/test/lib/parse/webhook_callbacks_test.rb b/test/lib/parse/webhook_callbacks_test.rb index 9840ad5..e2413ad 100644 --- a/test/lib/parse/webhook_callbacks_test.rb +++ b/test/lib/parse/webhook_callbacks_test.rb @@ -278,6 +278,71 @@ def test_after_save_callback_handling puts "✅ Client-initiated existing object calls after_save" end + def test_after_save_handler_returning_object_still_fires_callbacks + puts "\n=== Testing After Save Callback Handling (handler returns object) ===" + + # Regression: Parse Server discards the afterSave response body, so the + # handler's return value must NOT gate callback dispatch. A handler that + # returns the parse_object (the recommended before_save pattern, easy to + # copy by mistake) must still fire the model's after_create/after_save + # callbacks for client-initiated saves, and the result must normalize to + # `true` so the object never leaks into the response/log. + after_create_called = false + after_save_called = false + + test_object = Object.new + test_object.define_singleton_method(:run_after_create_callbacks) { after_create_called = true } + test_object.define_singleton_method(:run_after_save_callbacks) { after_save_called = true } + test_object.define_singleton_method(:is_a?) { |klass| klass == Parse::Object } + + # Handler returns the object instead of true/nil (the mistake we now tolerate). + Parse::Webhooks.route(:after_save, "TestObject") do |payload| + payload.parse_object + end + + # Client-initiated new object: both callbacks must fire despite the object return. + client_new_payload_data = { + "triggerName" => "afterSave", + "object" => { "className" => "TestObject", "objectId" => "obj_return_new" }, + "original" => nil, + "headers" => { "x-parse-request-id" => "client_obj_return_new" }, + } + client_new_payload = Parse::Webhooks::Payload.new(client_new_payload_data) + client_new_payload.define_singleton_method(:parse_object) { test_object } + client_new_payload.define_singleton_method(:original) { nil } + + result = Parse::Webhooks.call_route(:after_save, "TestObject", client_new_payload) + + assert after_create_called, "after_create must fire even when handler returns the object" + assert after_save_called, "after_save must fire even when handler returns the object" + assert_equal true, result, "Result must normalize to true so the object never leaks into the response" + refute_kind_of Parse::Object, result, "Returned object must not leak into the response body" + puts "✅ Object-returning handler still fires client-initiated callbacks and normalizes result" + + # Trusted-ruby-initiated object: still skips webhook callbacks (Ruby fires them locally), + # and still normalizes the result regardless of the object return. + after_create_called = false + after_save_called = false + + ruby_payload_data = { + "triggerName" => "afterSave", + "master" => true, + "object" => { "className" => "TestObject", "objectId" => "obj_return_ruby" }, + "original" => nil, + "headers" => { "x-parse-request-id" => "_RB_obj_return_ruby" }, + } + ruby_payload = Parse::Webhooks::Payload.new(ruby_payload_data) + ruby_payload.define_singleton_method(:parse_object) { test_object } + ruby_payload.define_singleton_method(:original) { nil } + + result = Parse::Webhooks.call_route(:after_save, "TestObject", ruby_payload) + + refute after_create_called, "after_create must stay suppressed for trusted-ruby-initiated saves" + refute after_save_called, "after_save must stay suppressed for trusted-ruby-initiated saves" + assert_equal true, result, "Result must normalize to true even for the trusted-ruby path" + puts "✅ Trusted-ruby-initiated handler keeps suppression and normalizes result" + end + def test_webhook_integration_with_request_idempotency puts "\n=== Testing Webhook Integration with Request Idempotency ==="