mirror of
https://github.com/we-promise/sure.git
synced 2026-04-18 11:34:13 +00:00
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:
206
test/controllers/api/v1/imports_controller_test.rb
Normal file
206
test/controllers/api/v1/imports_controller_test.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
178
test/controllers/coinstats_items_controller_test.rb
Normal file
178
test/controllers/coinstats_items_controller_test.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user