diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb index d4875d514..ed654aaa0 100644 --- a/app/controllers/api_controller.rb +++ b/app/controllers/api_controller.rb @@ -4,6 +4,8 @@ require "auth" class APIController < ActionController::API + class UnprocessableContentError < StandardError; end + include CanCan::ControllerAdditions include JSONAPI::ActsAsResourceController @@ -23,6 +25,7 @@ def context rescue_from ActiveRecord::RecordNotFound, with: :record_not_found rescue_from ActionController::RoutingError, with: :record_not_found rescue_from JWT::VerificationError, with: :bad_auth_key + rescue_from UnprocessableContentError, with: :unprocessable_content rescue_from CanCan::AccessDenied do |exception| Rails.logger.debug { "Access denied on #{exception.action} #{exception.subject.inspect}" } @@ -81,6 +84,10 @@ def record_not_found render json: {errors: [{status: 404, title: "Record not found"}]}, status: :not_found end + def unprocessable_content(exception) + render json: {errors: [{status: 422, title: exception.message}]}, status: :unprocessable_content + end + def render_unprocessable_entity_error(errors) json_errors = {errors: []} diff --git a/app/resources/concerns/operator_documentable.rb b/app/resources/concerns/operator_documentable.rb index 14d9a6df1..db7f9709a 100644 --- a/app/resources/concerns/operator_documentable.rb +++ b/app/resources/concerns/operator_documentable.rb @@ -10,7 +10,8 @@ module OperatorDocumentable :status, :created_at, :updated_at, :attachment, :operator_id, :required_operator_document_id, :fmu_id, :uploaded_by, :reason, :response_date, - :public, :source_info, :admin_comment + :public, :source_info, :admin_comment, + :source_operator_document_id, :source_annex_id attribute :source_type, delegate: :source has_one :country @@ -59,6 +60,32 @@ def attachment=(attachment) @model.new_document_uploaded = true end + def source_operator_document_id + nil + end + + def source_operator_document_id=(id) + source = OperatorDocument.find(id) + unless current_user_operator_ids.include?(source.operator_id) + raise APIController::UnprocessableContentError, "source-operator-document-id must belong to your operator" + end + + self.attachment = File.open(source.document_file.attachment.path) + end + + def source_annex_id + nil + end + + def source_annex_id=(id) + source = OperatorDocumentAnnex.find(id) + unless current_user_operator_ids.include?(source.operator_document&.operator_id) + raise APIController::UnprocessableContentError, "source-annex-id must belong to your operator" + end + + self.attachment = File.open(source.attachment.path) + end + def document_visible? can_see_document? || document_public? end @@ -79,6 +106,10 @@ def can_see_document? false end + + def current_user_operator_ids + @context[:current_user]&.operator_ids || [] + end end module ClassMethods diff --git a/app/resources/v1/operator_document_annex_resource.rb b/app/resources/v1/operator_document_annex_resource.rb index 34f75a562..dbfe0bda2 100644 --- a/app/resources/v1/operator_document_annex_resource.rb +++ b/app/resources/v1/operator_document_annex_resource.rb @@ -7,9 +7,9 @@ class OperatorDocumentAnnexResource < BaseResource include Privateable caching - attributes :name, - :start_date, :expire_date, :status, :invalidation_reason, :attachment, - :uploaded_by, :created_at, :updated_at + attributes :name, :start_date, :expire_date, :status, :invalidation_reason, :attachment, + :uploaded_by, :created_at, :updated_at, + :source_operator_document_id, :source_annex_id privateable :show_attributes?, [:name, :invalidation_reason, :start_date, :expire_date, :status, :attachment, :uploaded_by, :created_at, :updated_at] @@ -21,13 +21,15 @@ class OperatorDocumentAnnexResource < BaseResource before_create :set_user_id, :set_status_pending, :set_public def self.updatable_fields(context) - [:name, :start_date, :expire_date, :attachment] + [:name, :start_date, :expire_date, :attachment, :source_operator_document_id, :source_annex_id] end def self.creatable_fields(context) updatable_fields(context) + [:operator_document] end + delegate :attachment=, to: :@model + def operator_document_id=(operator_document_id) od = OperatorDocument.find operator_document_id @model.operator_document = od # this will also set @model.annex_document @@ -37,6 +39,32 @@ def operator_document_id=(operator_document_id) @model.annex_documents_history << adh end + def source_operator_document_id + nil + end + + def source_operator_document_id=(id) + source = OperatorDocument.find(id) + unless current_user_operator_ids.include?(source.operator_id) + raise APIController::UnprocessableContentError, "source-operator-document-id must belong to your operator" + end + + self.attachment = File.open(source.document_file.attachment.path) + end + + def source_annex_id + nil + end + + def source_annex_id=(id) + source = OperatorDocumentAnnex.find(id) + unless current_user_operator_ids.include?(source.operator_document&.operator_id) + raise APIController::UnprocessableContentError, "source-annex-id must belong to your operator" + end + + self.attachment = File.open(source.attachment.path) + end + def set_user_id if context[:current_user].present? @model.user_id = context[:current_user].id @@ -84,5 +112,9 @@ def belongs_to_user? user.is_operator?(@model.operator&.id) end + + def current_user_operator_ids + context[:current_user]&.operator_ids || [] + end end end diff --git a/config/brakeman.ignore b/config/brakeman.ignore index b84c325a9..d2ceb490f 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -1,4 +1,97 @@ { - "ignored_warnings": [], - "brakeman_version": "7.1.0" + "ignored_warnings": [ + { + "warning_type": "File Access", + "warning_code": 16, + "fingerprint": "024f9c6c74d2f952c245a67b12de951edc532e5d570da663e2da9fe004a2c8ba", + "check_name": "FileAccess", + "message": "Model attribute used in file name", + "file": "app/resources/concerns/operator_documentable.rb", + "line": 73, + "link": "https://brakemanscanner.org/docs/warning_types/file_access/", + "code": "File.open(OperatorDocument.find(id).document_file.attachment.path)", + "render_path": null, + "location": { + "type": "method", + "class": "OperatorDocumentable", + "method": "source_operator_document_id=" + }, + "user_input": "OperatorDocument.find(id).document_file.attachment.path", + "confidence": "Medium", + "cwe_id": [ + 22 + ], + "note": "" + }, + { + "warning_type": "File Access", + "warning_code": 16, + "fingerprint": "029b1df6a60878f89705b53f34359caa764d990e011d24ac1bd22a263c66e323", + "check_name": "FileAccess", + "message": "Model attribute used in file name", + "file": "app/resources/v1/operator_document_annex_resource.rb", + "line": 44, + "link": "https://brakemanscanner.org/docs/warning_types/file_access/", + "code": "File.open(OperatorDocument.find(id).document_file.attachment.path)", + "render_path": null, + "location": { + "type": "method", + "class": "V1::OperatorDocumentAnnexResource", + "method": "source_operator_document_id=" + }, + "user_input": "OperatorDocument.find(id).document_file.attachment.path", + "confidence": "Medium", + "cwe_id": [ + 22 + ], + "note": "" + }, + { + "warning_type": "File Access", + "warning_code": 16, + "fingerprint": "02f13e5f55adc59afe435b7296349aaeee025c456004334f0f70925597a3132f", + "check_name": "FileAccess", + "message": "Model attribute used in file name", + "file": "app/resources/concerns/operator_documentable.rb", + "line": 86, + "link": "https://brakemanscanner.org/docs/warning_types/file_access/", + "code": "File.open(OperatorDocumentAnnex.find(id).attachment.path)", + "render_path": null, + "location": { + "type": "method", + "class": "OperatorDocumentable", + "method": "source_annex_id=" + }, + "user_input": "OperatorDocumentAnnex.find(id).attachment.path", + "confidence": "Medium", + "cwe_id": [ + 22 + ], + "note": "" + }, + { + "warning_type": "File Access", + "warning_code": 16, + "fingerprint": "49345b1c4e5bd083953300f9545745fbc0dd77f23f7070aba71cd6f04cc0011d", + "check_name": "FileAccess", + "message": "Model attribute used in file name", + "file": "app/resources/v1/operator_document_annex_resource.rb", + "line": 57, + "link": "https://brakemanscanner.org/docs/warning_types/file_access/", + "code": "File.open(OperatorDocumentAnnex.find(id).attachment.path)", + "render_path": null, + "location": { + "type": "method", + "class": "V1::OperatorDocumentAnnexResource", + "method": "source_annex_id=" + }, + "user_input": "OperatorDocumentAnnex.find(id).attachment.path", + "confidence": "Medium", + "cwe_id": [ + 22 + ], + "note": "" + } + ], + "brakeman_version": "8.0.4" } diff --git a/config/initializers/jsonapi_resources.rb b/config/initializers/jsonapi_resources.rb index 0a904e498..24b91794f 100644 --- a/config/initializers/jsonapi_resources.rb +++ b/config/initializers/jsonapi_resources.rb @@ -9,7 +9,7 @@ config.always_include_to_one_linkage_data = false config.warn_on_missing_routes = false config.default_exclude_links = :default - config.exception_class_whitelist = [CanCan::AccessDenied] + config.exception_class_whitelist = [CanCan::AccessDenied, "APIController::UnprocessableContentError"] # Metadata # Output record count in top level meta for find operation diff --git a/spec/integration/v1/operator_document_annexes_spec.rb b/spec/integration/v1/operator_document_annexes_spec.rb index a5fc08923..ff4850e65 100644 --- a/spec/integration/v1/operator_document_annexes_spec.rb +++ b/spec/integration/v1/operator_document_annexes_spec.rb @@ -288,4 +288,75 @@ module V1 end end end + + describe "File reuse" do + let(:operator_document) { create(:operator_document_fmu, operator: operator_user.operator) } + let(:source_annex) { create(:operator_document_annex, operator_document: operator_document) } + let(:other_operator_document) { create(:operator_document_fmu) } + let(:other_source_annex) { create(:operator_document_annex, operator_document: other_operator_document) } + + let(:base_params) { + { + name: "Reused annex", + "start-date": Time.zone.today.to_s, + relationships: {"operator-document": operator_document.id} + } + } + + describe "source-operator-document-id" do + context "when same operator" do + it "creates annex copying attachment from the operator document" do + post( + "/operator-document-annexes", + params: jsonapi_params("operator-document-annexes", nil, base_params.merge("source-operator-document-id": operator_document.id)), + headers: operator_user_headers + ) + + expect(status).to eq(201) + expect(OperatorDocumentAnnex.find(parsed_data[:id]).attachment_identifier).to be_present + end + end + + context "when different operator" do + it "returns 422 with error message" do + post( + "/operator-document-annexes", + params: jsonapi_params("operator-document-annexes", nil, base_params.merge("source-operator-document-id": other_operator_document.id)), + headers: operator_user_headers + ) + + expect(status).to eq(422) + expect(parsed_body[:errors].first[:title]).to include("must belong to your operator") + end + end + end + + describe "source-annex-id" do + context "when same operator" do + it "creates annex copying attachment from another annex" do + post( + "/operator-document-annexes", + params: jsonapi_params("operator-document-annexes", nil, base_params.merge("source-annex-id": source_annex.id)), + headers: operator_user_headers + ) + + expect(status).to eq(201) + expect(OperatorDocumentAnnex.find(parsed_data[:id]).attachment_identifier).to be_present + end + end + + context "when different operator" do + it "returns 422 with error message" do + post( + "/operator-document-annexes", + params: jsonapi_params("operator-document-annexes", nil, base_params.merge("source-annex-id": other_source_annex.id)), + headers: operator_user_headers + ) + + expect(status).to eq(422) + expect(parsed_body[:errors].first[:title]).to include("must belong to your operator") + end + end + end + end end diff --git a/spec/integration/v1/operator_documents_spec.rb b/spec/integration/v1/operator_documents_spec.rb index af5e10c65..5a382a707 100644 --- a/spec/integration/v1/operator_documents_spec.rb +++ b/spec/integration/v1/operator_documents_spec.rb @@ -214,5 +214,76 @@ def sign_publication_authorization! end end end + + describe "File reuse" do + let(:operator_document) { create(:operator_document_fmu, operator: operator_user.operator) } + let(:source_operator_document) { create(:operator_document_fmu, operator: operator_user.operator) } + let(:source_annex) { create(:operator_document_annex, operator_document: source_operator_document) } + let(:other_operator_document) { create(:operator_document_fmu) } + let(:other_annex) { create(:operator_document_annex, operator_document: other_operator_document) } + + let(:base_params) { + { + "start-date": "2025-12-01", + "expire-date": "2040-12-01" + } + } + + describe "source-operator-document-id" do + context "when same operator" do + it "uploads document copying attachment from another operator document" do + patch( + "/operator-document-fmus/#{operator_document.id}", + params: jsonapi_params("operator-document-fmus", operator_document.id, base_params.merge("source-operator-document-id": source_operator_document.id)), + headers: operator_user_headers + ) + + expect(status).to eq(200) + expect(OperatorDocument.find(operator_document.id).document_file.attachment_identifier).to be_present + end + end + + context "when different operator" do + it "returns 422 with error message" do + patch( + "/operator-document-fmus/#{operator_document.id}", + params: jsonapi_params("operator-document-fmus", operator_document.id, base_params.merge("source-operator-document-id": other_operator_document.id)), + headers: operator_user_headers + ) + + expect(status).to eq(422) + expect(parsed_body[:errors].first[:title]).to include("must belong to your operator") + end + end + end + + describe "source-annex-id" do + context "when same operator" do + it "uploads document copying attachment from annex" do + patch( + "/operator-document-fmus/#{operator_document.id}", + params: jsonapi_params("operator-document-fmus", operator_document.id, base_params.merge("source-annex-id": source_annex.id)), + headers: operator_user_headers + ) + + expect(status).to eq(200) + expect(OperatorDocument.find(operator_document.id).document_file.attachment_identifier).to be_present + end + end + + context "when different operator" do + it "returns 422 with error message" do + patch( + "/operator-document-fmus/#{operator_document.id}", + params: jsonapi_params("operator-document-fmus", operator_document.id, base_params.merge("source-annex-id": other_annex.id)), + headers: operator_user_headers + ) + + expect(status).to eq(422) + expect(parsed_body[:errors].first[:title]).to include("must belong to your operator") + end + end + end + end end end