diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 5a6b45668..f831f29f5 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -25,6 +25,7 @@ class ApplicationController < ActionController::Base
def comma_support_for(parent_key, key)
return if params[parent_key].blank?
return if params[parent_key][key].blank?
+
params[parent_key][key].sub!(/,/, '.')
end
diff --git a/app/controllers/eeid/webhooks/identification_requests_controller.rb b/app/controllers/eeid/webhooks/identification_requests_controller.rb
new file mode 100644
index 000000000..1c82969d6
--- /dev/null
+++ b/app/controllers/eeid/webhooks/identification_requests_controller.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+module Eeid
+ module Webhooks
+ # Controller for handling eeID identification requests webhook
+ class IdentificationRequestsController < ActionController::Base
+ THROTTLED_ACTIONS = %i[create].freeze
+ include Shunter::Integration::Throttle
+
+ rescue_from Shunter::ThrottleError, with: :handle_throttle_error
+
+ # POST /eeid/webhooks/identification_requests
+ def create
+ return render_unauthorized unless ip_whitelisted?
+ return render_invalid_signature unless valid_hmac_signature?(request.headers['X-HMAC-Signature'])
+
+ verify_contact(permitted_params[:reference])
+ render json: { status: 'success' }, status: :ok
+ rescue StandardError => e
+ Rails.logger.error("Error handling webhook: #{e.message}")
+ render json: { error: 'Internal Server Error' }, status: :internal_server_error
+ end
+
+ private
+
+ def permitted_params
+ params.permit(:identification_request_id, :reference, :client_id)
+ end
+
+ def render_unauthorized
+ render json: { error: "IPAddress #{request.remote_ip} not authorized" }, status: :unauthorized
+ end
+
+ def render_invalid_signature
+ render json: { error: 'Invalid HMAC signature' }, status: :unauthorized
+ end
+
+ def valid_hmac_signature?(hmac_signature)
+ secret = ENV['ident_service_client_secret']
+ computed_signature = OpenSSL::HMAC.hexdigest('SHA256', secret, request.raw_post)
+ ActiveSupport::SecurityUtils.secure_compare(computed_signature, hmac_signature)
+ end
+
+ def verify_contact(ref)
+ contact = Contact.find_by_code(ref)
+
+ if contact&.ident_request_sent_at.present?
+ contact.update(verified_at: Time.zone.now)
+ Rails.logger.info("Contact verified: #{ref}")
+ else
+ Rails.logger.error("Valid contact not found for reference: #{ref}")
+ end
+ end
+
+ def ip_whitelisted?
+ allowed_ips = ENV['webhook_allowed_ips'].to_s.split(',').map(&:strip)
+
+ allowed_ips.include?(request.remote_ip) || Rails.env.development?
+ end
+
+ # Mock throttled_user using request IP
+ def throttled_user
+ # Create a mock user-like object with the request IP
+ OpenStruct.new(id: request.remote_ip, class: 'WebhookRequest')
+ end
+
+ def handle_throttle_error
+ render json: { error: Shunter.default_error_message }, status: :bad_request
+ end
+ end
+ end
+end
diff --git a/app/controllers/repp/v1/contacts_controller.rb b/app/controllers/repp/v1/contacts_controller.rb
index 83b98835b..623722cb8 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]
+ before_action :find_contact, only: %i[show update destroy verify]
skip_around_action :log_request, only: :search
- THROTTLED_ACTIONS = %i[index check search create show update destroy].freeze
+ THROTTLED_ACTIONS = %i[index check search create show update destroy verify].freeze
include Shunter::Integration::Throttle
api :get, '/repp/v1/contacts'
@@ -116,6 +116,22 @@ module Repp
render_success
end
+ api :POST, '/repp/v1/contacts/verify/:contact_code'
+ desc 'Generate and send identification request to a contact'
+ def verify
+ authorize! :verify, Epp::Contact
+ action = Actions::ContactVerify.new(@contact)
+
+ unless action.call
+ handle_non_epp_errors(@contact)
+ return
+ end
+
+ data = { contact: { code: params[:id] } }
+
+ render_success(data: data)
+ end
+
private
def index_params
@@ -217,7 +233,8 @@ module Repp
end
def contact_params
- params.require(:contact).permit(:id, :name, :email, :phone, :legal_document,
+ params.require(:contact).permit(:id, :name, :email, :phone, :legal_document, :verified,
+ :verification_link,
legal_document: %i[body type],
ident: [%i[ident ident_type ident_country_code]],
addr: [%i[country_code city street zip state]])
diff --git a/app/interactions/actions/contact_verify.rb b/app/interactions/actions/contact_verify.rb
new file mode 100644
index 000000000..d0fedb3ec
--- /dev/null
+++ b/app/interactions/actions/contact_verify.rb
@@ -0,0 +1,47 @@
+module Actions
+ class ContactVerify
+ attr_reader :contact
+
+ def initialize(contact)
+ @contact = contact
+ end
+
+ def call
+ if contact.verified_at.present?
+ contact.errors.add(:base, :verification_exists)
+ return
+ end
+
+ create_identification_request
+
+ return false if contact.errors.any?
+
+ commit
+ end
+
+ private
+
+ 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
+ rescue Eeid::IdentError => e
+ Rails.logger.error e.message
+ contact.errors.add(:base, :verification_error)
+ end
+
+ def request_payload
+ {
+ claims_required: [{
+ type: 'sub',
+ value: "#{contact.ident_country_code}#{contact.ident}"
+ }],
+ reference: contact.code
+ }
+ end
+
+ def commit
+ @contact.update(ident_request_sent_at: Time.zone.now)
+ end
+ end
+end
diff --git a/app/mailers/contact_mailer.rb b/app/mailers/contact_mailer.rb
index 81d215386..f9197b0f8 100644
--- a/app/mailers/contact_mailer.rb
+++ b/app/mailers/contact_mailer.rb
@@ -9,6 +9,14 @@ class ContactMailer < ApplicationMailer
mail(to: contact.email, bcc: old_email, subject: subject)
end
+ def identification_requested(contact:, link:)
+ @contact = contact
+ @verification_link = link
+
+ subject = default_i18n_subject(contact_code: contact.code)
+ mail(to: contact.email, subject: subject)
+ end
+
private
def address_processing
diff --git a/app/models/ability.rb b/app/models/ability.rb
index 7f3a96177..ba6971dcf 100644
--- a/app/models/ability.rb
+++ b/app/models/ability.rb
@@ -74,6 +74,7 @@ class Ability
can(:delete, Epp::Contact) { |c, pw| c.registrar_id == @user.registrar_id || c.auth_info == pw }
can(:renew, Epp::Contact)
can(:transfer, Epp::Contact)
+ can(:verify, Epp::Contact)
can(:view_password, Epp::Contact) { |c, pw| c.registrar_id == @user.registrar_id || c.auth_info == pw }
end
diff --git a/app/services/eeid/base.rb b/app/services/eeid/base.rb
new file mode 100644
index 000000000..b44872272
--- /dev/null
+++ b/app/services/eeid/base.rb
@@ -0,0 +1,103 @@
+# frozen_string_literal: true
+
+module Eeid
+ class IdentError < StandardError; end
+
+ # Base class for handling EEID identification requests.
+ class Base
+ BASE_URL = ENV['eeid_base_url']
+ TOKEN_ENDPOINT = '/api/auth/v1/token'
+
+ def initialize(client_id, client_secret)
+ @client_id = client_id
+ @client_secret = client_secret
+ @token = nil
+ end
+
+ def request_endpoint(endpoint, method: :get, body: nil)
+ Rails.logger.debug("Requesting endpoint: #{endpoint} with method: #{method}")
+ authenticate unless @token
+ request = build_request(endpoint, method, body)
+ response = send_request(request)
+ handle_response(response)
+ rescue StandardError => e
+ handle_error(e)
+ end
+
+ def authenticate
+ Rails.logger.debug("Authenticating with client_id: #{@client_id}")
+ uri = URI.parse("#{BASE_URL}#{TOKEN_ENDPOINT}")
+ request = build_auth_request(uri)
+
+ response = send_auth_request(uri, request)
+ handle_auth_response(response)
+ end
+
+ private
+
+ def build_auth_request(uri)
+ request = Net::HTTP::Post.new(uri)
+ request['Authorization'] = "Basic #{Base64.strict_encode64("#{@client_id}:#{@client_secret}")}"
+ request
+ end
+
+ def send_auth_request(uri, request)
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: ssl_enabled?) do |http|
+ Rails.logger.debug("Sending authentication request to #{uri}")
+ http.request(request)
+ end
+ end
+
+ def handle_auth_response(response)
+ raise IdentError, "Authentication failed: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
+
+ @token = JSON.parse(response.body)['access_token']
+ Rails.logger.debug('Authentication successful, token received')
+ end
+
+ def build_request(endpoint, method, body)
+ uri = URI.parse("#{BASE_URL}/#{endpoint}")
+ request = create_request(uri, method)
+ request['Authorization'] = "Bearer #{@token}"
+ request.body = body.to_json if body
+ request.content_type = 'application/json'
+
+ request
+ end
+
+ def create_request(uri, method)
+ case method.to_sym
+ when :get
+ Net::HTTP::Get.new(uri)
+ when :post
+ Net::HTTP::Post.new(uri)
+ else
+ raise IdentError, "Unsupported HTTP method: #{method}"
+ end
+ end
+
+ def send_request(request)
+ uri = URI.parse(request.uri.to_s)
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: ssl_enabled?) do |http|
+ Rails.logger.debug("Sending #{request.method} request to #{uri} with body: #{request.body}")
+ http.request(request)
+ end
+ end
+
+ def handle_response(response)
+ parsed_response = JSON.parse(response.body)
+ raise IdentError, parsed_response['error'] unless response.is_a?(Net::HTTPSuccess)
+
+ Rails.logger.debug("Request successful: #{response.body}")
+ parsed_response
+ end
+
+ def handle_error(exception)
+ raise IdentError, exception.message
+ end
+
+ def ssl_enabled?
+ !%w[test].include?(Rails.env)
+ end
+ end
+end
diff --git a/app/services/eeid/identification_service.rb b/app/services/eeid/identification_service.rb
new file mode 100644
index 000000000..a3ebc321b
--- /dev/null
+++ b/app/services/eeid/identification_service.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Eeid
+ # This class handles identification services.
+ class IdentificationService < Base
+ CLIENT_ID = ENV['ident_service_client_id']
+ CLIENT_SECRET = ENV['ident_service_client_secret']
+
+ def initialize
+ super(CLIENT_ID, CLIENT_SECRET)
+ end
+
+ def create_identification_request(request_params)
+ request_endpoint('/api/ident/v1/identification_requests', method: :post, body: request_params)
+ end
+
+ def get_identification_request(id)
+ request_endpoint("/api/ident/v1/identification_requests/#{id}")
+ end
+ end
+end
diff --git a/app/views/mailers/contact_mailer/identification_requested.html.erb b/app/views/mailers/contact_mailer/identification_requested.html.erb
new file mode 100644
index 000000000..b1b03c5e0
--- /dev/null
+++ b/app/views/mailers/contact_mailer/identification_requested.html.erb
@@ -0,0 +1,34 @@
+<%
+ contact = RegistrantPresenter.new(registrant: @contact, view: self)
+ registrar = RegistrarPresenter.new(registrar: @contact.registrar, view: self)
+%>
+Tere <%= contact.name %>,
+
+Teie registripidaja <%= registrar.name %> on palunud Teil kinnitada oma isikut, et jätkata domeeni toimingutega.
+
+Palun kinnitage oma isikut, et jätkata.
+
+Jätka eeID-ga
+
+Kontaktandmed:
+<%= render 'mailers/shared/registrant/registrant.et.html', registrant: contact, with_phone: true %>
+
+Probleemide korral pöörduge oma registripidaja poole:
+<%= render 'mailers/shared/registrar/registrar.et.html', registrar: registrar %>
+<%= render 'mailers/shared/signatures/signature.et.html' %>
+
+
+Hi <%= contact.name %>,
+
+Your registrar <%= registrar.name %> has requested that you verify your identity in order to proceed with domain actions.
+
+Please confirm your identity to continue.
+
+Continue with eeID
+
+Contact information:
+<%= render 'mailers/shared/registrant/registrant.en.html', registrant: contact, with_phone: true %>
+
+In case of problems please turn to your registrar:
+<%= render 'mailers/shared/registrar/registrar.en.html', registrar: registrar %>
+<%= render 'mailers/shared/signatures/signature.en.html' %>
\ No newline at end of file
diff --git a/app/views/mailers/contact_mailer/identification_requested.text.erb b/app/views/mailers/contact_mailer/identification_requested.text.erb
new file mode 100644
index 000000000..a1a8e1d5f
--- /dev/null
+++ b/app/views/mailers/contact_mailer/identification_requested.text.erb
@@ -0,0 +1,31 @@
+<%
+ contact = RegistrantPresenter.new(registrant: @contact, view: self)
+ registrar = RegistrarPresenter.new(registrar: @contact.registrar, view: self)
+%>
+Tere <%= contact.name %>,
+
+Teie registripidaja <%= registrar.name %> on palunud teil kinnitada oma isikut, et jätkata domeeni toimingutega.
+Palun kinnitage oma isikut, klõpsates alloleval lingil:
+<%= @verification_link %>
+
+Kontaktandmed:
+<%= render 'mailers/shared/registrant/registrant.et.text', registrant: contact, with_phone: true %>
+
+Palun veenduge, et muudatus on korrektne ning probleemide korral pöörduge oma registripidaja poole:
+<%= render 'mailers/shared/registrar/registrar.et.text', registrar: registrar %>
+<%= render 'mailers/shared/signatures/signature.et.text' %>
+
+----------------------------------------------------------------------------------
+
+Hi <%= contact.name %>,
+
+Your registrar <%= registrar.name %> has requested that you verify your identity in order to proceed with domain actions.
+Please confirm your identity to continue by clicking the link below:
+<%= @verification_link %>
+
+Contact information:
+<%= render 'mailers/shared/registrant/registrant.en.text', registrant: contact, with_phone: true %>
+
+In case of problems please turn to your registrar:
+<%= render 'mailers/shared/registrar/registrar.en.text', registrar: registrar %>
+<%= render 'mailers/shared/signatures/signature.en.text' %>
\ No newline at end of file
diff --git a/config/application.yml.sample b/config/application.yml.sample
index 91f6d09b0..8e35d74fe 100644
--- a/config/application.yml.sample
+++ b/config/application.yml.sample
@@ -88,6 +88,9 @@ registrant_api_auth_allowed_ips: '127.0.0.1, 0.0.0.0' #ips, separated with comma
# Accreditation Center API
accr_center_api_auth_allowed_ips: '127.0.0.1, 0.0.0.0' #ips, separated with commas
+# Webhooks
+webhook_allowed_ips: '127.0.0.1, 0.0.0.0' #ips, separated with commas
+
# Shared key for REST-WHOIS Bounces API incl. CERT
rwhois_bounces_api_shared_key: testkey
diff --git a/config/locales/contacts.en.yml b/config/locales/contacts.en.yml
index 361e63530..86576b00c 100644
--- a/config/locales/contacts.en.yml
+++ b/config/locales/contacts.en.yml
@@ -9,6 +9,9 @@ en:
models:
contact:
attributes:
+ base:
+ verification_exists: Contact already verified
+ verification_error: Sending identification request failed
code:
blank: "Required parameter missing - code"
too_long_contact_code: "Contact code is too long, max 100 characters"
diff --git a/config/routes.rb b/config/routes.rb
index 6652f013e..2b12f0fda 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -74,6 +74,7 @@ Rails.application.routes.draw do
collection do
get 'check/:id', to: 'contacts#check'
get 'search(/:id)', to: 'contacts#search'
+ post 'verify/:id', to: 'contacts#verify'
end
end
end
@@ -372,6 +373,12 @@ Rails.application.routes.draw do
end
end
+ namespace :eeid do
+ namespace :webhooks do
+ resources :identification_requests, only: :create
+ end
+ end
+
# To prevent users seeing the default welcome message "Welcome aboard" from Rails
root to: redirect('admin/sign_in')
end
diff --git a/db/migrate/20240924103554_add_verification_fields_to_contacts.rb b/db/migrate/20240924103554_add_verification_fields_to_contacts.rb
new file mode 100644
index 000000000..1d4120a7b
--- /dev/null
+++ b/db/migrate/20240924103554_add_verification_fields_to_contacts.rb
@@ -0,0 +1,9 @@
+class AddVerificationFieldsToContacts < ActiveRecord::Migration[6.1]
+ disable_ddl_transaction!
+
+ def change
+ add_column :contacts, :ident_request_sent_at, :datetime
+ add_column :contacts, :verified_at, :datetime
+ add_index :contacts, :verified_at, algorithm: :concurrently
+ end
+end
diff --git a/db/structure.sql b/db/structure.sql
index 6b189b54c..82f10a207 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -695,6 +695,8 @@ CREATE TABLE public.contacts (
registrant_publishable boolean DEFAULT false,
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
);
@@ -4263,6 +4265,13 @@ CREATE INDEX index_contacts_on_registrar_id ON public.contacts USING btree (regi
CREATE INDEX index_contacts_on_registrar_id_and_ident_type ON public.contacts USING btree (registrar_id, ident_type);
+--
+-- Name: index_contacts_on_verified_at; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_contacts_on_verified_at ON public.contacts USING btree (verified_at);
+
+
--
-- Name: index_csync_records_on_domain_id; Type: INDEX; Schema: public; Owner: -
--
@@ -5602,6 +5611,8 @@ INSERT INTO "schema_migrations" (version) VALUES
('20230710120154'),
('20230711083811'),
('20240816091049'),
-('20240816092636');
+('20240816092636'),
+('20240903131540'),
+('20240924103554');
diff --git a/lib/serializers/repp/contact.rb b/lib/serializers/repp/contact.rb
index 5afab98f0..5c587e404 100644
--- a/lib/serializers/repp/contact.rb
+++ b/lib/serializers/repp/contact.rb
@@ -18,7 +18,8 @@ module Serializers
json = { code: obj.code, name: obj.name, ident: ident, phone: obj.phone,
created_at: obj.created_at, auth_info: obj.auth_info, email: obj.email,
statuses: statuses, disclosed_attributes: obj.disclosed_attributes,
- registrar: registrar }
+ registrar: registrar, ident_request_sent_at: obj.ident_request_sent_at,
+ verified_at: obj.verified_at }
json[:address] = address if @show_address
if @domain_params
json[:domains] = domains
diff --git a/test/integration/eeid/identification_requests_webhook_test.rb b/test/integration/eeid/identification_requests_webhook_test.rb
new file mode 100644
index 000000000..26bbac6cd
--- /dev/null
+++ b/test/integration/eeid/identification_requests_webhook_test.rb
@@ -0,0 +1,63 @@
+require 'test_helper'
+
+class Eeid::IdentificationRequestsWebhookTest < ActionDispatch::IntegrationTest
+ setup do
+ @contact = contacts(:john)
+ @secret = 'valid_secret'
+ ENV['ident_service_client_secret'] = @secret
+ payload = {
+ identification_request_id: '123',
+ reference: @contact.code
+ }
+ @valid_hmac_signature = OpenSSL::HMAC.hexdigest('SHA256', @secret, payload.to_json)
+
+ adapter = ENV['shunter_default_adapter'].constantize.new
+ adapter&.clear!
+ end
+
+ test 'should verify contact with valid signature and parameters' do
+ @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 :ok
+ assert_equal({ 'status' => 'success' }, JSON.parse(response.body))
+ assert_not_nil @contact.reload.verified_at
+ end
+
+ test 'should return unauthorized for invalid HMAC signature' do
+ post '/eeid/webhooks/identification_requests', params: { identification_request_id: '123', reference: @contact.code }, as: :json, headers: { 'X-HMAC-Signature' => 'invalid_signature' }
+
+ assert_response :unauthorized
+ assert_equal({ 'error' => 'Invalid HMAC signature' }, JSON.parse(response.body))
+ 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))
+ end
+
+ test 'should handle internal server error gracefully' do
+ # Simulate an error in the verify_contact method
+ Contact.stub :find_by_code, ->(_) { raise StandardError, 'Simulated error' } do
+ 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))
+ end
+ end
+
+ test 'returns error response if throttled' do
+ ENV['shunter_default_threshold'] = '1'
+ ENV['shunter_enabled'] = 'true'
+
+ post '/eeid/webhooks/identification_requests', params: { identification_request_id: '123', reference: @contact.code }, as: :json, headers: { 'X-HMAC-Signature' => @valid_hmac_signature }
+ post '/eeid/webhooks/identification_requests', params: { identification_request_id: '123', reference: @contact.code }, as: :json, headers: { 'X-HMAC-Signature' => @valid_hmac_signature }
+
+ assert_response :bad_request
+ 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
new file mode 100644
index 000000000..3a48b4552
--- /dev/null
+++ b/test/integration/repp/v1/contacts/verify_test.rb
@@ -0,0 +1,71 @@
+require 'test_helper'
+
+class ReppV1ContactsVerifyTest < 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: {})
+ 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: {})
+ end
+
+ def test_returns_error_when_not_found
+ post '/repp/v1/contacts/verify/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_verifies_contact
+ post "/repp/v1/contacts/verify/#{@contact.code}", headers: @auth_headers
+ json = JSON.parse(response.body, symbolize_names: true)
+
+ assert_response :ok
+ assert_equal 1000, json[:code]
+ assert_equal 'Command completed successfully', json[:message]
+
+ contact = Contact.find_by(code: json[:data][:contact][:code])
+ assert contact.present?
+ assert contact.ident_request_sent_at
+ assert_nil contact.verified_at
+ end
+
+ def test_does_not_verify_already_verified_contact
+ @contact.update!(verified_at: Time.zone.now - 1.day)
+ post "/repp/v1/contacts/verify/#{@contact.code}", headers: @auth_headers
+ json = JSON.parse(response.body, symbolize_names: true)
+
+ assert_response :bad_request
+ assert_equal 'Contact already verified', json[:message]
+ end
+
+ def test_returns_error_response_if_throttled
+ ENV['shunter_default_threshold'] = '1'
+ ENV['shunter_enabled'] = 'true'
+
+ post "/repp/v1/contacts/verify/#{@contact.code}", headers: @auth_headers
+ post "/repp/v1/contacts/verify/#{@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/services/identification_service_test.rb b/test/services/identification_service_test.rb
new file mode 100644
index 000000000..a11ef7806
--- /dev/null
+++ b/test/services/identification_service_test.rb
@@ -0,0 +1,104 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+class IdentificationServiceTest < ActiveSupport::TestCase
+ def setup
+ @service = Eeid::IdentificationService.new
+ end
+
+ def test_create_identification_request_success
+ request_params = {
+ claims_required: [{
+ type: 'sub',
+ value: 'EE1234567'
+ }],
+ reference: '111:111'
+ }
+ response_body = { id: '123', status: 'created' }.to_json
+
+ stub_request(:post, %r{api/auth/v1/token})
+ .to_return(status: 200, body: { access_token: 'mock_token' }.to_json)
+
+ stub_request(:post, %r{api/ident/v1/identification_requests})
+ .with(
+ headers: { 'Authorization' => 'Bearer mock_token' },
+ body: request_params.to_json
+ )
+ .to_return(status: 201, body: response_body)
+
+ result = @service.create_identification_request(request_params)
+ assert_equal JSON.parse(response_body), result
+ assert_equal 'mock_token', @service.instance_variable_get(:@token)
+ end
+
+ def test_create_identification_request_failure
+ request_params = {
+ claims_required: [{
+ type: 'sub',
+ value: 'EE1234567'
+ }],
+ reference: '111:111'
+ }
+
+ stub_request(:post, %r{api/auth/v1/token})
+ .to_return(status: 200, body: { access_token: 'mock_token' }.to_json)
+
+ stub_request(:post, %r{api/ident/v1/identification_requests})
+ .with(
+ headers: { 'Authorization' => 'Bearer mock_token' },
+ body: request_params.to_json
+ )
+ .to_return(status: 400, body: { error: 'Bad Request' }.to_json)
+
+ assert_raises(Eeid::IdentError, 'Bad Request') do
+ @service.create_identification_request(request_params)
+ end
+ end
+
+ def test_get_identification_request_success
+ id = '123'
+ response_body = { id: id, status: 'completed' }.to_json
+
+ stub_request(:post, %r{api/auth/v1/token})
+ .to_return(status: 200, body: { access_token: 'mock_token' }.to_json)
+
+ stub_request(:get, %r{api/ident/v1/identification_requests/#{id}})
+ .with(headers: { 'Authorization' => 'Bearer mock_token' })
+ .to_return(status: 200, body: response_body)
+
+ result = @service.get_identification_request(id)
+ assert_equal JSON.parse(response_body), result
+ assert_equal 'mock_token', @service.instance_variable_get(:@token)
+ end
+
+ def test_get_identification_request_failure
+ id = '123'
+
+ stub_request(:post, %r{api/auth/v1/token})
+ .to_return(status: 200, body: { access_token: 'mock_token' }.to_json)
+
+ stub_request(:get, %r{api/ident/v1/identification_requests/#{id}})
+ .with(headers: { 'Authorization' => 'Bearer mock_token' })
+ .to_return(status: 404, body: { error: 'Not Found' }.to_json)
+
+ assert_raises(Eeid::IdentError, 'Not Found') do
+ @service.get_identification_request(id)
+ end
+ end
+
+ def test_authentication_needed_for_requests
+ stub_request(:post, %r{api/auth/v1/token})
+ .to_return(status: 401, body: { error: 'Invalid credentials' }.to_json)
+
+ assert_raises(Eeid::IdentError) do
+ @service.create_identification_request({ key: 'value' })
+ end
+
+ assert_raises(Eeid::IdentError) do
+ @service.get_identification_request('123')
+ end
+
+ assert_equal nil, @service.instance_variable_get(:@token)
+ end
+end