diff --git a/app/components/DS/buttonish.rb b/app/components/DS/buttonish.rb index 7eeb5ee66..0c68d1dbe 100644 --- a/app/components/DS/buttonish.rb +++ b/app/components/DS/buttonish.rb @@ -80,7 +80,7 @@ class DS::Buttonish < DesignSystemComponent merged_base_classes, full_width ? "w-full justify-center" : nil, container_size_classes, - size_data.dig(:text_classes), + icon_only? ? nil : size_data.dig(:text_classes), variant_data.dig(:container_classes) ) end @@ -108,7 +108,7 @@ class DS::Buttonish < DesignSystemComponent end def icon_only? - variant.in?([ :icon, :icon_inverse ]) + variant.in?([ :icon, :icon_inverse ]) || (icon.present? && text.blank?) end private diff --git a/app/controllers/transaction_attachments_controller.rb b/app/controllers/transaction_attachments_controller.rb new file mode 100644 index 000000000..6aa39bffd --- /dev/null +++ b/app/controllers/transaction_attachments_controller.rb @@ -0,0 +1,98 @@ +class TransactionAttachmentsController < ApplicationController + before_action :set_transaction + before_action :set_attachment, only: [ :show, :destroy ] + + def show + disposition = params[:disposition] == "attachment" ? "attachment" : "inline" + redirect_to rails_blob_url(@attachment, disposition: disposition) + end + + def create + attachments = attachment_params + + if attachments.present? + @transaction.with_lock do + # Check attachment count limit before attaching + current_count = @transaction.attachments.count + new_count = attachments.is_a?(Array) ? attachments.length : 1 + + if current_count + new_count > Transaction::MAX_ATTACHMENTS_PER_TRANSACTION + respond_to do |format| + format.html { redirect_back_or_to transaction_path(@transaction), alert: t("transactions.attachments.cannot_exceed", count: Transaction::MAX_ATTACHMENTS_PER_TRANSACTION) } + format.turbo_stream { flash.now[:alert] = t("transactions.attachments.cannot_exceed", count: Transaction::MAX_ATTACHMENTS_PER_TRANSACTION) } + end + return + end + + existing_ids = @transaction.attachments.pluck(:id) + attachment_proxy = @transaction.attachments.attach(attachments) + + if @transaction.valid? + count = new_count + message = count == 1 ? t("transactions.attachments.uploaded_one") : t("transactions.attachments.uploaded_many", count: count) + respond_to do |format| + format.html { redirect_back_or_to transaction_path(@transaction), notice: message } + format.turbo_stream { flash.now[:notice] = message } + end + else + # Remove invalid attachments + newly_added = Array(attachment_proxy).reject { |a| existing_ids.include?(a.id) } + newly_added.each(&:purge) + error_messages = @transaction.errors.full_messages_for(:attachments).join(", ") + respond_to do |format| + format.html { redirect_back_or_to transaction_path(@transaction), alert: t("transactions.attachments.failed_upload", error: error_messages) } + format.turbo_stream { flash.now[:alert] = t("transactions.attachments.failed_upload", error: error_messages) } + end + end + end + else + respond_to do |format| + format.html { redirect_back_or_to transaction_path(@transaction), alert: t("transactions.attachments.no_files_selected") } + format.turbo_stream { flash.now[:alert] = t("transactions.attachments.no_files_selected") } + end + end + rescue => e + logger.error "#{e.class}: #{e.message}\n#{e.backtrace.join("\n")}" + respond_to do |format| + format.html { redirect_back_or_to transaction_path(@transaction), alert: t("transactions.attachments.upload_failed") } + format.turbo_stream { flash.now[:alert] = t("transactions.attachments.upload_failed") } + end + end + + def destroy + @attachment.purge + message = t("transactions.attachments.attachment_deleted") + respond_to do |format| + format.html { redirect_back_or_to transaction_path(@transaction), notice: message } + format.turbo_stream { flash.now[:notice] = message } + end + rescue => e + logger.error "#{e.class}: #{e.message}\n#{e.backtrace.join("\n")}" + respond_to do |format| + format.html { redirect_back_or_to transaction_path(@transaction), alert: t("transactions.attachments.delete_failed") } + format.turbo_stream { flash.now[:alert] = t("transactions.attachments.delete_failed") } + end + end + + private + + def set_transaction + @transaction = Current.family.transactions.find(params[:transaction_id]) + end + + def set_attachment + @attachment = @transaction.attachments.find(params[:id]) + end + + def attachment_params + if params.has_key?(:attachments) + Array(params.fetch(:attachments, [])).reject(&:blank?).map do |param| + param.respond_to?(:permit) ? param.permit(:file, :filename, :content_type, :description, :metadata) : param + end + elsif params.has_key?(:attachment) + param = params[:attachment] + return nil if param.blank? + param.respond_to?(:permit) ? param.permit(:file, :filename, :content_type, :description, :metadata) : param + end + end +end diff --git a/app/javascript/controllers/attachment_upload_controller.js b/app/javascript/controllers/attachment_upload_controller.js new file mode 100644 index 000000000..ba1632d75 --- /dev/null +++ b/app/javascript/controllers/attachment_upload_controller.js @@ -0,0 +1,63 @@ +import { Controller } from "@hotwired/stimulus" + +export default class AttachmentUploadController extends Controller { + static targets = ["fileInput", "submitButton", "fileName", "uploadText"] + static values = { + maxFiles: Number, + maxSize: Number + } + + connect() { + this.updateSubmitButton() + } + + triggerFileInput() { + this.fileInputTarget.click() + } + + updateSubmitButton() { + const files = Array.from(this.fileInputTarget.files) + const hasFiles = files.length > 0 + + // Basic validation hints (server validates definitively) + let isValid = hasFiles + let errorMessage = "" + + if (hasFiles) { + if (this.hasUploadTextTarget) this.uploadTextTarget.classList.add("hidden") + if (this.hasFileNameTarget) { + const filenames = files.map(f => f.name).join(", ") + const textElement = this.fileNameTarget.querySelector("p") + if (textElement) textElement.textContent = filenames + this.fileNameTarget.classList.remove("hidden") + } + + // Check file count + if (files.length > this.maxFilesValue) { + isValid = false + errorMessage = `Too many files (max ${this.maxFilesValue})` + } + + // Check file sizes + const oversizedFiles = files.filter(file => file.size > this.maxSizeValue) + if (oversizedFiles.length > 0) { + isValid = false + errorMessage = `File too large (max ${Math.round(this.maxSizeValue / 1024 / 1024)}MB)` + } + } else { + if (this.hasUploadTextTarget) this.uploadTextTarget.classList.remove("hidden") + if (this.hasFileNameTarget) this.fileNameTarget.classList.add("hidden") + } + + this.submitButtonTarget.disabled = !isValid + + if (hasFiles && isValid) { + const count = files.length + this.submitButtonTarget.textContent = count === 1 ? "Upload 1 file" : `Upload ${count} files` + } else if (errorMessage) { + this.submitButtonTarget.textContent = errorMessage + } else { + this.submitButtonTarget.textContent = "Upload" + } + } +} diff --git a/app/models/transaction.rb b/app/models/transaction.rb index 47a9ce5a5..cabe4cede 100644 --- a/app/models/transaction.rb +++ b/app/models/transaction.rb @@ -7,6 +7,23 @@ class Transaction < ApplicationRecord has_many :taggings, as: :taggable, dependent: :destroy has_many :tags, through: :taggings + # File attachments (receipts, invoices, etc.) using Active Storage + # Supports images (JPEG, PNG, GIF, WebP) and PDFs up to 10MB each + # Maximum 10 attachments per transaction, family-scoped access + has_many_attached :attachments do |attachable| + attachable.variant :thumbnail, resize_to_limit: [ 150, 150 ] + end + + # Attachment validation constants + MAX_ATTACHMENTS_PER_TRANSACTION = 10 + MAX_ATTACHMENT_SIZE = 10.megabytes + ALLOWED_CONTENT_TYPES = %w[ + image/jpeg image/jpg image/png image/gif image/webp + application/pdf + ].freeze + + validate :validate_attachments, if: -> { attachments.attached? } + accepts_nested_attributes_for :taggings, allow_destroy: true after_save :clear_merchant_unlinked_association, if: :merchant_id_previously_changed? @@ -178,6 +195,26 @@ class Transaction < ApplicationRecord private + def validate_attachments + # Check attachment count limit + if attachments.size > MAX_ATTACHMENTS_PER_TRANSACTION + errors.add(:attachments, :too_many, max: MAX_ATTACHMENTS_PER_TRANSACTION) + end + + # Validate each attachment + attachments.each_with_index do |attachment, index| + # Check file size + if attachment.byte_size > MAX_ATTACHMENT_SIZE + errors.add(:attachments, :too_large, index: index + 1, max_mb: MAX_ATTACHMENT_SIZE / 1.megabyte) + end + + # Check content type + unless ALLOWED_CONTENT_TYPES.include?(attachment.content_type) + errors.add(:attachments, :invalid_format, index: index + 1, file_format: attachment.content_type) + end + end + end + def potential_posted_match_data return nil unless extra.is_a?(Hash) extra["potential_posted_match"] diff --git a/app/views/transaction_attachments/create.turbo_stream.erb b/app/views/transaction_attachments/create.turbo_stream.erb new file mode 100644 index 000000000..571379426 --- /dev/null +++ b/app/views/transaction_attachments/create.turbo_stream.erb @@ -0,0 +1,4 @@ +<%= turbo_stream.replace "transaction_attachments_#{@transaction.id}", partial: "transactions/attachments", locals: { transaction: @transaction } %> +<% flash_notification_stream_items.each do |item| %> + <%= item %> +<% end %> diff --git a/app/views/transaction_attachments/destroy.turbo_stream.erb b/app/views/transaction_attachments/destroy.turbo_stream.erb new file mode 100644 index 000000000..571379426 --- /dev/null +++ b/app/views/transaction_attachments/destroy.turbo_stream.erb @@ -0,0 +1,4 @@ +<%= turbo_stream.replace "transaction_attachments_#{@transaction.id}", partial: "transactions/attachments", locals: { transaction: @transaction } %> +<% flash_notification_stream_items.each do |item| %> + <%= item %> +<% end %> diff --git a/app/views/transactions/_attachments.html.erb b/app/views/transactions/_attachments.html.erb new file mode 100644 index 000000000..7dc5df74c --- /dev/null +++ b/app/views/transactions/_attachments.html.erb @@ -0,0 +1,122 @@ +
+ + <% if transaction.attachments.count < Transaction::MAX_ATTACHMENTS_PER_TRANSACTION %> + <%= styled_form_with url: transaction_attachments_path(transaction), + method: :post, + multipart: true, + local: true, + class: "mb-4", + data: { + controller: "attachment-upload", + attachment_upload_max_files_value: Transaction::MAX_ATTACHMENTS_PER_TRANSACTION - transaction.attachments.count, + attachment_upload_max_size_value: Transaction::MAX_ATTACHMENT_SIZE + } do |form| %> +
+
+
+ +
+ <%= icon "plus", size: "lg", class: "mb-2 text-secondary" %> +

<%= t(".browse_to_add") %>

+
+ + + + <%= form.file_field :attachments, + multiple: true, + accept: Transaction::ALLOWED_CONTENT_TYPES.join(','), + class: "hidden", + data: { + attachment_upload_target: "fileInput", + action: "change->attachment-upload#updateSubmitButton" + } %> +
+ +

+ <%= t(".select_up_to", + count: Transaction::MAX_ATTACHMENTS_PER_TRANSACTION, + size: Transaction::MAX_ATTACHMENT_SIZE / 1.megabyte, + used: transaction.attachments.count) %> +

+
+ +
+ <%= render DS::Button.new( + text: t(".upload"), + variant: :primary, + size: :sm, + data: { attachment_upload_target: "submitButton" } + ) %> +
+
+ <% end %> + <% else %> +
+ <%= icon "alert-circle", size: "sm", color: "warning", class: "mt-0.5" %> +
+ <%= t(".max_reached", count: transaction.attachments.count, max: Transaction::MAX_ATTACHMENTS_PER_TRANSACTION) %> +
+
+ <% end %> + + + <% if transaction.attachments.any? %> +
+

<%= t(".files", count: transaction.attachments.count) %>

+
+ <% transaction.attachments.each do |attachment| %> +
+
+
+ <% if attachment.image? %> + <%= icon "image", size: "sm", color: "secondary" %> + <% else %> + <%= icon "file", size: "sm", color: "secondary" %> + <% end %> +
+
+

<%= attachment.filename %>

+

<%= number_to_human_size(attachment.byte_size) %>

+
+
+ +
+ <%= render DS::Link.new( + href: transaction_attachment_path(transaction, attachment, disposition: :inline), + variant: :outline, + size: :sm, + icon: "eye", + text: "", + target: "_blank" + ) %> + + <%= render DS::Link.new( + href: transaction_attachment_path(transaction, attachment, disposition: :attachment), + variant: :outline, + size: :sm, + icon: "download", + text: "", + data: { turbo: false } + ) %> + + <%= render DS::Button.new( + href: transaction_attachment_path(transaction, attachment), + method: :delete, + variant: :outline_destructive, + size: :sm, + icon: "trash-2", + confirm: CustomConfirm.for_resource_deletion("attachment") + ) %> +
+
+ <% end %> +
+
+ <% else %> +

<%= t(".no_attachments") %>

+ <% end %> +
diff --git a/app/views/transactions/show.html.erb b/app/views/transactions/show.html.erb index 8512572d1..4f520f976 100644 --- a/app/views/transactions/show.html.erb +++ b/app/views/transactions/show.html.erb @@ -124,6 +124,11 @@ "data-auto-submit-form-target": "auto" %> <% end %> <% end %> + + <% dialog.with_section(title: t(".attachments")) do %> + <%= render "transactions/attachments", transaction: @entry.transaction %> + <% end %> + <% if (details = build_transaction_extra_details(@entry)) %> <% dialog.with_section(title: "Additional details", open: false) do %>
diff --git a/config/initializers/active_storage_authorization.rb b/config/initializers/active_storage_authorization.rb new file mode 100644 index 000000000..7766dc3c3 --- /dev/null +++ b/config/initializers/active_storage_authorization.rb @@ -0,0 +1,45 @@ +# Override Active Storage blob serving to enforce authorization +Rails.application.config.to_prepare do + module ActiveStorageAttachmentAuthorization + extend ActiveSupport::Concern + + included do + include Authentication + before_action :authorize_transaction_attachment, if: :transaction_attachment? + end + + private + + def authorize_transaction_attachment + attachment = ActiveStorage::Attachment.find_by(blob: authorized_blob) + return unless attachment&.record_type == "Transaction" + + transaction = attachment.record + + # Check if current user has access to this transaction's family + unless Current.family == transaction.entry.account.family + raise ActiveRecord::RecordNotFound + end + end + + def transaction_attachment? + return false unless authorized_blob + + attachment = ActiveStorage::Attachment.find_by(blob: authorized_blob) + attachment&.record_type == "Transaction" + end + + def authorized_blob + @blob || @representation&.blob + end + end + + [ + ActiveStorage::Blobs::RedirectController, + ActiveStorage::Blobs::ProxyController, + ActiveStorage::Representations::RedirectController, + ActiveStorage::Representations::ProxyController + ].each do |controller| + controller.include ActiveStorageAttachmentAuthorization + end +end diff --git a/config/locales/models/transaction/en.yml b/config/locales/models/transaction/en.yml new file mode 100644 index 000000000..7359a69b4 --- /dev/null +++ b/config/locales/models/transaction/en.yml @@ -0,0 +1,11 @@ +--- +en: + activerecord: + errors: + models: + transaction: + attributes: + attachments: + too_many: "cannot exceed %{max} files per transaction" + too_large: "file %{index} is too large (maximum %{max_mb}MB)" + invalid_format: "file %{index} has unsupported format (%{file_format})" diff --git a/config/locales/views/transactions/en.yml b/config/locales/views/transactions/en.yml index 49bb4e8d9..f01b15e98 100644 --- a/config/locales/views/transactions/en.yml +++ b/config/locales/views/transactions/en.yml @@ -31,6 +31,7 @@ en: balances, and cannot be undone. delete_title: Delete transaction details: Details + attachments: Attachments exclude: Exclude exclude_description: Excluded transactions will be removed from budgeting calculations and reports. activity_type: Activity Type @@ -206,3 +207,21 @@ en: less_than: less than form: toggle_selection_checkboxes: Toggle all checkboxes + attachments: + cannot_exceed: "Cannot exceed %{count} attachments per transaction" + uploaded_one: "Attachment uploaded successfully" + uploaded_many: "%{count} attachments uploaded successfully" + failed_upload: "Failed to upload attachment: %{error}" + no_files_selected: "No files selected for upload" + attachment_deleted: "Attachment deleted successfully" + failed_delete: "Failed to delete attachment: %{error}" + upload_failed: "Failed to upload attachment. Please try again or contact support." + delete_failed: "Failed to delete attachment. Please try again or contact support." + upload: "Upload" + no_attachments: "No attachments yet" + select_up_to: "Select up to %{count} files (images or PDFs, max %{size}MB each) • %{used} of %{count} used" + files: + one: "File (1)" + other: "Files (%{count})" + browse_to_add: "Browse to add files" + max_reached: "Maximum file limit reached (%{count}/%{max}). Delete an existing file to upload another." diff --git a/config/routes.rb b/config/routes.rb index 3bdeeb35d..e3b494761 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -272,6 +272,7 @@ Rails.application.routes.draw do resource :transfer_match, only: %i[new create] resource :pending_duplicate_merges, only: %i[new create] resource :category, only: :update, controller: :transaction_categories + resources :attachments, only: %i[show create destroy], controller: :transaction_attachments collection do delete :clear_filter diff --git a/test/controllers/transaction_attachments_controller_test.rb b/test/controllers/transaction_attachments_controller_test.rb new file mode 100644 index 000000000..fdb6e22b9 --- /dev/null +++ b/test/controllers/transaction_attachments_controller_test.rb @@ -0,0 +1,144 @@ +require "test_helper" + +class TransactionAttachmentsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in @user = users(:family_admin) + @entry = entries(:transaction) + @transaction = @entry.entryable + end + + test "should upload attachment to transaction" do + file = fixture_file_upload("test.txt", "application/pdf") + + assert_difference "@transaction.attachments.count", 1 do + post transaction_attachments_path(@transaction), params: { attachment: file } + end + + assert_redirected_to transaction_path(@transaction) + assert_match "Attachment uploaded successfully", flash[:notice] + end + + test "should upload multiple attachments to transaction" do + file1 = fixture_file_upload("test.txt", "application/pdf") + file2 = fixture_file_upload("test.txt", "image/jpeg") + + assert_difference "@transaction.attachments.count", 2 do + post transaction_attachments_path(@transaction), params: { attachments: [ file1, file2 ] } + end + + assert_redirected_to transaction_path(@transaction) + assert_match "2 attachments uploaded successfully", flash[:notice] + end + + test "should ignore blank attachments in array" do + file = fixture_file_upload("test.txt", "application/pdf") + + assert_difference "@transaction.attachments.count", 1 do + # Simulate Rails behavior where an empty string is often sent in the array + post transaction_attachments_path(@transaction), params: { attachments: [ file, "" ] } + end + + assert_redirected_to transaction_path(@transaction) + assert_match "Attachment uploaded successfully", flash[:notice] # Should be singular + end + + test "should handle upload with no files" do + assert_no_difference "@transaction.attachments.count" do + post transaction_attachments_path(@transaction), params: {} + end + + assert_redirected_to transaction_path(@transaction) + assert_match "No files selected for upload", flash[:alert] + end + + test "should reject unsupported file types" do + file = fixture_file_upload("test.txt", "text/plain") + + assert_no_difference "@transaction.attachments.count" do + post transaction_attachments_path(@transaction), params: { attachment: file } + end + + assert_redirected_to transaction_path(@transaction) + assert_match "unsupported format", flash[:alert] + end + + test "should reject exceeding attachment count limit" do + # Fill up to the limit + (Transaction::MAX_ATTACHMENTS_PER_TRANSACTION).times do |i| + @transaction.attachments.attach( + io: StringIO.new("content #{i}"), + filename: "file#{i}.pdf", + content_type: "application/pdf" + ) + end + + file = fixture_file_upload("test.txt", "application/pdf") + + assert_no_difference "@transaction.attachments.count" do + post transaction_attachments_path(@transaction), params: { attachment: file } + end + + assert_redirected_to transaction_path(@transaction) + assert_match "Cannot exceed #{Transaction::MAX_ATTACHMENTS_PER_TRANSACTION} attachments", flash[:alert] + end + + test "should show attachment for authorized user" do + @transaction.attachments.attach( + io: StringIO.new("test content"), + filename: "test.pdf", + content_type: "application/pdf" + ) + + attachment = @transaction.attachments.first + get transaction_attachment_path(@transaction, attachment) + + assert_response :redirect + end + + test "should upload attachment via turbo_stream" do + file = fixture_file_upload("test.txt", "application/pdf") + + assert_difference "@transaction.attachments.count", 1 do + post transaction_attachments_path(@transaction), params: { attachment: file }, as: :turbo_stream + end + + assert_response :success + assert_match(/turbo-stream action="replace" target="transaction_attachments_#{@transaction.id}"/, response.body) + assert_match(/turbo-stream action="append" target="notification-tray"/, response.body) + assert_match("Attachment uploaded successfully", response.body) + end + + test "should show attachment inline" do + @transaction.attachments.attach(io: StringIO.new("test"), filename: "test.pdf", content_type: "application/pdf") + attachment = @transaction.attachments.first + + get transaction_attachment_path(@transaction, attachment, disposition: :inline) + + assert_response :redirect + assert_match(/disposition=inline/, response.redirect_url) + end + + test "should show attachment as download" do + @transaction.attachments.attach(io: StringIO.new("test"), filename: "test.pdf", content_type: "application/pdf") + attachment = @transaction.attachments.first + + get transaction_attachment_path(@transaction, attachment, disposition: :attachment) + + assert_response :redirect + assert_match(/disposition=attachment/, response.redirect_url) + end + + test "should delete attachment via turbo_stream" do + @transaction.attachments.attach(io: StringIO.new("test"), filename: "test.pdf", content_type: "application/pdf") + attachment = @transaction.attachments.first + + assert_difference "@transaction.attachments.count", -1 do + delete transaction_attachment_path(@transaction, attachment), as: :turbo_stream + end + + assert_response :success + assert_match(/turbo-stream action="replace" target="transaction_attachments_#{@transaction.id}"/, response.body) + assert_match(/turbo-stream action="append" target="notification-tray"/, response.body) + assert_match("Attachment deleted successfully", response.body) + end +end diff --git a/test/fixtures/files/test.txt b/test/fixtures/files/test.txt new file mode 100644 index 000000000..69fadbfa4 --- /dev/null +++ b/test/fixtures/files/test.txt @@ -0,0 +1 @@ +This is a test file for attachment uploads. diff --git a/test/integration/active_storage_authorization_test.rb b/test/integration/active_storage_authorization_test.rb new file mode 100644 index 000000000..75698a536 --- /dev/null +++ b/test/integration/active_storage_authorization_test.rb @@ -0,0 +1,58 @@ +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 + 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 "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 +end diff --git a/test/models/transaction_attachment_validation_test.rb b/test/models/transaction_attachment_validation_test.rb new file mode 100644 index 000000000..16182ccdb --- /dev/null +++ b/test/models/transaction_attachment_validation_test.rb @@ -0,0 +1,61 @@ +require "test_helper" + +class TransactionAttachmentValidationTest < ActiveSupport::TestCase + setup do + @transaction = transactions(:one) + end + + test "should validate attachment content types" do + # Valid content type should pass + @transaction.attachments.attach( + io: StringIO.new("valid content"), + filename: "test.pdf", + content_type: "application/pdf" + ) + assert @transaction.valid? + + # Invalid content type should fail + @transaction.attachments.attach( + io: StringIO.new("invalid content"), + filename: "test.txt", + content_type: "text/plain" + ) + assert_not @transaction.valid? + assert_includes @transaction.errors.full_messages_for(:attachments).join, "unsupported format" + end + + test "should validate attachment count limit" do + # Fill up to the limit + Transaction::MAX_ATTACHMENTS_PER_TRANSACTION.times do |i| + @transaction.attachments.attach( + io: StringIO.new("content #{i}"), + filename: "file#{i}.pdf", + content_type: "application/pdf" + ) + end + assert @transaction.valid? + + # Exceeding the limit should fail + @transaction.attachments.attach( + io: StringIO.new("extra content"), + filename: "extra.pdf", + content_type: "application/pdf" + ) + assert_not @transaction.valid? + assert_includes @transaction.errors.full_messages_for(:attachments).join, "cannot exceed" + end + + test "should validate attachment file size" do + # Create a mock large attachment + large_content = "x" * (Transaction::MAX_ATTACHMENT_SIZE + 1) + + @transaction.attachments.attach( + io: StringIO.new(large_content), + filename: "large.pdf", + content_type: "application/pdf" + ) + + assert_not @transaction.valid? + assert_includes @transaction.errors.full_messages_for(:attachments).join, "too large" + end +end