Files
sure/test/models/user_test.rb
Juan José Mata 705b5a8b26 First cut of a simplified "intro" UI layout (#265)
* First cut of a simplified "intro" UI layout

* Linter

* Add guest role and intro-only access

* Fix guest role UI defaults (#940)

Use enum predicate to avoid missing role helper.

* Remove legacy user role mapping (#941)

Drop the unused user role references in role normalization
and SSO role mapping forms to avoid implying a role that
never existed.

Refs: #0

* Remove role normalization (#942)

Remove role normalization

Roles are now stored directly without legacy mappings.

* Revert role mapping logic

* Remove `normalize_role_settings`

* Remove unnecessary migration

* Make `member` the default

* Broken `.erb`

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
2026-02-09 11:09:25 +01:00

441 lines
14 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 "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!
user.enable_mfa!
assert user.otp_required?
assert_equal 8, user.otp_backup_codes.length
assert user.otp_backup_codes.all? { |code| code.length == 8 }
end
test "disable_mfa! removes all MFA data" do
user = users(:family_member)
user.setup_mfa!
user.enable_mfa!
user.disable_mfa!
assert_nil user.otp_secret
assert_not user.otp_required?
assert_empty user.otp_backup_codes
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? accepts backup codes" do
user = users(:family_member)
user.setup_mfa!
user.enable_mfa!
backup_code = user.otp_backup_codes.first
assert user.verify_otp?(backup_code)
# Backup code should be consumed
assert_not user.otp_backup_codes.include?(backup_code)
assert_equal 7, user.otp_backup_codes.length
# Used backup code should not work again
assert_not user.verify_otp?(backup_code)
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 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 "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 "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 "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[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
# 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.destroy_all
assert_equal :super_admin, User.role_for_new_family_creator
end
test "role_for_new_family_creator returns fallback role when users exist" do
# Users exist from fixtures
assert User.exists?
assert_equal :admin, User.role_for_new_family_creator
assert_equal :member, User.role_for_new_family_creator(fallback_role: :member)
assert_equal "custom_role", User.role_for_new_family_creator(fallback_role: "custom_role")
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
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
end