Files
sure/test/integration/active_storage_authorization_test.rb
ghost e59235fdc5 feat(statements): add account statement vault (#1753)
* feat(statements): add account statement vault

Add web-only statement uploads, account linking, duplicate detection, and per-account coverage/reconciliation checks without mutating transactions. Extend ActiveStorage authorization and targeted tests for family/account scoping.

* fix(statements): return deleted account statements to inbox

Preserve linked statement records when an account is deleted by moving them back to the unmatched inbox, then expand coverage for upload validation, sanitized parser metadata, unavailable reconciliation, and missing-month coverage.

* fix(statements): harden vault upload review flows

Address review and security findings in the statement vault by preserving sanitized parser metadata, failing closed on orphaned statement blobs, avoiding account_id mass assignment permits, and adding regression coverage for link/delete edge cases.

* fix(statements): harden vault upload and access controls

* fix(statements): address vault hardening review

* fix(statements): address vault review feedback

Prioritize SHA-256 duplicate detection while preserving MD5 fallback for legacy rows.

Remove free-form account notes from statement matching, document direct account-destroy unlinking, and add year-selectable historical coverage with muted out-of-range months.

* fix(statements): harden vault review follow-ups

Clarify legacy MD5 checksum use, whitelist statement balance helper dispatch, and preserve sanitized parser metadata.

Hide statement management controls from read-only viewers while keeping server-side authorization unchanged.

* fix(statements): repair settings system coverage

Allow the changelog provider lookup in the self-hosting settings system test, include Statement Vault in settings navigation coverage, and align the feature title casing. Update the devcontainer so ActiveStorage and parallel system tests can run in the documented environment.

* fix(statements): move vault beside accounts

Place Statement Vault with account settings instead of between Imports and Exports. Keep settings footer ordering and system navigation coverage aligned, including the non-admin visibility guard.

* fix(statements): address vault review cleanup

Resolve CodeRabbit review feedback for statement upload validation, duplicate race handling, account statement matching semantics, metadata detection, ActiveStorage authorization tests, and small UI/style cleanups.

* fix(statements): address vault cleanup review

* fix(statements): deduplicate vault style helpers

* fix(statements): close vault review follow-ups

* fix(statements): refresh schema after upstream rebase

* fix(statements): process vault uploads sequentially

* fix(statements): close vault review follow-ups

* fix(statements): scope vault index to accessible accounts

* fix(statements): harden statement vault readiness

Squash the statement vault migration hardening into the feature migration, tighten Active Storage authorization edge cases, bound CSV metadata detection, and add real PDF fixture coverage for stored statements.

Validation: targeted statement/auth/controller/provider tests, full Rails suite, system tests, RuboCop, Biome, Brakeman, Zeitwerk, importmap audit, npm audit, ERB lint, CodeRabbit, and Codex Security all passed locally.

* fix(statements): close vault review follow-ups

Move statement unlinking to after account destroy commit, keep Kraken account creation on the shared crypto helper, and add statement metadata length limits with DB checks.

Validation: fresh devcontainer with fresh DB via db:prepare, focused account/statement/Kraken/Binance tests, RuboCop, Brakeman, Zeitwerk, git diff --check, CodeRabbit, and Codex Security passed before commit.

* fix(statements): address vault scan follow-ups

Move statement tab data setup out of the ERB partial, harden reconciliation labels and coverage initialization, and tighten statement schema constraints.

Validation: CodeRabbit and Codex Security reviewed the current PR diff; Rails focused tests, full Rails tests, system tests, RuboCop, Brakeman, Zeitwerk, ERB lint, npm lint, importmap audit, npm audit, and git diff --check passed.

* fix(statements): defer vault tab loading

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-05-13 21:05:11 +02:00

305 lines
8.5 KiB
Ruby

require "test_helper"
class ActiveStorageAuthorizationTest < ActionDispatch::IntegrationTest
setup do
@user_a = users(:family_admin) # In dylan_family
@user_b = users(:empty) # In empty family
@transaction_a = transactions(:one) # Assuming it belongs to dylan_family via its entry/account
@transaction_a.attachments.attach(
io: StringIO.new("Family A Secret Receipt"),
filename: "receipt.pdf",
content_type: "application/pdf"
)
@attachment_a = @transaction_a.attachments.first
@statement_a = AccountStatement.create_from_upload!(
family: @user_a.family,
account: @transaction_a.entry.account,
file: uploaded_file(
filename: "statement.pdf",
content_type: "application/pdf",
content: "%PDF-1.4 Family A Secret Statement"
)
)
end
test "user can access attachments within their own family" do
sign_in @user_a
# Get the redirect URL from our controller
get transaction_attachment_path(@transaction_a, @attachment_a)
assert_response :redirect
# Follow the redirect to ActiveStorage::Blobs::RedirectController
follow_redirect!
# In test/local environment, it will redirect again to a disk URL
assert_response :redirect
assert_match(/rails\/active_storage\/disk/, response.header["Location"])
end
test "disk service urls require authentication" do
sign_in @user_a
get rails_blob_path(@statement_a.original_file)
assert_response :redirect
disk_url = response.location
sign_out @user_a
get disk_url
assert_redirected_to new_session_url
end
test "disk service urls enforce statement blob authorization" do
sign_in @user_a
get rails_blob_path(@statement_a.original_file)
assert_response :redirect
disk_url = response.location
sign_out @user_a
sign_in @user_b
get disk_url
assert_response :not_found
end
test "user cannot access attachments from a different family" do
sign_in @user_b
# Even if they find the signed global ID (which is hard but possible),
# the monkey patch should block them at the blob controller level.
# We bypass our controller and go straight to the blob serving URL to test the security layer
get rails_blob_path(@attachment_a)
# The monkey patch raises ActiveRecord::RecordNotFound which rails converts to 404
assert_response :not_found
end
test "user cannot access variants from a different family" do
# Attach an image to test variants
file = File.open(Rails.root.join("test/fixtures/files/square-placeholder.png"))
@transaction_a.attachments.attach(io: file, filename: "test.png", content_type: "image/png")
attachment = @transaction_a.attachments.last
variant = attachment.variant(resize_to_limit: [ 100, 100 ]).processed
sign_in @user_b
# Straight to the representation URL
get rails_representation_path(variant)
assert_response :not_found
end
test "user cannot access statement blob from a different family" do
sign_in @user_b
get rails_blob_path(@statement_a.original_file)
assert_response :not_found
end
test "unauthenticated user is redirected before statement blob access" do
get rails_blob_path(@statement_a.original_file)
assert_redirected_to new_session_url
end
test "user cannot access linked statement blob for an inaccessible account" do
private_account = accounts(:other_asset)
statement = AccountStatement.create_from_upload!(
family: @user_a.family,
account: private_account,
file: uploaded_file(
filename: "private_statement.pdf",
content_type: "application/pdf",
content: "%PDF-1.4 Private Family Statement"
)
)
sign_in users(:family_member)
get rails_blob_path(statement.original_file)
assert_response :not_found
end
test "user can access linked statement blob for a shared account" do
statement = AccountStatement.create_from_upload!(
family: @user_a.family,
account: accounts(:credit_card),
file: uploaded_file(
filename: "shared_statement.pdf",
content_type: "application/pdf",
content: "%PDF-1.4 Shared Family Statement"
)
)
sign_in users(:family_member)
get rails_blob_path(statement.original_file)
assert_response :redirect
follow_redirect!
assert_response :success
assert_match(/rails\/active_storage\/disk/, request.path)
end
test "guest cannot access unmatched statement blob" do
statement = AccountStatement.create_from_upload!(
family: @user_a.family,
account: nil,
file: uploaded_file(
filename: "unmatched_statement.pdf",
content_type: "application/pdf",
content: "%PDF-1.4 Unmatched Family Statement"
)
)
sign_in family_guest
get rails_blob_path(statement.original_file)
assert_response :not_found
end
test "orphaned statement attachment fails closed" do
attachment = @statement_a.original_file.attachment
attachment.update_columns(record_id: SecureRandom.uuid)
sign_in @user_a
get rails_blob_path(attachment)
assert_response :not_found
end
test "unattached blobs fail closed" do
blob = ActiveStorage::Blob.create_and_upload!(
io: StringIO.new("unattached statement"),
filename: "unattached.csv",
content_type: "text/csv"
)
sign_in @user_a
get rails_blob_path(blob)
assert_response :not_found
end
test "blob authorization checks protected attachments even when blob is also attached elsewhere" do
document = FamilyDocument.create!(family: @user_a.family, filename: "shared.pdf", status: "ready")
document.file.attach(@statement_a.original_file.blob)
sign_in @user_b
get rails_blob_path(document.file)
assert_response :not_found
end
test "blob authorization denies when any protected attachment is unauthorized" do
statement_b = AccountStatement.new(
family: @user_b.family,
filename: "shared_statement.pdf",
content_type: @statement_a.content_type,
byte_size: @statement_a.byte_size,
checksum: @statement_a.checksum,
content_sha256: @statement_a.content_sha256,
currency: @user_b.family.currency
)
statement_b.original_file.attach(@statement_a.original_file.blob)
statement_b.save!
sign_in @user_a
get rails_blob_path(@statement_a.original_file)
assert_response :not_found
end
test "unknown protected attachment types fail closed" do
blob = ActiveStorage::Blob.create_and_upload!(
io: StringIO.new("unknown protected attachment"),
filename: "unknown.csv",
content_type: "text/csv"
)
ActiveStorage::Attachment.insert!(
{
name: "file",
record_type: "ProtectedAttachmentProbe",
record_id: SecureRandom.uuid,
blob_id: blob.id,
created_at: Time.current
}
)
with_protected_record_types("Transaction", "AccountStatement", "ProtectedAttachmentProbe") do
sign_in @user_a
get rails_blob_path(blob)
assert_response :not_found
end
end
test "direct uploads require authentication" do
post rails_direct_uploads_path, params: {
blob: {
filename: "statement.csv",
byte_size: 1,
checksum: Digest::MD5.base64digest("1"),
content_type: "text/csv"
}
}, as: :json
assert_redirected_to new_session_url
end
test "authenticated direct uploads can create unattached blobs" do
sign_in @user_a
post rails_direct_uploads_path, params: {
blob: {
filename: "statement.csv",
byte_size: 1,
checksum: Digest::MD5.base64digest("1"),
content_type: "text/csv"
}
}, as: :json
assert_response :success
assert response.parsed_body["signed_id"].present?
end
test "orphaned transaction attachment fails closed" do
@attachment_a.update_columns(record_id: SecureRandom.uuid)
sign_in @user_a
get rails_blob_path(@attachment_a)
assert_response :not_found
end
private
def sign_out(user)
user.sessions.each { |session| delete session_path(session) }
end
def with_protected_record_types(*types)
previous_types = ActiveStorageAttachmentAuthorization::PROTECTED_RECORD_TYPES
ActiveStorageAttachmentAuthorization.send(:remove_const, :PROTECTED_RECORD_TYPES)
ActiveStorageAttachmentAuthorization.const_set(:PROTECTED_RECORD_TYPES, types.flatten.freeze)
yield
ensure
ActiveStorageAttachmentAuthorization.send(:remove_const, :PROTECTED_RECORD_TYPES)
ActiveStorageAttachmentAuthorization.const_set(:PROTECTED_RECORD_TYPES, previous_types)
end
end