Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/parse/stack/version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 9 additions & 1 deletion lib/parse/webhooks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
65 changes: 65 additions & 0 deletions test/lib/parse/webhook_callbacks_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 ==="

Expand Down