Merge remote-tracking branch 'upstream/main' into sso-upgrades

# Conflicts:
#	app/views/simplefin_items/_simplefin_item.html.erb
#	db/schema.rb
This commit is contained in:
Josh Waldrep
2026-01-10 11:57:23 -05:00
301 changed files with 20707 additions and 967 deletions

View File

@@ -0,0 +1,206 @@
require "test_helper"
class Api::V1::ImportsControllerTest < ActionDispatch::IntegrationTest
setup do
@family = families(:dylan_family)
@user = users(:family_admin)
@account = accounts(:depository)
@import = imports(:transaction)
@token = valid_token_for(@user)
end
test "should list imports" do
get api_v1_imports_url, headers: { Authorization: "Bearer #{@token}" }
assert_response :success
json_response = JSON.parse(response.body)
assert_not_empty json_response["data"]
assert_equal @family.imports.count, json_response["meta"]["total_count"]
end
test "should show import" do
get api_v1_import_url(@import), headers: { Authorization: "Bearer #{@token}" }
assert_response :success
json_response = JSON.parse(response.body)
assert_equal @import.id, json_response["data"]["id"]
assert_equal @import.status, json_response["data"]["status"]
end
test "should create import with raw content" do
csv_content = "date,amount,name\n2023-01-01,-10.00,Test Transaction"
assert_difference("Import.count") do
post api_v1_imports_url,
params: {
raw_file_content: csv_content,
date_col_label: "date",
amount_col_label: "amount",
name_col_label: "name",
account_id: @account.id
},
headers: { Authorization: "Bearer #{@token}" }
end
assert_response :created
json_response = JSON.parse(response.body)
assert_equal "pending", json_response["data"]["status"]
created_import = Import.find(json_response["data"]["id"])
assert_equal csv_content, created_import.raw_file_str
end
test "should create import and generate rows when configured" do
csv_content = "date,amount,name\n2023-01-01,-10.00,Test Transaction"
assert_difference([ "Import.count", "Import::Row.count" ], 1) do
post api_v1_imports_url,
params: {
raw_file_content: csv_content,
date_col_label: "date",
amount_col_label: "amount",
name_col_label: "name",
account_id: @account.id
},
headers: { Authorization: "Bearer #{@token}" }
end
assert_response :created
json_response = JSON.parse(response.body)
import = Import.find(json_response["data"]["id"])
assert_equal 1, import.rows_count
assert_equal "Test Transaction", import.rows.first.name
assert_equal "-10.00", import.rows.first.amount # Normalized
end
test "should create import and auto-publish when configured and requested" do
csv_content = "date,amount,name\n2023-01-01,-10.00,Test Transaction"
assert_enqueued_with(job: ImportJob) do
post api_v1_imports_url,
params: {
raw_file_content: csv_content,
date_col_label: "date",
amount_col_label: "amount",
name_col_label: "name",
account_id: @account.id,
date_format: "%Y-%m-%d",
publish: "true"
},
headers: { Authorization: "Bearer #{@token}" }
end
assert_response :created
json_response = JSON.parse(response.body)
assert_equal "importing", json_response["data"]["status"]
end
test "should not create import for account in another family" do
other_family = Family.create!(name: "Other Family", currency: "USD", locale: "en")
other_depository = Depository.create!(subtype: "checking")
other_account = Account.create!(family: other_family, name: "Other Account", currency: "USD", classification: "asset", accountable: other_depository, balance: 0)
csv_content = "date,amount,name\n2023-01-01,-10.00,Test Transaction"
post api_v1_imports_url,
params: {
raw_file_content: csv_content,
account_id: other_account.id
},
headers: { Authorization: "Bearer #{@token}" }
assert_response :unprocessable_entity
json_response = JSON.parse(response.body)
assert_includes json_response["errors"], "Account must belong to your family"
end
test "should reject file upload exceeding max size" do
large_file = Rack::Test::UploadedFile.new(
StringIO.new("x" * (Import::MAX_CSV_SIZE + 1)),
"text/csv",
original_filename: "large.csv"
)
assert_no_difference("Import.count") do
post api_v1_imports_url,
params: { file: large_file },
headers: { Authorization: "Bearer #{@token}" }
end
assert_response :unprocessable_entity
json_response = JSON.parse(response.body)
assert_equal "file_too_large", json_response["error"]
end
test "should reject file upload with invalid mime type" do
invalid_file = Rack::Test::UploadedFile.new(
StringIO.new("not a csv"),
"application/pdf",
original_filename: "document.pdf"
)
assert_no_difference("Import.count") do
post api_v1_imports_url,
params: { file: invalid_file },
headers: { Authorization: "Bearer #{@token}" }
end
assert_response :unprocessable_entity
json_response = JSON.parse(response.body)
assert_equal "invalid_file_type", json_response["error"]
end
test "should reject raw content exceeding max size" do
# Use a small test limit to avoid Rack request size limits
test_limit = 1.kilobyte
large_content = "x" * (test_limit + 1)
original_value = Import::MAX_CSV_SIZE
Import.send(:remove_const, :MAX_CSV_SIZE)
Import.const_set(:MAX_CSV_SIZE, test_limit)
assert_no_difference("Import.count") do
post api_v1_imports_url,
params: { raw_file_content: large_content },
headers: { Authorization: "Bearer #{@token}" }
end
assert_response :unprocessable_entity
json_response = JSON.parse(response.body)
assert_equal "content_too_large", json_response["error"]
ensure
Import.send(:remove_const, :MAX_CSV_SIZE)
Import.const_set(:MAX_CSV_SIZE, original_value)
end
test "should accept file upload with valid csv mime type" do
csv_content = "date,amount,name\n2023-01-01,-10.00,Test Transaction"
valid_file = Rack::Test::UploadedFile.new(
StringIO.new(csv_content),
"text/csv",
original_filename: "transactions.csv"
)
assert_difference("Import.count") do
post api_v1_imports_url,
params: {
file: valid_file,
date_col_label: "date",
amount_col_label: "amount",
name_col_label: "name",
account_id: @account.id
},
headers: { Authorization: "Bearer #{@token}" }
end
assert_response :created
end
private
def valid_token_for(user)
application = Doorkeeper::Application.create!(name: "Test App", redirect_uri: "urn:ietf:wg:oauth:2.0:oob", scopes: "read read_write")
Doorkeeper::AccessToken.create!(application: application, resource_owner_id: user.id, scopes: "read read_write").token
end
end

View File

@@ -84,7 +84,8 @@ class CategoriesControllerTest < ActionDispatch::IntegrationTest
end
test "bootstrap" do
assert_difference "Category.count", 19 do
# 22 default categories minus 2 that already exist in fixtures (Income, Food & Drink)
assert_difference "Category.count", 20 do
post bootstrap_categories_url
end

View File

@@ -0,0 +1,178 @@
require "test_helper"
class CoinstatsItemsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in users(:family_admin)
@family = families(:dylan_family)
@coinstats_item = CoinstatsItem.create!(
family: @family,
name: "Test CoinStats Connection",
api_key: "test_api_key_123"
)
end
# Helper to wrap data in Provider::Response
def success_response(data)
Provider::Response.new(success?: true, data: data, error: nil)
end
def error_response(message)
Provider::Response.new(success?: false, data: nil, error: Provider::Error.new(message))
end
test "should get new" do
get new_coinstats_item_url
assert_response :success
end
test "should create coinstats item with valid api key" do
# Mock the API key validation
Provider::Coinstats.any_instance.expects(:get_blockchains).returns(success_response([])).once
assert_difference("CoinstatsItem.count", 1) do
post coinstats_items_url, params: {
coinstats_item: {
name: "New CoinStats Connection",
api_key: "valid_api_key"
}
}
end
end
test "should not create coinstats item with invalid api key" do
# Mock the API key validation to fail
Provider::Coinstats.any_instance.expects(:get_blockchains)
.returns(error_response("Invalid API key"))
assert_no_difference("CoinstatsItem.count") do
post coinstats_items_url, params: {
coinstats_item: {
name: "New CoinStats Connection",
api_key: "invalid_api_key"
}
}
end
end
test "should destroy coinstats item" do
# Schedules for deletion, doesn't actually delete immediately
assert_no_difference("CoinstatsItem.count") do
delete coinstats_item_url(@coinstats_item)
end
assert_redirected_to settings_providers_path
@coinstats_item.reload
assert @coinstats_item.scheduled_for_deletion?
end
test "should sync coinstats item" do
post sync_coinstats_item_url(@coinstats_item)
assert_redirected_to accounts_path
end
test "sync responds to json format" do
post sync_coinstats_item_url(@coinstats_item, format: :json)
assert_response :ok
end
test "should update coinstats item with valid api key" do
Provider::Coinstats.any_instance.expects(:get_blockchains).returns(success_response([])).once
patch coinstats_item_url(@coinstats_item), params: {
coinstats_item: {
name: "Updated Name",
api_key: "new_valid_api_key"
}
}
@coinstats_item.reload
assert_equal "Updated Name", @coinstats_item.name
end
test "should not update coinstats item with invalid api key" do
Provider::Coinstats.any_instance.expects(:get_blockchains)
.returns(error_response("Invalid API key"))
original_name = @coinstats_item.name
patch coinstats_item_url(@coinstats_item), params: {
coinstats_item: {
name: "Updated Name",
api_key: "invalid_api_key"
}
}
@coinstats_item.reload
assert_equal original_name, @coinstats_item.name
end
test "link_wallet requires all parameters" do
post link_wallet_coinstats_items_url, params: {
coinstats_item_id: @coinstats_item.id,
address: "0x123"
# missing blockchain
}
assert_response :unprocessable_entity
end
test "link_wallet with valid params creates accounts" do
balance_data = [
{ coinId: "ethereum", name: "Ethereum", symbol: "ETH", amount: 1.5, price: 2000 }
]
bulk_response = [
{ blockchain: "ethereum", address: "0x123abc", connectionId: "ethereum", balances: balance_data }
]
Provider::Coinstats.any_instance.expects(:get_wallet_balances)
.with("ethereum:0x123abc")
.returns(success_response(bulk_response))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance)
.with(bulk_response, "0x123abc", "ethereum")
.returns(balance_data)
assert_difference("Account.count", 1) do
assert_difference("CoinstatsAccount.count", 1) do
post link_wallet_coinstats_items_url, params: {
coinstats_item_id: @coinstats_item.id,
address: "0x123abc",
blockchain: "ethereum"
}
end
end
assert_redirected_to accounts_path
end
test "link_wallet handles provider errors" do
Provider::Coinstats.any_instance.expects(:get_wallet_balances)
.raises(Provider::Coinstats::Error.new("Invalid API key"))
post link_wallet_coinstats_items_url, params: {
coinstats_item_id: @coinstats_item.id,
address: "0x123abc",
blockchain: "ethereum"
}
assert_response :unprocessable_entity
end
test "link_wallet handles no tokens found" do
Provider::Coinstats.any_instance.expects(:get_wallet_balances)
.returns(success_response([]))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance)
.returns([])
post link_wallet_coinstats_items_url, params: {
coinstats_item_id: @coinstats_item.id,
address: "0x123abc",
blockchain: "ethereum"
}
assert_response :unprocessable_entity
assert_match(/No tokens found/, response.body)
end
end

View File

@@ -191,4 +191,30 @@ class OidcAccountsControllerTest < ActionController::TestCase
assert_redirected_to new_session_path
assert_equal "No pending OIDC authentication found", flash[:alert]
end
# Security: JIT users should NOT have password_digest set
test "JIT user is created without password_digest to prevent chained auth attacks" do
session[:pending_oidc_auth] = new_user_auth
post :create_user
new_user = User.find_by(email: new_user_auth["email"])
assert_not_nil new_user, "User should be created"
assert_nil new_user.password_digest, "JIT user should have nil password_digest"
assert new_user.sso_only?, "JIT user should be SSO-only"
end
test "JIT user cannot authenticate with local password" do
session[:pending_oidc_auth] = new_user_auth
post :create_user
new_user = User.find_by(email: new_user_auth["email"])
# Attempting to authenticate should return nil (no password set)
assert_nil User.authenticate_by(
email: new_user.email,
password: "anypassword"
), "SSO-only user should not authenticate with password"
end
end

View File

@@ -48,4 +48,43 @@ class PasswordResetsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to new_session_path
assert_equal "Password reset via Sure is disabled. Please reset your password through your identity provider.", flash[:alert]
end
# Security: SSO-only users should not receive password reset emails
test "create does not send email for SSO-only user" do
sso_user = users(:sso_only)
assert sso_user.sso_only?, "Test user should be SSO-only"
assert_no_enqueued_emails do
post password_reset_path, params: { email: sso_user.email }
end
# Should still redirect to pending to prevent email enumeration
assert_redirected_to new_password_reset_url(step: "pending")
end
test "create sends email for user with local password" do
assert @user.has_local_password?, "Test user should have local password"
assert_enqueued_emails 1 do
post password_reset_path, params: { email: @user.email }
end
assert_redirected_to new_password_reset_url(step: "pending")
end
# Security: SSO-only users cannot set password via reset
test "update blocks password setting for SSO-only user" do
sso_user = users(:sso_only)
token = sso_user.generate_token_for(:password_reset)
patch password_reset_path(token: token),
params: { user: { password: "NewSecure1!", password_confirmation: "NewSecure1!" } }
assert_redirected_to new_session_path
assert_equal "Your account uses SSO for authentication. Please contact your administrator to manage your credentials.", flash[:alert]
# Verify password was not set
sso_user.reload
assert_nil sso_user.password_digest, "SSO-only user should still have nil password_digest"
end
end

View File

@@ -179,4 +179,17 @@ class RulesControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to rules_url
end
test "should get confirm_all" do
get confirm_all_rules_url
assert_response :success
end
test "apply_all enqueues job and redirects" do
assert_enqueued_with(job: ApplyAllRulesJob) do
post apply_all_rules_url
end
assert_redirected_to rules_url
end
end

View File

@@ -1,6 +1,7 @@
require "test_helper"
class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
include ActiveJob::TestHelper
fixtures :users, :families
setup do
sign_in users(:family_admin)
@@ -154,22 +155,20 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
test "should update simplefin item with valid token" do
@simplefin_item.update!(status: :requires_update)
# Mock the SimpleFin provider to prevent real API calls
mock_provider = mock()
mock_provider.expects(:claim_access_url).with("valid_token").returns("https://example.com/new_access")
mock_provider.expects(:get_accounts).returns({ accounts: [] }).at_least_once
Provider::Simplefin.expects(:new).returns(mock_provider).at_least_once
token = Base64.strict_encode64("https://example.com/claim")
# Let the real create_simplefin_item! method run - don't mock it
SimplefinConnectionUpdateJob.expects(:perform_later).with(
family_id: @family.id,
old_simplefin_item_id: @simplefin_item.id,
setup_token: token
).once
patch simplefin_item_url(@simplefin_item), params: {
simplefin_item: { setup_token: "valid_token" }
simplefin_item: { setup_token: token }
}
assert_redirected_to accounts_path
assert_equal "SimpleFin connection updated.", flash[:notice]
@simplefin_item.reload
assert @simplefin_item.scheduled_for_deletion?
assert_equal "SimpleFIN connection updated.", flash[:notice]
end
test "should handle update with invalid token" do
@@ -180,13 +179,15 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
}
assert_response :unprocessable_entity
assert_includes response.body, I18n.t("simplefin_items.update.errors.blank_token", default: "Please enter a SimpleFin setup token")
assert_includes response.body, I18n.t("simplefin_items.update.errors.blank_token", default: "Please enter a SimpleFIN setup token")
end
test "should transfer accounts when updating simplefin item token" do
@simplefin_item.update!(status: :requires_update)
# Create old SimpleFin accounts linked to Maybe accounts
token = Base64.strict_encode64("https://example.com/claim")
# Create old SimpleFIN accounts linked to Maybe accounts
old_simplefin_account1 = @simplefin_item.simplefin_accounts.create!(
name: "Test Checking",
account_id: "sf_account_123",
@@ -202,7 +203,7 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
account_type: "depository"
)
# Create Maybe accounts linked to the SimpleFin accounts
# Create Maybe accounts linked to the SimpleFIN accounts
maybe_account1 = Account.create!(
family: @family,
name: "Checking Account",
@@ -222,13 +223,13 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
simplefin_account_id: old_simplefin_account2.id
)
# Update old SimpleFin accounts to reference the Maybe accounts
# Update old SimpleFIN accounts to reference the Maybe accounts
old_simplefin_account1.update!(account: maybe_account1)
old_simplefin_account2.update!(account: maybe_account2)
# Mock only the external API calls, let business logic run
mock_provider = mock()
mock_provider.expects(:claim_access_url).with("valid_token").returns("https://example.com/new_access")
mock_provider.expects(:claim_access_url).with(token).returns("https://example.com/new_access")
mock_provider.expects(:get_accounts).returns({
accounts: [
{
@@ -251,41 +252,40 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
}).at_least_once
Provider::Simplefin.expects(:new).returns(mock_provider).at_least_once
# Perform the update
patch simplefin_item_url(@simplefin_item), params: {
simplefin_item: { setup_token: "valid_token" }
}
# Perform the update (async job), but execute enqueued jobs inline so we can
# assert the link transfers.
perform_enqueued_jobs(only: SimplefinConnectionUpdateJob) do
patch simplefin_item_url(@simplefin_item), params: {
simplefin_item: { setup_token: token }
}
end
assert_redirected_to accounts_path
assert_equal "SimpleFin connection updated.", flash[:notice]
assert_equal "SimpleFIN connection updated.", flash[:notice]
# Verify accounts were transferred to new SimpleFin accounts
# Verify accounts were transferred to new SimpleFIN accounts
assert Account.exists?(maybe_account1.id), "maybe_account1 should still exist"
assert Account.exists?(maybe_account2.id), "maybe_account2 should still exist"
maybe_account1.reload
maybe_account2.reload
# Find the new SimpleFin item that was created
# Find the new SimpleFIN item that was created
new_simplefin_item = @family.simplefin_items.where.not(id: @simplefin_item.id).first
assert_not_nil new_simplefin_item, "New SimpleFin item should have been created"
assert_not_nil new_simplefin_item, "New SimpleFIN item should have been created"
new_sf_account1 = new_simplefin_item.simplefin_accounts.find_by(account_id: "sf_account_123")
new_sf_account2 = new_simplefin_item.simplefin_accounts.find_by(account_id: "sf_account_456")
assert_not_nil new_sf_account1, "New SimpleFin account with ID sf_account_123 should exist"
assert_not_nil new_sf_account2, "New SimpleFin account with ID sf_account_456 should exist"
assert_not_nil new_sf_account1, "New SimpleFIN account with ID sf_account_123 should exist"
assert_not_nil new_sf_account2, "New SimpleFIN account with ID sf_account_456 should exist"
assert_equal new_sf_account1.id, maybe_account1.simplefin_account_id
assert_equal new_sf_account2.id, maybe_account2.simplefin_account_id
# Verify old SimpleFin accounts no longer reference Maybe accounts
old_simplefin_account1.reload
old_simplefin_account2.reload
assert_nil old_simplefin_account1.current_account
assert_nil old_simplefin_account2.current_account
# The old item will be deleted asynchronously; until then, legacy links should be moved.
# Verify old SimpleFin item is scheduled for deletion
# Verify old SimpleFIN item is scheduled for deletion
@simplefin_item.reload
assert @simplefin_item.scheduled_for_deletion?
end
@@ -293,7 +293,9 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
test "should handle partial account matching during token update" do
@simplefin_item.update!(status: :requires_update)
# Create old SimpleFin account
token = Base64.strict_encode64("https://example.com/claim")
# Create old SimpleFIN account
old_simplefin_account = @simplefin_item.simplefin_accounts.create!(
name: "Test Checking",
account_id: "sf_account_123",
@@ -302,7 +304,7 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
account_type: "depository"
)
# Create Maybe account linked to the SimpleFin account
# Create Maybe account linked to the SimpleFIN account
maybe_account = Account.create!(
family: @family,
name: "Checking Account",
@@ -316,21 +318,21 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
# Mock only the external API calls, let business logic run
mock_provider = mock()
mock_provider.expects(:claim_access_url).with("valid_token").returns("https://example.com/new_access")
mock_provider.expects(:claim_access_url).with(token).returns("https://example.com/new_access")
# Return empty accounts list to simulate account was removed from bank
mock_provider.expects(:get_accounts).returns({ accounts: [] }).at_least_once
Provider::Simplefin.expects(:new).returns(mock_provider).at_least_once
# Perform update
patch simplefin_item_url(@simplefin_item), params: {
simplefin_item: { setup_token: "valid_token" }
}
perform_enqueued_jobs(only: SimplefinConnectionUpdateJob) do
patch simplefin_item_url(@simplefin_item), params: {
simplefin_item: { setup_token: token }
}
end
assert_response :redirect
uri2 = URI(response.redirect_url)
assert_equal "/accounts", uri2.path
assert_redirected_to accounts_path
# Verify Maybe account still linked to old SimpleFin account (no transfer occurred)
# Verify Maybe account still linked to old SimpleFIN account (no transfer occurred)
maybe_account.reload
old_simplefin_account.reload
assert_equal old_simplefin_account.id, maybe_account.simplefin_account_id
@@ -450,30 +452,27 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
test "update redirects to accounts after setup without forcing a modal" do
@simplefin_item.update!(status: :requires_update)
# Mock provider to return one account so updated_item creates SFAs
mock_provider = mock()
mock_provider.expects(:claim_access_url).with("valid_token").returns("https://example.com/new_access")
mock_provider.expects(:get_accounts).returns({
accounts: [
{ id: "sf_auto_open_1", name: "Auto Open Checking", type: "depository", currency: "USD", balance: 100, transactions: [] }
]
}).at_least_once
Provider::Simplefin.expects(:new).returns(mock_provider).at_least_once
token = Base64.strict_encode64("https://example.com/claim")
patch simplefin_item_url(@simplefin_item), params: { simplefin_item: { setup_token: "valid_token" } }
SimplefinConnectionUpdateJob.expects(:perform_later).with(
family_id: @family.id,
old_simplefin_item_id: @simplefin_item.id,
setup_token: token
).once
assert_response :redirect
uri = URI(response.redirect_url)
assert_equal "/accounts", uri.path
patch simplefin_item_url(@simplefin_item), params: { simplefin_item: { setup_token: token } }
assert_redirected_to accounts_path
end
test "create does not auto-open when no candidates or unlinked" do
# Mock provider interactions for item creation (no immediate account import on create)
mock_provider = mock()
mock_provider.expects(:claim_access_url).with("valid_token").returns("https://example.com/new_access")
token = Base64.strict_encode64("https://example.com/claim")
mock_provider.expects(:claim_access_url).with(token).returns("https://example.com/new_access")
Provider::Simplefin.expects(:new).returns(mock_provider).at_least_once
post simplefin_items_url, params: { simplefin_item: { setup_token: "valid_token" } }
post simplefin_items_url, params: { simplefin_item: { setup_token: token } }
assert_response :redirect
uri = URI(response.redirect_url)
@@ -485,12 +484,15 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
test "update does not auto-open when no SFAs present" do
@simplefin_item.update!(status: :requires_update)
mock_provider = mock()
mock_provider.expects(:claim_access_url).with("valid_token").returns("https://example.com/new_access")
mock_provider.expects(:get_accounts).returns({ accounts: [] }).at_least_once
Provider::Simplefin.expects(:new).returns(mock_provider).at_least_once
token = Base64.strict_encode64("https://example.com/claim")
patch simplefin_item_url(@simplefin_item), params: { simplefin_item: { setup_token: "valid_token" } }
SimplefinConnectionUpdateJob.expects(:perform_later).with(
family_id: @family.id,
old_simplefin_item_id: @simplefin_item.id,
setup_token: token
).once
patch simplefin_item_url(@simplefin_item), params: { simplefin_item: { setup_token: token } }
assert_response :redirect
uri = URI(response.redirect_url)
@@ -498,4 +500,201 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
q = Rack::Utils.parse_nested_query(uri.query)
assert !q.key?("open_relink_for"), "did not expect auto-open when update produced no SFAs/candidates"
end
# Stale account detection and handling tests
test "setup_accounts detects stale accounts not in upstream API" do
# Create a linked SimpleFIN account
linked_sfa = @simplefin_item.simplefin_accounts.create!(
name: "Old Bitcoin",
account_id: "stale_btc_123",
currency: "USD",
current_balance: 0,
account_type: "crypto"
)
linked_account = Account.create!(
family: @family,
name: "Old Bitcoin",
balance: 0,
currency: "USD",
accountable: Crypto.create!
)
linked_sfa.update!(account: linked_account)
linked_account.update!(simplefin_account_id: linked_sfa.id)
# Set raw_payload to simulate upstream API response WITHOUT the stale account
@simplefin_item.update!(raw_payload: {
accounts: [
{ id: "active_cash_456", name: "Cash", balance: 1000, currency: "USD" }
]
})
get setup_accounts_simplefin_item_url(@simplefin_item)
assert_response :success
# Should detect the stale account
assert_includes response.body, "Accounts No Longer in SimpleFIN"
assert_includes response.body, "Old Bitcoin"
end
test "complete_account_setup deletes stale account when delete action selected" do
# Create a linked SimpleFIN account that will be stale
stale_sfa = @simplefin_item.simplefin_accounts.create!(
name: "Stale Account",
account_id: "stale_123",
currency: "USD",
current_balance: 0,
account_type: "depository"
)
stale_account = Account.create!(
family: @family,
name: "Stale Account",
balance: 0,
currency: "USD",
accountable: Depository.create!(subtype: "checking")
)
stale_sfa.update!(account: stale_account)
stale_account.update!(simplefin_account_id: stale_sfa.id)
# Add a transaction to the account
Entry.create!(
account: stale_account,
name: "Test Transaction",
amount: 100,
currency: "USD",
date: Date.today,
entryable: Transaction.create!
)
# Set raw_payload without the stale account
@simplefin_item.update!(raw_payload: { accounts: [] })
assert_difference [ "Account.count", "SimplefinAccount.count", "Entry.count" ], -1 do
post complete_account_setup_simplefin_item_url(@simplefin_item), params: {
stale_account_actions: {
stale_sfa.id => { action: "delete" }
}
}
end
assert_redirected_to accounts_path
end
test "complete_account_setup moves transactions when move action selected" do
# Create source (stale) account
stale_sfa = @simplefin_item.simplefin_accounts.create!(
name: "Bitcoin",
account_id: "stale_btc",
currency: "USD",
current_balance: 0,
account_type: "crypto"
)
stale_account = Account.create!(
family: @family,
name: "Bitcoin",
balance: 0,
currency: "USD",
accountable: Crypto.create!
)
stale_sfa.update!(account: stale_account)
stale_account.update!(simplefin_account_id: stale_sfa.id)
# Create target account (active)
target_sfa = @simplefin_item.simplefin_accounts.create!(
name: "Cash",
account_id: "active_cash",
currency: "USD",
current_balance: 1000,
account_type: "depository"
)
target_account = Account.create!(
family: @family,
name: "Cash",
balance: 1000,
currency: "USD",
accountable: Depository.create!(subtype: "checking")
)
target_sfa.update!(account: target_account)
target_account.update!(simplefin_account_id: target_sfa.id)
target_sfa.ensure_account_provider!
# Add transactions to stale account
entry1 = Entry.create!(
account: stale_account,
name: "P2P Transfer",
amount: 300,
currency: "USD",
date: Date.today,
entryable: Transaction.create!
)
entry2 = Entry.create!(
account: stale_account,
name: "Another Transfer",
amount: 200,
currency: "USD",
date: Date.today - 1,
entryable: Transaction.create!
)
# Set raw_payload with only the target account (stale account missing)
@simplefin_item.update!(raw_payload: {
accounts: [
{ id: "active_cash", name: "Cash", balance: 1000, currency: "USD" }
]
})
# Stale account should be deleted, target account should gain entries
assert_difference "Account.count", -1 do
assert_difference "SimplefinAccount.count", -1 do
post complete_account_setup_simplefin_item_url(@simplefin_item), params: {
stale_account_actions: {
stale_sfa.id => { action: "move", target_account_id: target_account.id }
}
}
end
end
assert_redirected_to accounts_path
# Verify transactions were moved to target account
entry1.reload
entry2.reload
assert_equal target_account.id, entry1.account_id
assert_equal target_account.id, entry2.account_id
end
test "complete_account_setup skips stale account when skip action selected" do
# Create a linked SimpleFIN account that will be stale
stale_sfa = @simplefin_item.simplefin_accounts.create!(
name: "Stale Account",
account_id: "stale_skip",
currency: "USD",
current_balance: 0,
account_type: "depository"
)
stale_account = Account.create!(
family: @family,
name: "Stale Account",
balance: 0,
currency: "USD",
accountable: Depository.create!(subtype: "checking")
)
stale_sfa.update!(account: stale_account)
stale_account.update!(simplefin_account_id: stale_sfa.id)
@simplefin_item.update!(raw_payload: { accounts: [] })
assert_no_difference [ "Account.count", "SimplefinAccount.count" ] do
post complete_account_setup_simplefin_item_url(@simplefin_item), params: {
stale_account_actions: {
stale_sfa.id => { action: "skip" }
}
}
end
assert_redirected_to accounts_path
# Account and SimplefinAccount should still exist
assert Account.exists?(stale_account.id)
assert SimplefinAccount.exists?(stale_sfa.id)
end
end