Files
sure/test/controllers/reports_controller_test.rb
Orange🍊 6e5f35b306 fix(api): prevent API auth from inheriting impersonation (#2405)
Build fresh API session contexts instead of reusing persisted web sessions that may carry impersonation state.

Reject deactivated report export API key owners and strengthen regression coverage for API key, OAuth, and report export authentication paths.
2026-06-19 17:14:43 +02:00

484 lines
16 KiB
Ruby

require "test_helper"
class ReportsControllerTest < ActionDispatch::IntegrationTest
include EntriesTestHelper
setup do
sign_in @user = users(:family_admin)
@family = @user.family
end
test "index renders successfully" do
get reports_path
assert_response :ok
end
test "index with monthly period" do
get reports_path(period_type: :monthly)
assert_response :ok
assert_select "h1", text: I18n.t("reports.index.title")
end
test "index with quarterly period" do
get reports_path(period_type: :quarterly)
assert_response :ok
end
test "index with ytd period" do
get reports_path(period_type: :ytd)
assert_response :ok
end
test "index with custom period and date range" do
get reports_path(
period_type: :custom,
start_date: 1.month.ago.to_date.to_s,
end_date: Date.current.to_s
)
assert_response :ok
end
test "index with last 6 months period" do
get reports_path(period_type: :last_6_months)
assert_response :ok
expected_end = Date.current.end_of_month
expected_start = (expected_end + 1.day - 6.months).beginning_of_month
# Should show exactly 6 months, not 7
assert_equal 6, (expected_end.year * 12 + expected_end.month) - (expected_start.year * 12 + expected_start.month) + 1
# Page should include both boundary months
assert_includes @response.body, expected_start.strftime("%b %Y")
assert_includes @response.body, expected_end.strftime("%b %Y")
# Page should NOT include the months immediately outside the window
prev_month = expected_start - 1.month
next_month = expected_end + 1.month
assert_not_includes @response.body, prev_month.strftime("%b %Y")
assert_not_includes @response.body, next_month.strftime("%b %Y")
end
test "last 6 months default start date is consistent with navigation" do
# First load (no params) should produce the same start/end as the navigation arrows would
get reports_path(period_type: :last_6_months)
assert_response :ok
expected_end = Date.current.end_of_month
expected_start = (expected_end + 1.day - 6.months).beginning_of_month
# Navigate forward from one window back — should land on the same default window
prev_start = expected_start - 6.months
prev_end = prev_start + 6.months - 1.day
get reports_path(period_type: :last_6_months, start_date: prev_start, end_date: prev_end)
assert_response :ok
# The next-window link should point to the same dates as the default
assert_select "a[href=?]",
reports_path(period_type: :last_6_months, start_date: expected_start, end_date: expected_end)
end
test "index shows empty state when no transactions" do
# Delete all transactions for the family by deleting from accounts
@family.accounts.each { |account| account.entries.destroy_all }
get reports_path
assert_response :ok
assert_select "h3", text: I18n.t("reports.empty_state.title")
end
test "index with budget performance for current month" do
# Create a budget for current month
budget = Budget.find_or_bootstrap(@family, start_date: Date.current.beginning_of_month)
category = @family.categories.expenses.first
# Fail fast if test setup is incomplete
assert_not_nil category, "Test setup failed: no expense category found for family"
assert_not_nil budget, "Test setup failed: budget could not be created or found"
# Find or create budget category to avoid duplicate errors
budget_category = budget.budget_categories.find_or_initialize_by(category: category)
budget_category.budgeted_spending = Money.new(50000, @family.currency)
budget_category.save!
get reports_path(period_type: :monthly)
assert_response :ok
end
test "index calculates summary metrics correctly" do
get reports_path(period_type: :monthly)
assert_response :ok
assert_select "h3", text: I18n.t("reports.summary.total_income")
assert_select "h3", text: I18n.t("reports.summary.total_expenses")
assert_select "h3", text: I18n.t("reports.summary.net_savings")
end
test "index builds trends data" do
get reports_path(period_type: :monthly)
assert_response :ok
assert_select "h2", text: I18n.t("reports.trends.title")
assert_select "thead" do
assert_select "th", text: I18n.t("reports.trends.month")
end
end
test "index handles invalid date parameters gracefully" do
get reports_path(
period_type: :custom,
start_date: "invalid-date",
end_date: "also-invalid"
)
assert_response :ok # Should not crash, uses defaults
end
test "index swaps dates when end_date is before start_date" do
start_date = Date.current
end_date = 1.month.ago.to_date
get reports_path(
period_type: :custom,
start_date: start_date.to_s,
end_date: end_date.to_s
)
assert_response :ok
assert_equal I18n.t("reports.invalid_date_range"), flash[:alert]
assert_includes @response.body, end_date.strftime("%b %Y")
assert_includes @response.body, start_date.strftime("%b %Y")
end
test "spending patterns returns data when expense transactions exist" do
# Create expense category
expense_category = @family.categories.create!(
name: "Test Groceries"
)
# Create account
account = @family.accounts.first
# Create expense transaction on a weekday (Monday)
weekday_date = Date.current.beginning_of_month + 2.days
weekday_date = weekday_date.next_occurring(:monday)
entry = account.entries.create!(
name: "Grocery shopping",
date: weekday_date,
amount: 50.00,
currency: "USD",
entryable: Transaction.new(
category: expense_category,
kind: "standard"
)
)
# Create expense transaction on a weekend (Saturday)
weekend_date = weekday_date.next_occurring(:saturday)
weekend_entry = account.entries.create!(
name: "Weekend shopping",
date: weekend_date,
amount: 75.00,
currency: "USD",
entryable: Transaction.new(
category: expense_category,
kind: "standard"
)
)
get reports_path(period_type: :monthly)
assert_response :ok
# Verify spending patterns shows data (not the "no data" message)
assert_select ".text-center.py-8.text-subdued", { text: /No spending data/, count: 0 }, "Should not show 'No spending data' message when transactions exist"
end
test "index avoids residual category lazy loads" do
account = accounts(:depository)
4.times do |idx|
parent = @family.categories.create!(
name: "Reports Parent #{idx}",
color: "#000000",
lucide_icon: "folder"
)
child = @family.categories.create!(
name: "Reports Child #{idx}",
color: "#111111",
lucide_icon: "folder",
parent: parent
)
entry = account.entries.create!(
name: "Reports transaction #{idx}",
date: Date.current,
amount: 10 + idx,
currency: "USD",
entryable: Transaction.new(
category: child,
kind: "standard"
)
)
assert entry.persisted?
end
queries = capture_sql_queries { get reports_path(period_type: :monthly) }
assert_response :ok
assert_empty queries.grep(/SELECT "categories"\.\* FROM "categories" WHERE "categories"\."id" = \$1 LIMIT \$2/)
end
test "export transactions with API key authentication" do
# Use an active API key with read permissions
api_key = api_keys(:active_key)
# Make sure the API key has the correct source
api_key.update!(source: "web") unless api_key.source == "web"
get export_transactions_reports_path(
format: :csv,
period_type: :ytd,
start_date: Date.current.beginning_of_year,
end_date: Date.current,
api_key: api_key.plain_key
)
assert_response :ok
assert_equal "text/csv", @response.media_type
assert_match /Category/, @response.body
end
test "export transactions with API key does not inherit web session impersonation" do
support_user = users(:sure_support_staff)
support_user.api_keys.active.destroy_all
token_value = ApiKey.generate_secure_key
export_credential = support_user.api_keys.build(
name: "Support Export Credential",
scopes: [ "read" ],
source: "web"
)
export_credential.key = token_value
export_credential.save!
impersonation_session = impersonation_sessions(:in_progress)
impersonated_user = impersonation_session.impersonated
leaked_category = impersonated_user.family.categories.create!(
name: "Impersonated Export Leak Probe"
)
impersonated_account = impersonated_user.finance_accounts.first
assert_not_nil impersonated_account, "Test setup failed: impersonated user has no finance account"
create_transaction(
account: impersonated_account,
name: "Impersonated export leak probe",
date: Date.current,
amount: 123.45,
category: leaked_category
)
support_user.sessions.destroy_all
support_user.sessions.create!(
user_agent: "Browser session",
ip_address: "127.0.0.1",
active_impersonator_session: impersonation_session
)
get export_transactions_reports_path(
format: :csv,
period_type: :ytd,
start_date: Date.current.beginning_of_year,
end_date: Date.current,
api_key: token_value
)
assert_response :ok
assert_equal "text/csv", @response.media_type
assert_no_match /Impersonated Export Leak Probe/, @response.body
end
test "export transactions with API key rejects deactivated user" do
user = users(:family_admin)
user.api_keys.active.destroy_all
token_value = ApiKey.generate_secure_key
export_access = user.api_keys.build(
name: "Inactive User Export Credential",
scopes: [ "read" ],
source: "web"
)
export_access.key = token_value
export_access.save!
user.update_column(:active, false)
get export_transactions_reports_path(
format: :csv,
period_type: :ytd,
api_key: token_value
)
assert_response :unauthorized
assert_match /Invalid or expired API key/, @response.body
ensure
user&.update_column(:active, true)
end
test "export transactions with invalid API key" do
get export_transactions_reports_path(
format: :csv,
period_type: :ytd,
api_key: "invalid_key"
)
assert_response :unauthorized
assert_match /Invalid or expired API key/, @response.body
end
test "export transactions without API key uses session auth" do
# Should use normal session-based authentication
# The setup already signs in @user = users(:family_admin)
assert_not_nil @user, "User should be set in test setup"
assert_not_nil @family, "Family should be set in test setup"
get export_transactions_reports_path(
format: :csv,
period_type: :ytd,
start_date: Date.current.beginning_of_year,
end_date: Date.current
)
assert_response :ok, "Export should work with session auth. Response: #{@response.body}"
assert_equal "text/csv", @response.media_type
end
test "export transactions swaps dates when end_date is before start_date" do
start_date = Date.current
end_date = 1.month.ago.to_date
get export_transactions_reports_path(
format: :csv,
period_type: :custom,
start_date: start_date.to_s,
end_date: end_date.to_s
)
assert_response :ok
assert_equal "text/csv", @response.media_type
# Verify the CSV content is generated (should not crash)
assert_not_nil @response.body
end
test "index groups transactions by parent and subcategories" do
# Create parent category with subcategories
parent_category = @family.categories.create!(name: "Entertainment", color: "#FF5733")
subcategory_movies = @family.categories.create!(name: "Movies", parent: parent_category, color: "#33FF57")
subcategory_games = @family.categories.create!(name: "Games", parent: parent_category, color: "#5733FF")
# Create transactions using helper
create_transaction(account: @family.accounts.first, name: "Cinema ticket", amount: 15, category: subcategory_movies)
create_transaction(account: @family.accounts.first, name: "Video game", amount: 60, category: subcategory_games)
get reports_path(period_type: :monthly)
assert_response :ok
# Parent category
assert_select "tr[data-category='category-#{parent_category.id}']", text: /^Entertainment/
# Subcategories
assert_select "tr[data-category='category-#{subcategory_movies.id}']", text: /^Movies/
assert_select "tr[data-category='category-#{subcategory_games.id}']", text: /^Games/
end
test "monthly period navigation shows previous month link" do
get reports_path(period_type: :monthly)
assert_response :ok
prev_start = Date.current.beginning_of_month - 1.month
prev_end = prev_start.end_of_month
assert_select "a[href=?]", reports_path(period_type: :monthly, start_date: prev_start, end_date: prev_end)
end
test "monthly period navigation disables next arrow on current month" do
get reports_path(period_type: :monthly)
assert_response :ok
assert_select "button[disabled][aria-label=?]", I18n.t("reports.index.next_period")
end
test "monthly period navigation shows next month link on past month" do
past_start = Date.current.beginning_of_month - 2.months
past_end = past_start.end_of_month
get reports_path(period_type: :monthly, start_date: past_start, end_date: past_end)
assert_response :ok
next_start = past_start + 1.month
next_end = next_start.end_of_month
assert_select "a[href=?]", reports_path(period_type: :monthly, start_date: next_start, end_date: next_end)
end
test "last 6 months next window extends to current month end when crossing boundary" do
start_date = Date.current.beginning_of_month - 12.months
end_date = start_date + 6.months - 1.day
get reports_path(period_type: :last_6_months, start_date: start_date, end_date: end_date)
assert_response :ok
candidate_start = start_date.beginning_of_month + 6.months
if candidate_start + 6.months >= Date.current.beginning_of_month
expected_next_end = Date.current.end_of_month
expected_next_start = (expected_next_end + 1.day - 6.months).beginning_of_month
else
expected_next_start = candidate_start
expected_next_end = expected_next_start + 6.months - 1.day
end
assert_select "a[href=?]",
reports_path(period_type: :last_6_months, start_date: expected_next_start, end_date: expected_next_end)
end
test "quarterly period navigation shows previous and next quarter links" do
get reports_path(period_type: :quarterly)
assert_response :ok
prev_start = (Date.current.beginning_of_quarter - 1.day).beginning_of_quarter
prev_end = prev_start.end_of_quarter
assert_select "a[href=?]", reports_path(period_type: :quarterly, start_date: prev_start, end_date: prev_end)
# Also verify a past quarter shows an enabled next-quarter link
get reports_path(period_type: :quarterly, start_date: prev_start, end_date: prev_end)
assert_response :ok
next_start = prev_start.next_quarter.beginning_of_quarter
next_end = next_start.end_of_quarter
assert_select "a[href=?]", reports_path(period_type: :quarterly, start_date: next_start, end_date: next_end)
end
test "custom period hides period display" do
get reports_path(
period_type: :custom,
start_date: 1.month.ago.to_date,
end_date: Date.current
)
assert_response :ok
prev_start = 1.month.ago.to_date.beginning_of_month - 1.month
next_start = 1.month.ago.to_date.beginning_of_month + 1.month
assert_select "a[href*=?]", "start_date=#{prev_start}", count: 0
assert_select "a[href*=?]", "start_date=#{next_start}", count: 0
end
test "ytd period navigation shows previous year link" do
get reports_path(period_type: :ytd)
assert_response :ok
prev_year = Date.current.year - 1
prev_start = Date.new(prev_year, 1, 1)
prev_end = Date.new(prev_year, 12, 31)
assert_select "a[href=?]", reports_path(period_type: :ytd, start_date: prev_start, end_date: prev_end)
end
test "ytd period navigation disables next arrow on current year" do
get reports_path(period_type: :ytd)
assert_response :ok
assert_select "button[disabled][aria-label=?]", I18n.t("reports.index.next_period")
end
end