From 0b5dc8862d31b102454615bd714c61641d89f92e Mon Sep 17 00:00:00 2001 From: xBlaz3kx Date: Tue, 23 Jun 2026 23:29:07 +0200 Subject: [PATCH] fix(wise): fix INTERBALANCE routing and encrypt pending token in session --- app/controllers/wise_items_controller.rb | 25 ++++- app/models/wise_item/importer.rb | 16 ++- app/views/wise_items/select_profiles.html.erb | 2 + .../controllers/wise_items_controller_test.rb | 98 +++++++++++++++++++ 4 files changed, 127 insertions(+), 14 deletions(-) create mode 100644 test/controllers/wise_items_controller_test.rb diff --git a/app/controllers/wise_items_controller.rb b/app/controllers/wise_items_controller.rb index 3fb7f5035..a11e96353 100644 --- a/app/controllers/wise_items_controller.rb +++ b/app/controllers/wise_items_controller.rb @@ -35,10 +35,12 @@ class WiseItemsController < ApplicationController render :new, status: :unprocessable_entity and return end - session[:wise_pending_token] = token session[:wise_pending_profiles] = profiles + @pending_profiles = profiles + @existing_profile_ids = Current.family.wise_items.pluck(:profile_id).map(&:to_s).to_set + @encrypted_pending_token = encrypt_pending_token(token) - redirect_to select_profiles_wise_items_path + render :select_profiles rescue Provider::Wise::WiseError => e @wise_item = Current.family.wise_items.build error_key = e.error_type == :unauthorized ? ".invalid_token" : ".connection_failed" @@ -59,7 +61,7 @@ class WiseItemsController < ApplicationController # Step 3: Create one WiseItem per selected profile. def link_profiles - token = session[:wise_pending_token] # pipelock:ignore + token = decrypt_pending_token(params[:encrypted_pending_token]) profiles = session[:wise_pending_profiles] if token.blank? || profiles.blank? @@ -89,7 +91,6 @@ class WiseItemsController < ApplicationController created += 1 end - session.delete(:wise_pending_token) session.delete(:wise_pending_profiles) if created.zero? @@ -289,6 +290,22 @@ class WiseItemsController < ApplicationController render turbo_stream: streams, status: status end + def encrypt_pending_token(token) + build_token_encryptor.encrypt_and_sign(token, expires_in: 15.minutes) + end + + def decrypt_pending_token(encrypted) + return nil if encrypted.blank? + build_token_encryptor.decrypt_and_verify(encrypted) + rescue ActiveSupport::MessageEncryptor::InvalidMessage, ArgumentError + nil + end + + def build_token_encryptor + key = Rails.application.key_generator.generate_key("wise_pending_token", 32) + ActiveSupport::MessageEncryptor.new(key) + end + def safe_return_to_path return nil if params[:return_to].blank? diff --git a/app/models/wise_item/importer.rb b/app/models/wise_item/importer.rb index f185c57e5..09ef7d954 100644 --- a/app/models/wise_item/importer.rb +++ b/app/models/wise_item/importer.rb @@ -215,20 +215,20 @@ class WiseItem::Importer { transactions_imported: transactions_imported, transactions_failed: transactions_failed } end - # Routes an activity to the given WiseAccount using structured resource metadata. - # JAR: receives INTERBALANCE where resource.id matches the JAR's balance_id, + # Routes an activity to the given WiseAccount. + # JAR: receives INTERBALANCE where the activity title's tag matches the JAR name, # plus BALANCE_CASHBACK and BALANCE_ASSET_FEE. - # STANDARD: receives INTERBALANCE where resource.id references any known JAR balance. + # STANDARD: receives all INTERBALANCE activities (outflow side of JAR transfers). def activity_for_account?(activity, wise_account) type = activity["type"] - resource_id = activity.dig("resource", "id").to_s case type when "INTERBALANCE" if wise_account.jar? - resource_id == wise_account.balance_id.to_s + jar_name_in_title = activity["title"].to_s.scan(/([^<]+)<\/strong>/).flatten.last.to_s.strip + jar_name_in_title.present? && jar_name_in_title.casecmp?(wise_account.name.to_s.strip) else - jar_balance_ids.include?(resource_id) + true end when "BALANCE_ASSET_FEE", "BALANCE_CASHBACK" wise_account.jar? @@ -278,10 +278,6 @@ class WiseItem::Importer .first end - def jar_balance_ids - @jar_balance_ids ||= wise_item.wise_accounts.select(&:jar?).map { |wa| wa.balance_id.to_s }.to_set - end - def with_rate_limit_retry(max_retries: 3) retries = 0 begin diff --git a/app/views/wise_items/select_profiles.html.erb b/app/views/wise_items/select_profiles.html.erb index bda51f60f..440ea9d82 100644 --- a/app/views/wise_items/select_profiles.html.erb +++ b/app/views/wise_items/select_profiles.html.erb @@ -15,6 +15,8 @@ data: { turbo_frame: "_top" }, class: "space-y-6" do |form| %> + <%= hidden_field_tag :encrypted_pending_token, @encrypted_pending_token %> +

<%= t(".description") %>

diff --git a/test/controllers/wise_items_controller_test.rb b/test/controllers/wise_items_controller_test.rb new file mode 100644 index 000000000..caf9989c1 --- /dev/null +++ b/test/controllers/wise_items_controller_test.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "test_helper" + +class WiseItemsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in users(:family_admin) + SyncJob.stubs(:perform_later) + @family = families(:dylan_family) + @wise_item = wise_items(:one) + + @valid_profiles = [ + { "id" => "99999999", "type" => "personal", "details" => { "firstName" => "Jane", "lastName" => "Doe" } } + ] + end + + # create renders select_profiles directly — token must NOT appear in the session + + test "create renders select_profiles and keeps token out of session" do + Provider::Wise.any_instance.stubs(:get_profiles).returns(@valid_profiles) + + post wise_items_url, params: { wise_item: { token: "live_token_abc" } } + + assert_response :success + assert_nil session[:wise_pending_token], "raw API token must not be stored in the session" + assert_select "input[name='encrypted_pending_token']" + end + + test "create stores an encrypted token that round-trips to the original value" do + Provider::Wise.any_instance.stubs(:get_profiles).returns(@valid_profiles) + + post wise_items_url, params: { wise_item: { token: "live_token_abc" } } + + encrypted = css_select("input[name='encrypted_pending_token']").first["value"] + assert encrypted.present?, "hidden encrypted_pending_token field must be present" + + key = Rails.application.key_generator.generate_key("wise_pending_token", 32) + decrypted = ActiveSupport::MessageEncryptor.new(key).decrypt_and_verify(encrypted) + assert_equal "live_token_abc", decrypted + end + + test "create re-renders new when token is blank" do + post wise_items_url, params: { wise_item: { token: "" } } + assert_response :unprocessable_entity + assert_nil session[:wise_pending_token] + end + + test "create re-renders new when Wise API rejects the token" do + Provider::Wise.any_instance.stubs(:get_profiles).raises( + Provider::Wise::WiseError.new("unauthorized", :unauthorized) + ) + + post wise_items_url, params: { wise_item: { token: "bad_token" } } + assert_response :unprocessable_entity + assert_nil session[:wise_pending_token] + end + + # link_profiles uses the encrypted hidden field, not the session + + test "link_profiles creates WiseItems using the encrypted token" do + Provider::Wise.any_instance.stubs(:get_profiles).returns(@valid_profiles) + post wise_items_url, params: { wise_item: { token: "live_token_abc" } } + + encrypted = css_select("input[name='encrypted_pending_token']").first["value"] + + assert_difference "WiseItem.count", 1 do + post link_profiles_wise_items_url, params: { + encrypted_pending_token: encrypted, + profile_ids: [ "99999999" ] + } + end + + assert_redirected_to settings_providers_path + assert_equal "live_token_abc", @family.wise_items.find_by!(profile_id: "99999999").token + assert_nil session[:wise_pending_profiles] + end + + test "link_profiles redirects to new when encrypted token is missing" do + post link_profiles_wise_items_url, params: { + encrypted_pending_token: "", + profile_ids: [ "99999999" ] + } + + assert_redirected_to new_wise_item_path + end + + test "link_profiles redirects to new when encrypted token is tampered" do + Provider::Wise.any_instance.stubs(:get_profiles).returns(@valid_profiles) + post wise_items_url, params: { wise_item: { token: "live_token_abc" } } + + post link_profiles_wise_items_url, params: { + encrypted_pending_token: "tampered_garbage_value", + profile_ids: [ "99999999" ] + } + + assert_redirected_to new_wise_item_path + end +end