fix(wise): fix INTERBALANCE routing and encrypt pending token in session

This commit is contained in:
xBlaz3kx
2026-06-23 23:29:07 +02:00
parent b68ceae70f
commit 0b5dc8862d
4 changed files with 127 additions and 14 deletions

View File

@@ -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?

View File

@@ -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 <strong> 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>([^<]+)<\/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

View File

@@ -15,6 +15,8 @@
data: { turbo_frame: "_top" },
class: "space-y-6" do |form| %>
<%= hidden_field_tag :encrypted_pending_token, @encrypted_pending_token %>
<p class="text-sm text-secondary"><%= t(".description") %></p>
<div class="space-y-3">

View File

@@ -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