diff --git a/app/controllers/eeid/webhooks/identification_requests_controller.rb b/app/controllers/eeid/webhooks/identification_requests_controller.rb index 346cd03c1..219ec1375 100644 --- a/app/controllers/eeid/webhooks/identification_requests_controller.rb +++ b/app/controllers/eeid/webhooks/identification_requests_controller.rb @@ -16,7 +16,10 @@ module Eeid return render_unauthorized unless ip_whitelisted? return render_invalid_signature unless valid_hmac_signature?(request.headers['X-HMAC-Signature']) - verify_contact(permitted_params[:reference]) + contact = Contact.find_by_code(permitted_params[:reference]) + poi = catch_poi + verify_contact(contact) + inform_registrar(contact, poi) render json: { status: 'success' }, status: :ok rescue StandardError => e handle_error(e) @@ -42,17 +45,32 @@ module Eeid ActiveSupport::SecurityUtils.secure_compare(computed_signature, hmac_signature) end - def verify_contact(ref) - contact = Contact.find_by_code(ref) - + def verify_contact(contact) + ref = permitted_params[:reference] if contact&.ident_request_sent_at.present? - contact.update(verified_at: Time.zone.now) + contact.update(verified_at: Time.zone.now, verification_id: permitted_params[:identification_request_id]) Rails.logger.info("Contact verified: #{ref}") else Rails.logger.error("Valid contact not found for reference: #{ref}") end end + def catch_poi + ident_service = Eeid::IdentificationService.new + response = ident_service.get_proof_of_identity(permitted_params[:identification_request_id]) + raise StandardError, response[:error] if response[:error].present? + + response[:data] + end + + def inform_registrar(contact, poi) + email = contact&.registrar&.email + return unless email + + RegistrarMailer.contact_verified(email: email, contact: contact, poi: poi) + .deliver_now + end + def ip_whitelisted? allowed_ips = ENV['webhook_allowed_ips'].to_s.split(',').map(&:strip) @@ -67,7 +85,7 @@ module Eeid def handle_error(error) Rails.logger.error("Error handling webhook: #{error.message}") - render json: { error: 'Internal Server Error' }, status: :internal_server_error + render json: { error: error.message }, status: :internal_server_error end def handle_throttle_error diff --git a/app/controllers/repp/v1/contacts_controller.rb b/app/controllers/repp/v1/contacts_controller.rb index 623722cb8..7b60d8b23 100644 --- a/app/controllers/repp/v1/contacts_controller.rb +++ b/app/controllers/repp/v1/contacts_controller.rb @@ -2,10 +2,10 @@ require 'serializers/repp/contact' module Repp module V1 class ContactsController < BaseController # rubocop:disable Metrics/ClassLength - before_action :find_contact, only: %i[show update destroy verify] - skip_around_action :log_request, only: :search + before_action :find_contact, only: %i[show update destroy verify download_poi] + skip_around_action :log_request, only: %i[search] - THROTTLED_ACTIONS = %i[index check search create show update destroy verify].freeze + THROTTLED_ACTIONS = %i[index check search create show update destroy verify download_poi].freeze include Shunter::Integration::Throttle api :get, '/repp/v1/contacts' @@ -132,6 +132,19 @@ module Repp render_success(data: data) end + api :get, '/repp/v1/contacts/download_poi/:contact_code' + desc 'Get proof of identity pdf file for a contact' + def download_poi + authorize! :verify, Epp::Contact + ident_service = Eeid::IdentificationService.new + response = ident_service.get_proof_of_identity(@contact.verification_id) + + send_data response[:data], filename: "proof_of_identity_#{@contact.verification_id}.pdf", + type: 'application/pdf', disposition: 'inline' + rescue Eeid::IdentError => e + handle_non_epp_errors(@contact, e.message) + end + private def index_params diff --git a/app/interactions/actions/contact_verify.rb b/app/interactions/actions/contact_verify.rb index d0fedb3ec..8b9fd4fd0 100644 --- a/app/interactions/actions/contact_verify.rb +++ b/app/interactions/actions/contact_verify.rb @@ -23,8 +23,8 @@ module Actions def create_identification_request ident_service = Eeid::IdentificationService.new - request = ident_service.create_identification_request(request_payload) - ContactMailer.identification_requested(contact: contact, link: request['link']).deliver_now + response = ident_service.create_identification_request(request_payload) + ContactMailer.identification_requested(contact: contact, link: response['link']).deliver_now rescue Eeid::IdentError => e Rails.logger.error e.message contact.errors.add(:base, :verification_error) diff --git a/app/mailers/registrar_mailer.rb b/app/mailers/registrar_mailer.rb new file mode 100644 index 000000000..aafc1e8ff --- /dev/null +++ b/app/mailers/registrar_mailer.rb @@ -0,0 +1,10 @@ +class RegistrarMailer < ApplicationMailer + helper ApplicationHelper + + def contact_verified(email:, contact:, poi:) + @contact = contact + subject = 'Successful Contact Verification' + attachments['proof_of_identity.pdf'] = poi + mail(to: email, subject: subject) + end +end diff --git a/app/services/eeid/base.rb b/app/services/eeid/base.rb index 78b6716f8..2bf552221 100644 --- a/app/services/eeid/base.rb +++ b/app/services/eeid/base.rb @@ -85,10 +85,17 @@ module Eeid end def handle_response(response) - parsed_response = JSON.parse(response.body) + case response['content-type'] + when 'application/pdf', 'application/octet-stream' + parsed_response = { data: response.body, message: response['content-disposition'] } + when %r{application/json} + parsed_response = JSON.parse(response.body).with_indifferent_access + else + raise IdentError, 'Unsupported content type' + end + raise IdentError, parsed_response['error'] unless response.is_a?(Net::HTTPSuccess) - Rails.logger.debug("Request successful: #{response.body}") parsed_response end diff --git a/app/services/eeid/identification_service.rb b/app/services/eeid/identification_service.rb index a3ebc321b..5757b307c 100644 --- a/app/services/eeid/identification_service.rb +++ b/app/services/eeid/identification_service.rb @@ -17,5 +17,9 @@ module Eeid def get_identification_request(id) request_endpoint("/api/ident/v1/identification_requests/#{id}") end + + def get_proof_of_identity(id) + request_endpoint("/api/ident/v1/identification_requests/#{id}/proof_of_identity") + end end end diff --git a/app/views/mailers/registrar_mailer/contact_verified.html.erb b/app/views/mailers/registrar_mailer/contact_verified.html.erb new file mode 100644 index 000000000..5a22df771 --- /dev/null +++ b/app/views/mailers/registrar_mailer/contact_verified.html.erb @@ -0,0 +1,44 @@ +Tere, +

+

+Anname teada, et järgmise kontakti kinnitamisprotsess on edukalt lõpule viidud. +

+

Kontaktandmed:

+ +

+Täielikud tulemused leiate manuses olevast PDF-failist. +

+Kui Teil on küsimusi seoses kontakti kinnitamise või muude teenustega registripidaja portaalis, võtke meiega ühendust. +

+

+Parimate soovidega, +

+<%= render 'mailers/shared/signatures/signature.et.html' %> +
+

+ +Hi, +

+

+We are writing to inform you that the verification process for the following contact has been successfully completed. +

+

Contact Details:

+ +

+The full result can be found in the attached PDF file. +

+

+If you have any questions regarding the contact verification or other services in the Registrar Portal, please do not hesitate to reach out to us. +

+

+Best regards, +

+<%= render 'mailers/shared/signatures/signature.en.html' %> \ No newline at end of file diff --git a/app/views/mailers/registrar_mailer/contact_verified.text.erb b/app/views/mailers/registrar_mailer/contact_verified.text.erb new file mode 100644 index 000000000..17cb6e323 --- /dev/null +++ b/app/views/mailers/registrar_mailer/contact_verified.text.erb @@ -0,0 +1,32 @@ +Tere, + +Anname teada, et järgmise kontakti kinnitamisprotsess on edukalt lõpule viidud. + +Kontaktandmed: +- Kood: <%= @contact.code %> +- Nimi: <%= @contact.name %> +- Identifikaator: <%= ident_for(@contact) %> + +Täielikke tulemusi saab vaadata kontakti lehelt registripidaja portaalis. + +Kui Teil on küsimusi seoses kontakti kinnitamise või muude teenustega registripidaja portaalis, võtke meiega ühendust. + +Parimate soovidega, +<%= render 'mailers/shared/signatures/signature.et.text' %> +--- + +Hi, + +We are writing to inform you that the verification process for the following contact has been successfully completed. + +Contact Details: +- Code: <%= @contact.code %> +- Name: <%= @contact.name %> +- Ident: <%= ident_for(@contact) %> + +The full result can be viewed on the contact page in the Registrar Portal. + +If you have any questions regarding the contact verification or other services in the Registrar Portal, please do not hesitate to reach out to us. + +Best regards, +<%= render 'mailers/shared/signatures/signature.en.text' %> \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 2b12f0fda..3a1c538a8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -75,6 +75,7 @@ Rails.application.routes.draw do get 'check/:id', to: 'contacts#check' get 'search(/:id)', to: 'contacts#search' post 'verify/:id', to: 'contacts#verify' + get 'download_poi/:id', to: 'contacts#download_poi' end end end diff --git a/db/migrate/20241015071505_add_verification_id_to_contacts.rb b/db/migrate/20241015071505_add_verification_id_to_contacts.rb new file mode 100644 index 000000000..7aff96abc --- /dev/null +++ b/db/migrate/20241015071505_add_verification_id_to_contacts.rb @@ -0,0 +1,5 @@ +class AddVerificationIdToContacts < ActiveRecord::Migration[6.1] + def change + add_column :contacts, :verification_id, :string + end +end diff --git a/db/structure.sql b/db/structure.sql index 82f10a207..d73bf3ebe 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -696,7 +696,8 @@ CREATE TABLE public.contacts ( checked_company_at timestamp without time zone, company_register_status character varying ident_request_sent_at timestamp without time zone, - verified_at timestamp without time zone + verified_at timestamp without time zone, + verification_id character varying ); @@ -5613,6 +5614,7 @@ INSERT INTO "schema_migrations" (version) VALUES ('20240816091049'), ('20240816092636'), ('20240903131540'), -('20240924103554'); +('20240924103554'), +('20241015071505'); diff --git a/test/integration/eeid/identification_requests_webhook_test.rb b/test/integration/eeid/identification_requests_webhook_test.rb index 26bbac6cd..625cfa3a5 100644 --- a/test/integration/eeid/identification_requests_webhook_test.rb +++ b/test/integration/eeid/identification_requests_webhook_test.rb @@ -11,6 +11,15 @@ class Eeid::IdentificationRequestsWebhookTest < ActionDispatch::IntegrationTest } @valid_hmac_signature = OpenSSL::HMAC.hexdigest('SHA256', @secret, payload.to_json) + stub_request(:post, %r{api/auth/v1/token}) + .to_return( + status: 200, + body: { access_token: 'token', token_type: 'Bearer', expires_in: 100 }.to_json, headers: {} + ) + pdf_content = File.read(Rails.root.join('test/fixtures/files/legaldoc.pdf')) + stub_request(:get, %r{api/ident/v1/identification_requests}) + .to_return(status: 200, body: pdf_content, headers: { 'Content-Type' => 'application/pdf' }) + adapter = ENV['shunter_default_adapter'].constantize.new adapter&.clear! end @@ -22,6 +31,8 @@ class Eeid::IdentificationRequestsWebhookTest < ActionDispatch::IntegrationTest assert_response :ok assert_equal({ 'status' => 'success' }, JSON.parse(response.body)) assert_not_nil @contact.reload.verified_at + assert_equal @contact.verification_id, '123' + assert_notify_registrar('Successful Contact Verification') end test 'should return unauthorized for invalid HMAC signature' do @@ -29,13 +40,15 @@ class Eeid::IdentificationRequestsWebhookTest < ActionDispatch::IntegrationTest assert_response :unauthorized assert_equal({ 'error' => 'Invalid HMAC signature' }, JSON.parse(response.body)) + assert_emails 0 end test 'should return unauthorized for missing parameters' do post '/eeid/webhooks/identification_requests', params: { reference: @contact.code }, as: :json, headers: { 'X-HMAC-Signature' => @valid_hmac_signature } assert_response :unauthorized - assert_equal({ 'error' => 'Invalid HMAC signature' }, JSON.parse(response.body)) + assert_equal({ 'error' => 'Invalid HMAC signature' }, JSON.parse(response.body)) + assert_emails 0 end test 'should handle internal server error gracefully' do @@ -44,10 +57,24 @@ class Eeid::IdentificationRequestsWebhookTest < ActionDispatch::IntegrationTest post '/eeid/webhooks/identification_requests', params: { identification_request_id: '123', reference: @contact.code }, as: :json, headers: { 'X-HMAC-Signature' => @valid_hmac_signature } assert_response :internal_server_error - assert_equal({ 'error' => 'Internal Server Error' }, JSON.parse(response.body)) + assert_equal({ 'error' => 'Simulated error' }, JSON.parse(response.body)) + assert_emails 0 end end + test 'should handle error from ident response' do + stub_request(:get, %r{api/ident/v1/identification_requests}) + .to_return(status: :not_found, body: { error: 'Proof of identity not found' }.to_json, headers: { 'Content-Type' => 'application/json' }) + + @contact.update!(ident_request_sent_at: Time.zone.now - 1.day) + post '/eeid/webhooks/identification_requests', params: { identification_request_id: '123', reference: @contact.code }, as: :json, headers: { 'X-HMAC-Signature' => @valid_hmac_signature } + + assert_response :internal_server_error + assert_equal({ 'error' => 'Proof of identity not found' }, JSON.parse(response.body)) + assert_emails 0 + assert_nil @contact.reload.verified_at + end + test 'returns error response if throttled' do ENV['shunter_default_threshold'] = '1' ENV['shunter_enabled'] = 'true' @@ -60,4 +87,15 @@ class Eeid::IdentificationRequestsWebhookTest < ActionDispatch::IntegrationTest ENV['shunter_default_threshold'] = '10000' ENV['shunter_enabled'] = 'false' end + + private + + def assert_notify_registrar(subject) + assert_emails 1 + email = ActionMailer::Base.deliveries.last + assert_equal [@contact.registrar.email], email.to + assert_equal subject, email.subject + assert_equal 1, email.attachments.size + assert_equal 'proof_of_identity.pdf', email.attachments.first.filename + end end diff --git a/test/integration/repp/v1/contacts/download_poi_test.rb b/test/integration/repp/v1/contacts/download_poi_test.rb new file mode 100644 index 000000000..1d943dc5c --- /dev/null +++ b/test/integration/repp/v1/contacts/download_poi_test.rb @@ -0,0 +1,72 @@ +require 'test_helper' + +class ReppV1ContactsDownloadPoiTest < ActionDispatch::IntegrationTest + def setup + @contact = contacts(:john) + @user = users(:api_bestnames) + token = Base64.encode64("#{@user.username}:#{@user.plain_text_password}") + token = "Basic #{token}" + + @auth_headers = { 'Authorization' => token } + + adapter = ENV['shunter_default_adapter'].constantize.new + adapter&.clear! + + stub_request(:post, %r{api/auth/v1/token}) + .to_return( + status: 200, + body: { access_token: 'token', token_type: 'Bearer', expires_in: 100 }.to_json, headers: {} + ) + pdf_content = File.read(Rails.root.join('test/fixtures/files/legaldoc.pdf')) + stub_request(:get, %r{api/ident/v1/identification_requests}) + .to_return(status: 200, body: pdf_content, headers: { 'Content-Type' => 'application/pdf' }) + end + + def test_returns_error_when_not_found + get '/repp/v1/contacts/download_poi/nonexistant:code', headers: @auth_headers + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :not_found + assert_equal 2303, json[:code] + assert_equal 'Object does not exist', json[:message] + end + + def test_downloads_poi_for_contact + @contact.update!(verified_at: Time.zone.now - 1.day, verification_id: '123') + get "/repp/v1/contacts/download_poi/#{@contact.code}", headers: @auth_headers + + assert_response :ok + assert_equal 'application/pdf', response.headers['Content-Type'] + assert_equal "inline; filename=\"proof_of_identity_123.pdf\"; filename*=UTF-8''proof_of_identity_123.pdf", response.headers['Content-Disposition'] + assert_not_empty response.body + end + + def test_handles_non_epp_error + stub_request(:get, %r{api/ident/v1/identification_requests}) + .to_return( + status: :not_found, + body: { error: 'Proof of identity not found' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + get "/repp/v1/contacts/download_poi/#{@contact.code}", headers: @auth_headers + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :bad_request + assert_equal 'Proof of identity not found', json[:message] + end + + def test_returns_error_response_if_throttled + ENV['shunter_default_threshold'] = '1' + ENV['shunter_enabled'] = 'true' + + get "/repp/v1/contacts/download_poi/#{@contact.code}", headers: @auth_headers + get "/repp/v1/contacts/download_poi/#{@contact.code}", headers: @auth_headers + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :bad_request + assert_equal json[:code], 2502 + assert response.body.include?(Shunter.default_error_message) + ENV['shunter_default_threshold'] = '10000' + ENV['shunter_enabled'] = 'false' + end +end diff --git a/test/integration/repp/v1/contacts/verify_test.rb b/test/integration/repp/v1/contacts/verify_test.rb index 3a48b4552..1d62c4b83 100644 --- a/test/integration/repp/v1/contacts/verify_test.rb +++ b/test/integration/repp/v1/contacts/verify_test.rb @@ -12,14 +12,18 @@ class ReppV1ContactsVerifyTest < ActionDispatch::IntegrationTest adapter = ENV['shunter_default_adapter'].constantize.new adapter&.clear! - stub_request(:post, %r{api/auth/v1/token}).to_return(status: 200, body: { access_token: 'token', token_type: 'Bearer', expires_in: 100 }.to_json, headers: {}) + stub_request(:post, %r{api/auth/v1/token}) + .to_return( + status: 200, + body: { access_token: 'token', token_type: 'Bearer', expires_in: 100 }.to_json, headers: {} + ) stub_request(:post, %r{api/ident/v1/identification_requests}) .with( body: { claims_required: [{ type: 'sub', value: "#{@contact.ident_country_code}#{@contact.ident}" }], reference: @contact.code } - ).to_return(status: 200, body: { id: '123' }.to_json, headers: {}) + ).to_return(status: 200, body: { id: '123' }.to_json, headers: { 'Content-Type' => 'application/json' }) end def test_returns_error_when_not_found @@ -43,6 +47,20 @@ class ReppV1ContactsVerifyTest < ActionDispatch::IntegrationTest assert contact.present? assert contact.ident_request_sent_at assert_nil contact.verified_at + assert_notify_contact('Identification requested') + end + + def test_handles_non_epp_error + stub_request(:post, %r{api/ident/v1/identification_requests}) + .to_return(status: :unprocessable_entity, body: { error: 'error' }.to_json, headers: { 'Content-Type' => 'application/json' }) + + post "/repp/v1/contacts/verify/#{@contact.code}", headers: @auth_headers + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :bad_request + assert_equal 'Sending identification request failed', json[:message] + assert_nil @contact.ident_request_sent_at + assert_emails 0 end def test_does_not_verify_already_verified_contact @@ -68,4 +86,13 @@ class ReppV1ContactsVerifyTest < ActionDispatch::IntegrationTest ENV['shunter_default_threshold'] = '10000' ENV['shunter_enabled'] = 'false' end + + private + + def assert_notify_contact(subject) + assert_emails 1 + email = ActionMailer::Base.deliveries.last + assert_equal [@contact.email], email.to + assert_equal subject, email.subject + end end