From 8f13fe47484450b5261a2d49ac8fa0cf1b7d4595 Mon Sep 17 00:00:00 2001 From: Gleb Tv Date: Mon, 2 Mar 2026 18:16:57 +0300 Subject: [PATCH 1/5] Add Rails webhook engine, docs, and dependency refresh --- .github/workflows/main.yml | 21 +- Gemfile | 17 +- Gemfile.lock | 416 +++++++++++++----- README.md | 71 +++ .../yookassa/webhooks_controller.rb | 83 ++++ config/routes.rb | 3 + docs/changelog.md | 24 + docs/chaos-yookassa.md | 135 ++++++ docs/webhooks.md | 70 +++ lib/yookassa.rb | 7 + lib/yookassa/config.rb | 17 +- lib/yookassa/engine.rb | 7 + lib/yookassa/railtie.rb | 8 + lib/yookassa/version.rb | 2 +- spec/spec_helper.rb | 7 + spec/support/rails_test_app.rb | 102 +++++ spec/yookassa/webhooks_browser_spec.rb | 29 ++ spec/yookassa/webhooks_controller_spec.rb | 93 ++++ yookassa.gemspec | 8 +- 19 files changed, 999 insertions(+), 121 deletions(-) create mode 100644 app/controllers/yookassa/webhooks_controller.rb create mode 100644 config/routes.rb create mode 100644 docs/changelog.md create mode 100644 docs/chaos-yookassa.md create mode 100644 docs/webhooks.md create mode 100644 lib/yookassa/engine.rb create mode 100644 lib/yookassa/railtie.rb create mode 100644 spec/support/rails_test_app.rb create mode 100644 spec/yookassa/webhooks_browser_spec.rb create mode 100644 spec/yookassa/webhooks_controller_spec.rb diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1ee97c4..00fcbee 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,8 +2,6 @@ name: CI on: push: - branches: - - main pull_request: @@ -19,7 +17,7 @@ jobs: - 3.0 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: @@ -28,4 +26,19 @@ jobs: - name: Rubocop run: bundle exec rubocop - name: Rspec - run: bundle exec rspec + run: bundle exec rspec --tag ~browser + + browser-tests: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.0 + bundler-cache: true + - name: Set up Chrome + uses: browser-actions/setup-chrome@v1 + - name: Browser specs (Cuprite) + run: bundle exec rspec --tag browser diff --git a/Gemfile b/Gemfile index 25f9d3e..e9430ae 100644 --- a/Gemfile +++ b/Gemfile @@ -14,13 +14,18 @@ group :documentation do end group :development, :test do + gem "capybara", "~> 3.40" + gem "cuprite", "~> 0.15" gem "pry" - gem "pry-byebug", "~> 3.8.0" + gem "pry-byebug", "~> 3.10" gem "rake", "~> 13.0" - gem "rspec", "~> 3.5" + gem "rack-test", "~> 2.1" + gem "rails", "~> 6.1.7" + gem "rspec", "~> 3.13" + gem "sqlite3", "~> 1.4" gem "rubocop", "~> 1.22" - gem "rubocop-rake", "~> 0.6.0" - gem "rubocop-rspec", "~> 2.5" - gem "simplecov", "~> 0.16" - gem "webmock", "~> 3.14" + gem "rubocop-rake", "~> 0.7" + gem "rubocop-rspec", "~> 2.31" + gem "simplecov", "~> 0.22" + gem "webmock", "~> 3.23" end diff --git a/Gemfile.lock b/Gemfile.lock index ddda7f5..5f8dedf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,158 +1,364 @@ PATH remote: . specs: - yookassa (0.2.0) + yookassa (0.2.1) dry-struct dry-validation - http (~> 5.0.1) + http (>= 5.0, < 6.0) GEM remote: https://rubygems.org/ specs: - addressable (2.8.0) - public_suffix (>= 2.0.2, < 5.0) - ast (2.4.2) - byebug (11.1.3) + actioncable (6.1.7.10) + actionpack (= 6.1.7.10) + activesupport (= 6.1.7.10) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + actionmailbox (6.1.7.10) + actionpack (= 6.1.7.10) + activejob (= 6.1.7.10) + activerecord (= 6.1.7.10) + activestorage (= 6.1.7.10) + activesupport (= 6.1.7.10) + mail (>= 2.7.1) + actionmailer (6.1.7.10) + actionpack (= 6.1.7.10) + actionview (= 6.1.7.10) + activejob (= 6.1.7.10) + activesupport (= 6.1.7.10) + mail (~> 2.5, >= 2.5.4) + rails-dom-testing (~> 2.0) + actionpack (6.1.7.10) + actionview (= 6.1.7.10) + activesupport (= 6.1.7.10) + rack (~> 2.0, >= 2.0.9) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.0) + rails-html-sanitizer (~> 1.0, >= 1.2.0) + actiontext (6.1.7.10) + actionpack (= 6.1.7.10) + activerecord (= 6.1.7.10) + activestorage (= 6.1.7.10) + activesupport (= 6.1.7.10) + nokogiri (>= 1.8.5) + actionview (6.1.7.10) + activesupport (= 6.1.7.10) + builder (~> 3.1) + erubi (~> 1.4) + rails-dom-testing (~> 2.0) + rails-html-sanitizer (~> 1.1, >= 1.2.0) + activejob (6.1.7.10) + activesupport (= 6.1.7.10) + globalid (>= 0.3.6) + activemodel (6.1.7.10) + activesupport (= 6.1.7.10) + activerecord (6.1.7.10) + activemodel (= 6.1.7.10) + activesupport (= 6.1.7.10) + activestorage (6.1.7.10) + actionpack (= 6.1.7.10) + activejob (= 6.1.7.10) + activerecord (= 6.1.7.10) + activesupport (= 6.1.7.10) + marcel (~> 1.0) + mini_mime (>= 1.1.0) + activesupport (6.1.7.10) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 1.6, < 2) + minitest (>= 5.1) + tzinfo (~> 2.0) + zeitwerk (~> 2.3) + addressable (2.8.9) + public_suffix (>= 2.0.2, < 8.0) + ast (2.4.3) + base64 (0.3.0) + bigdecimal (4.0.1) + builder (3.3.0) + byebug (13.0.0) + reline (>= 0.6.0) + capybara (3.40.0) + addressable + matrix + mini_mime (>= 0.1.3) + nokogiri (~> 1.11) + rack (>= 1.6.0) + rack-test (>= 0.6.3) + regexp_parser (>= 1.5, < 3.0) + xpath (~> 3.2) coderay (1.1.3) - concurrent-ruby (1.1.9) - crack (0.4.5) + concurrent-ruby (1.3.6) + crack (1.0.1) + bigdecimal rexml - diff-lcs (1.4.4) - docile (1.4.0) - domain_name (0.5.20190701) - unf (>= 0.0.5, < 1.0.0) - dry-configurable (0.13.0) + crass (1.0.6) + cuprite (0.17) + capybara (~> 3.0) + ferrum (~> 0.17.0) + date (3.5.1) + diff-lcs (1.6.2) + docile (1.4.1) + domain_name (0.6.20240107) + drb (2.2.3) + dry-configurable (1.3.0) + dry-core (~> 1.1) + zeitwerk (~> 2.6) + dry-core (1.2.0) concurrent-ruby (~> 1.0) - dry-core (~> 0.6) - dry-container (0.9.0) + logger + zeitwerk (~> 2.6) + dry-inflector (1.3.1) + dry-initializer (3.2.0) + dry-logic (1.6.0) + bigdecimal concurrent-ruby (~> 1.0) - dry-configurable (~> 0.13, >= 0.13.0) - dry-core (0.7.1) + dry-core (~> 1.1) + zeitwerk (~> 2.6) + dry-schema (1.15.0) concurrent-ruby (~> 1.0) - dry-inflector (0.2.1) - dry-initializer (3.0.4) - dry-logic (1.2.0) - concurrent-ruby (~> 1.0) - dry-core (~> 0.5, >= 0.5) - dry-schema (1.8.0) - concurrent-ruby (~> 1.0) - dry-configurable (~> 0.13, >= 0.13.0) - dry-core (~> 0.5, >= 0.5) - dry-initializer (~> 3.0) - dry-logic (~> 1.0) - dry-types (~> 1.5) - dry-struct (1.4.0) - dry-core (~> 0.5, >= 0.5) - dry-types (~> 1.5) + dry-configurable (~> 1.0, >= 1.0.1) + dry-core (~> 1.1) + dry-initializer (~> 3.2) + dry-logic (~> 1.6) + dry-types (~> 1.8) + zeitwerk (~> 2.6) + dry-struct (1.8.0) + dry-core (~> 1.1) + dry-types (~> 1.8, >= 1.8.2) ice_nine (~> 0.11) - dry-types (1.5.1) + zeitwerk (~> 2.6) + dry-types (1.9.1) + bigdecimal (>= 3.0) concurrent-ruby (~> 1.0) - dry-container (~> 0.3) - dry-core (~> 0.5, >= 0.5) - dry-inflector (~> 0.1, >= 0.1.2) - dry-logic (~> 1.0, >= 1.0.2) - dry-validation (1.7.0) + dry-core (~> 1.0) + dry-inflector (~> 1.0) + dry-logic (~> 1.4) + zeitwerk (~> 2.6) + dry-validation (1.11.1) concurrent-ruby (~> 1.0) - dry-container (~> 0.7, >= 0.7.1) - dry-core (~> 0.5, >= 0.5) - dry-initializer (~> 3.0) - dry-schema (~> 1.8, >= 1.8.0) - ffi (1.15.4) - ffi-compiler (1.0.1) - ffi (>= 1.0.0) + dry-core (~> 1.1) + dry-initializer (~> 3.2) + dry-schema (~> 1.14) + zeitwerk (~> 2.6) + erubi (1.13.1) + ferrum (0.17.1) + addressable (~> 2.5) + base64 (~> 0.2) + concurrent-ruby (~> 1.1) + webrick (~> 1.7) + websocket-driver (~> 0.7) + ffi (1.17.3) + ffi-compiler (1.3.2) + ffi (>= 1.15.5) rake - hashdiff (1.0.1) - http (5.0.4) + globalid (1.3.0) + activesupport (>= 6.1) + hashdiff (1.2.1) + http (5.3.1) addressable (~> 2.8) http-cookie (~> 1.0) http-form_data (~> 2.2) - llhttp-ffi (~> 0.4.0) - http-cookie (1.0.4) + llhttp-ffi (~> 0.5.0) + http-cookie (1.1.0) domain_name (~> 0.5) http-form_data (2.3.0) + i18n (1.14.8) + concurrent-ruby (~> 1.0) ice_nine (0.11.2) - llhttp-ffi (0.4.0) + io-console (0.8.2) + json (2.18.1) + json-schema (6.1.0) + addressable (~> 2.8) + bigdecimal (>= 3.1, < 5) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + llhttp-ffi (0.5.1) ffi-compiler (~> 1.0) rake (~> 13.0) - method_source (1.0.0) - parallel (1.21.0) - parser (3.0.2.0) + logger (1.7.0) + loofah (2.25.0) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mail (2.9.0) + logger + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.1.0) + matrix (0.4.3) + mcp (0.7.1) + json-schema (>= 4.1) + method_source (1.1.0) + mini_mime (1.1.5) + minitest (6.0.2) + drb (~> 2.0) + prism (~> 1.5) + net-imap (0.6.3) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.2) + timeout + net-smtp (0.5.1) + net-protocol + nio4r (2.7.5) + nokogiri (1.19.1-x86_64-linux-gnu) + racc (~> 1.4) + parallel (1.27.0) + parser (3.3.10.2) ast (~> 2.4.1) - pry (0.14.1) + racc + prism (1.9.0) + pry (0.16.0) coderay (~> 1.1) method_source (~> 1.0) - pry-byebug (3.8.0) - byebug (~> 11.0) - pry (~> 0.10) - public_suffix (4.0.6) - rack (2.2.3) - rainbow (3.0.0) - rake (13.0.6) - regexp_parser (2.1.1) - rexml (3.2.5) - rspec (3.10.0) - rspec-core (~> 3.10.0) - rspec-expectations (~> 3.10.0) - rspec-mocks (~> 3.10.0) - rspec-core (3.10.1) - rspec-support (~> 3.10.0) - rspec-expectations (3.10.1) + reline (>= 0.6.0) + pry-byebug (3.12.0) + byebug (~> 13.0) + pry (>= 0.13, < 0.17) + public_suffix (7.0.2) + racc (1.8.1) + rack (2.2.22) + rack-test (2.2.0) + rack (>= 1.3) + rails (6.1.7.10) + actioncable (= 6.1.7.10) + actionmailbox (= 6.1.7.10) + actionmailer (= 6.1.7.10) + actionpack (= 6.1.7.10) + actiontext (= 6.1.7.10) + actionview (= 6.1.7.10) + activejob (= 6.1.7.10) + activemodel (= 6.1.7.10) + activerecord (= 6.1.7.10) + activestorage (= 6.1.7.10) + activesupport (= 6.1.7.10) + bundler (>= 1.15.0) + railties (= 6.1.7.10) + sprockets-rails (>= 2.0.0) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.7.0) + loofah (~> 2.25) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + railties (6.1.7.10) + actionpack (= 6.1.7.10) + activesupport (= 6.1.7.10) + method_source + rake (>= 12.2) + thor (~> 1.0) + rainbow (3.1.1) + rake (13.3.1) + regexp_parser (2.11.3) + reline (0.6.3) + io-console (~> 0.5) + rexml (3.4.4) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.10.0) - rspec-mocks (3.10.2) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.10.0) - rspec-support (3.10.2) - rubocop (1.22.3) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + rubocop (1.85.0) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + mcp (~> 0.6) parallel (~> 1.10) - parser (>= 3.0.0.0) + parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.8, < 3.0) - rexml - rubocop-ast (>= 1.12.0, < 2.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 1.4.0, < 3.0) - rubocop-ast (1.12.0) - parser (>= 3.0.1.1) - rubocop-rake (0.6.0) - rubocop (~> 1.0) - rubocop-rspec (2.5.0) - rubocop (~> 1.19) - ruby-progressbar (1.11.0) - simplecov (0.21.2) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.0) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-capybara (2.22.1) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-factory_bot (2.28.0) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-rake (0.7.1) + lint_roller (~> 1.1) + rubocop (>= 1.72.1) + rubocop-rspec (2.31.0) + rubocop (~> 1.40) + rubocop-capybara (~> 2.17) + rubocop-factory_bot (~> 2.22) + rubocop-rspec_rails (~> 2.28) + rubocop-rspec_rails (2.29.1) + rubocop (~> 1.61) + ruby-progressbar (1.13.0) + simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) simplecov_json_formatter (~> 0.1) - simplecov-html (0.12.3) - simplecov_json_formatter (0.1.3) - unf (0.1.4) - unf_ext - unf_ext (0.0.8) - unicode-display_width (2.1.0) - webmock (3.14.0) + simplecov-html (0.13.2) + simplecov_json_formatter (0.1.4) + sprockets (4.2.2) + concurrent-ruby (~> 1.0) + logger + rack (>= 2.2.4, < 4) + sprockets-rails (3.5.2) + actionpack (>= 6.1) + activesupport (>= 6.1) + sprockets (>= 3.0.0) + sqlite3 (1.7.3-x86_64-linux) + thor (1.5.0) + timeout (0.6.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + webmock (3.26.1) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - webrick (1.7.0) - yard (0.9.26) + webrick (1.9.2) + websocket-driver (0.8.0) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + xpath (3.2.0) + nokogiri (~> 1.8) + yard (0.9.38) + zeitwerk (2.7.5) PLATFORMS - ruby + x86_64-linux DEPENDENCIES + capybara (~> 3.40) + cuprite (~> 0.15) pry - pry-byebug (~> 3.8.0) + pry-byebug (~> 3.10) rack + rack-test (~> 2.1) + rails (~> 6.1.7) rake (~> 13.0) - rspec (~> 3.5) + rspec (~> 3.13) rubocop (~> 1.22) - rubocop-rake (~> 0.6.0) - rubocop-rspec (~> 2.5) - simplecov (~> 0.16) - webmock (~> 3.14) + rubocop-rake (~> 0.7) + rubocop-rspec (~> 2.31) + simplecov (~> 0.22) + sqlite3 (~> 1.4) + webmock (~> 3.23) webrick yard yookassa! BUNDLED WITH - 2.2.22 + 2.6.7 diff --git a/README.md b/README.md index a2e6fbf..6f0297a 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,77 @@ Yookassa.payments.capture(payment_id: '12345') Yookassa.payments.cancel(payment_id: '12345') ``` +### Rails webhook endpoint (engine) + +The gem ships with a Rails engine and a default webhook controller you can use directly. + +1) Configure the webhook token and (optionally) allowed source IPs: + +```ruby +# config/initializers/yookassa.rb +Yookassa.configure do |config| + config.shop_id = ENV.fetch('YOOKASSA_SHOP_ID') + config.api_key = ENV.fetch('YOOKASSA_API_KEY') + + # Random, long, secret token used in webhook URL path. + config.webhook_token = ENV.fetch('YOOKASSA_WEBHOOK_TOKEN') + + # Optional override. Defaults come from YooKassa docs: + # https://yookassa.ru/developers/using-api/webhooks#ip + # config.webhook_allowed_ips = ['185.71.76.0/27', ...] +end +``` + +2) Mount the engine in routes: + +```ruby +# config/routes.rb +Rails.application.routes.draw do + mount Yookassa::Engine => '/yookassa' +end +``` + +This exposes: + +- `POST /yookassa/webhooks/:token` + +Set your YooKassa webhook URL to include the real token value, for example: + +- `https://example.com/yookassa/webhooks/` + +The default `Yookassa::WebhooksController` verifies: + +- token in URL path +- request source `request.remote_ip` is in allowlist +- webhook object matches fresh API fetch by `id` and `status` + +No signature headers are used. + +### Overriding webhook handling + +In host app, inherit from the gem controller and implement business logic in `process_webhook`: + +```ruby +# app/controllers/my_yookassa_webhooks_controller.rb +class MyYookassaWebhooksController < Yookassa::WebhooksController + private + + def process_webhook(payload) + object = payload['object'] || payload.dig('data', 'object') + return unless object + + # Your app-specific processing + end +end +``` + +Then route to your controller (keeping token in path): + +```ruby +# config/routes.rb +post '/webhooks/yookassa/:token', to: 'my_yookassa_webhooks#create' +``` + ### Path to 1.0 **Настройки SDK API ЮKassa** diff --git a/app/controllers/yookassa/webhooks_controller.rb b/app/controllers/yookassa/webhooks_controller.rb new file mode 100644 index 0000000..29a0a78 --- /dev/null +++ b/app/controllers/yookassa/webhooks_controller.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require "ipaddr" +require "json" + +module Yookassa + class WebhooksController < ::ActionController::Base + skip_before_action :verify_authenticity_token, raise: false + + def create + payload = parsed_payload + return head :unauthorized unless authentic_webhook?(payload) + + process_webhook(payload) + head :ok + rescue JSON::ParserError + head :bad_request + end + + private + + def process_webhook(_payload) + nil + end + + def authentic_webhook?(payload) + token_valid? && source_ip_allowed? && payload_matches_api_object?(payload) + end + + def token_valid? + token = params[:token].to_s + configured_token = Yookassa.config.webhook_token.to_s + return false if token.empty? || configured_token.empty? + + ActiveSupport::SecurityUtils.secure_compare(token, configured_token) + end + + def source_ip_allowed? + source_ip = request.remote_ip + allowed_cidrs.any? { |cidr| IPAddr.new(cidr).include?(source_ip) } + rescue IPAddr::InvalidAddressError + false + end + + def allowed_cidrs + ips = Yookassa.config.webhook_allowed_ips + return Yookassa::Config::DEFAULT_WEBHOOK_ALLOWED_IPS if ips.nil? || ips.empty? + + ips + end + + def parsed_payload + @parsed_payload ||= JSON.parse(request.raw_post) + end + + def payload_matches_api_object?(payload) + object = payload["object"] || payload.dig("data", "object") + return false unless object.is_a?(Hash) + + object_id = object["id"].to_s + object_status = object["status"].to_s + return false if object_id.empty? || object_status.empty? + + fetched_object = fetch_object_from_api(payload, object_id) + return false if fetched_object.nil? + + fetched_object.id == object_id && fetched_object.status == object_status + rescue StandardError + false + end + + def fetch_object_from_api(payload, object_id) + event_name = payload["event"].to_s + event_name = payload["type"].to_s if event_name.empty? + + if event_name.start_with?("payment.") + Yookassa.payments.find(payment_id: object_id) + elsif event_name.start_with?("refund.") + Yookassa.refunds.find(payment_id: object_id) + end + end + end +end diff --git a/config/routes.rb b/config/routes.rb new file mode 100644 index 0000000..0ef3391 --- /dev/null +++ b/config/routes.rb @@ -0,0 +1,3 @@ +Yookassa::Engine.routes.draw do + post "/webhooks/:token", to: "webhooks#create" +end diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..6b24eb3 --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,24 @@ +# Changelog + +## 2026-03-02 + +### Added +- Rails engine integration for webhook handling (`Yookassa::Engine`). +- Default webhook controller (`Yookassa::WebhooksController`) with built-in authenticity checks. +- Config options for webhook security: + - `webhook_token` (secret URL token in path) + - `webhook_allowed_ips` (YooKassa source allowlist, overridable) +- RSpec coverage for webhook controller behavior. +- Cuprite browser spec for real browser webhook request flow. +- CI job for browser tests in GitHub Actions. + +### Changed +- Documentation updated to use gem-based webhook integration instead of app-side hand-rolled service/controller code. +- Webhook security guidance switched to YooKassa-documented approach: + - secret URL token + - source IP allowlist + - API re-fetch and object/status comparison + +### Notes +- No webhook signature header validation is implemented because YooKassa webhook docs describe authenticity checks via source IP and object status verification. +- Source for default IP ranges: https://yookassa.ru/developers/using-api/webhooks#ip diff --git a/docs/chaos-yookassa.md b/docs/chaos-yookassa.md new file mode 100644 index 0000000..50bf340 --- /dev/null +++ b/docs/chaos-yookassa.md @@ -0,0 +1,135 @@ +# YooKassa Smart Payment Integration Guide + +## Overview + +This guide uses the `yookassa` gem for both API calls and webhook security handling. +Do not re-implement webhook signature checks in your app: this integration uses a +secret URL token + source IP allowlist + API re-fetch verification. + +## Payment flow + +``` +User taps "Pay" -> backend creates payment via gem -> user pays on YooKassa page -> +YooKassa sends webhook -> gem controller verifies webhook -> app applies business logic +``` + +## 1) Install and configure the gem + +```ruby +# Gemfile +gem "yookassa" +``` + +```ruby +# config/initializers/yookassa.rb +Yookassa.configure do |config| + config.shop_id = ENV.fetch("YOOKASSA_SHOP_ID") + config.api_key = ENV.fetch("YOOKASSA_SECRET_KEY") + + # Required: long random token used in webhook route path. + config.webhook_token = ENV.fetch("YOOKASSA_WEBHOOK_TOKEN") + + # Optional override, if you need a custom list. + # Defaults are from YooKassa docs: + # https://yookassa.ru/developers/using-api/webhooks#ip + # config.webhook_allowed_ips = ["185.71.76.0/27", ...] +end +``` + +## 2) Create payments with gem client + +```ruby +payload = { + amount: { + value: "100.00", + currency: "RUB" + }, + capture: true, + confirmation: { + type: "redirect", + return_url: "https://example.com/payment/return" + }, + description: "Order 123" +} + +payment = Yookassa.payments.create(payment: payload) +confirmation_url = payment.confirmation.confirmation_url +``` + +## 3) Mount gem webhook engine + +```ruby +# config/routes.rb +Rails.application.routes.draw do + mount Yookassa::Engine => "/yookassa" +end +``` + +Default webhook endpoint: + +- `POST /yookassa/webhooks/:token` + +Your YooKassa dashboard webhook URL must include the configured token value, for example: + +- `https://your-domain.com/yookassa/webhooks/` + +## 4) Add app-specific webhook logic by inheritance + +`Yookassa::WebhooksController` handles authenticity checks. Override only processing. + +```ruby +# app/controllers/yookassa_events_controller.rb +class YookassaEventsController < Yookassa::WebhooksController + private + + def process_webhook(payload) + event = payload["event"] || payload["type"] + object = payload["object"] || payload.dig("data", "object") + return unless object + + case event + when "payment.succeeded" + # apply balance / mark paid in your app + when "payment.canceled" + # mark failed in your app + end + end +end +``` + +```ruby +# config/routes.rb +post "/webhooks/yookassa/:token", to: "yookassa_events#create" +``` + +## Webhook security model used by gem + +The default controller accepts webhook only if all checks pass: + +1. route includes token and it matches `Yookassa.config.webhook_token` +2. source address from `request.remote_ip` is in allowlist +3. webhook object `id` + `status` matches a fresh API fetch + +This approach intentionally does not use HMAC signature headers. + +## Defaults and allowlist override + +Default allowed IP ranges in gem: + +- `185.71.76.0/27` +- `185.71.77.0/27` +- `77.75.153.0/25` +- `77.75.156.11` +- `77.75.156.35` +- `77.75.154.128/25` +- `2a02:5180::/32` + +Source: https://yookassa.ru/developers/using-api/webhooks#ip + +If needed: + +```ruby +Yookassa.configure do |config| + config.webhook_allowed_ips = ["203.0.113.10"] +end +``` diff --git a/docs/webhooks.md b/docs/webhooks.md new file mode 100644 index 0000000..0999f06 --- /dev/null +++ b/docs/webhooks.md @@ -0,0 +1,70 @@ +# Webhooks + +## Overview + +The gem provides a Rails engine endpoint for YooKassa webhooks and a default controller +that performs security checks before running your app-specific logic. + +Default endpoint after mounting engine: + +- `POST /yookassa/webhooks/:token` + +## Security model + +The controller accepts a webhook only if all checks pass: + +1. Route token matches configured `Yookassa.config.webhook_token`. +2. Source IP from `request.remote_ip` belongs to allowed CIDRs. +3. Webhook object `id` and `status` match a fresh API fetch (`payments.find` or `refunds.find`). + +This follows YooKassa webhook guidance: + +- https://yookassa.ru/developers/using-api/webhooks + +## Default allowed IP ranges + +From YooKassa docs (source: https://yookassa.ru/developers/using-api/webhooks#ip): + +- `185.71.76.0/27` +- `185.71.77.0/27` +- `77.75.153.0/25` +- `77.75.156.11` +- `77.75.156.35` +- `77.75.154.128/25` +- `2a02:5180::/32` + +## Configuration + +```ruby +Yookassa.configure do |config| + config.shop_id = ENV.fetch("YOOKASSA_SHOP_ID") + config.api_key = ENV.fetch("YOOKASSA_API_KEY") + config.webhook_token = ENV.fetch("YOOKASSA_WEBHOOK_TOKEN") + + # Optional override + # config.webhook_allowed_ips = ["203.0.113.10"] +end +``` + +## Overriding controller behavior + +Inherit from `Yookassa::WebhooksController` and override `process_webhook`. +All security checks remain in the base controller. + +```ruby +class MyYookassaWebhooksController < Yookassa::WebhooksController + private + + def process_webhook(payload) + event = payload["event"] + object = payload["object"] + # app-specific logic + end +end +``` + +Route example: + +```ruby +post "/webhooks/yookassa/:token", to: "my_yookassa_webhooks#create" +``` diff --git a/lib/yookassa.rb b/lib/yookassa.rb index a4d8510..42af4bc 100644 --- a/lib/yookassa.rb +++ b/lib/yookassa.rb @@ -7,6 +7,7 @@ require "yookassa/config" require "yookassa/payments" require "yookassa/refunds" require "yookassa/receipts" +require "yookassa/webhooks" module Yookassa class << self @@ -31,5 +32,11 @@ module Yookassa def receipts @receipts ||= Receipts.new end + + def webhooks + @webhooks ||= Webhooks.new + end end end + +require "yookassa/railtie" if defined?(Rails::Railtie) diff --git a/lib/yookassa/config.rb b/lib/yookassa/config.rb index e6a1a8e..ee0b5f0 100644 --- a/lib/yookassa/config.rb +++ b/lib/yookassa/config.rb @@ -2,6 +2,21 @@ module Yookassa class Config - attr_accessor :shop_id, :api_key + # Source: https://yookassa.ru/developers/using-api/webhooks#ip + DEFAULT_WEBHOOK_ALLOWED_IPS = [ + "185.71.76.0/27", + "185.71.77.0/27", + "77.75.153.0/25", + "77.75.156.11", + "77.75.156.35", + "77.75.154.128/25", + "2a02:5180::/32" + ].freeze + + attr_accessor :shop_id, :api_key, :webhook_token, :webhook_allowed_ips + + def initialize + @webhook_allowed_ips = DEFAULT_WEBHOOK_ALLOWED_IPS.dup + end end end diff --git a/lib/yookassa/engine.rb b/lib/yookassa/engine.rb new file mode 100644 index 0000000..2c8d50b --- /dev/null +++ b/lib/yookassa/engine.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Yookassa + class Engine < ::Rails::Engine + isolate_namespace Yookassa + end +end diff --git a/lib/yookassa/railtie.rb b/lib/yookassa/railtie.rb new file mode 100644 index 0000000..a81a832 --- /dev/null +++ b/lib/yookassa/railtie.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require "yookassa/engine" + +module Yookassa + class Railtie < ::Rails::Railtie + end +end diff --git a/lib/yookassa/version.rb b/lib/yookassa/version.rb index 08c1f0e..6c599b4 100644 --- a/lib/yookassa/version.rb +++ b/lib/yookassa/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Yookassa - VERSION = "0.2.0" + VERSION = "0.2.1" end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 28fa36f..6734548 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -8,9 +8,16 @@ end require "yookassa" require "webmock/rspec" +WebMock.disable_net_connect!(allow_localhost: true) + +Dir[File.expand_path("support/**/*.rb", __dir__)].sort.each { |file| require file } RSpec.configure do |config| config.order = :random config.filter_run focus: true config.run_all_when_everything_filtered = true + + config.before do + WebMock.reset! + end end diff --git a/spec/support/rails_test_app.rb b/spec/support/rails_test_app.rb new file mode 100644 index 0000000..f170a67 --- /dev/null +++ b/spec/support/rails_test_app.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +begin + require "logger" + require "rack/test" + require "rails" + require "action_controller/railtie" + require "capybara" + require "capybara/rspec" + require "capybara/cuprite" +rescue LoadError + nil +end + +if defined?(Rails) + require "yookassa/engine" + require File.expand_path("../../app/controllers/yookassa/webhooks_controller", __dir__) + + class YookassaSpecBrowserController < ActionController::Base + def index + render inline: <<~HTML + + + + +
pending
+ + + + HTML + end + end + + class YookassaSpecCustomWebhooksController < Yookassa::WebhooksController + class_attribute :last_payload, default: nil + + private + + def process_webhook(payload) + self.class.last_payload = payload + end + end + + class YookassaSpecApp < Rails::Application + config.root = File.expand_path("../..", __dir__) + config.eager_load = false + config.secret_key_base = "test-secret-key-base" + config.logger = Logger.new(nil) + config.hosts << "www.example.com" + config.hosts << "example.org" + config.hosts << "localhost" + config.hosts << "127.0.0.1" + config.consider_all_requests_local = true + end + + YookassaSpecApp.initialize! + + YookassaSpecApp.routes.draw do + mount Yookassa::Engine => "/yookassa" + post "/custom-yookassa/:token", to: "yookassa_spec_custom_webhooks#create" + get "/browser", to: "yookassa_spec_browser#index" + end + + Capybara.app = YookassaSpecApp + Capybara.server = :webrick + Capybara.default_max_wait_time = 5 + Capybara.register_driver(:cuprite) do |app| + Capybara::Cuprite::Driver.new(app, headless: true) + end + + RSpec.configure do |config| + config.include Rack::Test::Methods + + config.define_derived_metadata(file_path: %r{/spec/yookassa/webhooks_}) do |metadata| + metadata[:rails] = true + end + + config.before(:each, rails: true) do + Yookassa.configure do |yookassa_config| + yookassa_config.shop_id = "shop-id" + yookassa_config.api_key = "api-key" + yookassa_config.webhook_token = "secret-token" + yookassa_config.webhook_allowed_ips = Yookassa::Config::DEFAULT_WEBHOOK_ALLOWED_IPS.dup + end + + YookassaSpecCustomWebhooksController.last_payload = nil + end + end +end diff --git a/spec/yookassa/webhooks_browser_spec.rb b/spec/yookassa/webhooks_browser_spec.rb new file mode 100644 index 0000000..fbfbd54 --- /dev/null +++ b/spec/yookassa/webhooks_browser_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +RSpec.describe "Yookassa webhook endpoint", :rails, :browser do + include Capybara::DSL + + around do |example| + previous_driver = Capybara.current_driver + Capybara.current_driver = :cuprite + example.run + Capybara.current_driver = previous_driver + end + + before do + Yookassa.config.webhook_allowed_ips = ["127.0.0.1"] + + payments_client = instance_double(Yookassa::Payments) + allow(Yookassa).to receive(:payments).and_return(payments_client) + allow(payments_client).to receive(:find).with(payment_id: "browser-payment") + .and_return(instance_double(Yookassa::Entity::Payment, id: "browser-payment", status: "succeeded")) + end + + it "accepts browser-submitted webhook request" do + visit "/browser" + + click_button "Send webhook" + + expect(page).to have_text("200") + end +end diff --git a/spec/yookassa/webhooks_controller_spec.rb b/spec/yookassa/webhooks_controller_spec.rb new file mode 100644 index 0000000..813d479 --- /dev/null +++ b/spec/yookassa/webhooks_controller_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +RSpec.describe Yookassa::WebhooksController do + def app + YookassaSpecApp + end + + let(:payload_hash) do + { + "event" => "payment.succeeded", + "object" => { + "id" => "payment-1", + "status" => "succeeded" + } + } + end + let(:payload) { JSON.generate(payload_hash) } + let(:headers) do + { + "CONTENT_TYPE" => "application/json", + "REMOTE_ADDR" => remote_addr + } + end + let(:remote_addr) { "185.71.76.1" } + + describe "POST /yookassa/webhooks/:token", :rails do + it "accepts webhook when token, remote_ip, and API re-fetch checks pass" do + payments_client = instance_double(Yookassa::Payments) + allow(Yookassa).to receive(:payments).and_return(payments_client) + allow(payments_client).to receive(:find).with(payment_id: "payment-1") + .and_return(instance_double(Yookassa::Entity::Payment, id: "payment-1", status: "succeeded")) + + post "/yookassa/webhooks/secret-token", payload, headers + + expect(last_response.status).to eq(200) + end + + it "rejects webhook when token is invalid" do + post "/yookassa/webhooks/wrong-token", payload, headers + + expect(last_response.status).to eq(401) + end + + it "rejects webhook when source remote_ip is outside allowlist" do + payments_client = instance_double(Yookassa::Payments) + allow(Yookassa).to receive(:payments).and_return(payments_client) + allow(payments_client).to receive(:find).with(payment_id: "payment-1") + .and_return(instance_double(Yookassa::Entity::Payment, id: "payment-1", status: "succeeded")) + + post "/yookassa/webhooks/secret-token", payload, headers.merge("REMOTE_ADDR" => "203.0.113.10") + + expect(last_response.status).to eq(401) + end + + it "rejects webhook when fetched object differs by status" do + payments_client = instance_double(Yookassa::Payments) + allow(Yookassa).to receive(:payments).and_return(payments_client) + allow(payments_client).to receive(:find).with(payment_id: "payment-1") + .and_return(instance_double(Yookassa::Entity::Payment, id: "payment-1", status: "pending")) + + post "/yookassa/webhooks/secret-token", payload, headers + + expect(last_response.status).to eq(401) + end + end + + describe "IP source check", :rails do + it "uses request.remote_ip" do + controller = described_class.new + request = instance_double(ActionDispatch::Request) + + allow(request).to receive(:remote_ip).and_return("185.71.76.5") + allow(request).to receive(:ip).and_raise("request.ip should not be used") + allow(controller).to receive(:request).and_return(request) + + expect(controller.send(:source_ip_allowed?)).to eq(true) + end + end + + describe "controller inheritance", :rails do + it "allows app controller override via inheritance" do + payments_client = instance_double(Yookassa::Payments) + allow(Yookassa).to receive(:payments).and_return(payments_client) + allow(payments_client).to receive(:find).with(payment_id: "payment-1") + .and_return(instance_double(Yookassa::Entity::Payment, id: "payment-1", status: "succeeded")) + + post "/custom-yookassa/secret-token", payload, headers + + expect(last_response.status).to eq(200) + expect(YookassaSpecCustomWebhooksController.last_payload).to eq(payload_hash) + end + end +end diff --git a/yookassa.gemspec b/yookassa.gemspec index 5136745..7c9d08d 100644 --- a/yookassa.gemspec +++ b/yookassa.gemspec @@ -15,14 +15,14 @@ Gem::Specification.new do |spec| spec.license = "MIT" spec.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR) - spec.test_files = spec.files.grep(/^spec/) spec.extra_rdoc_files = Dir["README.md", "LICENSE", "CHANGELOG.md"] spec.require_paths = ["lib"] spec.required_ruby_version = ">= 2.6" - spec.add_runtime_dependency "dry-struct" - spec.add_runtime_dependency "dry-validation" - spec.add_runtime_dependency "http", "~> 5.0" + spec.add_dependency "dry-struct" + spec.add_dependency "dry-validation" + spec.add_dependency "http", ">= 5.0", "< 6.0" + spec.metadata["rubygems_mfa_required"] = "true" end From 04a43ec51da060c286c17cce3553586cedb14649 Mon Sep 17 00:00:00 2001 From: Gleb Tv Date: Fri, 6 Mar 2026 17:41:05 +0300 Subject: [PATCH 2/5] Update Ruby to 4.0 and Rails to 8.0 --- .github/workflows/main.yml | 7 +- Gemfile | 6 +- Gemfile.lock | 259 +++++++++++++++++++++++-------------- 3 files changed, 171 insertions(+), 101 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 00fcbee..7352025 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,9 +12,8 @@ jobs: strategy: matrix: ruby: - - 2.6 - - 2.7 - - 3.0 + - 3.4 + - 4.0 steps: - uses: actions/checkout@v4 @@ -36,7 +35,7 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.0 + ruby-version: 4.0 bundler-cache: true - name: Set up Chrome uses: browser-actions/setup-chrome@v1 diff --git a/Gemfile b/Gemfile index e9430ae..b766968 100644 --- a/Gemfile +++ b/Gemfile @@ -20,10 +20,10 @@ group :development, :test do gem "pry-byebug", "~> 3.10" gem "rake", "~> 13.0" gem "rack-test", "~> 2.1" - gem "rails", "~> 6.1.7" + gem "rails", "~> 8.0" gem "rspec", "~> 3.13" - gem "sqlite3", "~> 1.4" - gem "rubocop", "~> 1.22" + gem "sqlite3", "~> 2.0" + gem "rubocop", "~> 1.60" gem "rubocop-rake", "~> 0.7" gem "rubocop-rspec", "~> 2.31" gem "simplecov", "~> 0.22" diff --git a/Gemfile.lock b/Gemfile.lock index 5f8dedf..5f28054 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -9,65 +9,80 @@ PATH GEM remote: https://rubygems.org/ specs: - actioncable (6.1.7.10) - actionpack (= 6.1.7.10) - activesupport (= 6.1.7.10) + action_text-trix (2.1.16) + railties + actioncable (8.1.2) + actionpack (= 8.1.2) + activesupport (= 8.1.2) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (6.1.7.10) - actionpack (= 6.1.7.10) - activejob (= 6.1.7.10) - activerecord (= 6.1.7.10) - activestorage (= 6.1.7.10) - activesupport (= 6.1.7.10) - mail (>= 2.7.1) - actionmailer (6.1.7.10) - actionpack (= 6.1.7.10) - actionview (= 6.1.7.10) - activejob (= 6.1.7.10) - activesupport (= 6.1.7.10) - mail (~> 2.5, >= 2.5.4) - rails-dom-testing (~> 2.0) - actionpack (6.1.7.10) - actionview (= 6.1.7.10) - activesupport (= 6.1.7.10) - rack (~> 2.0, >= 2.0.9) - rack-test (>= 0.6.3) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (6.1.7.10) - actionpack (= 6.1.7.10) - activerecord (= 6.1.7.10) - activestorage (= 6.1.7.10) - activesupport (= 6.1.7.10) + zeitwerk (~> 2.6) + actionmailbox (8.1.2) + actionpack (= 8.1.2) + activejob (= 8.1.2) + activerecord (= 8.1.2) + activestorage (= 8.1.2) + activesupport (= 8.1.2) + mail (>= 2.8.0) + actionmailer (8.1.2) + actionpack (= 8.1.2) + actionview (= 8.1.2) + activejob (= 8.1.2) + activesupport (= 8.1.2) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (8.1.2) + actionview (= 8.1.2) + activesupport (= 8.1.2) nokogiri (>= 1.8.5) - actionview (6.1.7.10) - activesupport (= 6.1.7.10) + rack (>= 2.2.4) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actiontext (8.1.2) + action_text-trix (~> 2.1.15) + actionpack (= 8.1.2) + activerecord (= 8.1.2) + activestorage (= 8.1.2) + activesupport (= 8.1.2) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (8.1.2) + activesupport (= 8.1.2) builder (~> 3.1) - erubi (~> 1.4) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.1, >= 1.2.0) - activejob (6.1.7.10) - activesupport (= 6.1.7.10) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (8.1.2) + activesupport (= 8.1.2) globalid (>= 0.3.6) - activemodel (6.1.7.10) - activesupport (= 6.1.7.10) - activerecord (6.1.7.10) - activemodel (= 6.1.7.10) - activesupport (= 6.1.7.10) - activestorage (6.1.7.10) - actionpack (= 6.1.7.10) - activejob (= 6.1.7.10) - activerecord (= 6.1.7.10) - activesupport (= 6.1.7.10) + activemodel (8.1.2) + activesupport (= 8.1.2) + activerecord (8.1.2) + activemodel (= 8.1.2) + activesupport (= 8.1.2) + timeout (>= 0.4.0) + activestorage (8.1.2) + actionpack (= 8.1.2) + activejob (= 8.1.2) + activerecord (= 8.1.2) + activesupport (= 8.1.2) marcel (~> 1.0) - mini_mime (>= 1.1.0) - activesupport (6.1.7.10) - concurrent-ruby (~> 1.0, >= 1.0.2) + activesupport (8.1.2) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb i18n (>= 1.6, < 2) + json + logger (>= 1.4.2) minitest (>= 5.1) - tzinfo (~> 2.0) - zeitwerk (~> 2.3) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) addressable (2.8.9) public_suffix (>= 2.0.2, < 8.0) ast (2.4.3) @@ -87,6 +102,7 @@ GEM xpath (~> 3.2) coderay (1.1.3) concurrent-ruby (1.3.6) + connection_pool (3.0.2) crack (1.0.1) bigdecimal rexml @@ -113,15 +129,15 @@ GEM concurrent-ruby (~> 1.0) dry-core (~> 1.1) zeitwerk (~> 2.6) - dry-schema (1.15.0) + dry-schema (1.16.0) concurrent-ruby (~> 1.0) dry-configurable (~> 1.0, >= 1.0.1) dry-core (~> 1.1) dry-initializer (~> 3.2) dry-logic (~> 1.6) - dry-types (~> 1.8) + dry-types (~> 1.9, >= 1.9.1) zeitwerk (~> 2.6) - dry-struct (1.8.0) + dry-struct (1.8.1) dry-core (~> 1.1) dry-types (~> 1.8, >= 1.8.2) ice_nine (~> 0.11) @@ -139,6 +155,7 @@ GEM dry-initializer (~> 3.2) dry-schema (~> 1.14) zeitwerk (~> 2.6) + erb (6.0.2) erubi (1.13.1) ferrum (0.17.1) addressable (~> 2.5) @@ -146,7 +163,14 @@ GEM concurrent-ruby (~> 1.1) webrick (~> 1.7) websocket-driver (~> 0.7) - ffi (1.17.3) + ffi (1.17.3-aarch64-linux-gnu) + ffi (1.17.3-aarch64-linux-musl) + ffi (1.17.3-arm-linux-gnu) + ffi (1.17.3-arm-linux-musl) + ffi (1.17.3-arm64-darwin) + ffi (1.17.3-x86_64-darwin) + ffi (1.17.3-x86_64-linux-gnu) + ffi (1.17.3-x86_64-linux-musl) ffi-compiler (1.3.2) ffi (>= 1.15.5) rake @@ -165,8 +189,13 @@ GEM concurrent-ruby (~> 1.0) ice_nine (0.11.2) io-console (0.8.2) - json (2.18.1) - json-schema (6.1.0) + irb (1.17.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json (2.19.0) + json-schema (6.2.0) addressable (~> 2.8) bigdecimal (>= 3.1, < 5) language_server-protocol (3.17.0.5) @@ -186,7 +215,7 @@ GEM net-smtp marcel (1.1.0) matrix (0.4.3) - mcp (0.7.1) + mcp (0.8.0) json-schema (>= 4.1) method_source (1.1.0) mini_mime (1.1.5) @@ -203,12 +232,29 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.5) + nokogiri (1.19.1-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.1-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.19.1-arm-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.1-arm-linux-musl) + racc (~> 1.4) + nokogiri (1.19.1-arm64-darwin) + racc (~> 1.4) + nokogiri (1.19.1-x86_64-darwin) + racc (~> 1.4) nokogiri (1.19.1-x86_64-linux-gnu) racc (~> 1.4) + nokogiri (1.19.1-x86_64-linux-musl) + racc (~> 1.4) parallel (1.27.0) parser (3.3.10.2) ast (~> 2.4.1) racc + pp (0.6.3) + prettyprint + prettyprint (0.2.0) prism (1.9.0) pry (0.16.0) coderay (~> 1.1) @@ -217,26 +263,33 @@ GEM pry-byebug (3.12.0) byebug (~> 13.0) pry (>= 0.13, < 0.17) - public_suffix (7.0.2) + psych (5.3.1) + date + stringio + public_suffix (7.0.5) racc (1.8.1) - rack (2.2.22) + rack (3.2.5) + rack-session (2.1.1) + base64 (>= 0.1.0) + rack (>= 3.0.0) rack-test (2.2.0) rack (>= 1.3) - rails (6.1.7.10) - actioncable (= 6.1.7.10) - actionmailbox (= 6.1.7.10) - actionmailer (= 6.1.7.10) - actionpack (= 6.1.7.10) - actiontext (= 6.1.7.10) - actionview (= 6.1.7.10) - activejob (= 6.1.7.10) - activemodel (= 6.1.7.10) - activerecord (= 6.1.7.10) - activestorage (= 6.1.7.10) - activesupport (= 6.1.7.10) + rackup (2.3.1) + rack (>= 3) + rails (8.1.2) + actioncable (= 8.1.2) + actionmailbox (= 8.1.2) + actionmailer (= 8.1.2) + actionpack (= 8.1.2) + actiontext (= 8.1.2) + actionview (= 8.1.2) + activejob (= 8.1.2) + activemodel (= 8.1.2) + activerecord (= 8.1.2) + activestorage (= 8.1.2) + activesupport (= 8.1.2) bundler (>= 1.15.0) - railties (= 6.1.7.10) - sprockets-rails (>= 2.0.0) + railties (= 8.1.2) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest @@ -244,14 +297,21 @@ GEM rails-html-sanitizer (1.7.0) loofah (~> 2.25) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - railties (6.1.7.10) - actionpack (= 6.1.7.10) - activesupport (= 6.1.7.10) - method_source + railties (8.1.2) + actionpack (= 8.1.2) + activesupport (= 8.1.2) + irb (~> 1.13) + rackup (>= 1.0.0) rake (>= 12.2) - thor (~> 1.0) + thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) + zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.3.1) + rdoc (7.2.0) + erb + psych (>= 4.0.0) + tsort regexp_parser (2.11.3) reline (0.6.3) io-console (~> 0.5) @@ -269,7 +329,7 @@ GEM diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-support (3.13.7) - rubocop (1.85.0) + rubocop (1.85.1) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -301,28 +361,32 @@ GEM rubocop-rspec_rails (2.29.1) rubocop (~> 1.61) ruby-progressbar (1.13.0) + securerandom (0.4.1) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) simplecov_json_formatter (~> 0.1) simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) - sprockets (4.2.2) - concurrent-ruby (~> 1.0) - logger - rack (>= 2.2.4, < 4) - sprockets-rails (3.5.2) - actionpack (>= 6.1) - activesupport (>= 6.1) - sprockets (>= 3.0.0) - sqlite3 (1.7.3-x86_64-linux) + sqlite3 (2.9.1-aarch64-linux-gnu) + sqlite3 (2.9.1-aarch64-linux-musl) + sqlite3 (2.9.1-arm-linux-gnu) + sqlite3 (2.9.1-arm-linux-musl) + sqlite3 (2.9.1-arm64-darwin) + sqlite3 (2.9.1-x86_64-darwin) + sqlite3 (2.9.1-x86_64-linux-gnu) + sqlite3 (2.9.1-x86_64-linux-musl) + stringio (3.2.0) thor (1.5.0) timeout (0.6.0) + tsort (0.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.2.0) + uri (1.1.1) + useragent (0.16.11) webmock (3.26.1) addressable (>= 2.8.0) crack (>= 0.3.2) @@ -338,7 +402,14 @@ GEM zeitwerk (2.7.5) PLATFORMS - x86_64-linux + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin + x86_64-darwin + x86_64-linux-gnu + x86_64-linux-musl DEPENDENCIES capybara (~> 3.40) @@ -347,18 +418,18 @@ DEPENDENCIES pry-byebug (~> 3.10) rack rack-test (~> 2.1) - rails (~> 6.1.7) + rails (~> 8.0) rake (~> 13.0) rspec (~> 3.13) - rubocop (~> 1.22) + rubocop (~> 1.60) rubocop-rake (~> 0.7) rubocop-rspec (~> 2.31) simplecov (~> 0.22) - sqlite3 (~> 1.4) + sqlite3 (~> 2.0) webmock (~> 3.23) webrick yard yookassa! BUNDLED WITH - 2.6.7 + 4.0.7 From b592cff0790985a03e3265eaab53f059373ea1bc Mon Sep 17 00:00:00 2001 From: Gleb Tv Date: Fri, 6 Mar 2026 17:46:32 +0300 Subject: [PATCH 3/5] Fix rubocop issues and browser test CHROME_PATH --- .github/workflows/main.yml | 3 +++ .rubocop.yml | 2 +- Gemfile | 4 ++-- app/controllers/yookassa/webhooks_controller.rb | 6 +++++- config/routes.rb | 2 ++ lib/yookassa/client.rb | 2 +- lib/yookassa/deals.rb | 8 ++++---- lib/yookassa/entity/amount.rb | 2 +- lib/yookassa/entity/authorization_details.rb | 2 +- lib/yookassa/entity/cancellation_details.rb | 2 +- lib/yookassa/entity/card.rb | 2 +- lib/yookassa/entity/collection.rb | 12 ++++++------ lib/yookassa/entity/confirmation.rb | 2 +- lib/yookassa/entity/customer.rb | 2 +- lib/yookassa/entity/deal.rb | 4 ++-- lib/yookassa/entity/error.rb | 2 +- lib/yookassa/entity/payment.rb | 16 ++++++++-------- lib/yookassa/entity/payment_methods.rb | 4 ++-- lib/yookassa/entity/payment_receipt.rb | 6 +++--- lib/yookassa/entity/payout.rb | 6 +++--- lib/yookassa/entity/payout_destinations.rb | 4 ++-- lib/yookassa/entity/product.rb | 6 +++--- lib/yookassa/entity/receipt.rb | 8 ++++---- lib/yookassa/entity/recipient.rb | 2 +- lib/yookassa/entity/refund.rb | 6 +++--- lib/yookassa/entity/settlement.rb | 4 ++-- lib/yookassa/entity/source.rb | 4 ++-- lib/yookassa/entity/store_info.rb | 2 +- lib/yookassa/entity/supplier.rb | 2 +- lib/yookassa/entity/transfer.rb | 4 ++-- lib/yookassa/entity/webhook.rb | 2 +- lib/yookassa/payments.rb | 12 ++++++------ lib/yookassa/payouts.rb | 8 ++++---- lib/yookassa/receipts.rb | 8 ++++---- lib/yookassa/refunds.rb | 8 ++++---- lib/yookassa/stores.rb | 4 ++-- lib/yookassa/webhooks.rb | 10 +++++----- spec/support/rails_test_app.rb | 2 +- 38 files changed, 97 insertions(+), 88 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7352025..295ffbb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -30,6 +30,9 @@ jobs: browser-tests: runs-on: ubuntu-latest + env: + CHROME_PATH: /usr/bin/google-chrome + steps: - uses: actions/checkout@v4 - name: Set up Ruby diff --git a/.rubocop.yml b/.rubocop.yml index 3a733b3..0bbacaf 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -17,4 +17,4 @@ Layout/LineLength: Max: 150 Metrics/BlockLength: - IgnoredMethods: ['describe', 'context', 'shared_examples'] + AllowedMethods: ['describe', 'context', 'shared_examples'] diff --git a/Gemfile b/Gemfile index b766968..5387f5e 100644 --- a/Gemfile +++ b/Gemfile @@ -18,14 +18,14 @@ group :development, :test do gem "cuprite", "~> 0.15" gem "pry" gem "pry-byebug", "~> 3.10" - gem "rake", "~> 13.0" gem "rack-test", "~> 2.1" gem "rails", "~> 8.0" + gem "rake", "~> 13.0" gem "rspec", "~> 3.13" - gem "sqlite3", "~> 2.0" gem "rubocop", "~> 1.60" gem "rubocop-rake", "~> 0.7" gem "rubocop-rspec", "~> 2.31" gem "simplecov", "~> 0.22" + gem "sqlite3", "~> 2.0" gem "webmock", "~> 3.23" end diff --git a/app/controllers/yookassa/webhooks_controller.rb b/app/controllers/yookassa/webhooks_controller.rb index 29a0a78..6d5e493 100644 --- a/app/controllers/yookassa/webhooks_controller.rb +++ b/app/controllers/yookassa/webhooks_controller.rb @@ -54,7 +54,7 @@ module Yookassa end def payload_matches_api_object?(payload) - object = payload["object"] || payload.dig("data", "object") + object = extract_object(payload) return false unless object.is_a?(Hash) object_id = object["id"].to_s @@ -69,6 +69,10 @@ module Yookassa false end + def extract_object(payload) + payload["object"] || payload.dig("data", "object") + end + def fetch_object_from_api(payload, object_id) event_name = payload["event"].to_s event_name = payload["type"].to_s if event_name.empty? diff --git a/config/routes.rb b/config/routes.rb index 0ef3391..5d9d15d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + Yookassa::Engine.routes.draw do post "/webhooks/:token", to: "webhooks#create" end diff --git a/lib/yookassa/client.rb b/lib/yookassa/client.rb index 1522405..7fe0f25 100644 --- a/lib/yookassa/client.rb +++ b/lib/yookassa/client.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require "http" -require_relative "./entity/error" +require_relative "entity/error" module Yookassa class Client diff --git a/lib/yookassa/deals.rb b/lib/yookassa/deals.rb index e296da2..b328ca6 100644 --- a/lib/yookassa/deals.rb +++ b/lib/yookassa/deals.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -require_relative "./client" -require_relative "./entity/deal" -require_relative "./entity/collection" +require_relative "client" +require_relative "entity/deal" +require_relative "entity/collection" module Yookassa class Deals < Client @@ -13,7 +13,7 @@ module Yookassa def create(payload:, idempotency_key: SecureRandom.hex(10)) data = post("deals", payload: payload, idempotency_key: idempotency_key) - Entity::Deal.new(**data.merge(idempotency_key: idempotency_key)) + Entity::Deal.new(**data, idempotency_key: idempotency_key) end def list diff --git a/lib/yookassa/entity/amount.rb b/lib/yookassa/entity/amount.rb index 2db9abf..ed42bb3 100644 --- a/lib/yookassa/entity/amount.rb +++ b/lib/yookassa/entity/amount.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative "./types" +require_relative "types" module Yookassa module Entity diff --git a/lib/yookassa/entity/authorization_details.rb b/lib/yookassa/entity/authorization_details.rb index 59d23d0..323544e 100644 --- a/lib/yookassa/entity/authorization_details.rb +++ b/lib/yookassa/entity/authorization_details.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative "./types" +require_relative "types" module Yookassa module Entity diff --git a/lib/yookassa/entity/cancellation_details.rb b/lib/yookassa/entity/cancellation_details.rb index 55da223..0e6e466 100644 --- a/lib/yookassa/entity/cancellation_details.rb +++ b/lib/yookassa/entity/cancellation_details.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative "./types" +require_relative "types" module Yookassa module Entity diff --git a/lib/yookassa/entity/card.rb b/lib/yookassa/entity/card.rb index 30c2561..bef49c2 100644 --- a/lib/yookassa/entity/card.rb +++ b/lib/yookassa/entity/card.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative "./types" +require_relative "types" module Yookassa module Entity diff --git a/lib/yookassa/entity/collection.rb b/lib/yookassa/entity/collection.rb index a3e20f8..3425c50 100644 --- a/lib/yookassa/entity/collection.rb +++ b/lib/yookassa/entity/collection.rb @@ -1,11 +1,11 @@ # frozen_string_literal: true -require_relative "./types" -require_relative "./payment" -require_relative "./receipt" -require_relative "./refund" -require_relative "./webhook" -require_relative "./deal" +require_relative "types" +require_relative "payment" +require_relative "receipt" +require_relative "refund" +require_relative "webhook" +require_relative "deal" module Yookassa module Entity diff --git a/lib/yookassa/entity/confirmation.rb b/lib/yookassa/entity/confirmation.rb index 4010a68..aaefc3f 100644 --- a/lib/yookassa/entity/confirmation.rb +++ b/lib/yookassa/entity/confirmation.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative "./types" +require_relative "types" module Yookassa module Entity diff --git a/lib/yookassa/entity/customer.rb b/lib/yookassa/entity/customer.rb index 7b68d81..8febf79 100644 --- a/lib/yookassa/entity/customer.rb +++ b/lib/yookassa/entity/customer.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative "./types" +require_relative "types" module Yookassa module Entity diff --git a/lib/yookassa/entity/deal.rb b/lib/yookassa/entity/deal.rb index a68adb0..cd3fd2a 100644 --- a/lib/yookassa/entity/deal.rb +++ b/lib/yookassa/entity/deal.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -require_relative "./types" -require_relative "./amount" +require_relative "types" +require_relative "amount" module Yookassa module Entity diff --git a/lib/yookassa/entity/error.rb b/lib/yookassa/entity/error.rb index 5e99f65..31ff06a 100644 --- a/lib/yookassa/entity/error.rb +++ b/lib/yookassa/entity/error.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative "./types" +require_relative "types" module Yookassa module Entity diff --git a/lib/yookassa/entity/payment.rb b/lib/yookassa/entity/payment.rb index fe60828..e7e1b6a 100644 --- a/lib/yookassa/entity/payment.rb +++ b/lib/yookassa/entity/payment.rb @@ -1,13 +1,13 @@ # frozen_string_literal: true -require_relative "./types" -require_relative "./amount" -require_relative "./payment_methods" -require_relative "./confirmation" -require_relative "./recipient" -require_relative "./cancellation_details" -require_relative "./authorization_details" -require_relative "./transfer" +require_relative "types" +require_relative "amount" +require_relative "payment_methods" +require_relative "confirmation" +require_relative "recipient" +require_relative "cancellation_details" +require_relative "authorization_details" +require_relative "transfer" module Yookassa module Entity diff --git a/lib/yookassa/entity/payment_methods.rb b/lib/yookassa/entity/payment_methods.rb index 4ee1c08..c309d3d 100644 --- a/lib/yookassa/entity/payment_methods.rb +++ b/lib/yookassa/entity/payment_methods.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -require_relative "./types" -require_relative "./card" +require_relative "types" +require_relative "card" module Yookassa module Entity diff --git a/lib/yookassa/entity/payment_receipt.rb b/lib/yookassa/entity/payment_receipt.rb index 59fa099..4b92d42 100644 --- a/lib/yookassa/entity/payment_receipt.rb +++ b/lib/yookassa/entity/payment_receipt.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -require_relative "./types" -require_relative "./customer" -require_relative "./product" +require_relative "types" +require_relative "customer" +require_relative "product" module Yookassa module Entity diff --git a/lib/yookassa/entity/payout.rb b/lib/yookassa/entity/payout.rb index f2e6ed1..1db0cd6 100644 --- a/lib/yookassa/entity/payout.rb +++ b/lib/yookassa/entity/payout.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -require_relative "./types" -require_relative "./amount" -require_relative "./payout_destinations" +require_relative "types" +require_relative "amount" +require_relative "payout_destinations" module Yookassa module Entity diff --git a/lib/yookassa/entity/payout_destinations.rb b/lib/yookassa/entity/payout_destinations.rb index ea9a829..d170573 100644 --- a/lib/yookassa/entity/payout_destinations.rb +++ b/lib/yookassa/entity/payout_destinations.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -require_relative "./types" -require_relative "./card" +require_relative "types" +require_relative "card" module Yookassa module Entity diff --git a/lib/yookassa/entity/product.rb b/lib/yookassa/entity/product.rb index 8191dfd..6c55923 100644 --- a/lib/yookassa/entity/product.rb +++ b/lib/yookassa/entity/product.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -require_relative "./types" -require_relative "./amount" -require_relative "./supplier" +require_relative "types" +require_relative "amount" +require_relative "supplier" module Yookassa module Entity diff --git a/lib/yookassa/entity/receipt.rb b/lib/yookassa/entity/receipt.rb index 883faf2..eeda0b5 100644 --- a/lib/yookassa/entity/receipt.rb +++ b/lib/yookassa/entity/receipt.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true -require_relative "./types" -require_relative "./customer" -require_relative "./product" -require_relative "./settlement" +require_relative "types" +require_relative "customer" +require_relative "product" +require_relative "settlement" module Yookassa module Entity diff --git a/lib/yookassa/entity/recipient.rb b/lib/yookassa/entity/recipient.rb index b8bdc39..4d303d3 100644 --- a/lib/yookassa/entity/recipient.rb +++ b/lib/yookassa/entity/recipient.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative "./types" +require_relative "types" module Yookassa module Entity diff --git a/lib/yookassa/entity/refund.rb b/lib/yookassa/entity/refund.rb index 379ed81..62deed1 100644 --- a/lib/yookassa/entity/refund.rb +++ b/lib/yookassa/entity/refund.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -require_relative "./types" -require_relative "./amount" -require_relative "./source" +require_relative "types" +require_relative "amount" +require_relative "source" module Yookassa module Entity diff --git a/lib/yookassa/entity/settlement.rb b/lib/yookassa/entity/settlement.rb index a2f6f43..1f2660c 100644 --- a/lib/yookassa/entity/settlement.rb +++ b/lib/yookassa/entity/settlement.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -require_relative "./types" -require_relative "./amount" +require_relative "types" +require_relative "amount" module Yookassa module Entity diff --git a/lib/yookassa/entity/source.rb b/lib/yookassa/entity/source.rb index 99c36a4..4fd32c9 100644 --- a/lib/yookassa/entity/source.rb +++ b/lib/yookassa/entity/source.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -require_relative "./types" -require_relative "./amount" +require_relative "types" +require_relative "amount" module Yookassa module Entity diff --git a/lib/yookassa/entity/store_info.rb b/lib/yookassa/entity/store_info.rb index 1638b5d..740be55 100644 --- a/lib/yookassa/entity/store_info.rb +++ b/lib/yookassa/entity/store_info.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative "./types" +require_relative "types" module Yookassa module Entity diff --git a/lib/yookassa/entity/supplier.rb b/lib/yookassa/entity/supplier.rb index 32d982a..d973cbf 100644 --- a/lib/yookassa/entity/supplier.rb +++ b/lib/yookassa/entity/supplier.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative "./types" +require_relative "types" module Yookassa module Entity diff --git a/lib/yookassa/entity/transfer.rb b/lib/yookassa/entity/transfer.rb index 48c9d17..ada4b72 100644 --- a/lib/yookassa/entity/transfer.rb +++ b/lib/yookassa/entity/transfer.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -require_relative "./types" -require_relative "./amount" +require_relative "types" +require_relative "amount" module Yookassa module Entity diff --git a/lib/yookassa/entity/webhook.rb b/lib/yookassa/entity/webhook.rb index b1620aa..e0b79b7 100644 --- a/lib/yookassa/entity/webhook.rb +++ b/lib/yookassa/entity/webhook.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative "./types" +require_relative "types" module Yookassa module Entity diff --git a/lib/yookassa/payments.rb b/lib/yookassa/payments.rb index 987817a..d351236 100644 --- a/lib/yookassa/payments.rb +++ b/lib/yookassa/payments.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -require_relative "./client" -require_relative "./entity/payment" -require_relative "./entity/collection" +require_relative "client" +require_relative "entity/payment" +require_relative "entity/collection" module Yookassa class Payments < Client @@ -13,17 +13,17 @@ module Yookassa def create(payment:, idempotency_key: SecureRandom.hex(10)) data = post("payments", payload: payment, idempotency_key: idempotency_key) - Entity::Payment.new(**data.merge(idempotency_key: idempotency_key)) + Entity::Payment.new(**data, idempotency_key: idempotency_key) end def capture(payment_id:, idempotency_key: SecureRandom.hex(10)) data = post("payments/#{payment_id}/capture", idempotency_key: idempotency_key) - Entity::Payment.new(**data.merge(idempotency_key: idempotency_key)) + Entity::Payment.new(**data, idempotency_key: idempotency_key) end def cancel(payment_id:, idempotency_key: SecureRandom.hex(10)) data = post("payments/#{payment_id}/cancel", idempotency_key: idempotency_key) - Entity::Payment.new(**data.merge(idempotency_key: idempotency_key)) + Entity::Payment.new(**data, idempotency_key: idempotency_key) end def list(filters: {}) diff --git a/lib/yookassa/payouts.rb b/lib/yookassa/payouts.rb index 8e5b3ed..6bfb64a 100644 --- a/lib/yookassa/payouts.rb +++ b/lib/yookassa/payouts.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -require_relative "./client" -require_relative "./entity/payout" -require_relative "./entity/collection" +require_relative "client" +require_relative "entity/payout" +require_relative "entity/collection" module Yookassa class Payouts < Client @@ -13,7 +13,7 @@ module Yookassa def create(payload:, idempotency_key: SecureRandom.hex(10)) data = post("payouts", payload: payload, idempotency_key: idempotency_key) - Entity::Payout.new(**data.merge(idempotency_key: idempotency_key)) + Entity::Payout.new(**data, idempotency_key: idempotency_key) end end end diff --git a/lib/yookassa/receipts.rb b/lib/yookassa/receipts.rb index b423911..6f395e1 100644 --- a/lib/yookassa/receipts.rb +++ b/lib/yookassa/receipts.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -require_relative "./client" -require_relative "./entity/receipt" -require_relative "./entity/collection" +require_relative "client" +require_relative "entity/receipt" +require_relative "entity/collection" module Yookassa class Receipts < Client @@ -13,7 +13,7 @@ module Yookassa def create(payload:, idempotency_key: SecureRandom.hex(10)) data = post("receipts", payload: payload, idempotency_key: idempotency_key) - Entity::Receipt.new(**data.merge(idempotency_key: idempotency_key)) + Entity::Receipt.new(**data, idempotency_key: idempotency_key) end def list(filters: {}) diff --git a/lib/yookassa/refunds.rb b/lib/yookassa/refunds.rb index 6fd18ca..56bdbd4 100644 --- a/lib/yookassa/refunds.rb +++ b/lib/yookassa/refunds.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -require_relative "./client" -require_relative "./entity/refund" -require_relative "./entity/collection" +require_relative "client" +require_relative "entity/refund" +require_relative "entity/collection" module Yookassa class Refunds < Client @@ -13,7 +13,7 @@ module Yookassa def create(payload:, idempotency_key: SecureRandom.hex(10)) data = post("refunds", payload: payload, idempotency_key: idempotency_key) - Entity::Refund.new(**data.merge(idempotency_key: idempotency_key)) + Entity::Refund.new(**data, idempotency_key: idempotency_key) end def list(filters: {}) diff --git a/lib/yookassa/stores.rb b/lib/yookassa/stores.rb index 9b9791a..07f1acb 100644 --- a/lib/yookassa/stores.rb +++ b/lib/yookassa/stores.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -require_relative "./client" -require_relative "./entity/store_info" +require_relative "client" +require_relative "entity/store_info" module Yookassa class Stores < Client diff --git a/lib/yookassa/webhooks.rb b/lib/yookassa/webhooks.rb index 0b8b6f7..fb79fae 100644 --- a/lib/yookassa/webhooks.rb +++ b/lib/yookassa/webhooks.rb @@ -1,14 +1,14 @@ # frozen_string_literal: true -require_relative "./client" -require_relative "./entity/webhook" -require_relative "./entity/collection" +require_relative "client" +require_relative "entity/webhook" +require_relative "entity/collection" module Yookassa class Webhooks < Client def create(payload:, idempotency_key: SecureRandom.hex(10)) data = post("webhooks", payload: payload, idempotency_key: idempotency_key) - Entity::Webhook.new(**data.merge(idempotency_key: idempotency_key)) + Entity::Webhook.new(**data, idempotency_key: idempotency_key) end def list @@ -16,7 +16,7 @@ module Yookassa Entity::WebhookCollection.new(**data) end - def delete(webhook_id:) + def delete(webhook_id:) # rubocop:disable Naming/PredicateMethod delete("webhooks/#{webhook_id}") true end diff --git a/spec/support/rails_test_app.rb b/spec/support/rails_test_app.rb index f170a67..0ecf3ca 100644 --- a/spec/support/rails_test_app.rb +++ b/spec/support/rails_test_app.rb @@ -17,7 +17,7 @@ if defined?(Rails) require File.expand_path("../../app/controllers/yookassa/webhooks_controller", __dir__) class YookassaSpecBrowserController < ActionController::Base - def index + def index # rubocop:disable Metrics/MethodLength render inline: <<~HTML From 5b002870b77c6013a0af53916665b0091731e3ce Mon Sep 17 00:00:00 2001 From: Gleb Tv Date: Fri, 6 Mar 2026 17:50:54 +0300 Subject: [PATCH 4/5] Fix browser tests: add CI flags and use setup-chrome output for CHROME_PATH --- .github/workflows/main.yml | 6 +++--- spec/support/rails_test_app.rb | 30 +++++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 295ffbb..0c056d7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -30,9 +30,6 @@ jobs: browser-tests: runs-on: ubuntu-latest - env: - CHROME_PATH: /usr/bin/google-chrome - steps: - uses: actions/checkout@v4 - name: Set up Ruby @@ -42,5 +39,8 @@ jobs: bundler-cache: true - name: Set up Chrome uses: browser-actions/setup-chrome@v1 + id: setup-chrome - name: Browser specs (Cuprite) run: bundle exec rspec --tag browser + env: + CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }} diff --git a/spec/support/rails_test_app.rb b/spec/support/rails_test_app.rb index 0ecf3ca..43d93a9 100644 --- a/spec/support/rails_test_app.rb +++ b/spec/support/rails_test_app.rb @@ -78,7 +78,35 @@ if defined?(Rails) Capybara.server = :webrick Capybara.default_max_wait_time = 5 Capybara.register_driver(:cuprite) do |app| - Capybara::Cuprite::Driver.new(app, headless: true) + browser_options = { + "no-sandbox" => nil, + "disable-dev-shm-usage" => nil, + "disable-gpu" => nil, + "disable-software-rasterizer" => nil, + "disable-setuid-sandbox" => nil + } + + if ENV["CI"] + browser_options.merge!( + "disable-background-timer-throttling" => nil, + "disable-backgrounding-occluded-windows" => nil, + "disable-renderer-backgrounding" => nil, + "disable-ipc-flooding-protection" => nil + ) + end + + options = { + window_size: [1280, 1024], + browser_options: browser_options, + timeout: ENV.fetch("FERRUM_TIMEOUT", 30).to_i, + headless: true, + process_timeout: ENV.fetch("FERRUM_PROCESS_TIMEOUT", ENV["CI"] ? 120 : 30).to_i + } + + chrome_path = ENV.fetch("CHROME_PATH", nil) + options[:browser_path] = chrome_path if chrome_path + + Capybara::Cuprite::Driver.new(app, **options) end RSpec.configure do |config| From 185a47064121822f1ba323388231ca411932bac9 Mon Sep 17 00:00:00 2001 From: Gleb Tv Date: Wed, 25 Mar 2026 18:27:58 +0300 Subject: [PATCH 5/5] fix webhook auth diagnostics and token length handling --- .../yookassa/webhooks_controller.rb | 53 +++++++++++++++---- spec/yookassa/webhooks_controller_spec.rb | 20 +++++++ 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/app/controllers/yookassa/webhooks_controller.rb b/app/controllers/yookassa/webhooks_controller.rb index 6d5e493..451ff01 100644 --- a/app/controllers/yookassa/webhooks_controller.rb +++ b/app/controllers/yookassa/webhooks_controller.rb @@ -24,21 +24,42 @@ module Yookassa end def authentic_webhook?(payload) - token_valid? && source_ip_allowed? && payload_matches_api_object?(payload) + token_ok = token_valid? + ip_ok = source_ip_allowed? + api_ok = payload_matches_api_object?(payload) + + unless token_ok && ip_ok && api_ok + Rails.logger.info("[Yookassa Webhook] Auth failed: token=#{token_ok}, ip=#{ip_ok}, api=#{api_ok}, remote_ip=#{request.remote_ip}") + end + + token_ok && ip_ok && api_ok end def token_valid? token = params[:token].to_s configured_token = Yookassa.config.webhook_token.to_s - return false if token.empty? || configured_token.empty? + if token.empty? || configured_token.empty? + Rails.logger.info("[Yookassa Webhook] Token validation failed: param_token=#{token.empty? ? "empty" : "present"}, configured_token=#{configured_token.empty? ? "empty" : "present"}") + return false + end - ActiveSupport::SecurityUtils.secure_compare(token, configured_token) + if token.bytesize != configured_token.bytesize + Rails.logger.info("[Yookassa Webhook] Token comparison: false (length mismatch)") + return false + end + + result = ActiveSupport::SecurityUtils.secure_compare(token, configured_token) + Rails.logger.info("[Yookassa Webhook] Token comparison: #{result}") + result end def source_ip_allowed? source_ip = request.remote_ip - allowed_cidrs.any? { |cidr| IPAddr.new(cidr).include?(source_ip) } - rescue IPAddr::InvalidAddressError + allowed = allowed_cidrs.any? { |cidr| IPAddr.new(cidr).include?(source_ip) } + Rails.logger.info("[Yookassa Webhook] IP check: remote_ip=#{source_ip}, allowed=#{allowed}, cidrs=#{allowed_cidrs}") + allowed + rescue IPAddr::InvalidAddressError => e + Rails.logger.info("[Yookassa Webhook] IP check failed: #{e.message}") false end @@ -55,17 +76,29 @@ module Yookassa def payload_matches_api_object?(payload) object = extract_object(payload) - return false unless object.is_a?(Hash) + unless object.is_a?(Hash) + Rails.logger.info("[Yookassa Webhook] API object check failed: object is not a Hash") + return false + end object_id = object["id"].to_s object_status = object["status"].to_s - return false if object_id.empty? || object_status.empty? + if object_id.empty? || object_status.empty? + Rails.logger.info("[Yookassa Webhook] API object check failed: object_id=#{object_id}, object_status=#{object_status}") + return false + end fetched_object = fetch_object_from_api(payload, object_id) - return false if fetched_object.nil? + if fetched_object.nil? + Rails.logger.info("[Yookassa Webhook] API object check failed: could not fetch object #{object_id}") + return false + end - fetched_object.id == object_id && fetched_object.status == object_status - rescue StandardError + result = fetched_object.id == object_id && fetched_object.status == object_status + Rails.logger.info("[Yookassa Webhook] API object check: fetched_id=#{fetched_object.id}, fetched_status=#{fetched_object.status}, matches=#{result}") + result + rescue StandardError => e + Rails.logger.info("[Yookassa Webhook] API object check failed: #{e.message}") false end diff --git a/spec/yookassa/webhooks_controller_spec.rb b/spec/yookassa/webhooks_controller_spec.rb index 813d479..83ca7c1 100644 --- a/spec/yookassa/webhooks_controller_spec.rb +++ b/spec/yookassa/webhooks_controller_spec.rb @@ -36,6 +36,11 @@ RSpec.describe Yookassa::WebhooksController do end it "rejects webhook when token is invalid" do + payments_client = instance_double(Yookassa::Payments) + allow(Yookassa).to receive(:payments).and_return(payments_client) + allow(payments_client).to receive(:find).with(payment_id: "payment-1") + .and_return(instance_double(Yookassa::Entity::Payment, id: "payment-1", status: "succeeded")) + post "/yookassa/webhooks/wrong-token", payload, headers expect(last_response.status).to eq(401) @@ -64,6 +69,21 @@ RSpec.describe Yookassa::WebhooksController do end end + describe "logging", :rails do + it "logs auth result when webhook authentication fails" do + controller = described_class.new + + allow(controller).to receive(:token_valid?).and_return(true) + allow(controller).to receive(:source_ip_allowed?).and_return(false) + allow(controller).to receive(:payload_matches_api_object?).and_return(true) + allow(controller).to receive(:request).and_return(instance_double(ActionDispatch::Request, remote_ip: "192.168.0.10")) + + expect(Rails.logger).to receive(:info).with(include("Auth failed: token=true, ip=false, api=true")) + + expect(controller.send(:authentic_webhook?, {})).to eq(false) + end + end + describe "IP source check", :rails do it "uses request.remote_ip" do controller = described_class.new