mirror of
https://github.com/we-promise/sure.git
synced 2026-07-12 12:55:20 +00:00
* feat(dashboard): masonry packing + per-widget size controls In two-column mode the dashboard used a row-based CSS grid, so cards stretched to equal row height and left dead space (e.g. the Net Worth chart padded out to match the tall Balance Sheet table). Replace the row-based layout with masonry packing and add per-widget size guardrails. - Masonry: CSS grid with computed row-spans + grid-auto-flow: dense, driven by a dashboard-masonry Stimulus controller (ResizeObserver + turbo:frame-load). The DOM stays a single flat list, so drag/keyboard reorder is unaffected. Active only in multi-column mode; single column falls back to normal flow. - Internal sizing: the net worth chart height is now driven by a --dash-widget-h CSS var (fixes an inert flex-1) so the card no longer pads out below the chart. - Guardrails: per-widget layout metadata (col_span, grow, min_height, width_toggle) in PagesController, with per-user overrides persisted under preferences["dashboard_section_layout"], deep-merged so width and height coexist. - Size menu: a hover control on size-capable cards — Width (Half/Full) for the cashflow sankey and net worth chart, Height (Compact/Auto/Tall) for grow widgets. The sankey defaults to full width. Adds model + controller tests for preference persistence and i18n keys. * refactor(dashboard): redesign size menu with segmented controls The size menu used a plain radio list and a diagonal maximize-2 trigger that collided with the cashflow sankey's modal-expand button. Replace it with a layout-config popover: a sliders-horizontal trigger plus two labeled axis groups (Width, Height), each rendered as a DS::SegmentedControl with the active option filled. Clearer, more compact, and it reads as a card-layout control. The widget-size controller now mirrors the segmented control's active-class + aria-pressed contract. * feat(dashboard): expose width toggle on balance sheet and investments Tables benefit from horizontal room, so give Balance Sheet and Investments the same Width (Half/Full) control as the sankey and net worth chart. Height presets stay chart-only — a table sized to a fixed height would just add whitespace or force scrolling. The Outflows donut is intentionally left out (full width is mostly whitespace for a donut). * fix(dashboard): address review feedback on size controls - Only apply the full-width col-span and show the Width control when the two-column layout is enabled. A full widget previously leaked 2xl:col-span-2 into the single-column grid, creating an implicit second column at 2xl widths and breaking the single-column preference. This also keeps the Width control coherent with the Appearance two-column setting (it now appears only where it does something). [Codex] - Stop size-menu keydowns from bubbling to the section reorder handler, so keyboard users can open the menu and pick options without entering grab/reorder mode. [Codex] - Harden preferences params: ignore a malformed (non-hash) dashboard_section_layout / collapsed_sections instead of raising a 500, and require section_order to be an array. [CodeRabbit] - Localize the dashboard sections aria-label. [CodeRabbit] * chore(settings): mention per-widget size controls in two-column copy Surface the new per-widget width/height controls in the Appearance "Two-column layout" description so the capability is discoverable. * test(dashboard): assert non-mutation for malformed layout input Addresses review feedback: asserting assert_nil made the test depend on the fixture happening to have no dashboard height for net_worth_chart. Capture the pre-PATCH value and assert it is unchanged, so the test stays valid (malformed input ignored) even if the fixture later gets a default height. --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Guillem Arias <guillem.arias@col.vueling.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
760 lines
25 KiB
Ruby
760 lines
25 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!
|
|
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[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
|
|
|
|
# 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
|
|
|
|
# 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
|
|
|
|
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
|