Files
sure/test/integration/active_storage_authorization_test.rb
T
Brandon bf5ceff269 Fix flaky sign_out teardown in six test suites (#3208)
Six suites (passkey, MFA, SnapTrade, categorize, onboarding and the Active
Storage authorization integration tests) share a sign_out helper that deletes
the user's sessions through the controller, one HTTP request per session,
iterating in unspecified order. The moment the loop deletes the session the
test itself is signed in with, every later request in the loop is
unauthenticated and silently deletes nothing, so whichever sessions happen to
sort after it survive. The sessions fixture belongs to the same user these
suites use, so a surviving fixture row then fails every assertion that expects
the user to have no sessions.

Row order usually favors the fixture, which is why the suites usually pass.
Under parallel CI they fail a few times a week, always in this file family,
always with the fixture session as the leftover. Forcing newest-first order
reproduces it deterministically on current main: ten of the fifteen passkey
tests fail.

Teardown hygiene is not the behavior under test, so the helpers now destroy
the sessions directly, which no order can break. All six suites run green
three times in a row.
2026-08-27 07:30:35 +02:00

307 lines
8.6 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)
# Deleting through the controller de-authenticates mid-loop and later
# deletes silently no-op; destroy directly, no order to break.
user.sessions.destroy_all
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