mirror of
https://github.com/we-promise/sure.git
synced 2026-09-05 23:01:26 +00:00
* Add admin family management features and tests
- Implement FamiliesController with destroy action to delete unused families.
- Add localization for success and error messages related to family deletion.
- Create FamiliesControllerTest to ensure proper functionality of family deletion.
- Update UserPolicyTest to include permissions for super admins to delete users.
- Enhance UsersControllerTest with tests for user family management, including moving users between families and creating new families.
* feat(users): enhance user management with family transfer validation and improved delete warnings
* Simplify user management actions column and combine family options
Move heavy user edit forms from table rows into a DS::Popover action
menu, add role badges to the user column, combine family migration and
creation inputs with a Stimulus controller, enable self-family
transfer for super admins, and add safety guards against demoting the
last super admin in the system.
* feat: add authentication type pills to admin user index to display SSO and local login status
* Add set password feature for local users in admin user management
- Add password field in action popover for users with local password login
- Enforce all registration password criteria (min 8 chars, mixed case, digit, special char)
- Block simultaneous family and password updates with clear error
- Show descriptive success notifications (role, password, both, family)
- Ignore password param for SSO-only users
- Add comprehensive tests for all password validation paths
* Resolve DS Drift Patrol findings and CI scan failures
- Wrap auth-type pills in DS::Tooltip instead of native title= attribute
- Add actions.manage_user key to locale and drop redundant default: fallbacks
- Fix RuboCop style offenses in Admin::UsersController
- Update Brakeman ignore entry fingerprint for Admin::UsersController#user_params
* fix: update badge query to target DS::Pill structure
* Fix DS::Tooltip misuse hiding SSO auth-type pill in admin users view
The SSO pill was passed as a block to DS::Tooltip, which caused it to
render inside the hidden div[role="tooltip"] instead of being visible.
The text: option ("SSO Provider: ...") was also silently ignored because
tooltip_content returns content (the block) over @text when a block is
given.
Fix: render the SSO/Local+SSO pill directly as visible content and pass
DS::Tooltip with no block so text: is used as the tooltip popup. An info
icon now appears next to the pill and shows the provider name on hover.
Fixes test: Admin::UsersControllerTest#test_index_renders_auth_type_pills_for_local_and_sso_users
* Remove redundant default: fallback from role pill i18n lookup
All admin.users.index.roles.{guest,member,admin,super_admin} keys are
defined in the locale file and used elsewhere in the same view without
a default:. The fallback was redundant for every valid role and would
silently mask a missing or renamed key instead of raising in
development.
Drop the default: user.role.humanize argument so that any future
missing key surfaces immediately as I18n::MissingTranslationData.
* Revert unrelated JS/schema/split churn; fix transfer_to_family! default role
- Revert 62 JS files (Biome formatter and unrelated controller changes)
- Revert db/schema.rb dump churn (no new migrations in this branch)
- Revert unrelated split transaction view changes (edit/new.html.erb)
- Fix User#transfer_to_family! role default: role: role evaluates to nil
when omitted; use explicit self.role to read model attribute
Keeps the PR focused on user/family management (~18-20 files).
* Fix last login and session count in admin user management
Store last_login_at and sessions_count directly on the users table
so they remain accurate after a user logs out.
- Add migration to add last_login_at (datetime) and sessions_count
(integer, default 0) columns to users, with backfill from sessions
- Add counter_cache: :sessions_count to Session#belongs_to :user so
the count auto-increments/decrements on session create/destroy
- Add after_create callback on Session to stamp user.last_login_at
- Update Admin::UsersController to read both values from users table
instead of aggregating Session rows (which disappear on logout)
* Fix user management PR pending CI items
* Keep test current session after sign in
* Address PR review comments for user transfers
* refactor: update user removal label to "Delete User" and standardize component attribute naming
* Address PR Review Feedback for User Management
* test: Fix families and users controller tests for user management PR
* Limit PR 2868 schema diff
* Fix PR 2868 user management CI failures
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: sure-admin <sure-admin@splashblot.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
949 lines
33 KiB
Ruby
949 lines
33 KiB
Ruby
require "test_helper"
|
|
|
|
class UserTest < ActiveSupport::TestCase
|
|
include ActiveJob::TestHelper
|
|
|
|
def setup
|
|
@user = users(:family_admin)
|
|
end
|
|
|
|
def teardown
|
|
clear_enqueued_jobs
|
|
clear_performed_jobs
|
|
end
|
|
|
|
test "should be valid" do
|
|
assert @user.valid?, @user.errors.full_messages.to_sentence
|
|
end
|
|
|
|
# email
|
|
test "email must be present" do
|
|
potential_user = User.new(
|
|
email: "david@davidbowie.com",
|
|
password_digest: BCrypt::Password.create("password"),
|
|
first_name: "David",
|
|
last_name: "Bowie"
|
|
)
|
|
potential_user.email = " "
|
|
assert_not potential_user.valid?
|
|
end
|
|
|
|
test "has email address" do
|
|
assert_equal "bob@bobdylan.com", @user.email
|
|
end
|
|
|
|
test "can update email" do
|
|
@user.update(email: "new_email@example.com")
|
|
assert_equal "new_email@example.com", @user.email
|
|
end
|
|
|
|
test "email addresses must be unique" do
|
|
duplicate_user = @user.dup
|
|
duplicate_user.email = @user.email.upcase
|
|
@user.save
|
|
assert_not duplicate_user.valid?
|
|
end
|
|
|
|
test "email address is normalized" do
|
|
@user.update!(email: " UNIQUE-User@ExAMPle.CoM ")
|
|
assert_equal "unique-user@example.com", @user.reload.email
|
|
end
|
|
|
|
test "display name" do
|
|
user = User.new(email: "user@example.com")
|
|
assert_equal "user@example.com", user.display_name
|
|
user.first_name = "Bob"
|
|
assert_equal "Bob", user.display_name
|
|
user.last_name = "Dylan"
|
|
assert_equal "Bob Dylan", user.display_name
|
|
end
|
|
|
|
test "initial" do
|
|
user = User.new(email: "user@example.com")
|
|
assert_equal "U", user.initial
|
|
user.first_name = "Bob"
|
|
assert_equal "B", user.initial
|
|
user.first_name = nil
|
|
user.last_name = "Dylan"
|
|
assert_equal "D", user.initial
|
|
end
|
|
|
|
test "family validation label uses localized default moniker" do
|
|
I18n.with_locale(:es) do
|
|
Current.stubs(:family).returns(nil)
|
|
user = User.new(email: "missing-family@example.com", password: user_password_test)
|
|
|
|
assert_not user.valid?
|
|
assert_includes user.errors.full_messages, "Familia debe existir"
|
|
end
|
|
end
|
|
|
|
test "family validation label uses current family moniker" do
|
|
family = families(:dylan_family)
|
|
family.update!(moniker: "Group")
|
|
|
|
I18n.with_locale(:es) do
|
|
Current.stubs(:family).returns(family)
|
|
user = User.new(email: "missing-group@example.com", password: user_password_test)
|
|
|
|
assert_not user.valid?
|
|
assert_includes user.errors.full_messages, "Grupo debe existir"
|
|
end
|
|
end
|
|
|
|
test "family attribute labels use requested locale for current family moniker" do
|
|
family = families(:dylan_family)
|
|
family.update!(moniker: "Group")
|
|
Current.stubs(:family).returns(family)
|
|
|
|
I18n.with_locale(:en) do
|
|
assert_equal "Grupo", User.human_attribute_name(:family, locale: :es)
|
|
assert_equal "Grupo", User.human_attribute_name(:family_id, locale: :es)
|
|
end
|
|
end
|
|
|
|
test "family validation label renders in every supported locale" do
|
|
family = families(:dylan_family)
|
|
family.update!(moniker: "Group")
|
|
Current.stubs(:family).returns(family)
|
|
|
|
LanguagesHelper::SUPPORTED_LOCALES.each do |locale|
|
|
I18n.with_locale(locale) do
|
|
user = User.new(email: "missing-family-#{locale.parameterize}@example.com", password: user_password_test)
|
|
family_label = User.human_attribute_name(:family, locale: locale)
|
|
family_id_label = User.human_attribute_name(:family_id, locale: locale)
|
|
|
|
assert_not user.valid?
|
|
assert_includes user.errors.full_messages_for(:family).to_sentence,
|
|
family_label,
|
|
"expected family error to include moniker label for #{locale}"
|
|
assert_equal family_label, family_id_label
|
|
end
|
|
end
|
|
end
|
|
|
|
test "names are normalized" do
|
|
@user.update!(first_name: "", last_name: "")
|
|
assert_nil @user.first_name
|
|
assert_nil @user.last_name
|
|
|
|
@user.update!(first_name: " Bob ", last_name: " Dylan ")
|
|
assert_equal "Bob", @user.first_name
|
|
assert_equal "Dylan", @user.last_name
|
|
end
|
|
|
|
# MFA Tests
|
|
test "setup_mfa! generates required fields" do
|
|
user = users(:family_member)
|
|
user.setup_mfa!
|
|
|
|
assert user.otp_secret.present?
|
|
assert_not user.otp_required?
|
|
assert_empty user.otp_backup_codes
|
|
end
|
|
|
|
test "enable_mfa! enables MFA and generates backup codes" do
|
|
user = users(:family_member)
|
|
user.setup_mfa!
|
|
backup_codes = user.enable_mfa!
|
|
|
|
assert user.otp_required?
|
|
assert_equal 8, backup_codes.length
|
|
assert backup_codes.all? { |code| code.match?(/\A[0-9a-f]{16}\z/) }
|
|
assert_equal 8, user.otp_backup_codes.length
|
|
assert user.otp_backup_codes.all? { |code| code.start_with?("$2") }
|
|
assert_empty backup_codes & user.otp_backup_codes
|
|
end
|
|
|
|
test "enable_mfa! requires an OTP secret" do
|
|
user = users(:family_member)
|
|
user.setup_mfa!
|
|
user.update_column(:otp_secret, nil)
|
|
|
|
assert_raises(ArgumentError) { user.enable_mfa! }
|
|
assert_not user.reload.otp_required?
|
|
assert_empty user.otp_backup_codes
|
|
end
|
|
|
|
test "disable_mfa! removes all MFA data" do
|
|
user = users(:family_member)
|
|
user.setup_mfa!
|
|
user.enable_mfa!
|
|
user.webauthn_credentials.create!(
|
|
nickname: "YubiKey",
|
|
credential_id: "credential-id",
|
|
public_key: "public-key"
|
|
)
|
|
|
|
user.disable_mfa!
|
|
|
|
assert_nil user.otp_secret
|
|
assert_not user.otp_required?
|
|
assert_empty user.otp_backup_codes
|
|
assert_empty user.webauthn_credentials
|
|
end
|
|
|
|
test "ensure_webauthn_id! generates a stable credential user handle" do
|
|
user = users(:family_member)
|
|
assert_nil user.webauthn_id
|
|
|
|
webauthn_id = user.ensure_webauthn_id!
|
|
|
|
assert webauthn_id.present?
|
|
assert_equal webauthn_id, user.reload.ensure_webauthn_id!
|
|
end
|
|
|
|
test "webauthn_enabled? requires MFA and at least one credential" do
|
|
user = users(:family_member)
|
|
assert_not user.webauthn_enabled?
|
|
|
|
user.setup_mfa!
|
|
user.enable_mfa!
|
|
assert_not user.webauthn_enabled?
|
|
|
|
user.webauthn_credentials.create!(
|
|
nickname: "Touch ID",
|
|
credential_id: "touch-id-credential",
|
|
public_key: "public-key"
|
|
)
|
|
|
|
assert user.webauthn_enabled?
|
|
end
|
|
|
|
test "verify_otp? validates TOTP codes" do
|
|
user = users(:family_member)
|
|
user.setup_mfa!
|
|
|
|
totp = ROTP::TOTP.new(user.otp_secret, issuer: "Sure Finances")
|
|
valid_code = totp.now
|
|
|
|
assert user.verify_otp?(valid_code)
|
|
assert_not user.verify_otp?("invalid")
|
|
assert_not user.verify_otp?("123456")
|
|
end
|
|
|
|
test "verify_otp? does not check backup code digests for normal TOTP input" do
|
|
user = users(:family_member)
|
|
user.setup_mfa!
|
|
user.enable_mfa!
|
|
valid_code = ROTP::TOTP.new(user.otp_secret, issuer: "Sure Finances").now
|
|
|
|
BCrypt::Password.expects(:new).never
|
|
|
|
assert user.verify_otp?(valid_code)
|
|
end
|
|
|
|
test "verify_otp? fast rejects non-backup-code input before digest checks" do
|
|
user = users(:family_member)
|
|
user.setup_mfa!
|
|
user.enable_mfa!
|
|
|
|
BCrypt::Password.expects(:new).never
|
|
|
|
assert_not user.verify_otp?("not-a-backup-code")
|
|
end
|
|
|
|
test "verify_otp? rejects unmatched legacy-shaped backup input" do
|
|
user = users(:family_member)
|
|
user.setup_mfa!
|
|
user.enable_mfa!
|
|
|
|
assert_not user.verify_otp?("deadbeef")
|
|
end
|
|
|
|
test "verify_otp? accepts backup codes" do
|
|
user = users(:family_member)
|
|
user.setup_mfa!
|
|
backup_codes = user.enable_mfa!
|
|
|
|
backup_code = backup_codes.first
|
|
matching_digest = user.otp_backup_codes.find { |digest| BCrypt::Password.new(digest).is_password?(backup_code) }
|
|
assert_not_nil matching_digest
|
|
|
|
assert user.verify_otp?(backup_code)
|
|
|
|
# Backup code should be consumed
|
|
assert_equal 7, user.otp_backup_codes.length
|
|
assert_not_includes user.otp_backup_codes, matching_digest
|
|
|
|
# Used backup code should not work again
|
|
assert_not user.verify_otp?(backup_code)
|
|
end
|
|
|
|
test "verify_otp? reloads backup codes while consuming under lock" do
|
|
user = users(:family_member)
|
|
user.setup_mfa!
|
|
backup_code = user.enable_mfa!.first
|
|
stale_user = User.find(user.id)
|
|
|
|
user.update!(otp_backup_codes: [])
|
|
|
|
assert_not stale_user.verify_otp?(backup_code)
|
|
assert_empty stale_user.reload.otp_backup_codes
|
|
end
|
|
|
|
test "verify_otp? accepts and consumes legacy plaintext backup codes once" do
|
|
user = users(:family_member)
|
|
user.setup_mfa!
|
|
user.update!(otp_required: true, otp_backup_codes: [ "deadbeef" ])
|
|
|
|
assert user.verify_otp?("deadbeef")
|
|
|
|
assert_empty user.reload.otp_backup_codes
|
|
assert_not user.verify_otp?("deadbeef")
|
|
end
|
|
|
|
test "verify_otp? accepts and consumes migrated legacy backup code digests" do
|
|
user = users(:family_member)
|
|
user.setup_mfa!
|
|
user.update!(
|
|
otp_required: true,
|
|
otp_backup_codes: [ BCrypt::Password.create("deadbeef", cost: BCrypt::Engine::MIN_COST).to_s ]
|
|
)
|
|
|
|
assert user.verify_otp?("deadbeef")
|
|
|
|
assert_empty user.reload.otp_backup_codes
|
|
assert_not user.verify_otp?("deadbeef")
|
|
end
|
|
|
|
test "provisioning_uri generates correct URI" do
|
|
user = users(:family_member)
|
|
user.setup_mfa!
|
|
|
|
assert_match %r{otpauth://totp/}, user.provisioning_uri
|
|
assert_match %r{secret=#{user.otp_secret}}, user.provisioning_uri
|
|
assert_match %r{issuer=Sure}, user.provisioning_uri
|
|
end
|
|
|
|
test "ai_available? returns true when openai access token set in settings" do
|
|
Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
|
|
previous = Setting.openai_access_token
|
|
with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: nil, EXTERNAL_ASSISTANT_TOKEN: nil do
|
|
Setting.openai_access_token = nil
|
|
assert_not @user.ai_available?
|
|
|
|
Setting.openai_access_token = "token"
|
|
assert @user.ai_available?
|
|
end
|
|
ensure
|
|
Setting.openai_access_token = previous
|
|
end
|
|
|
|
test "ai_available? returns true when external assistant is configured and family type is external" do
|
|
Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
|
|
previous = Setting.openai_access_token
|
|
@user.family.update!(assistant_type: "external")
|
|
with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: "http://localhost:18789/v1/chat", EXTERNAL_ASSISTANT_TOKEN: "test-token" do
|
|
Setting.openai_access_token = nil
|
|
assert @user.ai_available?
|
|
end
|
|
ensure
|
|
Setting.openai_access_token = previous
|
|
@user.family.update!(assistant_type: "builtin")
|
|
end
|
|
|
|
test "ai_available? returns false when external assistant is configured but family type is builtin" do
|
|
Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
|
|
previous = Setting.openai_access_token
|
|
with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: "http://localhost:18789/v1/chat", EXTERNAL_ASSISTANT_TOKEN: "test-token" do
|
|
Setting.openai_access_token = nil
|
|
assert_not @user.ai_available?
|
|
end
|
|
ensure
|
|
Setting.openai_access_token = previous
|
|
end
|
|
|
|
test "ai_available? returns false when external assistant is configured but user is not in allowlist" do
|
|
Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
|
|
previous = Setting.openai_access_token
|
|
@user.family.update!(assistant_type: "external")
|
|
with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: "http://localhost:18789/v1/chat", EXTERNAL_ASSISTANT_TOKEN: "test-token", EXTERNAL_ASSISTANT_ALLOWED_EMAILS: "other@example.com" do
|
|
Setting.openai_access_token = nil
|
|
assert_not @user.ai_available?
|
|
end
|
|
ensure
|
|
Setting.openai_access_token = previous
|
|
@user.family.update!(assistant_type: "builtin")
|
|
end
|
|
|
|
test "intro layout collapses sidebars and enables ai" do
|
|
user = User.new(
|
|
family: families(:empty),
|
|
email: "intro-new@example.com",
|
|
password: "Password1!",
|
|
password_confirmation: "Password1!",
|
|
role: :guest,
|
|
ui_layout: :intro
|
|
)
|
|
|
|
assert user.save, user.errors.full_messages.to_sentence
|
|
assert user.ui_layout_intro?
|
|
assert_not user.show_sidebar?
|
|
assert_not user.show_ai_sidebar?
|
|
assert user.ai_enabled?
|
|
end
|
|
|
|
test "non-guest role cannot persist intro layout" do
|
|
user = User.new(
|
|
family: families(:empty),
|
|
email: "dashboard-only@example.com",
|
|
password: "Password1!",
|
|
password_confirmation: "Password1!",
|
|
role: :member,
|
|
ui_layout: :intro
|
|
)
|
|
|
|
assert user.save, user.errors.full_messages.to_sentence
|
|
assert user.ui_layout_dashboard?
|
|
end
|
|
|
|
test "upgrading guest role restores dashboard layout defaults" do
|
|
user = users(:intro_user)
|
|
user.update!(role: :member)
|
|
user.reload
|
|
|
|
assert user.ui_layout_dashboard?
|
|
assert user.show_sidebar?
|
|
assert user.show_ai_sidebar?
|
|
end
|
|
|
|
test "new member defaults show_ai_sidebar to false when AI is not available" do
|
|
Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
|
|
previous = Setting.openai_access_token
|
|
with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: nil, EXTERNAL_ASSISTANT_TOKEN: nil do
|
|
Setting.openai_access_token = nil
|
|
user = User.new(
|
|
family: families(:empty),
|
|
email: "member-no-ai@example.com",
|
|
password: "Password1!",
|
|
password_confirmation: "Password1!",
|
|
role: :member
|
|
)
|
|
assert user.save, user.errors.full_messages.to_sentence
|
|
assert_not user.show_ai_sidebar?
|
|
end
|
|
ensure
|
|
Setting.openai_access_token = previous
|
|
end
|
|
|
|
test "new admin defaults show_ai_sidebar to true even when AI is not available" do
|
|
Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
|
|
previous = Setting.openai_access_token
|
|
with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: nil, EXTERNAL_ASSISTANT_TOKEN: nil do
|
|
Setting.openai_access_token = nil
|
|
user = User.new(
|
|
family: families(:empty),
|
|
email: "admin-no-ai@example.com",
|
|
password: "Password1!",
|
|
password_confirmation: "Password1!",
|
|
role: :admin
|
|
)
|
|
assert user.save, user.errors.full_messages.to_sentence
|
|
assert user.show_ai_sidebar?
|
|
end
|
|
ensure
|
|
Setting.openai_access_token = previous
|
|
end
|
|
|
|
test "new member defaults show_ai_sidebar to true when AI is available" do
|
|
Rails.application.config.app_mode.stubs(:self_hosted?).returns(false)
|
|
user = User.new(
|
|
family: families(:empty),
|
|
email: "member-with-ai@example.com",
|
|
password: "Password1!",
|
|
password_confirmation: "Password1!",
|
|
role: :member
|
|
)
|
|
assert user.save, user.errors.full_messages.to_sentence
|
|
assert user.show_ai_sidebar?
|
|
end
|
|
|
|
test "new guest defaults show_ai_sidebar to false when AI is not available" do
|
|
Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
|
|
previous = Setting.openai_access_token
|
|
with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: nil, EXTERNAL_ASSISTANT_TOKEN: nil do
|
|
Setting.openai_access_token = nil
|
|
user = User.new(
|
|
family: families(:empty),
|
|
email: "guest-no-ai@example.com",
|
|
password: "Password1!",
|
|
password_confirmation: "Password1!",
|
|
role: :guest
|
|
)
|
|
assert user.save, user.errors.full_messages.to_sentence
|
|
assert_not user.show_ai_sidebar?
|
|
end
|
|
ensure
|
|
Setting.openai_access_token = previous
|
|
end
|
|
|
|
test "new guest defaults show_ai_sidebar to false when AI is available" do
|
|
Rails.application.config.app_mode.stubs(:self_hosted?).returns(false)
|
|
user = User.new(
|
|
family: families(:empty),
|
|
email: "guest-with-ai@example.com",
|
|
password: "Password1!",
|
|
password_confirmation: "Password1!",
|
|
role: :guest
|
|
)
|
|
assert user.save, user.errors.full_messages.to_sentence
|
|
assert_not user.show_ai_sidebar?
|
|
end
|
|
|
|
test "update_dashboard_preferences handles concurrent updates atomically" do
|
|
@user.update!(preferences: {})
|
|
|
|
# Simulate concurrent updates from multiple requests
|
|
# Each thread collapses a different section simultaneously
|
|
threads = []
|
|
sections = %w[net_worth_chart outflows_donut cashflow_sankey balance_sheet]
|
|
|
|
sections.each_with_index do |section, index|
|
|
threads << Thread.new do
|
|
# Small staggered delays to increase chance of race conditions
|
|
sleep(index * 0.01)
|
|
|
|
# Each thread loads its own instance and updates
|
|
user = User.find(@user.id)
|
|
user.update_dashboard_preferences({
|
|
"collapsed_sections" => { section => true }
|
|
})
|
|
end
|
|
end
|
|
|
|
# Wait for all threads to complete
|
|
threads.each(&:join)
|
|
|
|
# Verify all updates persisted (no data loss from race conditions)
|
|
@user.reload
|
|
sections.each do |section|
|
|
assert @user.dashboard_section_collapsed?(section),
|
|
"Expected #{section} to be collapsed, but it was not. " \
|
|
"Preferences: #{@user.preferences.inspect}"
|
|
end
|
|
|
|
# Verify all sections are in the preferences hash
|
|
assert_equal sections.sort,
|
|
@user.preferences.dig("collapsed_sections")&.keys&.sort,
|
|
"Expected all sections to be in preferences"
|
|
end
|
|
|
|
test "update_dashboard_preferences merges nested hashes correctly" do
|
|
@user.update!(preferences: {})
|
|
|
|
# First update: collapse net_worth
|
|
@user.update_dashboard_preferences({
|
|
"collapsed_sections" => { "net_worth_chart" => true }
|
|
})
|
|
@user.reload
|
|
|
|
assert @user.dashboard_section_collapsed?("net_worth_chart")
|
|
assert_not @user.dashboard_section_collapsed?("outflows_donut")
|
|
|
|
# Second update: collapse outflows (should preserve net_worth)
|
|
@user.update_dashboard_preferences({
|
|
"collapsed_sections" => { "outflows_donut" => true }
|
|
})
|
|
@user.reload
|
|
|
|
assert @user.dashboard_section_collapsed?("net_worth_chart"),
|
|
"First collapsed section should still be collapsed"
|
|
assert @user.dashboard_section_collapsed?("outflows_donut"),
|
|
"Second collapsed section should be collapsed"
|
|
end
|
|
|
|
test "update_dashboard_preferences handles section_order updates" do
|
|
@user.update!(preferences: {})
|
|
|
|
# Set initial order
|
|
new_order = %w[outflows_donut net_worth_chart cashflow_sankey balance_sheet]
|
|
@user.update_dashboard_preferences({ "section_order" => new_order })
|
|
@user.reload
|
|
|
|
assert_equal new_order, @user.dashboard_section_order
|
|
end
|
|
|
|
test "dashboard_section_height returns stored preset or nil" do
|
|
@user.update!(preferences: {})
|
|
assert_nil @user.dashboard_section_height("net_worth_chart")
|
|
|
|
@user.update_dashboard_preferences({
|
|
"dashboard_section_layout" => { "net_worth_chart" => { "height" => "tall" } }
|
|
})
|
|
@user.reload
|
|
|
|
assert_equal "tall", @user.dashboard_section_height("net_worth_chart")
|
|
assert_nil @user.dashboard_section_height("balance_sheet")
|
|
end
|
|
|
|
test "dashboard_section_width returns stored col_span or nil" do
|
|
@user.update!(preferences: {})
|
|
assert_nil @user.dashboard_section_width("cashflow_sankey")
|
|
|
|
@user.update_dashboard_preferences({
|
|
"dashboard_section_layout" => { "cashflow_sankey" => { "col_span" => "single" } }
|
|
})
|
|
|
|
assert_equal "single", @user.reload.dashboard_section_width("cashflow_sankey")
|
|
end
|
|
|
|
test "dashboard_section_layout merges width and height without clobbering" do
|
|
@user.update!(preferences: {})
|
|
|
|
@user.update_dashboard_preferences({
|
|
"dashboard_section_layout" => { "net_worth_chart" => { "height" => "tall" } }
|
|
})
|
|
@user.update_dashboard_preferences({
|
|
"dashboard_section_layout" => { "net_worth_chart" => { "col_span" => "full" } }
|
|
})
|
|
@user.reload
|
|
|
|
assert_equal "tall", @user.dashboard_section_height("net_worth_chart")
|
|
assert_equal "full", @user.dashboard_section_width("net_worth_chart")
|
|
end
|
|
|
|
test "handles empty preferences gracefully for dashboard methods" do
|
|
@user.update!(preferences: {})
|
|
|
|
# dashboard_section_collapsed? should return false when key is missing
|
|
assert_not @user.dashboard_section_collapsed?("net_worth_chart"),
|
|
"Should return false when collapsed_sections key is missing"
|
|
|
|
# dashboard_section_order should return default order when key is missing
|
|
assert_equal %w[insights_feed cashflow_sankey outflows_donut net_worth_chart balance_sheet],
|
|
@user.dashboard_section_order,
|
|
"Should return default order when section_order key is missing"
|
|
|
|
# update_dashboard_preferences should work with empty preferences
|
|
@user.update_dashboard_preferences({ "section_order" => %w[balance_sheet] })
|
|
@user.reload
|
|
|
|
assert_equal %w[balance_sheet], @user.preferences["section_order"]
|
|
end
|
|
|
|
test "handles empty preferences gracefully for reports methods" do
|
|
@user.update!(preferences: {})
|
|
|
|
# reports_section_collapsed? should return false when key is missing
|
|
assert_not @user.reports_section_collapsed?("trends_insights"),
|
|
"Should return false when reports_collapsed_sections key is missing"
|
|
|
|
# reports_section_order should return default order when key is missing
|
|
assert_equal %w[trends_insights transactions_breakdown],
|
|
@user.reports_section_order,
|
|
"Should return default order when reports_section_order key is missing"
|
|
|
|
# update_reports_preferences should work with empty preferences
|
|
@user.update_reports_preferences({ "reports_section_order" => %w[transactions_breakdown] })
|
|
@user.reload
|
|
|
|
assert_equal %w[transactions_breakdown], @user.preferences["reports_section_order"]
|
|
end
|
|
|
|
test "handles missing nested keys in preferences for collapsed sections" do
|
|
@user.update!(preferences: { "section_order" => %w[cashflow] })
|
|
|
|
# Should return false when collapsed_sections key is missing entirely
|
|
assert_not @user.dashboard_section_collapsed?("net_worth_chart"),
|
|
"Should return false when collapsed_sections key is missing"
|
|
|
|
# Should return false when section_key is missing from collapsed_sections
|
|
@user.update!(preferences: { "collapsed_sections" => {} })
|
|
assert_not @user.dashboard_section_collapsed?("net_worth_chart"),
|
|
"Should return false when section key is missing from collapsed_sections"
|
|
end
|
|
|
|
# Default account for transactions
|
|
test "default_account_for_transactions returns account when active and manual" do
|
|
account = accounts(:depository)
|
|
@user.update!(default_account: account)
|
|
assert_equal account, @user.default_account_for_transactions
|
|
end
|
|
|
|
test "default_account_for_transactions returns nil when account is disabled" do
|
|
account = accounts(:depository)
|
|
@user.update!(default_account: account)
|
|
account.disable!
|
|
assert_nil @user.default_account_for_transactions
|
|
end
|
|
|
|
test "default_account_for_transactions returns nil when account is linked" do
|
|
account = accounts(:depository)
|
|
@user.update!(default_account: account)
|
|
plaid_account = plaid_accounts(:one)
|
|
AccountProvider.create!(account: account, provider: plaid_account)
|
|
account.reload
|
|
assert_nil @user.default_account_for_transactions
|
|
end
|
|
|
|
test "default_account_for_transactions returns nil when no default set" do
|
|
assert_nil @user.default_account_for_transactions
|
|
end
|
|
|
|
test "transfer_to_family! clears a shared default account" do
|
|
user = users(:family_member)
|
|
user.update!(role: "admin", default_account: accounts(:depository))
|
|
|
|
new_family = Family.create!(name: "Transferred Family")
|
|
|
|
user.transfer_to_family!(new_family, role: "admin")
|
|
|
|
user.reload
|
|
|
|
assert_equal new_family, user.family
|
|
assert_nil user.default_account_id
|
|
assert_nil user.default_account_for_transactions
|
|
end
|
|
|
|
test "transfer_to_family! moves owned account provider items and statements" do
|
|
user = users(:family_member)
|
|
source_family = user.family
|
|
new_family = Family.create!(name: "Transferred Provider Family")
|
|
account = Account.create!(family: source_family, owner: user, name: "Synced Checking", balance: 100, currency: "USD", accountable: Depository.new)
|
|
plaid_item = PlaidItem.create!(family: source_family, plaid_id: "item_transfer_#{SecureRandom.hex(4)}", access_token: "token", name: "Transfer Bank")
|
|
plaid_account = PlaidAccount.create!(plaid_item: plaid_item, plaid_id: "acct_transfer_#{SecureRandom.hex(4)}", name: "Transfer Checking", plaid_type: "depository", currency: "USD", current_balance: 100)
|
|
AccountProvider.create!(account: account, provider: plaid_account)
|
|
statement = AccountStatement.create_from_upload!(
|
|
family: source_family,
|
|
account: account,
|
|
file: uploaded_file(filename: "transfer-statement.csv", content_type: "text/csv", content: "date,amount\n2026-01-01,10\n")
|
|
)
|
|
|
|
user.transfer_to_family!(new_family, role: "admin")
|
|
|
|
assert_equal new_family, user.reload.family
|
|
assert_equal new_family, account.reload.family
|
|
assert_equal new_family, plaid_item.reload.family
|
|
assert_equal new_family, statement.reload.family
|
|
end
|
|
|
|
test "transfer_to_family! rejects provider items linked to accounts outside the transfer" do
|
|
user = users(:family_member)
|
|
other_user = users(:family_admin)
|
|
source_family = user.family
|
|
new_family = Family.create!(name: "Rejected Provider Family")
|
|
moved_account = Account.create!(family: source_family, owner: user, name: "Moved Synced", balance: 100, currency: "USD", accountable: Depository.new)
|
|
remaining_account = Account.create!(family: source_family, owner: other_user, name: "Remaining Synced", balance: 200, currency: "USD", accountable: Depository.new)
|
|
plaid_item = PlaidItem.create!(family: source_family, plaid_id: "item_reject_#{SecureRandom.hex(4)}", access_token: "token", name: "Shared Bank")
|
|
moved_plaid_account = PlaidAccount.create!(plaid_item: plaid_item, plaid_id: "acct_reject_moved_#{SecureRandom.hex(4)}", name: "Moved Checking", plaid_type: "depository", currency: "USD", current_balance: 100)
|
|
remaining_plaid_account = PlaidAccount.create!(plaid_item: plaid_item, plaid_id: "acct_reject_remaining_#{SecureRandom.hex(4)}", name: "Remaining Checking", plaid_type: "depository", currency: "USD", current_balance: 200)
|
|
AccountProvider.create!(account: moved_account, provider: moved_plaid_account)
|
|
AccountProvider.create!(account: remaining_account, provider: remaining_plaid_account)
|
|
|
|
error = assert_raises(ActiveRecord::RecordInvalid) do
|
|
user.transfer_to_family!(new_family, role: "admin")
|
|
end
|
|
|
|
assert_includes error.record.errors[:base], I18n.t("activerecord.errors.models.user.attributes.base.provider_item_has_other_accounts")
|
|
assert_equal source_family, user.reload.family
|
|
assert_equal source_family, moved_account.reload.family
|
|
assert_equal source_family, plaid_item.reload.family
|
|
end
|
|
|
|
# SSO-only user security tests
|
|
test "sso_only? returns true for user with OIDC identity and no password" do
|
|
sso_user = users(:sso_only)
|
|
assert_nil sso_user.password_digest
|
|
assert sso_user.oidc_identities.exists?
|
|
assert sso_user.sso_only?
|
|
end
|
|
|
|
test "sso_only? returns false for user with password and OIDC identity" do
|
|
# family_admin has both password and OIDC identity
|
|
assert @user.password_digest.present?
|
|
assert @user.oidc_identities.exists?
|
|
assert_not @user.sso_only?
|
|
end
|
|
|
|
test "sso_only? returns false for user with password but no OIDC identity" do
|
|
user_without_oidc = users(:empty)
|
|
assert user_without_oidc.password_digest.present?
|
|
assert_not user_without_oidc.oidc_identities.exists?
|
|
assert_not user_without_oidc.sso_only?
|
|
end
|
|
|
|
test "has_local_password? returns true when password_digest is present" do
|
|
assert @user.has_local_password?
|
|
end
|
|
|
|
test "has_local_password? returns false when password_digest is nil" do
|
|
sso_user = users(:sso_only)
|
|
assert_not sso_user.has_local_password?
|
|
end
|
|
|
|
test "user can be created without password when skip_password_validation is true" do
|
|
user = User.new(
|
|
email: "newssuser@example.com",
|
|
first_name: "New",
|
|
last_name: "SSO User",
|
|
skip_password_validation: true,
|
|
family: families(:empty)
|
|
)
|
|
assert user.valid?, user.errors.full_messages.to_sentence
|
|
assert user.save
|
|
assert_nil user.password_digest
|
|
end
|
|
|
|
test "user requires password on create when skip_password_validation is false" do
|
|
user = User.new(
|
|
email: "needspassword@example.com",
|
|
first_name: "Needs",
|
|
last_name: "Password",
|
|
family: families(:empty)
|
|
)
|
|
assert_not user.valid?
|
|
assert_includes user.errors[:password], "can't be blank"
|
|
end
|
|
|
|
# First user role assignment tests
|
|
test "role_for_new_family_creator returns super_admin when no users exist" do
|
|
# Delete all users to simulate fresh instance
|
|
User.connection.disable_referential_integrity { User.delete_all }
|
|
|
|
assert_equal :super_admin, User.role_for_new_family_creator
|
|
end
|
|
|
|
test "role_for_new_family_creator returns admin-capable fallback role when users exist" do
|
|
# Users exist from fixtures
|
|
assert User.exists?
|
|
|
|
assert_equal :admin, User.role_for_new_family_creator
|
|
assert_equal :admin, User.role_for_new_family_creator(fallback_role: :member)
|
|
assert_equal :admin, User.role_for_new_family_creator(fallback_role: :guest)
|
|
assert_equal :admin, User.role_for_new_family_creator(fallback_role: "custom_role")
|
|
assert_equal "super_admin", User.role_for_new_family_creator(fallback_role: "super_admin")
|
|
end
|
|
|
|
# Preview features preference tests
|
|
test "preview_features_enabled? defaults to false" do
|
|
@user.update!(preferences: {})
|
|
assert_not @user.preview_features_enabled?
|
|
end
|
|
|
|
test "preview_features_enabled? true only when explicitly true" do
|
|
@user.update!(preferences: { "preview_features_enabled" => true })
|
|
assert @user.preview_features_enabled?
|
|
|
|
@user.update!(preferences: { "preview_features_enabled" => false })
|
|
assert_not @user.preview_features_enabled?
|
|
|
|
@user.update!(preferences: { "preview_features_enabled" => "yes" })
|
|
assert_not @user.preview_features_enabled?, "truthy non-boolean should not enable"
|
|
end
|
|
|
|
# ActiveStorage attachment cleanup tests
|
|
test "purging a user removes attached profile image" do
|
|
user = users(:family_admin)
|
|
user.profile_image.attach(
|
|
io: StringIO.new("profile-image-data"),
|
|
filename: "profile.png",
|
|
content_type: "image/png"
|
|
)
|
|
|
|
attachment_id = user.profile_image.id
|
|
assert ActiveStorage::Attachment.exists?(attachment_id)
|
|
|
|
perform_enqueued_jobs do
|
|
user.purge
|
|
end
|
|
|
|
assert_not User.exists?(user.id)
|
|
assert_not ActiveStorage::Attachment.exists?(attachment_id)
|
|
end
|
|
|
|
# Admin-initiated permanent removal (super-admin action)
|
|
test "permanently_remove! deactivates, revokes all credentials, and schedules purge" do
|
|
target = users(:family_member)
|
|
target.sessions.create!
|
|
assert target.sessions.exists?
|
|
assert target.api_keys.exists?
|
|
assert target.oidc_identities.exists?
|
|
|
|
assert target.permanently_remove!
|
|
|
|
target.reload
|
|
assert_not target.active?
|
|
assert_empty target.sessions
|
|
assert_empty target.api_keys
|
|
assert_empty target.oidc_identities
|
|
end
|
|
|
|
test "permanently_remove! is blocked (fail-closed) for an admin with co-members and keeps credentials" do
|
|
target = users(:family_admin)
|
|
target.sessions.create!
|
|
assert_operator target.family.users.count, :>, 1
|
|
|
|
assert_not target.permanently_remove!
|
|
|
|
assert target.reload.active?
|
|
assert target.sessions.exists?
|
|
assert target.oidc_identities.exists?
|
|
end
|
|
|
|
test "permanently_remove! schedules purge for an already inactive user" do
|
|
target = users(:family_member)
|
|
target.update_column(:active, false)
|
|
|
|
assert_enqueued_with(job: UserPurgeJob, args: [ target ]) do
|
|
assert target.permanently_remove!
|
|
end
|
|
end
|
|
|
|
test "deactivate refuses the last active super admin" do
|
|
family = Family.create!(name: "Sole admin family", locale: "en", date_format: "%m-%d-%Y", currency: "USD")
|
|
target = User.create!(
|
|
family: family,
|
|
email: "sole-super-admin@example.com",
|
|
password: user_password_test,
|
|
role: :super_admin
|
|
)
|
|
User.where(role: :super_admin).where.not(id: target.id).update_all(active: false)
|
|
|
|
assert_not target.deactivate
|
|
assert target.reload.active?
|
|
assert_match(/last active super admin/, target.errors.full_messages.to_sentence)
|
|
end
|
|
|
|
test "purging the last user cascades to remove family and its export attachments" do
|
|
family = Family.create!(name: "Solo Family", locale: "en", date_format: "%m-%d-%Y", currency: "USD")
|
|
user = User.create!(family: family, email: "solo@example.com", password: "password123")
|
|
export = family.family_exports.create!
|
|
export.export_file.attach(
|
|
io: StringIO.new("export-data"),
|
|
filename: "export.zip",
|
|
content_type: "application/zip"
|
|
)
|
|
|
|
export_attachment_id = export.export_file.id
|
|
assert ActiveStorage::Attachment.exists?(export_attachment_id)
|
|
|
|
perform_enqueued_jobs do
|
|
user.purge
|
|
end
|
|
|
|
assert_not Family.exists?(family.id)
|
|
assert_not ActiveStorage::Attachment.exists?(export_attachment_id)
|
|
end
|
|
|
|
test "cannot demote the last super admin in the system" do
|
|
User.where(role: :super_admin).update_all(role: :member)
|
|
solo_super_admin = users(:sure_support_staff)
|
|
solo_super_admin.update!(role: :super_admin)
|
|
|
|
solo_super_admin.role = :member
|
|
assert_not solo_super_admin.valid?
|
|
assert_includes solo_super_admin.errors[:role], "Cannot demote the last super admin in the system."
|
|
end
|
|
|
|
test "can demote super admin if another super admin exists" do
|
|
admin1 = users(:family_admin)
|
|
admin1.update!(role: :super_admin)
|
|
|
|
admin2 = users(:sure_support_staff)
|
|
admin2.update!(role: :super_admin)
|
|
|
|
assert admin1.update(role: :member)
|
|
end
|
|
end
|