From e74ae9e18b78ec18a7e9447dc080c7f86feadf00 Mon Sep 17 00:00:00 2001 From: Sergei Tsoganov Date: Tue, 2 Jun 2026 14:06:23 +0300 Subject: [PATCH 01/11] Verification feature for api users --- app/controllers/admin/api_users_controller.rb | 2 +- .../identification_requests_controller.rb | 82 +++++++-- .../repp/v1/api_users_controller.rb | 68 +++++++- app/controllers/repp/v1/base_controller.rb | 6 +- .../repp/v1/registrar/auth_controller.rb | 15 +- .../actions/api_user_approve_verification.rb | 61 +++++++ .../actions/api_user_reject_verification.rb | 26 +++ app/interactions/actions/api_user_verify.rb | 66 ++++++++ ...process_api_user_identification_webhook.rb | 114 +++++++++++++ app/lib/api_users/subject_backfill.rb | 33 ++++ app/mailers/api_user_mailer.rb | 11 ++ app/mailers/registrar_mailer.rb | 23 +++ app/models/ability.rb | 1 + app/models/api_user.rb | 79 ++++++++- app/models/user.rb | 7 +- app/views/admin/api_users/_form.html.erb | 4 +- .../admin/api_users/show/_details.html.erb | 15 ++ .../admin/registrars/show/_api_users.html.erb | 16 +- .../identification_requested.html.erb | 16 ++ .../identification_requested.text.erb | 13 ++ .../api_user_subject_changed.html.erb | 35 ++++ .../api_user_subject_changed.text.erb | 26 +++ .../api_user_verification_pending.html.erb | 55 ++++++ .../api_user_verification_pending.text.erb | 34 ++++ .../api_user_verified.html.erb | 37 +++++ .../api_user_verified.text.erb | 26 +++ config/locales/admin/registrars.en.yml | 5 + config/locales/api_users.en.yml | 16 +- config/locales/api_users.et.yml | 17 ++ config/locales/mailers/api_user.en.yml | 6 + config/locales/mailers/api_user.et.yml | 4 + config/locales/mailers/registrar.en.yml | 14 +- config/locales/mailers/registrar.et.yml | 8 + config/routes.rb | 6 + ...20200608084321_fill_email_verifications.rb | 4 +- ...20200911104302_fix_typo_in_setting_name.rb | 2 +- ...0260601120100_backfill_api_user_subject.rb | 10 ++ db/data_schema.rb | 2 +- ...120000_add_verification_fields_to_users.rb | 13 ++ .../20260601120000_add_subject_to_users.rb | 12 ++ db/structure.sql | 93 ++++++++--- lib/serializers/repp/api_user.rb | 15 +- lib/tasks/api_users/backfill_subject.rake | 7 + test/fixtures/users.yml | 2 + .../identification_requests_webhook_test.rb | 126 ++++++++++++++ .../repp/v1/accounts/switch_user_test.rb | 6 +- .../repp/v1/api_users/verify_test.rb | 70 ++++++++ .../repp/v1/registrar/auth/check_info_test.rb | 10 ++ .../v1/registrar/auth/tara_callback_test.rb | 20 +++ .../api_user_approve_verification_test.rb | 57 +++++++ test/models/api_user_test.rb | 157 ++++++++++++++++++ test/models/user/from_omniauth_test.rb | 44 +++++ 52 files changed, 1524 insertions(+), 73 deletions(-) create mode 100644 app/interactions/actions/api_user_approve_verification.rb create mode 100644 app/interactions/actions/api_user_reject_verification.rb create mode 100644 app/interactions/actions/api_user_verify.rb create mode 100644 app/interactions/actions/process_api_user_identification_webhook.rb create mode 100644 app/lib/api_users/subject_backfill.rb create mode 100644 app/mailers/api_user_mailer.rb create mode 100644 app/views/mailers/api_user_mailer/identification_requested.html.erb create mode 100644 app/views/mailers/api_user_mailer/identification_requested.text.erb create mode 100644 app/views/mailers/registrar_mailer/api_user_subject_changed.html.erb create mode 100644 app/views/mailers/registrar_mailer/api_user_subject_changed.text.erb create mode 100644 app/views/mailers/registrar_mailer/api_user_verification_pending.html.erb create mode 100644 app/views/mailers/registrar_mailer/api_user_verification_pending.text.erb create mode 100644 app/views/mailers/registrar_mailer/api_user_verified.html.erb create mode 100644 app/views/mailers/registrar_mailer/api_user_verified.text.erb create mode 100644 config/locales/api_users.et.yml create mode 100644 config/locales/mailers/api_user.en.yml create mode 100644 config/locales/mailers/api_user.et.yml create mode 100644 config/locales/mailers/registrar.et.yml create mode 100644 db/data/20260601120100_backfill_api_user_subject.rb create mode 100644 db/migrate/20260529120000_add_verification_fields_to_users.rb create mode 100644 db/migrate/20260601120000_add_subject_to_users.rb create mode 100644 lib/tasks/api_users/backfill_subject.rake create mode 100644 test/integration/repp/v1/api_users/verify_test.rb create mode 100644 test/interactions/actions/api_user_approve_verification_test.rb create mode 100644 test/models/user/from_omniauth_test.rb diff --git a/app/controllers/admin/api_users_controller.rb b/app/controllers/admin/api_users_controller.rb index 8a22219c94..6ca2af4862 100644 --- a/app/controllers/admin/api_users_controller.rb +++ b/app/controllers/admin/api_users_controller.rb @@ -47,7 +47,7 @@ def destroy def api_user_params params.require(:api_user).permit(:username, :plain_text_password, :active, - :identity_code, { roles: [] }) + :subject, { roles: [] }) end def registrar diff --git a/app/controllers/eeid/webhooks/identification_requests_controller.rb b/app/controllers/eeid/webhooks/identification_requests_controller.rb index b358a736ec..17c139d6e6 100644 --- a/app/controllers/eeid/webhooks/identification_requests_controller.rb +++ b/app/controllers/eeid/webhooks/identification_requests_controller.rb @@ -15,23 +15,48 @@ class IdentificationRequestsController < ActionController::Base def create return render_unauthorized unless ip_allowed?(request.remote_ip) - contact = Contact.find_by_code(permitted_params[:reference]) - return render_invalid_signature unless valid_hmac_signature?(contact.ident_type, request.headers['X-HMAC-Signature']) + entity = resolve_entity + return render_not_found unless entity - poi = catch_poi(contact) - verify_contact(contact) - inform_registrar(contact, poi) - render json: { status: 'success' }, status: :ok + return render_invalid_signature unless valid_hmac_signature?(ident_type_for(entity), request.headers['X-HMAC-Signature']) + + ::PaperTrail.request(whodunnit: webhook_whodunnit(entity)) do + poi = catch_poi(entity) + process_verification(entity) + inform_registrar(entity, poi) + render json: { status: 'success' }, status: :ok + end rescue StandardError => e handle_error(e) end - private + private def permitted_params params.permit(:identification_request_id, :reference, :client_id) end + def resolve_entity + reference = permitted_params[:reference] + return nil if reference.blank? + + Contact.find_by(code: reference) || ApiUser.find_by(uuid: reference) + end + + def ident_type_for(entity) + entity.is_a?(ApiUser) ? 'priv' : entity.ident_type + end + + def webhook_whodunnit(entity) + identifier = entity.is_a?(ApiUser) ? entity.username : entity.code + "eeid-webhook:#{entity.class.name}:#{identifier}" + end + + def render_not_found + Rails.logger.error("Webhook reference not found: #{permitted_params[:reference]}") + render json: { error: 'Reference not found' }, status: :not_found + end + def render_unauthorized Rails.logger.debug("IPAddress #{request.remote_ip} not authorized") render json: { error: "IPAddress #{request.remote_ip} not authorized" }, status: :unauthorized @@ -63,6 +88,14 @@ def valid_hmac_signature?(ident_type, hmac_signature) result end + def process_verification(entity) + if entity.is_a?(ApiUser) + process_api_user(entity) + else + verify_contact(entity) + end + end + def verify_contact(contact) ref = permitted_params[:reference] if contact&.ident_request_sent_at.present? @@ -73,20 +106,43 @@ def verify_contact(contact) end end - def catch_poi(contact) - ident_service = Eeid::IdentificationService.new(contact.ident_type) + def process_api_user(api_user) + ident_service = Eeid::IdentificationService.new('priv') + response = ident_service.get_identification_request(permitted_params[:identification_request_id]) + result = response[:result] || response['result'] || {} + + @api_user_outcome = Actions::ProcessApiUserIdentificationWebhook.new( + api_user, + identification_request_id: permitted_params[:identification_request_id], + result: result + ).call + end + + def catch_poi(entity) + ident_service = Eeid::IdentificationService.new(ident_type_for(entity)) 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 + def inform_registrar(entity, poi) + email = entity&.registrar&.email return unless email - RegistrarMailer.contact_verified(email: email, contact: contact, poi: poi) - .deliver_now + if entity.is_a?(ApiUser) + inform_registrar_api_user(email, entity, poi) + else + RegistrarMailer.contact_verified(email: email, contact: entity, poi: poi).deliver_now + end + end + + def inform_registrar_api_user(email, api_user, poi) + if api_user.verification_pending_at.present? + RegistrarMailer.api_user_verification_pending(email: email, api_user: api_user, poi: poi).deliver_now + elsif api_user.verified_at.present? + RegistrarMailer.api_user_verified(email: email, api_user: api_user, poi: poi).deliver_now + end end def ip_allowed?(ip) diff --git a/app/controllers/repp/v1/api_users_controller.rb b/app/controllers/repp/v1/api_users_controller.rb index 6598021294..b76c62fcfb 100644 --- a/app/controllers/repp/v1/api_users_controller.rb +++ b/app/controllers/repp/v1/api_users_controller.rb @@ -2,10 +2,10 @@ module Repp module V1 class ApiUsersController < BaseController - before_action :find_api_user, only: %i[show update destroy] + before_action :find_api_user, only: %i[show update destroy verify download_poi approve_verification reject_verification] load_and_authorize_resource - THROTTLED_ACTIONS = %i[index show create update destroy].freeze + THROTTLED_ACTIONS = %i[index show create update destroy verify download_poi approve_verification reject_verification].freeze include Shunter::Integration::Throttle api :GET, '/repp/v1/api_users' @@ -59,6 +59,64 @@ def destroy render_success end + api :POST, '/repp/v1/api_users/verify/:id' + desc 'Generate and send identification request to an api user' + def verify + authorize! :verify, ApiUser + action = Actions::ApiUserVerify.new(@api_user) + + unless action.call + handle_non_epp_errors(@api_user) + return + end + + render_success(data: { api_user: { id: @api_user.id } }) + end + + api :GET, '/repp/v1/api_users/download_poi/:id' + desc 'Get proof of identity pdf file for an api user' + def download_poi + authorize! :verify, ApiUser + ident_service = Eeid::IdentificationService.new('priv') + response = ident_service.get_proof_of_identity(@api_user.verification_id) + + send_data response[:data], filename: "proof_of_identity_#{@api_user.verification_id}.pdf", + type: 'application/pdf', disposition: 'inline' + rescue Eeid::IdentError => e + handle_non_epp_errors(@api_user, e.message) + end + + api :POST, '/repp/v1/api_users/approve_verification/:id' + desc 'Manually approve pending api user identification' + def approve_verification + authorize! :verify, ApiUser + action = Actions::ApiUserApproveVerification.new( + @api_user, + subject: approve_verification_params[:subject] + ) + + unless action.call + handle_non_epp_errors(@api_user) + return + end + + render_success(data: { api_user: { id: @api_user.id } }) + end + + api :POST, '/repp/v1/api_users/reject_verification/:id' + desc 'Reject pending api user identification' + def reject_verification + authorize! :verify, ApiUser + action = Actions::ApiUserRejectVerification.new(@api_user) + + unless action.call + handle_non_epp_errors(@api_user) + return + end + + render_success(data: { api_user: { id: @api_user.id } }) + end + private def find_api_user @@ -67,7 +125,11 @@ def find_api_user def api_user_params params.require(:api_user).permit(:username, :plain_text_password, :active, - :identity_code, { roles: [] }) + :subject, :email, { roles: [] }) + end + + def approve_verification_params + params.fetch(:api_user, {}).permit(:subject) end def serialized_users(users) diff --git a/app/controllers/repp/v1/base_controller.rb b/app/controllers/repp/v1/base_controller.rb index 7c5aaec3c9..fb2c35cf2d 100644 --- a/app/controllers/repp/v1/base_controller.rb +++ b/app/controllers/repp/v1/base_controller.rb @@ -82,14 +82,14 @@ def basic_token def authenticate_user username, password = Base64.urlsafe_decode64(basic_token).split(':', 2) @current_user ||= ApiUser.find_by(username: username, plain_text_password: password) - user_active = @current_user.active? + user_eligible = @current_user&.eligible_for_sign_in? - return if @current_user && user_active + return if @current_user && user_eligible raise(ArgumentError) rescue NoMethodError, ArgumentError @response = { code: 2202, message: 'Invalid authorization information', - data: { username: username, password: password, active: user_active } } + data: { username: username, password: password, eligible_for_sign_in: user_eligible } } render(json: @response, status: :unauthorized) end diff --git a/app/controllers/repp/v1/registrar/auth_controller.rb b/app/controllers/repp/v1/registrar/auth_controller.rb index 46c1774287..258b3e5f06 100644 --- a/app/controllers/repp/v1/registrar/auth_controller.rb +++ b/app/controllers/repp/v1/registrar/auth_controller.rb @@ -22,11 +22,14 @@ def index def tara_callback user = ApiUser.from_omniauth(auth_params) response = { code: 401, message: I18n.t(:no_such_user), data: {} } - unless user&.active && webclient_request? + unless user && webclient_request? render(json: response, status: :unauthorized) return end + ::PaperTrail.request(whodunnit: eeid_auto_verify_whodunnit(user)) do + auto_verify_from_eeid!(user) + end token = Base64.urlsafe_encode64("#{user.username}:#{user.plain_text_password}") data = auth_values_to_data(user, mode: 'accreditation').merge(token: token) render_success(data: data) @@ -34,6 +37,16 @@ def tara_callback private + def eeid_auto_verify_whodunnit(user) + "eeid-auto-verify:#{user.username}" + end + + def auto_verify_from_eeid!(user) + return if user.verified_at.present? + + user.update!(verified_at: Time.zone.now, verification_pending_at: nil) + end + def auth_params params.require(:auth).permit(:uid, :new_user_id) end diff --git a/app/interactions/actions/api_user_approve_verification.rb b/app/interactions/actions/api_user_approve_verification.rb new file mode 100644 index 0000000000..a353c33e7c --- /dev/null +++ b/app/interactions/actions/api_user_approve_verification.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Actions + # Registrar manually approves a pending ApiUser identification. + class ApiUserApproveVerification + SUB_PATTERN = /\A([A-Z]{2})([0-9A-Za-z]+)\z/ + + attr_reader :api_user + + def initialize(api_user, subject: nil) + @api_user = api_user + @subject = subject.to_s.strip.presence + end + + def call + unless api_user.verification_pending_at.present? + api_user.errors.add(:base, :not_pending_verification) + return false + end + + snapshot = (api_user.verification_snapshot || {}).with_indifferent_access + # Pending snapshot comes from eeID result (OIDC +sub+). + subject = @subject || snapshot[:sub].to_s.strip.presence + + if subject.blank? + api_user.errors.add(:base, :missing_subject) + return false + end + + if subject_conflict?(subject) + api_user.errors.add(:subject, :taken) + return false + end + + attrs = { + verified_at: Time.zone.now, + verification_pending_at: nil, + subject: subject + } + + country_code = country_code_from_subject(subject) + attrs[:country_code] = country_code if country_code.present? + + api_user.update!(attrs) + true + end + + private + + def country_code_from_subject(subject) + match = subject.match(SUB_PATTERN) + match&.[](1) + end + + def subject_conflict?(subject) + ApiUser.where(registrar_id: api_user.registrar_id, subject: subject) + .where.not(id: api_user.id) + .exists? + end + end +end diff --git a/app/interactions/actions/api_user_reject_verification.rb b/app/interactions/actions/api_user_reject_verification.rb new file mode 100644 index 0000000000..dc3e62bdc2 --- /dev/null +++ b/app/interactions/actions/api_user_reject_verification.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Actions + # Clears pending ApiUser identification after registrar rejection. + class ApiUserRejectVerification + attr_reader :api_user + + def initialize(api_user) + @api_user = api_user + end + + def call + unless api_user.verification_pending_at.present? + api_user.errors.add(:base, :not_pending_verification) + return false + end + + api_user.update!( + verification_pending_at: nil, + verification_id: nil, + verification_snapshot: {} + ) + true + end + end +end diff --git a/app/interactions/actions/api_user_verify.rb b/app/interactions/actions/api_user_verify.rb new file mode 100644 index 0000000000..cc4d6842d7 --- /dev/null +++ b/app/interactions/actions/api_user_verify.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Actions + # Sends an eeID identification request for an API user. + class ApiUserVerify + attr_reader :api_user + + def initialize(api_user) + @api_user = api_user + end + + def call + return false unless validate_email! + + create_identification_request + return false if api_user.errors.any? + + commit + end + + private + + def validate_email! + if api_user.email.blank? + api_user.errors.add(:email, :blank) + return false + end + + true + end + + def create_identification_request + ident_service = Eeid::IdentificationService.new('priv') + response = ident_service.create_identification_request(request_payload) + ApiUserMailer.identification_requested(api_user: api_user, link: response['link']).deliver_now + rescue Eeid::IdentError => e + Rails.logger.error e.message + api_user.errors.add(:base, :verification_error) + end + + def request_payload + { + claims_required: claims_required, + reference: api_user.uuid + } + end + + def claims_required + if api_user.subject.present? + [{ type: 'sub', value: api_user.subject }] + else + [{ type: 'sub', value: '' }] + end + end + + def commit + api_user.update( + ident_request_sent_at: Time.zone.now, + verified_at: nil, + verification_id: nil, + verification_pending_at: nil, + verification_snapshot: {} + ) + end + end +end diff --git a/app/interactions/actions/process_api_user_identification_webhook.rb b/app/interactions/actions/process_api_user_identification_webhook.rb new file mode 100644 index 0000000000..712762e87f --- /dev/null +++ b/app/interactions/actions/process_api_user_identification_webhook.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +module Actions + # Applies eeID identification results to an ApiUser (auto-verify or pending review). + class ProcessApiUserIdentificationWebhook + SUB_PATTERN = /\A([A-Z]{2})([0-9A-Za-z]+)\z/ + + attr_reader :api_user, :outcome + + def initialize(api_user, identification_request_id:, result:) + @api_user = api_user + @identification_request_id = identification_request_id + @result = (result || {}).with_indifferent_access + end + + def call + unless api_user.ident_request_sent_at.present? + Rails.logger.error("ApiUser verification ignored: ident not requested for user #{api_user.id}") + @outcome = :ignored + return false + end + + if auto_verifiable? + apply_auto_verification! + @outcome = :auto_verified + else + apply_pending_review!(pending_reason) + @outcome = :pending_review + end + + true + end + + def pending_reason + @pending_reason ||= compute_pending_reason + end + + private + + def compute_pending_reason + subject = result_subject + return :missing_subject if subject.blank? + + return :subject_conflict if subject_conflict?(subject) + return :subject_mismatch if pre_set_subject_mismatch?(subject) + + nil + end + + def auto_verifiable? + pending_reason.nil? + end + + # eeID identification result uses OIDC userinfo; login id is in +sub+, not +subject+. + def result_subject + @result[:sub].to_s.strip.presence + end + + def subject_conflict?(subject) + ApiUser.where(registrar_id: api_user.registrar_id, subject: subject) + .where.not(id: api_user.id) + .exists? + end + + def pre_set_subject_mismatch?(subject) + expected = api_user.subject.presence || subject_from_identity_code + expected.present? && expected != subject + end + + def subject_from_identity_code + return nil if api_user.identity_code.blank? + + country = api_user.country_code.presence || 'EE' + "#{country}#{api_user.identity_code}" + end + + def apply_auto_verification! + subject = result_subject + attrs = { + subject: subject, + verified_at: Time.zone.now, + verification_id: @identification_request_id, + verification_pending_at: nil, + verification_snapshot: {} + } + + country_code = country_code_from_subject(subject) + attrs[:country_code] = country_code if country_code.present? + + api_user.update!(attrs) + Rails.logger.info("ApiUser verified (auto): #{api_user.id}") + end + + def country_code_from_subject(subject) + match = subject.match(SUB_PATTERN) + match&.[](1) + end + + def apply_pending_review!(reason) + api_user.update!( + verification_id: @identification_request_id, + verification_pending_at: Time.zone.now, + verification_snapshot: verification_snapshot, + verified_at: nil + ) + Rails.logger.info("ApiUser verification pending (#{reason}): #{api_user.id}") + end + + def verification_snapshot + @result.slice(:sub, :given_name, :family_name, :name, :date_of_birth, :birthdate, :country, + :authentication_type).compact + end + end +end diff --git a/app/lib/api_users/subject_backfill.rb b/app/lib/api_users/subject_backfill.rb new file mode 100644 index 0000000000..831b97b1f3 --- /dev/null +++ b/app/lib/api_users/subject_backfill.rb @@ -0,0 +1,33 @@ +module ApiUsers + module SubjectBackfill + module_function + + def run + updated = 0 + skipped = 0 + + ApiUser.where(subject: [nil, '']) + .where.not(identity_code: [nil, '']) + .find_each do |user| + country = user.country_code.presence || 'EE' + subject = "#{country}#{user.identity_code}" + + if ApiUser.where(registrar_id: user.registrar_id, subject: subject) + .where.not(id: user.id) + .exists? + skipped += 1 + Rails.logger.warn( + "api_user subject backfill skipped user_id=#{user.id} " \ + "registrar_id=#{user.registrar_id} subject=#{subject} (conflict)" + ) + next + end + + user.update_columns(subject: subject) + updated += 1 + end + + { updated: updated, skipped: skipped } + end + end +end diff --git a/app/mailers/api_user_mailer.rb b/app/mailers/api_user_mailer.rb new file mode 100644 index 0000000000..e1d00fa2f0 --- /dev/null +++ b/app/mailers/api_user_mailer.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class ApiUserMailer < ApplicationMailer + def identification_requested(api_user:, link:) + @api_user = api_user + @verification_link = link + + subject = default_i18n_subject(username: api_user.username) + mail(to: api_user.email, subject: subject) + end +end diff --git a/app/mailers/registrar_mailer.rb b/app/mailers/registrar_mailer.rb index 99a3645273..44ae4843e1 100644 --- a/app/mailers/registrar_mailer.rb +++ b/app/mailers/registrar_mailer.rb @@ -7,4 +7,27 @@ def contact_verified(email:, contact:, poi:) attachments['proof_of_identity.pdf'] = poi mail(to: email, subject: subject) end + + def api_user_verified(email:, api_user:, poi:) + @api_user = api_user + subject = default_i18n_subject(username: api_user.username) + attachments['proof_of_identity.pdf'] = poi + mail(to: email, subject: subject) + end + + def api_user_verification_pending(email:, api_user:, poi:) + @api_user = api_user + @verification_snapshot = api_user.verification_snapshot + subject = default_i18n_subject(username: api_user.username) + attachments['proof_of_identity.pdf'] = poi if poi.present? + mail(to: email, subject: subject) + end + + def api_user_subject_changed(email:, api_user:, old_subject:, new_subject:) + @api_user = api_user + @old_subject = old_subject + @new_subject = new_subject + subject = default_i18n_subject(username: api_user.username) + mail(to: email, subject: subject) + end end diff --git a/app/models/ability.rb b/app/models/ability.rb index 6e5071de79..26e6668ff1 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -30,6 +30,7 @@ def super # Registrar/api_user dynamic role epp billing can :manage, ApiUser + can :verify, ApiUser can :manage, WhiteIp can :manage, Certificate end diff --git a/app/models/api_user.rb b/app/models/api_user.rb index b06ba29644..805dcc13cd 100644 --- a/app/models/api_user.rb +++ b/app/models/api_user.rb @@ -8,7 +8,13 @@ class ApiUser < User def epp_code_map { '2306' => [ # Parameter policy error - %i[plain_text_password blank] + %i[plain_text_password blank], + %i[email blank], + %i[base verification_error], + %i[base not_pending_verification], + %i[identity_code taken], + %i[subject taken], + %i[base missing_subject] ] } end @@ -21,10 +27,34 @@ def self.min_password_length # Must precede .validates belongs_to :registrar has_many :certificates + VALID_EMAIL_REGEX = /\A([\w+\-]\.?)+@[a-z\d\-]+(\.[a-z]+)*\.[a-z]+\z/i.freeze + validates :username, :plain_text_password, :registrar, :roles, presence: true validates :plain_text_password, length: { minimum: min_password_length } validates :username, uniqueness: true + validates :email, format: { with: VALID_EMAIL_REGEX, allow_blank: true } validates :identity_code, uniqueness: { scope: :registrar_id }, if: -> { identity_code.present? } + validates :subject, uniqueness: { scope: :registrar_id }, if: -> { subject.present? } + before_validation :clear_verification_status_on_subject_change, if: :subject_changed_from_existing? + after_commit :notify_registrar_subject_changed, on: :update + + scope :eligible_for_sign_in, lambda { + where(active: true) + .where.not(verified_at: nil) + .where.not(subject: [nil, '']) + } + + def identity_verified? + verified_at.present? + end + + def eligible_for_sign_in? + active? && identity_verified? && subject.present? + end + + def verification_pending? + verification_pending_at.present? + end delegate :code, :name, to: :registrar, prefix: true delegate :legaldoc_mandatory?, to: :registrar @@ -74,8 +104,10 @@ def pki_ok?(crt, com, api: true) end def linked_users - self.class.where(identity_code: identity_code, active: true) - .where("identity_code IS NOT NULL AND identity_code != ''") + return self.class.none if subject.blank? + + self.class.where(subject: subject, active: true) + .where.not(verified_at: nil) .where.not(id: id) .includes(:registrar) end @@ -85,7 +117,9 @@ def api_users end def linked_with?(another_api_user) - another_api_user.identity_code == identity_code + return false if another_api_user.blank? || subject.blank? + + another_api_user.subject == subject end def as_csv_row @@ -114,6 +148,43 @@ def self.ransackable_attributes(*) private + def subject_changed_from_existing? + return false unless persisted? + return false unless will_save_change_to_subject? + return false if subject_in_database.to_s.blank? + + subject.to_s != subject_in_database.to_s + end + + def clear_verification_status_on_subject_change + @subject_change_notification_data = { + old_subject: subject_in_database.to_s, + new_subject: subject.to_s + } + + self.ident_request_sent_at = nil + self.verified_at = nil + self.verification_id = nil + self.verification_pending_at = nil + self.verification_snapshot = {} + end + + def notify_registrar_subject_changed + data = @subject_change_notification_data + @subject_change_notification_data = nil + return if data.blank? + + email = registrar&.email + return if email.blank? + + RegistrarMailer.api_user_subject_changed( + email: email, + api_user: self, + old_subject: data[:old_subject], + new_subject: data[:new_subject] + ).deliver_now + end + def machine_readable_certificate(cert) Rails.logger.debug "[machine_readable_certificate] Original cert: #{cert}" diff --git a/app/models/user.rb b/app/models/user.rb index 980c3cf44f..109b341190 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -14,10 +14,9 @@ def id_role_username end def self.from_omniauth(omniauth_hash) - uid = omniauth_hash['uid'] - identity_code = uid&.slice(2..-1) - # country_code = uid.slice(0..1) + uid = omniauth_hash['uid'] || omniauth_hash[:uid] + return if uid.blank? - find_by(identity_code: identity_code, active: true) + ApiUser.find_by(subject: uid, active: true) end end diff --git a/app/views/admin/api_users/_form.html.erb b/app/views/admin/api_users/_form.html.erb index ad6ee6b2b3..d91351928e 100644 --- a/app/views/admin/api_users/_form.html.erb +++ b/app/views/admin/api_users/_form.html.erb @@ -23,10 +23,10 @@
- <%= f.label :identity_code %> + <%= f.label :subject %>
- <%= f.text_field(:identity_code, class: 'form-control') %> + <%= f.text_field(:subject, class: 'form-control') %>
diff --git a/app/views/admin/api_users/show/_details.html.erb b/app/views/admin/api_users/show/_details.html.erb index c98f23866e..9dee079a8e 100644 --- a/app/views/admin/api_users/show/_details.html.erb +++ b/app/views/admin/api_users/show/_details.html.erb @@ -13,6 +13,9 @@
<%= ApiUser.human_attribute_name :plain_text_password %>
<%= @api_user.plain_text_password %>
+
<%= ApiUser.human_attribute_name :subject %>
+
<%= @api_user.subject %>
+
<%= Registrar.model_name.human %>
<%= link_to(@api_user.registrar, admin_registrar_path(@api_user.registrar)) %>
@@ -21,6 +24,18 @@
<%= ApiUser.human_attribute_name :active %>
<%= @api_user.active %>
+ +
<%= ApiUser.human_attribute_name :ident_request_sent_at %>
+
<%= @api_user.ident_request_sent_at %>
+ +
<%= ApiUser.human_attribute_name :verified_at %>
+
<%= @api_user.verified_at %>
+ +
<%= ApiUser.human_attribute_name :verification_pending_at %>
+
<%= @api_user.verification_pending_at %>
+ +
<%= ApiUser.human_attribute_name :verification_id %>
+
<%= @api_user.verification_id %>
diff --git a/app/views/admin/registrars/show/_api_users.html.erb b/app/views/admin/registrars/show/_api_users.html.erb index ed6a0b6001..42dd3c7a9e 100644 --- a/app/views/admin/registrars/show/_api_users.html.erb +++ b/app/views/admin/registrars/show/_api_users.html.erb @@ -6,8 +6,9 @@ - - + + + @@ -15,6 +16,17 @@ <% registrar.api_users.each do |api_user| %> + <% end %> diff --git a/app/views/mailers/api_user_mailer/identification_requested.html.erb b/app/views/mailers/api_user_mailer/identification_requested.html.erb new file mode 100644 index 0000000000..85874e5c07 --- /dev/null +++ b/app/views/mailers/api_user_mailer/identification_requested.html.erb @@ -0,0 +1,16 @@ +Tere <%= @api_user.username %>, +

+Teie registripidaja <%= @api_user.registrar.name %> on palunud Teil kinnitada oma isikut API kasutaja konto jaoks. +

+" style="display: inline-block; padding: 10px 20px; background-color: #007BFF; color: white; text-decoration: none; border-radius: 5px;">Jätka eeID-ga +

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

+Hi <%= @api_user.username %>, +

+Your registrar <%= @api_user.registrar.name %> has requested that you verify your identity for your API user account. +

+" style="display: inline-block; padding: 10px 20px; background-color: #007BFF; color: white; text-decoration: none; border-radius: 5px;">Continue with eeID +

+<%= render 'mailers/shared/signatures/signature.en.html' %> diff --git a/app/views/mailers/api_user_mailer/identification_requested.text.erb b/app/views/mailers/api_user_mailer/identification_requested.text.erb new file mode 100644 index 0000000000..847ccae60e --- /dev/null +++ b/app/views/mailers/api_user_mailer/identification_requested.text.erb @@ -0,0 +1,13 @@ +Tere <%= @api_user.username %>, + +Teie registripidaja <%= @api_user.registrar.name %> on palunud Teil kinnitada oma isikut API kasutaja konto jaoks. + +<%= @verification_link + "?ui_locales=et" %> + +--- + +Hi <%= @api_user.username %>, + +Your registrar <%= @api_user.registrar.name %> has requested that you verify your identity for your API user account. + +<%= @verification_link + "?ui_locales=en" %> diff --git a/app/views/mailers/registrar_mailer/api_user_subject_changed.html.erb b/app/views/mailers/registrar_mailer/api_user_subject_changed.html.erb new file mode 100644 index 0000000000..b79781e7ee --- /dev/null +++ b/app/views/mailers/registrar_mailer/api_user_subject_changed.html.erb @@ -0,0 +1,35 @@ +Tere, +

+

+Anname teada, et järgmise API kasutaja sisselogimise identifikaatorit muudeti. +Seetõttu nulliti kasutaja isikutuvastuse staatus ja kasutaja peab läbima isikutuvastuse uuesti. +

+

API kasutaja andmed:

+ +

+Parimate soovidega, +

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

+ +Hi, +

+

+We are writing to inform you that the login subject for the following API user was changed. +As a result, the identity verification status was reset and the user must complete verification again. +

+

API user details:

+ +

+Best regards, +

+<%= render 'mailers/shared/signatures/signature.en.html' %> diff --git a/app/views/mailers/registrar_mailer/api_user_subject_changed.text.erb b/app/views/mailers/registrar_mailer/api_user_subject_changed.text.erb new file mode 100644 index 0000000000..60984b70c3 --- /dev/null +++ b/app/views/mailers/registrar_mailer/api_user_subject_changed.text.erb @@ -0,0 +1,26 @@ +Tere, + +Anname teada, et järgmise API kasutaja sisselogimise identifikaatorit muudeti. +Seetõttu nulliti kasutaja isikutuvastuse staatus ja kasutaja peab läbima isikutuvastuse uuesti. + +API kasutaja andmed: +- Kasutajanimi: <%= @api_user.username %> +- Varasem identifikaator: <%= @old_subject %> +- Uus identifikaator: <%= @new_subject %> + +Parimate soovidega, +<%= render 'mailers/shared/signatures/signature.et.text' %> +--- + +Hi, + +We are writing to inform you that the login subject for the following API user was changed. +As a result, the identity verification status was reset and the user must complete verification again. + +API user details: +- Username: <%= @api_user.username %> +- Previous subject: <%= @old_subject %> +- New subject: <%= @new_subject %> + +Best regards, +<%= render 'mailers/shared/signatures/signature.en.text' %> diff --git a/app/views/mailers/registrar_mailer/api_user_verification_pending.html.erb b/app/views/mailers/registrar_mailer/api_user_verification_pending.html.erb new file mode 100644 index 0000000000..ecbb916560 --- /dev/null +++ b/app/views/mailers/registrar_mailer/api_user_verification_pending.html.erb @@ -0,0 +1,55 @@ +Tere, +

+

+Anname teada, et järgmise API kasutaja isikutuvastus vajab registripidaja käsitsi kinnitust. +Automaatne kinnitamine ei olnud võimalik (nt isikukood puudus tulemuses või andmed ei ole üheselt seostatavad). +

+

API kasutaja andmed:

+ +<% if @verification_snapshot.present? %> +

eeID tulemus:

+ +<% end %> +

+Palun kinnitage või lükake isikutuvastus tagasi registripidaja portaalis. +

+

+Parimate soovidega, +

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

+ +Hi, +

+

+We are writing to inform you that the identity verification for the following API user requires manual registrar confirmation. +Automatic verification was not possible (e.g. identity code missing in the result or data not unambiguously matchable). +

+

API user details:

+ +<% if @verification_snapshot.present? %> +

eeID result:

+ +<% end %> +

+Please confirm or reject the identity verification in the Registrar Portal. +

+

+Best regards, +

+<%= render 'mailers/shared/signatures/signature.en.html' %> diff --git a/app/views/mailers/registrar_mailer/api_user_verification_pending.text.erb b/app/views/mailers/registrar_mailer/api_user_verification_pending.text.erb new file mode 100644 index 0000000000..ca67ce70bd --- /dev/null +++ b/app/views/mailers/registrar_mailer/api_user_verification_pending.text.erb @@ -0,0 +1,34 @@ +Tere, + +Anname teada, et järgmise API kasutaja isikutuvastus vajab registripidaja käsitsi kinnitust. +Automaatne kinnitamine ei olnud võimalik (nt isikukood puudus tulemuses või andmed ei ole üheselt seostatavad). + +API kasutaja andmed: +- Kasutajanimi: <%= @api_user.username %> +<% if @verification_snapshot.present? %> +eeID tulemus: +<% @verification_snapshot.each do |key, value| %><% next if value.blank? %>- <%= key %>: <%= value %> +<% end %> +<% end %> +Palun kinnitage või lükake isikutuvastus tagasi registripidaja portaalis. + +Parimate soovidega, +<%= render 'mailers/shared/signatures/signature.et.text' %> +--- + +Hi, + +We are writing to inform you that the identity verification for the following API user requires manual registrar confirmation. +Automatic verification was not possible (e.g. identity code missing in the result or data not unambiguously matchable). + +API user details: +- Username: <%= @api_user.username %> +<% if @verification_snapshot.present? %> +eeID result: +<% @verification_snapshot.each do |key, value| %><% next if value.blank? %>- <%= key %>: <%= value %> +<% end %> +<% end %> +Please confirm or reject the identity verification in the Registrar Portal. + +Best regards, +<%= render 'mailers/shared/signatures/signature.en.text' %> diff --git a/app/views/mailers/registrar_mailer/api_user_verified.html.erb b/app/views/mailers/registrar_mailer/api_user_verified.html.erb new file mode 100644 index 0000000000..133e81abb8 --- /dev/null +++ b/app/views/mailers/registrar_mailer/api_user_verified.html.erb @@ -0,0 +1,37 @@ +Tere, +

+

+Anname teada, et järgmise API kasutaja isikutuvastus on automaatselt edukalt lõpule viidud. +

+

API kasutaja andmed:

+ +

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

+

+Parimate soovidega, +

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

+ +Hi, +

+

+We are writing to inform you that the identity verification for the following API user has been automatically completed. +

+

API user details:

+ +

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

+

+Best regards, +

+<%= render 'mailers/shared/signatures/signature.en.html' %> diff --git a/app/views/mailers/registrar_mailer/api_user_verified.text.erb b/app/views/mailers/registrar_mailer/api_user_verified.text.erb new file mode 100644 index 0000000000..39807454e5 --- /dev/null +++ b/app/views/mailers/registrar_mailer/api_user_verified.text.erb @@ -0,0 +1,26 @@ +Tere, + +Anname teada, et järgmise API kasutaja isikutuvastus on automaatselt edukalt lõpule viidud. + +API kasutaja andmed: +- Kasutajanimi: <%= @api_user.username %> +- Isikukood: <%= @api_user.identity_code %> + +Täielikud tulemused leiate manuses olevast PDF-failist. + +Parimate soovidega, +<%= render 'mailers/shared/signatures/signature.et.text' %> +--- + +Hi, + +We are writing to inform you that the identity verification for the following API user has been automatically completed. + +API user details: +- Username: <%= @api_user.username %> +- Identity code: <%= @api_user.identity_code %> + +The full result can be found in the attached PDF file. + +Best regards, +<%= render 'mailers/shared/signatures/signature.en.text' %> diff --git a/config/locales/admin/registrars.en.yml b/config/locales/admin/registrars.en.yml index 0550650586..e02d18b77a 100644 --- a/config/locales/admin/registrars.en.yml +++ b/config/locales/admin/registrars.en.yml @@ -30,6 +30,11 @@ en: api_users: header: API Users new_btn: New API user + verification_status: Verification status + verified: Verified + pending_review: Pending registrar review + requested: Requested + unverified: Unverified set_test_btn: Set Accreditation remove_test_btn: Remove Accreditation diff --git a/config/locales/api_users.en.yml b/config/locales/api_users.en.yml index 9d4fcb63a5..609ca42dfa 100644 --- a/config/locales/api_users.en.yml +++ b/config/locales/api_users.en.yml @@ -1,6 +1,14 @@ en: activerecord: - attributes: - api_user: - plain_text_password: Password - roles: Role + errors: + models: + api_user: + attributes: + email: + blank: is required for identity verification + base: + verification_error: Sending identification request failed + not_pending_verification: No pending verification to approve or reject + missing_subject: Login subject is required to approve verification + subject: + taken: is already used by another api user at this registrar diff --git a/config/locales/api_users.et.yml b/config/locales/api_users.et.yml new file mode 100644 index 0000000000..5d142c0d34 --- /dev/null +++ b/config/locales/api_users.et.yml @@ -0,0 +1,17 @@ +et: + activerecord: + attributes: + api_user: + email: E-post + errors: + models: + api_user: + attributes: + email: + blank: on vajalik isikutuvastamiseks + base: + verification_error: Identifitseerimistaotluse saatmine ebaõnnestus + not_pending_verification: Kinnitamist ootavat identifitseerimist pole + missing_subject: Sisselogimise identifikaator on kinnitamiseks kohustuslik + subject: + taken: on selle registripidaja juures juba teise API kasutaja poolt kasutusel diff --git a/config/locales/mailers/api_user.en.yml b/config/locales/mailers/api_user.en.yml new file mode 100644 index 0000000000..a86c733799 --- /dev/null +++ b/config/locales/mailers/api_user.en.yml @@ -0,0 +1,6 @@ +en: + api_user_mailer: + identification_requested: + subject: >- + Palun kinnitage oma isik API kasutaja kontoga seotud toimingute jaoks + / Please verify your identity for API user account operations diff --git a/config/locales/mailers/api_user.et.yml b/config/locales/mailers/api_user.et.yml new file mode 100644 index 0000000000..894eaa9918 --- /dev/null +++ b/config/locales/mailers/api_user.et.yml @@ -0,0 +1,4 @@ +et: + api_user_mailer: + identification_requested: + subject: Palun kinnitage oma isik API kasutaja kontoga seotud toimingute jaoks diff --git a/config/locales/mailers/registrar.en.yml b/config/locales/mailers/registrar.en.yml index a230113ca7..ce9cb762f5 100644 --- a/config/locales/mailers/registrar.en.yml +++ b/config/locales/mailers/registrar.en.yml @@ -3,4 +3,16 @@ en: contact_verified: subject: >- Teade: Kontakti [%{contact_code}] kinnitamine edukalt lõpule viidud - / Notification: Contact [%{contact_code}] verification successfully completed \ No newline at end of file + / Notification: Contact [%{contact_code}] verification successfully completed + api_user_verified: + subject: >- + Teade: API kasutaja [%{username}] isikutuvastus kinnitatud automaatselt + / Notification: API user [%{username}] identity verification completed + api_user_verification_pending: + subject: >- + Teade: API kasutaja [%{username}] isikutuvastus vajab registripidaja kinnitust + / Notification: API user [%{username}] identity verification needs registrar review + api_user_subject_changed: + subject: >- + Teade: API kasutaja [%{username}] sisselogimise identifikaator muudeti + / Notification: API user [%{username}] login subject changed \ No newline at end of file diff --git a/config/locales/mailers/registrar.et.yml b/config/locales/mailers/registrar.et.yml new file mode 100644 index 0000000000..8b0942455a --- /dev/null +++ b/config/locales/mailers/registrar.et.yml @@ -0,0 +1,8 @@ +et: + registrar_mailer: + api_user_verified: + subject: 'Teade: API kasutaja [%{username}] isikutuvastus kinnitatud automaatselt' + api_user_verification_pending: + subject: 'Teade: API kasutaja [%{username}] isikutuvastus vajab registripidaja kinnitust' + api_user_subject_changed: + subject: 'Teade: API kasutaja [%{username}] sisselogimise identifikaator muudeti' diff --git a/config/routes.rb b/config/routes.rb index 7bb2e7c963..0c226e330c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -112,6 +112,12 @@ end end resources :api_users, only: %i[index show update create destroy] do + collection do + post 'verify/:id', to: 'api_users#verify' + get 'download_poi/:id', to: 'api_users#download_poi' + post 'approve_verification/:id', to: 'api_users#approve_verification' + post 'reject_verification/:id', to: 'api_users#reject_verification' + end resources :certificates, only: %i[show] do member do get 'download' diff --git a/db/data/20200608084321_fill_email_verifications.rb b/db/data/20200608084321_fill_email_verifications.rb index c51570d1b4..d65e3b64a4 100644 --- a/db/data/20200608084321_fill_email_verifications.rb +++ b/db/data/20200608084321_fill_email_verifications.rb @@ -1,6 +1,4 @@ class FillEmailVerifications < ActiveRecord::Migration[6.0] - include Concerns::EmailVerifable - def up # registrar_billing_emails = Registrar.pluck(:billing_email).uniq.reject(&:blank?) # registrar_emails = Registrar.pluck(:email).uniq.reject(&:blank?) @@ -17,6 +15,6 @@ def up end def down - EmailAddressVerification.delete_all + # EmailAddressVerification table removed in 20220413073315_remove_email_address_verifications end end diff --git a/db/data/20200911104302_fix_typo_in_setting_name.rb b/db/data/20200911104302_fix_typo_in_setting_name.rb index 9dd2b3b2b9..533b142751 100644 --- a/db/data/20200911104302_fix_typo_in_setting_name.rb +++ b/db/data/20200911104302_fix_typo_in_setting_name.rb @@ -1,6 +1,6 @@ class FixTypoInSettingName < ActiveRecord::Migration[6.0] def up - setting = Setting.find_by(code: 'request_confrimation_on_registrant_change_enabled') + setting = Setting.find_by(code: 'request_confirmation_on_registrant_change_enabled') setting.update(code: 'request_confirmation_on_registrant_change_enabled') end diff --git a/db/data/20260601120100_backfill_api_user_subject.rb b/db/data/20260601120100_backfill_api_user_subject.rb new file mode 100644 index 0000000000..290ebd5a5b --- /dev/null +++ b/db/data/20260601120100_backfill_api_user_subject.rb @@ -0,0 +1,10 @@ +class BackfillApiUserSubject < ActiveRecord::Migration[6.1] + def up + result = ApiUsers::SubjectBackfill.run + say "ApiUser subject backfill: updated=#{result[:updated]} skipped=#{result[:skipped]}" + end + + def down + ApiUser.where.not(subject: [nil, '']).update_all(subject: nil) + end +end diff --git a/db/data_schema.rb b/db/data_schema.rb index 657376ab90..bb04bd70b0 100644 --- a/db/data_schema.rb +++ b/db/data_schema.rb @@ -1 +1 @@ -DataMigrate::Data.define(version: 20201007104651) +DataMigrate::Data.define(version: 20260601120100) diff --git a/db/migrate/20260529120000_add_verification_fields_to_users.rb b/db/migrate/20260529120000_add_verification_fields_to_users.rb new file mode 100644 index 0000000000..e6b0523461 --- /dev/null +++ b/db/migrate/20260529120000_add_verification_fields_to_users.rb @@ -0,0 +1,13 @@ +class AddVerificationFieldsToUsers < ActiveRecord::Migration[6.1] + disable_ddl_transaction! + + def change + add_column :users, :ident_request_sent_at, :datetime + add_column :users, :verified_at, :datetime + add_column :users, :verification_id, :string + add_column :users, :verification_pending_at, :datetime + add_column :users, :verification_snapshot, :jsonb, default: {} + add_index :users, :verified_at, algorithm: :concurrently + add_index :users, :verification_pending_at, algorithm: :concurrently + end +end diff --git a/db/migrate/20260601120000_add_subject_to_users.rb b/db/migrate/20260601120000_add_subject_to_users.rb new file mode 100644 index 0000000000..e0d3ea8796 --- /dev/null +++ b/db/migrate/20260601120000_add_subject_to_users.rb @@ -0,0 +1,12 @@ +class AddSubjectToUsers < ActiveRecord::Migration[6.1] + disable_ddl_transaction! + + def change + add_column :users, :subject, :string + add_index :users, :subject, algorithm: :concurrently + add_index :users, %i[registrar_id subject], + unique: true, + where: "subject IS NOT NULL AND subject != ''", + algorithm: :concurrently + end +end diff --git a/db/structure.sql b/db/structure.sql index 9061c1f3a3..a8c309c4e8 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -1,8 +1,7 @@ -\restrict OgpBVS4LcDZVV6fZTwmnzAAWL70pwHISsUhjW2gqrf2CNdzHNtVCcfzYehS3JFu - +\restrict UcsiEKfHzXBZIZHqjYwWEEWgCcCXbG1YFqBmhSq7V4X0gwin7b9h3ziY4zklaPe -- Dumped from database version 13.4 (Debian 13.4-4.pgdg110+1) --- Dumped by pg_dump version 13.22 (Debian 13.22-0+deb11u1) +-- Dumped by pg_dump version 13.23 (Debian 13.23-1.pgdg11+1) SET statement_timeout = 0; SET lock_timeout = 0; @@ -734,11 +733,11 @@ CREATE TABLE public.contacts ( disclosed_attributes character varying[] DEFAULT '{}'::character varying[] NOT NULL, email_history character varying, 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, verification_id character varying, + checked_company_at timestamp without time zone, + company_register_status character varying, system_disclosed_attributes character varying[] DEFAULT '{}'::character varying[] ); @@ -2649,9 +2648,9 @@ CREATE TABLE public.registrars ( legaldoc_optout boolean DEFAULT false NOT NULL, legaldoc_optout_comment text, email_history character varying, - accept_pdf_invoices boolean DEFAULT true, accreditation_date timestamp without time zone, - accreditation_expire_date timestamp without time zone + accreditation_expire_date timestamp without time zone, + accept_pdf_invoices boolean DEFAULT true ); @@ -2674,6 +2673,25 @@ CREATE SEQUENCE public.registrars_id_seq ALTER SEQUENCE public.registrars_id_seq OWNED BY public.registrars.id; +-- +-- Name: reports_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.reports_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: reports_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.reports_id_seq OWNED BY public.admin_reports.id; + + -- -- Name: repp_logs; Type: TABLE; Schema: public; Owner: - -- @@ -2760,9 +2778,7 @@ CREATE TABLE public.reserved_domains ( legacy_id integer, name character varying NOT NULL, password character varying NOT NULL, - expire_at timestamp without time zone, - access_token character varying, - token_created_at timestamp without time zone + expire_at timestamp without time zone ); @@ -2898,7 +2914,13 @@ CREATE TABLE public.users ( legacy_id integer, accreditation_date timestamp without time zone, accreditation_expire_date timestamp without time zone, - uuid uuid DEFAULT public.gen_random_uuid() + uuid uuid DEFAULT public.gen_random_uuid(), + ident_request_sent_at timestamp without time zone, + verified_at timestamp without time zone, + verification_id character varying, + verification_pending_at timestamp without time zone, + verification_snapshot jsonb DEFAULT '{}'::jsonb, + subject character varying ); @@ -3129,7 +3151,7 @@ ALTER TABLE ONLY public.actions ALTER COLUMN id SET DEFAULT nextval('public.acti -- Name: admin_reports id; Type: DEFAULT; Schema: public; Owner: - -- -ALTER TABLE ONLY public.admin_reports ALTER COLUMN id SET DEFAULT nextval('public.admin_reports_id_seq'::regclass); +ALTER TABLE ONLY public.admin_reports ALTER COLUMN id SET DEFAULT nextval('public.reports_id_seq'::regclass); -- @@ -4985,6 +5007,34 @@ CREATE INDEX index_users_on_identity_code ON public.users USING btree (identity_ CREATE INDEX index_users_on_registrar_id ON public.users USING btree (registrar_id); +-- +-- Name: index_users_on_registrar_id_and_subject; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_users_on_registrar_id_and_subject ON public.users USING btree (registrar_id, subject) WHERE ((subject IS NOT NULL) AND ((subject)::text <> ''::text)); + + +-- +-- Name: index_users_on_subject; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_users_on_subject ON public.users USING btree (subject); + + +-- +-- Name: index_users_on_verification_pending_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_users_on_verification_pending_at ON public.users USING btree (verification_pending_at); + + +-- +-- Name: index_users_on_verified_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_users_on_verified_at ON public.users USING btree (verified_at); + + -- -- Name: index_validation_events_on_event_data; Type: INDEX; Schema: public; Owner: - -- @@ -5296,12 +5346,11 @@ ALTER TABLE ONLY public.users -- PostgreSQL database dump complete -- -\unrestrict OgpBVS4LcDZVV6fZTwmnzAAWL70pwHISsUhjW2gqrf2CNdzHNtVCcfzYehS3JFu +\unrestrict UcsiEKfHzXBZIZHqjYwWEEWgCcCXbG1YFqBmhSq7V4X0gwin7b9h3ziY4zklaPe SET search_path TO "$user", public; INSERT INTO "schema_migrations" (version) VALUES -('0'), ('20140616073945'), ('20140620130107'), ('20140627082711'), @@ -5780,19 +5829,14 @@ INSERT INTO "schema_migrations" (version) VALUES ('20221214073933'), ('20221214074252'), ('20230531111154'), -('20230612094319'), -('20230612094326'), -('20230612094335'), ('20230707084741'), ('20230710120154'), ('20230711083811'), -('20240722085530'), -('20240723110208'), ('20240816091049'), ('20240816092636'), +('20240903131540'), ('20240924103554'), ('20241015071505'), -('20241022121525'), ('20241030095636'), ('20241104104620'), ('20241112093540'), @@ -5803,9 +5847,12 @@ INSERT INTO "schema_migrations" (version) VALUES ('20250219102811'), ('20250310133151'), ('20250313122119'), -('20250314133357'), ('20250319104749'), ('20250627084536'), -('20260406125446'), ('20251230104312'), -('20260220111500'); +('20260220111500'), +('20260406125446'), +('20260529120000'), +('20260601120000'); + + diff --git a/lib/serializers/repp/api_user.rb b/lib/serializers/repp/api_user.rb index 2f57df1f67..4b98e3c0fb 100644 --- a/lib/serializers/repp/api_user.rb +++ b/lib/serializers/repp/api_user.rb @@ -14,12 +14,19 @@ def to_json(obj = user) name: obj.username, password: obj.plain_text_password, identity_code: obj.identity_code, + subject: obj.subject, + email: obj.email, roles: obj.roles.join(', '), active: obj.active, created_at: obj.created_at, updated_at: obj.updated_at, creator: obj.creator_str, updator: obj.updator_str, + ident_request_sent_at: obj.ident_request_sent_at, + verified_at: obj.verified_at, + verification_id: obj.verification_id, + verification_pending_at: obj.verification_pending_at, + verification_snapshot: obj.verification_snapshot } json[:certificates] = certificates json @@ -34,10 +41,10 @@ def certificates { id: x.id, subject: subject_str, status: x.status } end end - + def extract_subject(certificate) subject = nil - + if certificate.csr.present? begin if certificate.parsed_csr @@ -47,7 +54,7 @@ def extract_subject(certificate) Rails.logger.warn("Error extracting subject from CSR: #{e.message}") end end - + if subject.blank? && certificate.crt.present? begin if certificate.parsed_crt @@ -57,7 +64,7 @@ def extract_subject(certificate) Rails.logger.warn("Error extracting subject from CRT: #{e.message}") end end - + subject.presence || certificate.common_name.presence || 'Unknown' end end diff --git a/lib/tasks/api_users/backfill_subject.rake b/lib/tasks/api_users/backfill_subject.rake new file mode 100644 index 0000000000..ed3682d585 --- /dev/null +++ b/lib/tasks/api_users/backfill_subject.rake @@ -0,0 +1,7 @@ +namespace :api_users do + desc 'Backfill subject from identity_code for ApiUsers (idempotent)' + task backfill_subject: :environment do + result = ApiUsers::SubjectBackfill.run + puts "ApiUser subject backfill: updated=#{result[:updated]} skipped=#{result[:skipped]}" + end +end diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index ed05e632dd..674a6993e9 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -2,6 +2,8 @@ api_bestnames: username: test_bestnames plain_text_password: testtest identity_code: 1234 + subject: EE1234 + verified_at: <%= Time.zone.parse('2024-01-01 12:00:00') %> type: ApiUser registrar: bestnames active: true diff --git a/test/integration/eeid/identification_requests_webhook_test.rb b/test/integration/eeid/identification_requests_webhook_test.rb index e8a1e2793f..da0b13f58c 100644 --- a/test/integration/eeid/identification_requests_webhook_test.rb +++ b/test/integration/eeid/identification_requests_webhook_test.rb @@ -78,6 +78,132 @@ class Eeid::IdentificationRequestsWebhookTest < ActionDispatch::IntegrationTest assert_nil @contact.reload.verified_at end + test 'should auto verify api user when sub is present' do + api_user = users(:api_bestnames_epp) + api_user.update!(email: 'api@example.test', ident_request_sent_at: 1.day.ago) + payload = { + identification_request_id: '456', + reference: api_user.uuid + } + signature = OpenSSL::HMAC.hexdigest('SHA256', @secret, payload.to_json) + + ident_body = { + id: '456', + status: 'completed', + result: { + sub: 'EE60001019906', + given_name: 'Test', + family_name: 'User' + } + }.to_json + stub_request(:get, %r{api/ident/v1/identification_requests/456}) + .to_return(status: 200, body: ident_body, headers: { 'Content-Type' => 'application/json' }) + + post '/eeid/webhooks/identification_requests', params: payload, as: :json, + headers: { 'X-HMAC-Signature' => signature } + + assert_response :ok + api_user.reload + assert api_user.verified_at.present? + assert_equal 'EE', api_user.country_code + assert_equal 'EE60001019906', api_user.subject + assert_equal "eeid-webhook:ApiUser:#{api_user.username}", api_user.updator_str + assert_nil api_user.identity_code + assert_nil api_user.verification_pending_at + end + + test 'should auto verify api user with document-only sub' do + api_user = users(:api_bestnames_epp) + api_user.update!(email: 'api@example.test', ident_request_sent_at: 1.day.ago) + payload = { + identification_request_id: '457', + reference: api_user.uuid + } + signature = OpenSSL::HMAC.hexdigest('SHA256', @secret, payload.to_json) + + ident_body = { + id: '457', + status: 'completed', + result: { + sub: 'GBAB123456', + given_name: 'Test', + family_name: 'User' + } + }.to_json + stub_request(:get, %r{api/ident/v1/identification_requests/457}) + .to_return(status: 200, body: ident_body, headers: { 'Content-Type' => 'application/json' }) + + post '/eeid/webhooks/identification_requests', params: payload, as: :json, + headers: { 'X-HMAC-Signature' => signature } + + assert_response :ok + api_user.reload + assert api_user.verified_at.present? + assert_equal 'GBAB123456', api_user.subject + assert_nil api_user.identity_code + assert_nil api_user.verification_pending_at + end + + test 'should pending review api user when subject cannot be resolved' do + api_user = users(:api_bestnames_epp) + api_user.update!(email: 'api@example.test', ident_request_sent_at: 1.day.ago) + payload = { + identification_request_id: '789', + reference: api_user.uuid + } + signature = OpenSSL::HMAC.hexdigest('SHA256', @secret, payload.to_json) + + ident_body = { + id: '789', + status: 'completed', + result: { given_name: 'Test', family_name: 'User', country: 'GB' } + }.to_json + stub_request(:get, %r{api/ident/v1/identification_requests/789}) + .to_return(status: 200, body: ident_body, headers: { 'Content-Type' => 'application/json' }) + + post '/eeid/webhooks/identification_requests', params: payload, as: :json, + headers: { 'X-HMAC-Signature' => signature } + + assert_response :ok + api_user.reload + assert_nil api_user.verified_at + assert api_user.verification_pending_at.present? + assert_equal 'User', api_user.verification_snapshot['family_name'] + assert_nil api_user.subject + assert_emails 1 + assert_includes ActionMailer::Base.deliveries.last.subject, api_user.username + end + + test 'should pending review api user when subject conflicts with another user' do + existing = users(:api_bestnames) + existing.update!(subject: 'GBAB999999', registrar: users(:api_bestnames_epp).registrar) + + api_user = users(:api_bestnames_epp) + api_user.update!(email: 'api@example.test', ident_request_sent_at: 1.day.ago) + payload = { + identification_request_id: '790', + reference: api_user.uuid + } + signature = OpenSSL::HMAC.hexdigest('SHA256', @secret, payload.to_json) + + ident_body = { + id: '790', + status: 'completed', + result: { sub: 'GBAB999999' } + }.to_json + stub_request(:get, %r{api/ident/v1/identification_requests/790}) + .to_return(status: 200, body: ident_body, headers: { 'Content-Type' => 'application/json' }) + + post '/eeid/webhooks/identification_requests', params: payload, as: :json, + headers: { 'X-HMAC-Signature' => signature } + + assert_response :ok + api_user.reload + assert_nil api_user.verified_at + assert api_user.verification_pending_at.present? + assert_nil api_user.subject + end + test 'returns error response if throttled' do ENV['shunter_default_threshold'] = '1' ENV['shunter_enabled'] = 'true' diff --git a/test/integration/repp/v1/accounts/switch_user_test.rb b/test/integration/repp/v1/accounts/switch_user_test.rb index fa26b442d4..f10315c989 100644 --- a/test/integration/repp/v1/accounts/switch_user_test.rb +++ b/test/integration/repp/v1/accounts/switch_user_test.rb @@ -14,7 +14,7 @@ def setup def test_switches_to_linked_api_user new_user = users(:api_goodnames) - new_user.update(identity_code: '1234') + new_user.update(subject: @user.subject) request_body = { account: { new_user_id: new_user.id, @@ -38,7 +38,7 @@ def test_switches_to_linked_api_user def test_switches_to_unlinked_api_user new_user = users(:api_goodnames) - new_user.update(identity_code: '4444') + new_user.update(subject: 'EE44444444444') request_body = { account: { new_user_id: new_user.id, @@ -57,7 +57,7 @@ def test_returns_error_response_if_throttled ENV['shunter_enabled'] = 'true' new_user = users(:api_goodnames) - new_user.update(identity_code: '1234') + new_user.update(subject: @user.subject) request_body = { account: { new_user_id: new_user.id, diff --git a/test/integration/repp/v1/api_users/verify_test.rb b/test/integration/repp/v1/api_users/verify_test.rb new file mode 100644 index 0000000000..aa3c129ccb --- /dev/null +++ b/test/integration/repp/v1/api_users/verify_test.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'test_helper' + +class ReppV1ApiUsersVerifyTest < ActionDispatch::IntegrationTest + def setup + @api_user = users(:api_bestnames_epp) + @api_user.update!(email: 'verify@example.test', subject: nil) + @user = users(:api_bestnames) + token = Base64.encode64("#{@user.username}:#{@user.plain_text_password}") + @auth_headers = { 'Authorization' => "Basic #{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: hash_including( + 'claims_required' => [{ 'type' => 'sub', 'value' => '' }], + 'reference' => @api_user.uuid + ) + ).to_return(status: 200, body: { id: '123', link: 'http://link' }.to_json, + headers: { 'Content-Type' => 'application/json' }) + end + + def test_verifies_api_user_with_discovery_claims + post "/repp/v1/api_users/verify/#{@api_user.id}", headers: @auth_headers + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :ok + assert_equal 1000, json[:code] + + @api_user.reload + assert @api_user.ident_request_sent_at.present? + assert_nil @api_user.verified_at + assert_emails 1 + assert_equal ['verify@example.test'], ActionMailer::Base.deliveries.last.to + end + + def test_returns_error_without_email + @api_user.update!(email: nil) + + post "/repp/v1/api_users/verify/#{@api_user.id}", headers: @auth_headers + + assert_response :bad_request + assert_nil @api_user.reload.ident_request_sent_at + assert_emails 0 + end + + def test_verifies_api_user_with_existing_subject + @api_user.update!(subject: 'EE60001019906', country_code: 'EE') + stub_request(:post, %r{api/ident/v1/identification_requests}) + .with( + body: hash_including( + 'claims_required' => [{ 'type' => 'sub', 'value' => 'EE60001019906' }] + ) + ).to_return(status: 200, body: { id: '123', link: 'http://link' }.to_json, + headers: { 'Content-Type' => 'application/json' }) + + post "/repp/v1/api_users/verify/#{@api_user.id}", headers: @auth_headers + + assert_response :ok + assert @api_user.reload.ident_request_sent_at.present? + end +end diff --git a/test/integration/repp/v1/registrar/auth/check_info_test.rb b/test/integration/repp/v1/registrar/auth/check_info_test.rb index 8fd321db18..bf761a436d 100644 --- a/test/integration/repp/v1/registrar/auth/check_info_test.rb +++ b/test/integration/repp/v1/registrar/auth/check_info_test.rb @@ -39,6 +39,16 @@ def test_invalid_user_login assert_equal json[:message], 'Invalid authorization information' end + def test_rejects_unverified_user_login_even_with_valid_password + @user.update_columns(verified_at: nil) + + get '/repp/v1/registrar/auth', headers: @auth_headers + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :unauthorized + assert_equal 'Invalid authorization information', json[:message] + end + def test_returns_error_response_if_throttled ENV['shunter_default_threshold'] = '1' ENV['shunter_enabled'] = 'true' diff --git a/test/integration/repp/v1/registrar/auth/tara_callback_test.rb b/test/integration/repp/v1/registrar/auth/tara_callback_test.rb index e9c1a101c5..56ae6a5380 100644 --- a/test/integration/repp/v1/registrar/auth/tara_callback_test.rb +++ b/test/integration/repp/v1/registrar/auth/tara_callback_test.rb @@ -35,6 +35,26 @@ def test_validates_user_from_omniauth_params assert_equal json[:data][:token], user_token end + def test_auto_verifies_unverified_api_user_with_matching_subject + @user.update_columns(verified_at: nil) + + request_body = { auth: { uid: 'EE1234' } } + + Repp::V1::BaseController.stub_any_instance(:webclient_request?, true) do + Repp::V1::BaseController.stub_any_instance(:validate_webclient, true) do + post '/repp/v1/registrar/auth/tara_callback', headers: @auth_headers, params: request_body + end + end + + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :ok + assert_equal 1000, json[:code] + @user.reload + assert @user.verified_at.present? + assert_equal "eeid-auto-verify:#{@user.username}", @user.updator_str + end + def test_invalidates_user_with_wrong_omniauth_params request_body = { auth: { diff --git a/test/interactions/actions/api_user_approve_verification_test.rb b/test/interactions/actions/api_user_approve_verification_test.rb new file mode 100644 index 0000000000..499b21a886 --- /dev/null +++ b/test/interactions/actions/api_user_approve_verification_test.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'test_helper' + +class Actions::ApiUserApproveVerificationTest < ActiveSupport::TestCase + setup do + @api_user = users(:api_bestnames_epp) + @api_user.update!( + email: 'pending@example.test', + ident_request_sent_at: 1.day.ago, + verification_pending_at: Time.zone.now, + verification_id: 'pending-1', + verification_snapshot: { + 'sub' => 'GBMANUAL123' + } + ) + end + + test 'approve sets subject and country from verification snapshot' do + assert Actions::ApiUserApproveVerification.new(@api_user).call + + @api_user.reload + assert @api_user.verified_at.present? + assert_nil @api_user.verification_pending_at + assert_equal 'GBMANUAL123', @api_user.subject + assert_equal 'GB', @api_user.country_code + assert_nil @api_user.identity_code + end + + test 'approve sets subject from manual override when snapshot has no subject' do + @api_user.update!(verification_snapshot: { 'given_name' => 'Test' }) + + assert Actions::ApiUserApproveVerification.new(@api_user, subject: 'EE60001019906').call + + @api_user.reload + assert_equal 'EE60001019906', @api_user.subject + assert_equal 'EE', @api_user.country_code + assert_nil @api_user.identity_code + end + + test 'approve fails when subject is missing' do + @api_user.update!(verification_snapshot: { 'given_name' => 'Test' }) + + assert_not Actions::ApiUserApproveVerification.new(@api_user).call + assert @api_user.errors.added?(:base, :missing_subject) + end + + test 'approve fails when subject conflicts' do + users(:api_bestnames).update!( + subject: 'GBMANUAL123', + registrar: @api_user.registrar + ) + + assert_not Actions::ApiUserApproveVerification.new(@api_user).call + assert @api_user.errors.added?(:subject, :taken) + end +end diff --git a/test/models/api_user_test.rb b/test/models/api_user_test.rb index ec53d2371e..59f4d249af 100644 --- a/test/models/api_user_test.rb +++ b/test/models/api_user_test.rb @@ -69,6 +69,163 @@ def test_active_by_default assert ApiUser.new.active? end + def test_linked_users_by_subject_same_registrar + login_subject = 'EE60001019906' + @user.update_columns(subject: login_subject) + linked = ApiUser.create!( + username: 'linked_by_subject', + plain_text_password: 'secret1', + registrar: @user.registrar, + roles: ['epp'], + subject: login_subject, + verified_at: Time.zone.now, + active: true + ) + + assert_includes @user.linked_users, linked + end + + def test_linked_users_by_subject_across_registrars + login_subject = 'GBAB123456' + @user.update_columns(subject: login_subject) + linked = ApiUser.create!( + username: 'linked_other_registrar', + plain_text_password: 'secret1', + registrar: registrars(:goodnames), + roles: ['epp'], + subject: login_subject, + verified_at: Time.zone.now, + active: true + ) + + assert_includes @user.linked_users, linked + end + + def test_linked_users_empty_when_subject_blank + @user.update_columns(subject: nil) + + assert_empty @user.linked_users + end + + def test_linked_users_excludes_inactive_users + login_subject = 'EE99999999999' + @user.update_columns(subject: login_subject) + ApiUser.create!( + username: 'linked_inactive', + plain_text_password: 'secret1', + registrar: @user.registrar, + roles: ['epp'], + subject: login_subject, + active: false + ) + + assert_empty @user.linked_users + end + + def test_linked_users_excludes_unverified_users + login_subject = 'EE77777777777' + @user.update_columns(subject: login_subject) + ApiUser.create!( + username: 'linked_unverified', + plain_text_password: 'secret1', + registrar: @user.registrar, + roles: ['epp'], + subject: login_subject, + verified_at: nil, + active: true + ) + + assert_empty @user.linked_users + end + + def test_linked_with_by_subject_only + @user.update_columns(subject: 'GBAB1') + same_subject = ApiUser.new(subject: 'GBAB1') + different_subject = ApiUser.new(subject: 'other') + + assert @user.linked_with?(same_subject) + assert_not @user.linked_with?(different_subject) + assert_not @user.linked_with?(ApiUser.new(subject: nil)) + assert_not @user.linked_with?(nil) + end + + def test_eligible_for_sign_in_requires_active_verified_subject + @user.update_columns(active: true, verified_at: Time.zone.now, subject: 'EE1234') + assert @user.eligible_for_sign_in? + + @user.update_columns(verified_at: nil) + assert_not @user.eligible_for_sign_in? + + @user.update_columns(verified_at: Time.zone.now, active: false) + assert_not @user.eligible_for_sign_in? + + @user.update_columns(active: true, subject: nil) + assert_not @user.eligible_for_sign_in? + end + + def test_subject_change_clears_verification_status_when_subject_previously_present + @user.update_columns( + subject: 'EE1234', + ident_request_sent_at: 2.days.ago, + verified_at: 1.day.ago, + verification_id: 'ver-1', + verification_pending_at: 3.hours.ago, + verification_snapshot: { 'sub' => 'EE1234' } + ) + + @user.update!(subject: 'EE9999') + @user.reload + + assert_nil @user.ident_request_sent_at + assert_nil @user.verified_at + assert_nil @user.verification_id + assert_nil @user.verification_pending_at + assert_equal({}, @user.verification_snapshot) + end + + def test_subject_change_notifies_registrar_when_subject_previously_present + @user.update_columns( + subject: 'EE1234', + verified_at: 1.day.ago + ) + + assert_emails 1 do + @user.update!(subject: 'EE9999') + end + + email = ActionMailer::Base.deliveries.last + assert_equal [@user.registrar.email], email.to + assert_match(@user.username, email.subject) + end + + def test_setting_subject_first_time_does_not_clear_verification_status + @user.update_columns( + subject: nil, + ident_request_sent_at: 2.days.ago, + verified_at: 1.day.ago, + verification_id: 'ver-2', + verification_pending_at: nil, + verification_snapshot: { 'sub' => 'EE1234' } + ) + + @user.update!(subject: 'EE1234') + @user.reload + + assert_not_nil @user.ident_request_sent_at + assert_not_nil @user.verified_at + assert_equal 'ver-2', @user.verification_id + assert_nil @user.verification_pending_at + assert_equal({ 'sub' => 'EE1234' }, @user.verification_snapshot) + end + + def test_setting_subject_first_time_does_not_notify_registrar + @user.update_columns(subject: nil) + + assert_emails 0 do + @user.update!(subject: 'EE1234') + end + end + def test_verifies_pki_status certificate = certificates(:api) diff --git a/test/models/user/from_omniauth_test.rb b/test/models/user/from_omniauth_test.rb new file mode 100644 index 0000000000..7ec0548223 --- /dev/null +++ b/test/models/user/from_omniauth_test.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'test_helper' + +class UserFromOmniauthTest < ActiveSupport::TestCase + setup do + @user = users(:api_bestnames) + end + + test 'finds active api user by subject when uid matches' do + @user.update_columns(subject: 'EE60001019906', verified_at: nil, active: true) + + found = ApiUser.from_omniauth('uid' => 'EE60001019906') + + assert_equal @user, found + end + + test 'does not match when subject is blank' do + @user.update_columns(subject: nil, verified_at: Time.zone.now, active: true) + + assert_nil ApiUser.from_omniauth('uid' => 'EE60001019906') + end + + test 'does not match by identity_code when subject is blank' do + @user.update_columns(subject: nil, identity_code: '1234') + + assert_nil ApiUser.from_omniauth('uid' => 'EE1234') + end + + test 'returns nil when uid is blank' do + assert_nil ApiUser.from_omniauth('uid' => '') + assert_nil ApiUser.from_omniauth({}) + end + + test 'returns nil when no api user matches subject' do + assert_nil ApiUser.from_omniauth('uid' => 'XXunknown') + end + + test 'does not match inactive api users' do + @user.update_columns(subject: 'EE1234', active: false, verified_at: Time.zone.now) + + assert_nil ApiUser.from_omniauth('uid' => 'EE1234') + end +end From cc9aed0823950f3699b9f07687939853dd592357 Mon Sep 17 00:00:00 2001 From: Sergei Tsoganov Date: Wed, 3 Jun 2026 16:36:08 +0300 Subject: [PATCH 02/11] Added missing translations and corrected tests --- .../concerns/error_and_log_handler.rb | 2 +- .../repp/v1/api_users_controller.rb | 2 +- app/controllers/repp/v1/base_controller.rb | 3 +- .../repp/v1/contacts_controller.rb | 2 +- .../repp/v1/domains/renews_controller.rb | 2 +- app/interactions/actions/contact_create.rb | 14 +- app/interactions/actions/contact_update.rb | 8 +- app/interactions/actions/domain_create.rb | 11 +- app/interactions/actions/domain_delete.rb | 2 +- app/interactions/actions/domain_transfer.rb | 11 +- app/interactions/actions/domain_update.rb | 18 +-- app/models/api_user.rb | 3 +- app/models/epp/domain.rb | 2 +- app/models/invoice_state_machine.rb | 2 +- config/locales/accounts.en.yml | 21 +++ config/locales/accounts.et.yml | 21 +++ config/locales/api_users.en.yml | 30 +++- config/locales/api_users.et.yml | 27 +++- config/locales/contacts.en.yml | 56 ++++++-- config/locales/contacts.et.yml | 67 +++++++++ config/locales/domains.en.yml | 132 ++++++++++++++++++ config/locales/domains.et.yml | 132 ++++++++++++++++++ config/locales/en.yml | 10 +- config/locales/epp/contacts.et.yml | 5 + config/locales/errors.et.yml | 6 + config/locales/et.yml | 8 ++ config/locales/idents.yml | 39 ++++++ config/locales/invoices.en.yml | 48 +++++++ config/locales/invoices.et.yml | 48 +++++++ config/locales/registrar/account.et.yml | 32 +++++ config/locales/repp.en.yml | 8 ++ config/locales/repp.et.yml | 8 ++ test/fixtures/users.yml | 2 + .../admin_area/certificates_test.rb | 2 +- .../identification_requests_webhook_test.rb | 8 +- .../repp/v1/api_users/create_test.rb | 18 +++ .../repp/v1/api_users/update_test.rb | 15 ++ .../repp/v1/domains/update_test.rb | 4 +- test/models/api_user_test.rb | 60 +++++--- test/models/domain_test.rb | 22 +-- test/models/invoice_state_machinte_test.rb | 12 +- .../admin_area/registrars/api_users_test.rb | 17 ++- 42 files changed, 838 insertions(+), 102 deletions(-) create mode 100644 config/locales/accounts.en.yml create mode 100644 config/locales/accounts.et.yml create mode 100644 config/locales/domains.en.yml create mode 100644 config/locales/domains.et.yml create mode 100644 config/locales/epp/contacts.et.yml create mode 100644 config/locales/errors.et.yml create mode 100644 config/locales/invoices.en.yml create mode 100644 config/locales/invoices.et.yml create mode 100644 config/locales/registrar/account.et.yml create mode 100644 config/locales/repp.en.yml create mode 100644 config/locales/repp.et.yml diff --git a/app/controllers/concerns/error_and_log_handler.rb b/app/controllers/concerns/error_and_log_handler.rb index 57f8c6b089..69e9af3547 100644 --- a/app/controllers/concerns/error_and_log_handler.rb +++ b/app/controllers/concerns/error_and_log_handler.rb @@ -26,7 +26,7 @@ def log_request # rubocop:enable Metrics/MethodLength def handle_record_not_found - @response = { code: 2303, message: 'Object does not exist' } + @response = { code: 2303, message: I18n.t('repp.object_does_not_exist') } render(json: @response, status: :not_found) end diff --git a/app/controllers/repp/v1/api_users_controller.rb b/app/controllers/repp/v1/api_users_controller.rb index b76c62fcfb..ac20ccef56 100644 --- a/app/controllers/repp/v1/api_users_controller.rb +++ b/app/controllers/repp/v1/api_users_controller.rb @@ -125,7 +125,7 @@ def find_api_user def api_user_params params.require(:api_user).permit(:username, :plain_text_password, :active, - :subject, :email, { roles: [] }) + :subject, :email, :identity_code, { roles: [] }) end def approve_verification_params diff --git a/app/controllers/repp/v1/base_controller.rb b/app/controllers/repp/v1/base_controller.rb index fb2c35cf2d..04932b9afe 100644 --- a/app/controllers/repp/v1/base_controller.rb +++ b/app/controllers/repp/v1/base_controller.rb @@ -35,7 +35,8 @@ def set_paper_trail_whodunnit end def render_success(code: nil, message: nil, data: nil) - @response = { code: code || 1000, message: message || 'Command completed successfully', + @response = { code: code || 1000, + message: message || I18n.t('repp.command_completed_successfully'), data: data || {} } render(json: @response, status: :ok) diff --git a/app/controllers/repp/v1/contacts_controller.rb b/app/controllers/repp/v1/contacts_controller.rb index a9b0c783ce..da1e360a5c 100644 --- a/app/controllers/repp/v1/contacts_controller.rb +++ b/app/controllers/repp/v1/contacts_controller.rb @@ -206,7 +206,7 @@ def contact_addr_present? def create_update_success_body { code: opt_addr? ? 1100 : nil, data: { contact: { code: @contact.code } }, - message: opt_addr? ? I18n.t('epp.contacts.completed_without_address') : nil } + message: opt_addr? ? I18n.t('epp.contacts.completed_without_address') : I18n.t('repp.command_completed_successfully') } end def opt_addr? diff --git a/app/controllers/repp/v1/domains/renews_controller.rb b/app/controllers/repp/v1/domains/renews_controller.rb index c911301190..007c32cf9c 100644 --- a/app/controllers/repp/v1/domains/renews_controller.rb +++ b/app/controllers/repp/v1/domains/renews_controller.rb @@ -84,7 +84,7 @@ def bulk_renew_domains next if domain @epp_errors.add(:epp_errors, - msg: "Object does not exist: #{idn}", + msg: I18n.t('repp.object_does_not_exist_with_name', name: idn), code: '2304') end else diff --git a/app/interactions/actions/contact_create.rb b/app/interactions/actions/contact_create.rb index ca1b4d7c5d..782efdf9a4 100644 --- a/app/interactions/actions/contact_create.rb +++ b/app/interactions/actions/contact_create.rb @@ -27,8 +27,12 @@ def maybe_change_email @result = Actions::SimpleMailValidator.run(email: contact.email, level: m) next if @result - err_text = "email '#{contact.email}' didn't pass validation" - contact.add_epp_error('2005', nil, nil, "#{I18n.t(:parameter_value_syntax_error)} #{err_text}") + contact.add_epp_error( + '2005', + nil, + nil, + "#{I18n.t(:parameter_value_syntax_error)} #{I18n.t(:email_did_not_pass_validation, email: contact.email)}" + ) @error = true return end @@ -62,10 +66,10 @@ def validate_ident_integrity if ident[:ident_type].blank? contact.add_epp_error('2003', nil, 'ident_type', - I18n.t('errors.messages.required_ident_attribute_missing')) + I18n.t('errors.messages.required_ident_attribute_missing', key: 'ident_type')) @error = true elsif !%w[priv org birthday].include?(ident[:ident_type]) - contact.add_epp_error('2003', nil, 'ident_type', 'Invalid ident type') + contact.add_epp_error('2003', nil, 'ident_type', I18n.t('errors.messages.invalid_ident_type')) @error = true end end @@ -75,7 +79,7 @@ def validate_ident_birthday return unless ident[:ident_type] != 'birthday' && ident[:ident_country_code].blank? contact.add_epp_error('2003', nil, 'ident_country_code', - I18n.t('errors.messages.required_ident_attribute_missing')) + I18n.t('errors.messages.required_ident_attribute_missing', key: 'ident_country_code')) @error = true end diff --git a/app/interactions/actions/contact_update.rb b/app/interactions/actions/contact_update.rb index dc9ebd944a..4869b2d6ad 100644 --- a/app/interactions/actions/contact_update.rb +++ b/app/interactions/actions/contact_update.rb @@ -30,8 +30,12 @@ def maybe_change_email result = Actions::SimpleMailValidator.run(email: @new_attributes[:email], level: m) next if result - err_text = "email '#{new_attributes[:email]}' didn't pass validation" - contact.add_epp_error('2005', nil, nil, "#{I18n.t(:parameter_value_syntax_error)} #{err_text}") + contact.add_epp_error( + '2005', + nil, + nil, + "#{I18n.t(:parameter_value_syntax_error)} #{I18n.t(:email_did_not_pass_validation, email: new_attributes[:email])}" + ) @error = true return end diff --git a/app/interactions/actions/domain_create.rb b/app/interactions/actions/domain_create.rb index 0d7dab709a..9046b88d72 100644 --- a/app/interactions/actions/domain_create.rb +++ b/app/interactions/actions/domain_create.rb @@ -45,10 +45,9 @@ def validate_domain_integrity dn = DNS::DomainName.new(domain.name) if dn.at_auction? || dn.is_deadline_is_reached? - domain.add_epp_error('2306', nil, nil, 'Parameter value policy error: domain is at auction') + domain.add_epp_error('2306', nil, nil, %i[base domain_at_auction]) elsif dn.awaiting_payment? - domain.add_epp_error('2003', nil, nil, 'Required parameter missing; reserved>pw element' \ - ' required for reserved domains') + domain.add_epp_error('2003', nil, nil, %i[base required_parameter_missing_reserved]) elsif dn.pending_registration? validate_reserved_password(dn) end @@ -56,12 +55,10 @@ def validate_domain_integrity def validate_reserved_password(domain_name) if params[:reserved_pw].blank? - domain.add_epp_error('2003', nil, nil, 'Required parameter missing; reserved>pw ' \ - 'element is required') + domain.add_epp_error('2003', nil, nil, %i[base reserved_pw_element_required]) else unless domain_name.available_with_code?(params[:reserved_pw]) - domain.add_epp_error('2202', nil, nil, 'Invalid authorization information; invalid ' \ - 'reserved>pw value') + domain.add_epp_error('2202', nil, nil, %i[base invalid_auth_information_reserved]) end end end diff --git a/app/interactions/actions/domain_delete.rb b/app/interactions/actions/domain_delete.rb index 750f0abac7..de318384a2 100644 --- a/app/interactions/actions/domain_delete.rb +++ b/app/interactions/actions/domain_delete.rb @@ -27,7 +27,7 @@ def maybe_attach_legal_doc def verify_not_discarded return unless domain.discarded? - domain.add_epp_error('2304', nil, nil, 'Object status prohibits operation') + domain.add_epp_error('2304', nil, nil, I18n.t(:object_status_prohibits_operation)) end def verify? diff --git a/app/interactions/actions/domain_transfer.rb b/app/interactions/actions/domain_transfer.rb index 1249846037..52cf2be7a7 100644 --- a/app/interactions/actions/domain_transfer.rb +++ b/app/interactions/actions/domain_transfer.rb @@ -25,7 +25,7 @@ def call def domain_exists? return true if domain.persisted? - domain.add_epp_error('2303', nil, nil, 'Object does not exist') + domain.add_epp_error('2303', nil, nil, I18n.t('repp.object_does_not_exist')) false end @@ -40,27 +40,26 @@ def run_validations def valid_transfer_code? return true if transfer_code == domain.transfer_code - domain.add_epp_error('2202', nil, nil, 'Invalid authorization information') + domain.add_epp_error('2202', nil, nil, I18n.t('repp.errors.invalid_authorization_information')) false end def validate_registrar return unless user == domain.registrar - domain.add_epp_error('2002', nil, nil, - I18n.t(:domain_already_belongs_to_the_querying_registrar)) + domain.add_epp_error('2002', nil, nil, %i[base domain_already_belongs_to_the_querying_registrar]) end def validate_eligilibty return unless domain.non_transferable? - domain.add_epp_error('2304', nil, nil, 'Object status prohibits operation') + domain.add_epp_error('2304', nil, nil, I18n.t(:object_status_prohibits_operation)) end def validate_not_discarded return unless domain.discarded? - domain.add_epp_error('2106', nil, nil, 'Object is not eligible for transfer') + domain.add_epp_error('2106', nil, nil, I18n.t('repp.errors.object_not_eligible_for_transfer')) end def commit diff --git a/app/interactions/actions/domain_update.rb b/app/interactions/actions/domain_update.rb index bbb30099df..a5de813aaa 100644 --- a/app/interactions/actions/domain_update.rb +++ b/app/interactions/actions/domain_update.rb @@ -43,7 +43,7 @@ def validate_domain_integrity return unless domain.discarded? - domain.add_epp_error('2304', nil, nil, 'Object status prohibits operation') + domain.add_epp_error('2304', nil, nil, I18n.t(:object_status_prohibits_operation)) end def assign_new_registrant @@ -144,8 +144,12 @@ def validate_email(email) result = Actions::SimpleMailValidator.run(email: email, level: m) next if result - err_text = "email #{email} didn't pass validation" - domain.add_epp_error('2005', nil, nil, "#{I18n.t(:parameter_value_syntax_error)} #{err_text}") + domain.add_epp_error( + '2005', + nil, + nil, + "#{I18n.t(:parameter_value_syntax_error)} #{I18n.t(:email_did_not_pass_validation, email: email)}" + ) @error = true return false end @@ -273,13 +277,9 @@ def validate_dispute_case Dispute.close_by_domain(domain.name) and return false if dispute if params[:reserved_pw].present? - domain.add_epp_error( - '2202', nil, nil, 'Invalid authorization information; invalid reserved>pw value' - ) + domain.add_epp_error('2202', nil, nil, %i[base invalid_auth_information_disputed]) else - domain.add_epp_error( - '2304', nil, nil, 'Required parameter missing; reservedpw element required for dispute domains' - ) + domain.add_epp_error('2304', nil, nil, %i[base required_parameter_missing_disputed]) end true end diff --git a/app/models/api_user.rb b/app/models/api_user.rb index 805dcc13cd..1c1d970f5a 100644 --- a/app/models/api_user.rb +++ b/app/models/api_user.rb @@ -41,7 +41,6 @@ def self.min_password_length # Must precede .validates scope :eligible_for_sign_in, lambda { where(active: true) .where.not(verified_at: nil) - .where.not(subject: [nil, '']) } def identity_verified? @@ -49,7 +48,7 @@ def identity_verified? end def eligible_for_sign_in? - active? && identity_verified? && subject.present? + active? && identity_verified? end def verification_pending? diff --git a/app/models/epp/domain.rb b/app/models/epp/domain.rb index 3190b261cd..8917959294 100644 --- a/app/models/epp/domain.rb +++ b/app/models/epp/domain.rb @@ -238,7 +238,7 @@ def add_renew_epp_errors def transfer(frame, action, current_user) if discarded? - add_epp_error('2106', nil, nil, 'Object is not eligible for transfer') + add_epp_error('2106', nil, nil, I18n.t('repp.errors.object_not_eligible_for_transfer')) return end diff --git a/app/models/invoice_state_machine.rb b/app/models/invoice_state_machine.rb index 0474778009..ebc22e37e3 100644 --- a/app/models/invoice_state_machine.rb +++ b/app/models/invoice_state_machine.rb @@ -46,7 +46,7 @@ def mark_as_unpaid end def push_error - invoice.errors.add(:base, "Inavalid state #{status}") + invoice.errors.add(:base, :invalid_state, status: status) false end diff --git a/config/locales/accounts.en.yml b/config/locales/accounts.en.yml new file mode 100644 index 0000000000..a749c8aefe --- /dev/null +++ b/config/locales/accounts.en.yml @@ -0,0 +1,21 @@ +en: + activerecord: + models: + account: + one: Account + other: Accounts + attributes: + account: + account_type: Account type + registrar: Registrar + balance: Balance + currency: Currency + errors: + models: + account: + attributes: + account_type: + blank: Account type is missing + registrar: + blank: Registrar is missing + required: Registrar is missing diff --git a/config/locales/accounts.et.yml b/config/locales/accounts.et.yml new file mode 100644 index 0000000000..0993a5cda2 --- /dev/null +++ b/config/locales/accounts.et.yml @@ -0,0 +1,21 @@ +et: + activerecord: + models: + account: + one: Konto + other: Kontod + attributes: + account: + account_type: Kontotüüp + registrar: Registraator + balance: Saldo + currency: Valuuta + errors: + models: + account: + attributes: + account_type: + blank: Kontotüüp on kohustuslik + registrar: + blank: Registraator on kohustuslik + required: Registraator on kohustuslik diff --git a/config/locales/api_users.en.yml b/config/locales/api_users.en.yml index 609ca42dfa..879c515aa9 100644 --- a/config/locales/api_users.en.yml +++ b/config/locales/api_users.en.yml @@ -1,14 +1,40 @@ en: activerecord: + models: + api_user: + one: API user + other: API users + attributes: + api_user: + username: Username + plain_text_password: Password + email: Email + identity_code: Identity code + subject: Login subject + registrar: Registrar + roles: Roles errors: models: api_user: attributes: + username: + blank: is missing + taken: already exists + plain_text_password: + blank: is missing + too_short: is too short (minimum is %{count} characters) email: blank: is required for identity verification + invalid: is invalid + identity_code: + taken: already exists at this registrar + subject: + taken: is already used by another api user at this registrar + registrar: + blank: is missing + roles: + blank: is missing base: verification_error: Sending identification request failed not_pending_verification: No pending verification to approve or reject missing_subject: Login subject is required to approve verification - subject: - taken: is already used by another api user at this registrar diff --git a/config/locales/api_users.et.yml b/config/locales/api_users.et.yml index 5d142c0d34..f12850cc94 100644 --- a/config/locales/api_users.et.yml +++ b/config/locales/api_users.et.yml @@ -1,17 +1,40 @@ et: activerecord: + models: + api_user: + one: API kasutaja + other: API kasutajad attributes: api_user: + username: Kasutajanimi + plain_text_password: Parool email: E-post + identity_code: Isikukood + subject: Sisselogimise identifikaator + registrar: Registripidaja + roles: Rollid errors: models: api_user: attributes: + username: + blank: on kohustuslik + taken: on juba kasutusel + plain_text_password: + blank: on kohustuslik + too_short: on liiga lühike (peab olema vähemalt %{count} tähemärki) email: blank: on vajalik isikutuvastamiseks + invalid: on vigane + identity_code: + taken: on selle registripidaja juures juba olemas + subject: + taken: on selle registripidaja juures juba teise API kasutaja poolt kasutusel + registrar: + blank: on kohustuslik + roles: + blank: on kohustuslik base: verification_error: Identifitseerimistaotluse saatmine ebaõnnestus not_pending_verification: Kinnitamist ootavat identifitseerimist pole missing_subject: Sisselogimise identifikaator on kinnitamiseks kohustuslik - subject: - taken: on selle registripidaja juures juba teise API kasutaja poolt kasutusel diff --git a/config/locales/contacts.en.yml b/config/locales/contacts.en.yml index 86576b00c4..f8aa823bbb 100644 --- a/config/locales/contacts.en.yml +++ b/config/locales/contacts.en.yml @@ -5,7 +5,26 @@ en: registrant: Registrant admin_domain_contact: Administrative contact tech_domain_contact: Technical contact + contact: + one: Contact + other: Contacts + attributes: + contact: + name: Name + email: Email + phone: Phone + street: Street + city: City + zip: Postcode + country_code: Country code + code: Contact code + registrant_publishable: Registrant publishable + disclosed_attributes: Disclosed attributes + statuses: Statuses + domains: Domains errors: + messages: + epp_id_taken: Contact id already exists models: contact: attributes: @@ -13,28 +32,43 @@ en: 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" + blank: Required parameter missing - code + invalid: Contact code is invalid + too_long_contact_code: Contact code is too long, max 100 characters + epp_id_taken: Contact id already exists name: - blank: "Required parameter missing - name" - invalid: "Name is invalid" - too_long_contact_name: "Contact name is too long, max 255 characters" + blank: Required parameter missing - name + invalid: Name is invalid + too_long_contact_name: Contact name is too long, max 255 characters phone: - blank: "Required parameter missing - phone" - invalid: "Phone nr is invalid" + blank: Required parameter missing - phone + invalid: Phone nr is invalid + too_long: Phone nr is too long, max 17 characters email: - blank: "Required parameter missing - email" - invalid: "Email is invalid" + blank: Required parameter missing - email + invalid: Email is invalid email_smtp_check_error: SMTP check error email_mx_check_error: Mail domain not found email_regex_check_error: Invalid format + street: + blank: Required parameter missing - street + invalid: Street is invalid + city: + blank: Required parameter missing - city + invalid: City is invalid + zip: + blank: Required parameter missing - zip + invalid: Postcode is invalid domains: - exist: 'Object association prohibits operation' + exist: Object association prohibits operation delete_prohibited: Contact delete prohibited by status statuses: - not_uniq: 'not uniq' + not_uniq: not uniq delete_prohibited: Contact delete prohibited by status country_code: + blank: Required parameter missing - country code invalid: Country code is not valid, should be in ISO_3166-1 alpha 2 format (%{value}) disclosed_attributes: invalid: contain unsupported attribute(s) + registrant_publishable: + inclusion: is not included in the list diff --git a/config/locales/contacts.et.yml b/config/locales/contacts.et.yml index 5cbaeb06a8..6d7d07d167 100644 --- a/config/locales/contacts.et.yml +++ b/config/locales/contacts.et.yml @@ -5,3 +5,70 @@ et: registrant: Registreerija admin_domain_contact: Halduskontakt tech_domain_contact: Tehniline kontakt + contact: + one: Kontakt + other: Kontaktid + attributes: + contact: + name: Nimi + email: E-post + phone: Telefon + street: Tänav + city: Linn + zip: Postiindeks + country_code: Riigikood + code: Kontakti kood + registrant_publishable: Registreerija avalikustatav + disclosed_attributes: Avalikustatavad andmed + statuses: Staatused + domains: Domeenid + errors: + messages: + epp_id_taken: Kontakti ID on juba olemas + models: + contact: + attributes: + base: + verification_exists: Kontakt on juba kinnitatud + verification_error: Identifitseerimistaotluse saatmine ebaõnnestus + code: + blank: Kohustuslik parameeter puudub – kood + invalid: Kontakti kood on vigane + too_long_contact_code: Kontakti kood on liiga pikk, maksimaalselt 100 tähemärki + epp_id_taken: Kontakti ID on juba olemas + name: + blank: Kohustuslik parameeter puudub – nimi + invalid: Nimi on vigane + too_long_contact_name: Kontakti nimi on liiga pikk, maksimaalselt 255 tähemärki + phone: + blank: Kohustuslik parameeter puudub – telefon + invalid: Telefoninumber on vigane + too_long: Telefoninumber on liiga pikk, maksimaalselt 17 tähemärki + email: + blank: Kohustuslik parameeter puudub – e-post + invalid: E-posti aadress on vigane + email_smtp_check_error: SMTP kontrolli viga + email_mx_check_error: Meilidomeeni ei leitud + email_regex_check_error: Vigane vorming + street: + blank: Kohustuslik parameeter puudub – tänav + invalid: Tänav on vigane + city: + blank: Kohustuslik parameeter puudub – linn + invalid: Linn on vigane + zip: + blank: Kohustuslik parameeter puudub – postiindeks + invalid: Postiindeks on vigane + domains: + exist: Objekti seos keelab toimingu + delete_prohibited: Kontakti kustutamine on staatuse tõttu keelatud + statuses: + not_uniq: pole unikaalne + delete_prohibited: Kontakti kustutamine on staatuse tõttu keelatud + country_code: + blank: Kohustuslik parameeter puudub – riigikood + invalid: Riigikood on vigane, peab olema ISO 3166-1 alpha-2 vormingus (%{value}) + disclosed_attributes: + invalid: sisaldab toetamata atribuute + registrant_publishable: + inclusion: ei ole lubatud väärtuste hulgas diff --git a/config/locales/domains.en.yml b/config/locales/domains.en.yml new file mode 100644 index 0000000000..3cfec3beb8 --- /dev/null +++ b/config/locales/domains.en.yml @@ -0,0 +1,132 @@ +en: + activerecord: + models: + domain: + one: Domain + other: Domains + attributes: + epp_domain: &epp_domain_attributes + name_dirty: Domain name + name: Domain name + name_puny: Domain name + puny_label: Domain name + period: Period + transfer_code: Transfer code + registrant: Registrant + registrar: Registrar + nameservers: Nameservers + dnskeys: DNS keys + admin_domain_contacts: Admin contacts + admin_contacts: Admin contacts + tech_domain_contacts: Technical contacts + tech_contacts: Technical contacts + domain_contacts: Contacts + statuses: Statuses + domain_statuses: Statuses + reserved_pw: Reserved password + domain: + <<: *epp_domain_attributes + nameserver: + hostname: Hostname + ipv4: IPv4 + ipv6: IPv6 + domain_contact: + contact: Contact + domain_status: + value: Status + errors: + models: + epp_domain: &epp_domain_errors + attributes: + base: + domain_status_prohibits_operation: Domain status prohibits operation + domain_already_belongs_to_the_querying_registrar: Domain already belongs to the querying registrar + ds_data_not_allowed: dsData object is not allowed + ds_data_with_key_not_allowed: dsData object with key data is not allowed + key_data_not_allowed: keyData object is not allowed + required_parameter_missing_reserved: Required parameter missing; reserved>pw element required for reserved domains + invalid_auth_information_reserved: Invalid authorization information; invalid reserved>pw value + required_parameter_missing_disputed: Required parameter missing; disputed pw element required for dispute domains + invalid_auth_information_disputed: Invalid authorization information; invalid disputed>pw value + domain_name_blocked: 'Data management policy violation: Domain name is blocked [name]' + session_limit_exceeded: Session limit exceeded; server closing connection + domain_at_auction: 'Parameter value policy error: domain is at auction' + reserved_pw_element_required: 'Required parameter missing; reserved>pw element is required' + name_dirty: + blank: Domain name is missing + invalid: Domain name is invalid + reserved: Domain name is reserved + taken: Domain name already exists + blocked: Domain name is blocked + puny_label: + too_long: Domain name is too long (maximum is 63 characters) + period: + blank: Period is missing + not_a_number: Period is not a number + not_an_integer: Period must be an integer + transfer_code: + blank: Transfer code is missing + wrong_pw: Authorization error + registrant: + blank: Registrant is missing + not_found: Registrant not found + cannot_be_missing: 'Parameter value policy error: registrant cannot be missing' + domain_contacts: + invalid: Contacts are invalid + not_found: Contact was not found + admin_contact_can_be_only_private_person: Admin contact can be private person only + admin_domain_contacts: + out_of_range: Admin contacts count must be between %{min}-%{max} + admin_contacts: + out_of_range: Admin contacts count must be between %{min}-%{max} + less_than_or_equal_to: Admin contacts count must be less than or equal to %{count} + greater_than_or_equal_to: Admin contacts count must be greater than or equal to %{count} + invalid_ident_type: Admin contact can be private person only + tech_domain_contacts: + out_of_range: Tech contacts count must be between %{min}-%{max} + tech_contacts: + out_of_range: Tech contacts count must be between %{min}-%{max} + nameservers: + invalid: Nameservers are invalid + out_of_range: Data management policy violation; Nameserver count must be between %{min}-%{max} for active domains + not_found: Nameserver was not found + taken: Nameserver already exists on this domain + less_than_or_equal_to: Nameservers count must be less than or equal to %{count} + greater_than_or_equal_to: Nameservers count must be greater than or equal to %{count} + domain_statuses: + invalid: Statuses are invalid + not_found: Status was not found + taken: Status already exists on this domain + statuses: + taken: Status already exists on this domain + registrar: + blank: Registrar is missing + dnskeys: + not_found: DS was not found + invalid: DNS keys are invalid + out_of_range: DNS keys count must be between %{min}-%{max} + domains: + invalid: Object status prohibits operation + domain: + <<: *epp_domain_errors + admin_contact_invalid_ident_type: Admin contact can be private person only (ident type %{ident_type} is not allowed) + nameserver: + attributes: + hostname: + invalid: Hostname is invalid + taken: Nameserver already exists on this domain + puny_to_long: Hostname puny label is too long (maximum is 63 characters) + ipv4: + blank: IP is missing + invalid: IPv4 is invalid + ipv6: + invalid: IPv6 is invalid + domain_contact: + attributes: + contact: + blank: Contact was not found + taken: Contact already exists on this domain + domain_status: + attributes: + value: + taken: Status already exists on this domain diff --git a/config/locales/domains.et.yml b/config/locales/domains.et.yml new file mode 100644 index 0000000000..2173c7e881 --- /dev/null +++ b/config/locales/domains.et.yml @@ -0,0 +1,132 @@ +et: + activerecord: + models: + domain: + one: Domeen + other: Domeenid + attributes: + epp_domain: &epp_domain_attributes + name_dirty: Domeeninimi + name: Domeeninimi + name_puny: Domeeninimi + puny_label: Domeeninimi + period: Periood + transfer_code: Ülekande kood + registrant: Registreerija + registrar: Registripidaja + nameservers: Nimeserverid + dnskeys: DNS võtmed + admin_domain_contacts: Halduskontaktid + admin_contacts: Halduskontaktid + tech_domain_contacts: Tehnilised kontaktid + tech_contacts: Tehnilised kontaktid + domain_contacts: Kontaktid + statuses: Staatused + domain_statuses: Staatused + reserved_pw: Reserveeritud domeeni parool + domain: + <<: *epp_domain_attributes + nameserver: + hostname: Hostinimi + ipv4: IPv4 + ipv6: IPv6 + domain_contact: + contact: Kontakt + domain_status: + value: Staatus + errors: + models: + epp_domain: &epp_domain_errors + attributes: + base: + domain_status_prohibits_operation: Domeeni staatus keelab toimingu + domain_already_belongs_to_the_querying_registrar: Domeen kuulub juba päringu teinud registripidajale + ds_data_not_allowed: dsData objekt pole lubatud + ds_data_with_key_not_allowed: dsData objekt võtmega pole lubatud + key_data_not_allowed: keyData objekt pole lubatud + required_parameter_missing_reserved: Kohustuslik parameeter puudub; reserveeritud domeenide puhul on nõutav reserved>pw element + invalid_auth_information_reserved: Vigane autoriseerimisteave; vigane reserved>pw väärtus + required_parameter_missing_disputed: Kohustuslik parameeter puudub; vaidlustatud domeenide puhul on nõutav disputed pw element + invalid_auth_information_disputed: Vigane autoriseerimisteave; vigane disputed pw väärtus + domain_name_blocked: 'Andmehalduse poliitika rikkumine: domeeninimi on blokeeritud [name]' + session_limit_exceeded: Seansi limiit on ületatud; server sulgeb ühenduse + domain_at_auction: 'Parameetriväärtuse poliitika viga: domeen on oksjonil' + reserved_pw_element_required: 'Kohustuslik parameeter puudub; reserved>pw element on nõutav' + name_dirty: + blank: Domeeninimi on kohustuslik + invalid: Domeeninimi on vigane + reserved: Domeeninimi on reserveeritud + taken: Domeeninimi on juba olemas + blocked: Domeeninimi on blokeeritud + puny_label: + too_long: Domeeninimi on liiga pikk (maksimaalselt 63 tähemärki) + period: + blank: Periood on kohustuslik + not_a_number: Periood ei ole number + not_an_integer: Periood peab olema täisarv + transfer_code: + blank: Ülekande kood on kohustuslik + wrong_pw: Autoriseerimisviga + registrant: + blank: Registreerija puudub + not_found: Registreerijat ei leitud + cannot_be_missing: 'Parameetriväärtuse poliitika viga: registreerija ei tohi puududa' + domain_contacts: + invalid: Kontaktid on vigased + not_found: Kontakti ei leitud + admin_contact_can_be_only_private_person: Halduskontakt võib olla ainult eraisik + admin_domain_contacts: + out_of_range: Halduskontaktide arv peab olema vahemikus %{min}–%{max} + admin_contacts: + out_of_range: Halduskontaktide arv peab olema vahemikus %{min}–%{max} + less_than_or_equal_to: Halduskontaktide arv peab olema väiksem või võrdne %{count}-ga + greater_than_or_equal_to: Halduskontaktide arv peab olema suurem või võrdne %{count}-ga + invalid_ident_type: Halduskontakt võib olla ainult eraisik + tech_domain_contacts: + out_of_range: Tehniliste kontaktide arv peab olema vahemikus %{min}–%{max} + tech_contacts: + out_of_range: Tehniliste kontaktide arv peab olema vahemikus %{min}–%{max} + nameservers: + invalid: Nimeserverid on vigased + out_of_range: Andmehalduse poliitika rikkumine; aktiivsete domeenide nimeserverite arv peab olema vahemikus %{min}–%{max} + not_found: Nimeserverit ei leitud + taken: Nimeserver on sellel domeenil juba olemas + less_than_or_equal_to: Nimeserverite arv peab olema väiksem või võrdne %{count}-ga + greater_than_or_equal_to: Nimeserverite arv peab olema suurem või võrdne %{count}-ga + domain_statuses: + invalid: Staatused on vigased + not_found: Staatust ei leitud + taken: Staatus on sellel domeenil juba olemas + statuses: + taken: Staatus on sellel domeenil juba olemas + registrar: + blank: Registripidaja puudub + dnskeys: + not_found: DS kirjet ei leitud + invalid: DNS võtmed on vigased + out_of_range: DNS võtmete arv peab olema vahemikus %{min}–%{max} + domains: + invalid: Objekti staatus keelab toimingu + domain: + <<: *epp_domain_errors + admin_contact_invalid_ident_type: Halduskontakt võib olla ainult eraisik (identifikaatori tüüp %{ident_type} pole lubatud) + nameserver: + attributes: + hostname: + invalid: Hostinimi on vigane + taken: Nimeserver on sellel domeenil juba olemas + puny_to_long: Hostinime puny label on liiga pikk (maksimaalselt 63 tähemärki) + ipv4: + blank: IP-aadress puudub + invalid: IPv4 on vigane + ipv6: + invalid: IPv6 on vigane + domain_contact: + attributes: + contact: + blank: Kontakti ei leitud + taken: Kontakt on sellel domeenil juba olemas + domain_status: + attributes: + value: + taken: Staatus on sellel domeenil juba olemas diff --git a/config/locales/en.yml b/config/locales/en.yml index 41558459fb..5366d070cb 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -25,10 +25,10 @@ en: ds_data_not_allowed: 'dsData object is not allowed' ds_data_with_key_not_allowed: 'dsData object with key data is not allowed' key_data_not_allowed: 'keyData object is not allowed' - required_parameter_missing_reserved: 'Required parameter missing; reserved>pw element required for reserved domains' - invalid_auth_information_reserved: 'Invalid authorization information; invalid reserved>pw value' - required_parameter_missing_disputed: 'Required parameter missing; disputed pw element required for dispute domains' - invalid_auth_information_disputed: 'Invalid authorization information; invalid disputed>pw value' + required_parameter_missing_reserved: 'Required parameter missing; reserved_pw element required for reserved domains' + invalid_auth_information_reserved: 'Invalid authorization information; invalid reserved_pw value' + required_parameter_missing_disputed: 'Required parameter missing; reserved_pw element required for dispute domains' + invalid_auth_information_disputed: 'Invalid authorization information; invalid reserved_pw value' domain_name_blocked: 'Data management policy violation: Domain name is blocked [name]' name_dirty: invalid: 'Domain name is invalid' @@ -207,6 +207,7 @@ en: failed_epp_conn: 'Failed to open connection to EPP server!' epp_conn_error: 'CONNECTION ERROR - Is the EPP server running?' company_not_registered: 'Company is not registered' + invalid_ident_type: Invalid ident type domain_name_blocked: 'Data management policy violation: Domain name is blocked [name]' code: 'Code' action: 'Action' @@ -405,6 +406,7 @@ en: internal_error: 'Internal error' client_side_status_editing_error: 'Parameter value policy error. Client-side object status management not supported' parameter_value_syntax_error: 'Parameter value syntax error:' + email_did_not_pass_validation: "email '%{email}' didn't pass validation" username: 'Username' register: 'Register' diff --git a/config/locales/epp/contacts.et.yml b/config/locales/epp/contacts.et.yml new file mode 100644 index 0000000000..7cc4b76ab6 --- /dev/null +++ b/config/locales/epp/contacts.et.yml @@ -0,0 +1,5 @@ +et: + epp: + contacts: + completed: Käsk edukalt täidetud + completed_without_address: Käsk edukalt täidetud; postiaadressi andmed jäeti arvestamata diff --git a/config/locales/errors.et.yml b/config/locales/errors.et.yml new file mode 100644 index 0000000000..e72b3bbe91 --- /dev/null +++ b/config/locales/errors.et.yml @@ -0,0 +1,6 @@ +et: + errors: + messages: + required_ident_attribute_missing: 'Kohustuslik ident atribuut puudub: %{key}' + company_not_registered: Ettevõte pole registreeritud + invalid_ident_type: Vigane identi tüüp diff --git a/config/locales/et.yml b/config/locales/et.yml index 51192c7087..b28cc5af2c 100644 --- a/config/locales/et.yml +++ b/config/locales/et.yml @@ -25,6 +25,14 @@ et: you_have_a_new_invoice: 'Teil on uus arve.' monthly_invoice: "Siit tuleb aruanne möödunud kuul ettemaksukontoga seotud tasuliste toimingutega." sincerely: 'Lugupidamisega' + client_side_status_editing_error: 'Parameetriväärtuse poliitika viga. Kliendipoolne objekti staatuse haldus pole toetatud' + parameter_value_syntax_error: 'Parameetri väärtuse süntaksiviga:' + email_did_not_pass_validation: "e-posti aadress '%{email}' ei läbinud valideerimist" + object_status_prohibits_operation: 'Objekti staatus keelab toimingu' + domain_already_belongs_to_the_querying_registrar: Domeen kuulub juba päringu teinud registripidajale + billing_failure_credit_balance_low: Arveldus ebaõnnestus – ettemaksukonto saldo on liiga madal + active_price_missing_for_this_operation: Selle toimingu aktiivne hind puudub! + create: Loomine activerecord: errors: diff --git a/config/locales/idents.yml b/config/locales/idents.yml index 8e6a677de4..31fa3231d9 100644 --- a/config/locales/idents.yml +++ b/config/locales/idents.yml @@ -1,5 +1,10 @@ en: activemodel: + attributes: + contact/ident: + code: Code + type: Ident type + country_code: Country code errors: models: contact/ident: @@ -7,6 +12,40 @@ en: base: mismatch: Ident type "%{type}" is invalid for %{country} code: + blank: is missing invalid_national_id: does not conform to national identification number format of %{country} invalid_reg_no: does not conform to registration number format of %{country} invalid_birth_date: Birth date is invalid, age must be over 0 and under 150 + invalid_iso8601_date: has invalid date format YYYY-MM-DD (ISO 8601) + type: + blank: is missing + inclusion: is not included in the list + country_code: + blank: is missing + invalid_iso31661_alpha2: does not conform to ISO 3166-1 alpha-2 standard + +et: + activemodel: + attributes: + contact/ident: + code: Kood + type: Identifikaatori tüüp + country_code: Riigikood + errors: + models: + contact/ident: + attributes: + base: + mismatch: Identifikaatori tüüp "%{type}" on kehtetu riigi %{country} jaoks + code: + blank: on kohustuslik + invalid_national_id: ei vasta riigi %{country} isikukoodi vormingule + invalid_reg_no: ei vasta riigi %{country} registrinumbri vormingule + invalid_birth_date: Sünnikuupäev on vigane, vanus peab olema üle 0 ja alla 150 aasta + invalid_iso8601_date: on vigane kuupäeva vorming YYYY-MM-DD (ISO 8601) + type: + blank: on kohustuslik + inclusion: ei ole lubatud väärtuste hulgas + country_code: + blank: on kohustuslik + invalid_iso31661_alpha2: ei vasta ISO 3166-1 alpha-2 standardile diff --git a/config/locales/invoices.en.yml b/config/locales/invoices.en.yml new file mode 100644 index 0000000000..7b0418118a --- /dev/null +++ b/config/locales/invoices.en.yml @@ -0,0 +1,48 @@ +en: + cannot get access: Cannot get access to billing service + failed_to_generate_invoice_invoice_number_limit_reached: Failed to generate invoice - invoice number limit reached + invoice_no: 'Invoice no. %{no}' + cancelled: Cancelled + unpaid: Unpaid + activerecord: + models: + invoice: + one: Invoice + other: Invoices + invoice_item: + one: Invoice line + other: Invoice lines + attributes: + invoice: + due_date: Due date + currency: Currency + seller_name: Seller name + seller_iban: Seller IBAN + buyer_name: Buyer name + buyer_vat_no: Buyer VAT no + number: Invoice number + total: Total + items: Invoice items + invoice_item: + price: Price + quantity: Quantity + description: Description + errors: + models: + invoice: + attributes: + base: + invoice_status_prohibits_operation: Invoice status prohibits operation + invalid_state: 'Invalid state %{status}' + due_date: + blank: Due date is missing + currency: + blank: Currency is missing + seller_name: + blank: Seller name is missing + seller_iban: + blank: Seller IBAN is missing + buyer_name: + blank: Buyer name is missing + items: + blank: Invoice must have at least one line item diff --git a/config/locales/invoices.et.yml b/config/locales/invoices.et.yml new file mode 100644 index 0000000000..7c0363cd58 --- /dev/null +++ b/config/locales/invoices.et.yml @@ -0,0 +1,48 @@ +et: + cannot get access: Arveldusteenusele juurdepääsu ei õnnestunud saada + failed_to_generate_invoice_invoice_number_limit_reached: Arve genereerimine ebaõnnestus – arve numbri limiit on täis + invoice_no: 'Arve nr %{no}' + cancelled: Tühistatud + unpaid: Maksmata + activerecord: + models: + invoice: + one: Arve + other: Arved + invoice_item: + one: Arve rida + other: Arve read + attributes: + invoice: + due_date: Tähtaeg + currency: Valuuta + seller_name: Müüja nimi + seller_iban: Müüja IBAN + buyer_name: Ostja nimi + buyer_vat_no: Ostja käibemaksukohustuslase number + number: Arve number + total: Summa + items: Arve read + invoice_item: + price: Hind + quantity: Kogus + description: Kirjeldus + errors: + models: + invoice: + attributes: + base: + invoice_status_prohibits_operation: Arve staatus keelab toimingu + invalid_state: 'Vigane staatus %{status}' + due_date: + blank: Tähtaeg on kohustuslik + currency: + blank: Valuuta on kohustuslik + seller_name: + blank: Müüja nimi on kohustuslik + seller_iban: + blank: Müüja IBAN on kohustuslik + buyer_name: + blank: Ostja nimi on kohustuslik + items: + blank: Arvel peab olema vähemalt üks rida diff --git a/config/locales/registrar/account.et.yml b/config/locales/registrar/account.et.yml new file mode 100644 index 0000000000..3719247f54 --- /dev/null +++ b/config/locales/registrar/account.et.yml @@ -0,0 +1,32 @@ +et: + registrar: + account: + show: + header: Teie konto + + edit: + header: Muuda oma kontot + + form: + iban_hint: Vajalik e-arvete saatmiseks panka + submit_btn: Salvesta muudatused + + update: + saved: Teie konto on uuendatud + + details: + header: Andmed + edit_btn: Muuda + + linked_users: + header: Seotud kasutajad + switch_btn: Vaheta + + balance_auto_reload: + header: Saldo automaatne täiendamine + enabled: Lubatud + enabled_state_details: Täienda %{amount}, kui saldo langeb alla %{threshold} + disabled: Keelatud + enable_btn: Luba + disable_btn: Keela + edit_btn: Muuda diff --git a/config/locales/repp.en.yml b/config/locales/repp.en.yml new file mode 100644 index 0000000000..b01521a790 --- /dev/null +++ b/config/locales/repp.en.yml @@ -0,0 +1,8 @@ +en: + repp: + command_completed_successfully: Command completed successfully + object_does_not_exist: Object does not exist + object_does_not_exist_with_name: 'Object does not exist: %{name}' + errors: + invalid_authorization_information: Invalid authorization information + object_not_eligible_for_transfer: Object is not eligible for transfer diff --git a/config/locales/repp.et.yml b/config/locales/repp.et.yml new file mode 100644 index 0000000000..6c05c6324a --- /dev/null +++ b/config/locales/repp.et.yml @@ -0,0 +1,8 @@ +et: + repp: + command_completed_successfully: Käsk edukalt täidetud + object_does_not_exist: Objekti ei ole olemas + object_does_not_exist_with_name: 'Objekti ei ole olemas: %{name}' + errors: + invalid_authorization_information: Vigane autoriseerimisteave + object_not_eligible_for_transfer: Objekt ei ole ülekandmiseks sobilik diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 674a6993e9..941546f30a 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -23,6 +23,7 @@ api_bestnames_epp: api_goodnames: username: test_goodnames plain_text_password: testtest + verified_at: <%= Time.zone.parse('2024-01-01 12:00:00') %> type: ApiUser registrar: goodnames active: true @@ -46,6 +47,7 @@ registrant: accr_bot: username: accr_bot plain_text_password: accr_bot + verified_at: <%= Time.zone.parse('2024-01-01 12:00:00') %> type: ApiUser registrar: goodnames active: true diff --git a/test/integration/admin_area/certificates_test.rb b/test/integration/admin_area/certificates_test.rb index 99e4bee9bf..0363f22cb5 100644 --- a/test/integration/admin_area/certificates_test.rb +++ b/test/integration/admin_area/certificates_test.rb @@ -57,7 +57,7 @@ def test_new_api_user fill_in 'Username', with: 'testapiuser' fill_in 'Password', with: 'secretpassword' - fill_in 'Identity code', with: '60305062718' + fill_in 'Login subject', with: 'EE60305062718' click_on 'Create API user' diff --git a/test/integration/eeid/identification_requests_webhook_test.rb b/test/integration/eeid/identification_requests_webhook_test.rb index da0b13f58c..3ba4f30867 100644 --- a/test/integration/eeid/identification_requests_webhook_test.rb +++ b/test/integration/eeid/identification_requests_webhook_test.rb @@ -55,8 +55,12 @@ class Eeid::IdentificationRequestsWebhookTest < ActionDispatch::IntegrationTest 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 + @contact.update!(ident_request_sent_at: Time.zone.now - 1.day) + + Eeid::Webhooks::IdentificationRequestsController.stub_any_instance( + :verify_contact, + proc { |_contact| 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 diff --git a/test/integration/repp/v1/api_users/create_test.rb b/test/integration/repp/v1/api_users/create_test.rb index 8973507734..9518cf47d4 100644 --- a/test/integration/repp/v1/api_users/create_test.rb +++ b/test/integration/repp/v1/api_users/create_test.rb @@ -55,6 +55,24 @@ def test_validates_identity_code_per_registrar assert json[:message].include? 'Identity code already exists' end + def test_validates_subject_per_registrar + request_body = { + api_user: { + username: 'username', + plain_text_password: 'password', + active: true, + subject: @user.subject, + roles: ['super'], + }, + } + + post '/repp/v1/api_users', headers: @auth_headers, params: request_body + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :bad_request + assert json[:message].include? 'already used by another api user at this registrar' + end + def test_returns_error_response_if_throttled ENV['shunter_default_threshold'] = '1' ENV['shunter_enabled'] = 'true' diff --git a/test/integration/repp/v1/api_users/update_test.rb b/test/integration/repp/v1/api_users/update_test.rb index 5c61856b4c..c3feadcb69 100644 --- a/test/integration/repp/v1/api_users/update_test.rb +++ b/test/integration/repp/v1/api_users/update_test.rb @@ -46,6 +46,21 @@ def test_can_not_change_identity_code_if_already_exists_per_registrar assert json[:message].include? 'Identity code already exists' end + def test_can_not_change_subject_if_already_exists_per_registrar + epp_user = users(:api_bestnames_epp) + request_body = { + api_user: { + subject: @user.subject, + }, + } + + put "/repp/v1/api_users/#{epp_user.id}", headers: @auth_headers, params: request_body + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :bad_request + assert json[:message].include? 'already used by another api user at this registrar' + end + def test_returns_error_if_password_wrong_format request_body = { api_user: { diff --git a/test/integration/repp/v1/domains/update_test.rb b/test/integration/repp/v1/domains/update_test.rb index a91639c7ac..de2773c59a 100644 --- a/test/integration/repp/v1/domains/update_test.rb +++ b/test/integration/repp/v1/domains/update_test.rb @@ -101,7 +101,7 @@ def test_adds_epp_error_when_reserved_pw_is_missing_for_disputed_domain json = JSON.parse(response.body, symbolize_names: true) assert_response :bad_request assert_equal 2304, json[:code] - assert_equal 'Required parameter missing; reservedpw element required for dispute domains', json[:message] + assert_equal 'Required parameter missing; reserved_pw element required for dispute domains', json[:message] end def test_adds_epp_error_when_reserved_pw_is_invalid_for_disputed_domain @@ -119,6 +119,6 @@ def test_adds_epp_error_when_reserved_pw_is_invalid_for_disputed_domain json = JSON.parse(response.body, symbolize_names: true) assert_response :bad_request assert_equal 2202, json[:code] - assert_equal 'Invalid authorization information; invalid reserved>pw value', json[:message] + assert_equal 'Invalid authorization information; invalid reserved_pw value', json[:message] end end diff --git a/test/models/api_user_test.rb b/test/models/api_user_test.rb index 59f4d249af..979be3b42e 100644 --- a/test/models/api_user_test.rb +++ b/test/models/api_user_test.rb @@ -1,8 +1,12 @@ require 'test_helper' class ApiUserTest < ActiveSupport::TestCase + include ActionMailer::TestHelper + setup do @user = users(:api_bestnames) + @user.reload + ActionMailer::Base.deliveries.clear end def test_valid_user_fixture_is_valid @@ -17,18 +21,20 @@ def test_invalid_without_username def test_invalid_when_username_is_already_taken user = valid_user - another_user = user.dup + another_user = duplicate_as_new_record(user) assert another_user.invalid? - another_user.username = 'another' - another_user.identity_code = '' - assert another_user.valid? + another_user.username = 'another_username_not_taken' + another_user.identity_code = nil + another_user.subject = nil + another_user.registrar = user.registrar + assert another_user.valid?, another_user.errors.full_messages end def test_invalid_when_one_registrar_and_identity_code_is_already_taken user = valid_user - another_user = user.dup + another_user = duplicate_as_new_record(user) assert another_user.invalid? @@ -69,20 +75,23 @@ def test_active_by_default assert ApiUser.new.active? end - def test_linked_users_by_subject_same_registrar - login_subject = 'EE60001019906' + def test_invalid_when_subject_already_used_at_registrar + login_subject = 'EE1234' @user.update_columns(subject: login_subject) - linked = ApiUser.create!( - username: 'linked_by_subject', + + another_user = ApiUser.new( + username: 'another_subject_user', plain_text_password: 'secret1', - registrar: @user.registrar, + registrar_id: @user.registrar_id, roles: ['epp'], - subject: login_subject, - verified_at: Time.zone.now, - active: true + subject: login_subject ) - assert_includes @user.linked_users, linked + assert another_user.invalid?, another_user.errors.full_messages + assert_match( + /already used by another api user at this registrar/i, + another_user.errors[:subject].join + ) end def test_linked_users_by_subject_across_registrars @@ -110,14 +119,15 @@ def test_linked_users_empty_when_subject_blank def test_linked_users_excludes_inactive_users login_subject = 'EE99999999999' @user.update_columns(subject: login_subject) - ApiUser.create!( + linked = ApiUser.create!( username: 'linked_inactive', plain_text_password: 'secret1', - registrar: @user.registrar, + registrar: registrars(:goodnames), roles: ['epp'], subject: login_subject, - active: false + verified_at: Time.zone.now ) + linked.update_columns(active: false) assert_empty @user.linked_users end @@ -128,7 +138,7 @@ def test_linked_users_excludes_unverified_users ApiUser.create!( username: 'linked_unverified', plain_text_password: 'secret1', - registrar: @user.registrar, + registrar: registrars(:goodnames), roles: ['epp'], subject: login_subject, verified_at: nil, @@ -149,8 +159,8 @@ def test_linked_with_by_subject_only assert_not @user.linked_with?(nil) end - def test_eligible_for_sign_in_requires_active_verified_subject - @user.update_columns(active: true, verified_at: Time.zone.now, subject: 'EE1234') + def test_eligible_for_sign_in_requires_active_and_verified + @user.update_columns(active: true, verified_at: Time.zone.now, subject: nil) assert @user.eligible_for_sign_in? @user.update_columns(verified_at: nil) @@ -159,8 +169,8 @@ def test_eligible_for_sign_in_requires_active_verified_subject @user.update_columns(verified_at: Time.zone.now, active: false) assert_not @user.eligible_for_sign_in? - @user.update_columns(active: true, subject: nil) - assert_not @user.eligible_for_sign_in? + @user.update_columns(active: true, subject: 'EE1234') + assert @user.eligible_for_sign_in? end def test_subject_change_clears_verification_status_when_subject_previously_present @@ -250,4 +260,10 @@ def test_verifies_pki_status def valid_user users(:api_bestnames) end + + def duplicate_as_new_record(user) + ApiUser.new( + user.attributes.except('id', 'created_at', 'updated_at', 'uuid', 'subject') + ) + end end diff --git a/test/models/domain_test.rb b/test/models/domain_test.rb index e94225343f..c08a2d5d2a 100644 --- a/test/models/domain_test.rb +++ b/test/models/domain_test.rb @@ -129,8 +129,8 @@ def test_invalid_when_domain_is_reserved domain.name = reserved_domain.name assert domain.invalid? - assert_includes domain.errors.full_messages, 'Required parameter missing; reserved>' \ - 'pw element required for reserved domains' + assert_includes domain.errors.full_messages, + I18n.t('activerecord.errors.models.domain.attributes.base.required_parameter_missing_reserved') end def test_invalid_without_registration_period @@ -537,7 +537,7 @@ def test_validates_admin_contact_required_for_legal_entity_registrant domain.admin_domain_contacts.clear assert domain.invalid? - assert_includes domain.errors.full_messages, 'Admin domain contacts Admin contacts count must be between 1-10' + assert_includes domain.errors.full_messages, admin_contacts_count_out_of_range_message domain.admin_domain_contacts.build(contact: contacts(:john)) assert domain.valid? @@ -585,7 +585,7 @@ def test_validates_admin_contact_required_for_underage_estonian_id domain.admin_domain_contacts.clear assert domain.invalid? - assert_includes domain.errors.full_messages, 'Admin domain contacts Admin contacts count must be between 1-10' + assert_includes domain.errors.full_messages, admin_contacts_count_out_of_range_message admin_contact = contacts(:john) admin_contact.update!( @@ -637,8 +637,7 @@ def test_validates_admin_contact_required_for_org_based_on_setting Setting.admin_contacts_required_for_org = true domain.admin_domain_contacts.clear assert domain.invalid? - assert_includes domain.errors.full_messages, - 'Admin domain contacts Admin contacts count must be between 1-10' + assert_includes domain.errors.full_messages, admin_contacts_count_out_of_range_message # When setting is false Setting.admin_contacts_required_for_org = false @@ -655,8 +654,7 @@ def test_validates_admin_contact_required_for_minors_based_on_setting Setting.admin_contacts_required_for_minors = true domain.admin_domain_contacts.clear assert domain.invalid? - assert_includes domain.errors.full_messages, - 'Admin domain contacts Admin contacts count must be between 1-10' + assert_includes domain.errors.full_messages, admin_contacts_count_out_of_range_message # When setting is false Setting.admin_contacts_required_for_minors = false @@ -666,6 +664,14 @@ def test_validates_admin_contact_required_for_minors_based_on_setting private + def admin_contacts_count_out_of_range_message + min = Setting.admin_contacts_min_count + max = Setting.admin_contacts_max_count + Domain.new.tap do |domain| + domain.errors.add(:admin_domain_contacts, :out_of_range, min: min, max: max) + end.errors.full_messages.first + end + def valid_domain domains(:shop) end diff --git a/test/models/invoice_state_machinte_test.rb b/test/models/invoice_state_machinte_test.rb index 319da1aa2d..f3989f13ff 100644 --- a/test/models/invoice_state_machinte_test.rb +++ b/test/models/invoice_state_machinte_test.rb @@ -45,8 +45,8 @@ def test_only_unpaid_invoice_can_be_cancelled InvoiceStateMachine.new(invoice: @invoice, status: 'cancelled').call @invoice.reload - assert_equal @invoice.errors.full_messages.join, 'Inavalid state cancelled' assert @invoice.errors.present? + assert_equal invalid_state_message('cancelled'), @invoice.errors.full_messages.join end def test_cancelled_invoiced_cannot_be_unpaid @@ -63,7 +63,7 @@ def test_cancelled_invoiced_cannot_be_unpaid assert @unpaid.cancelled? assert @unpaid.errors.present? - assert_equal @unpaid.errors.full_messages.join, 'Inavalid state unpaid' + assert_equal invalid_state_message('unpaid'), @unpaid.errors.full_messages.join end def test_if_paid_invoice_not_have_response_from_everypay_it_can_be_unpaid_back @@ -100,6 +100,12 @@ def test_if_paid_invoice_has_response_from_everypay_it_cannot_be_rollback assert @unpaid.paid? assert @unpaid.errors.present? - assert_equal @unpaid.errors.full_messages.join, 'Inavalid state unpaid' + assert_equal invalid_state_message('unpaid'), @unpaid.errors.full_messages.join + end + + private + + def invalid_state_message(status) + I18n.t('activerecord.errors.models.invoice.attributes.base.invalid_state', status: status) end end diff --git a/test/system/admin_area/registrars/api_users_test.rb b/test/system/admin_area/registrars/api_users_test.rb index 2cd927b707..d2d0db7be1 100644 --- a/test/system/admin_area/registrars/api_users_test.rb +++ b/test/system/admin_area/registrars/api_users_test.rb @@ -51,7 +51,7 @@ def test_shows_api_user_details assert_text "Username #{api_user.username}" assert_text "Password #{api_user.plain_text_password}" assert_link api_user.registrar.name, href: admin_registrar_path(api_user.registrar) - assert_text "Role #{api_user.roles.first}" + assert_text "#{ApiUser.human_attribute_name(:roles)} #{api_user.roles.join(', ')}" assert_text "Active #{api_user.active}" end @@ -83,11 +83,16 @@ def test_deletes_api_user private def unassociated_api_user - new_api_user = users(:api_bestnames).dup - new_api_user.username = "unique-#{rand(100)}" - new_api_user.identity_code = rand(10) - new_api_user.save! - new_api_user + source = users(:api_bestnames) + ApiUser.create!( + username: "unique-#{SecureRandom.hex(4)}", + plain_text_password: source.plain_text_password, + identity_code: SecureRandom.random_number(10_000_000), + registrar: source.registrar, + roles: source.roles, + active: true, + subject: nil + ) end def valid_password From 59da367cad73a91cd14592026511297ce59187a6 Mon Sep 17 00:00:00 2001 From: Sergei Tsoganov Date: Wed, 3 Jun 2026 16:53:29 +0300 Subject: [PATCH 03/11] Corrected tests --- config/locales/domains.en.yml | 1 + config/locales/en.yml | 1 + .../repp/v1/domains/update_test.rb | 34 +++++++++---------- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/config/locales/domains.en.yml b/config/locales/domains.en.yml index 3cfec3beb8..c5b7e13837 100644 --- a/config/locales/domains.en.yml +++ b/config/locales/domains.en.yml @@ -48,6 +48,7 @@ en: invalid_auth_information_reserved: Invalid authorization information; invalid reserved>pw value required_parameter_missing_disputed: Required parameter missing; disputed pw element required for dispute domains invalid_auth_information_disputed: Invalid authorization information; invalid disputed>pw value + dispute_update_requires_registrant_change: Object status prohibits operation; disputed domain update requires registrant change domain_name_blocked: 'Data management policy violation: Domain name is blocked [name]' session_limit_exceeded: Session limit exceeded; server closing connection domain_at_auction: 'Parameter value policy error: domain is at auction' diff --git a/config/locales/en.yml b/config/locales/en.yml index 5366d070cb..3dd238e01e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -29,6 +29,7 @@ en: invalid_auth_information_reserved: 'Invalid authorization information; invalid reserved_pw value' required_parameter_missing_disputed: 'Required parameter missing; reserved_pw element required for dispute domains' invalid_auth_information_disputed: 'Invalid authorization information; invalid reserved_pw value' + dispute_update_requires_registrant_change: 'Object status prohibits operation; disputed domain update requires registrant change' domain_name_blocked: 'Data management policy violation: Domain name is blocked [name]' name_dirty: invalid: 'Domain name is invalid' diff --git a/test/integration/repp/v1/domains/update_test.rb b/test/integration/repp/v1/domains/update_test.rb index 76ef46cc35..c5dfd9c995 100644 --- a/test/integration/repp/v1/domains/update_test.rb +++ b/test/integration/repp/v1/domains/update_test.rb @@ -51,7 +51,7 @@ def test_adds_epp_error_when_disputed_domain_updated_without_registrant_change json, http_status: :bad_request, code: 2304, - message: 'Object status prohibits operation; disputed domain update requires registrant change' + message: I18n.t('activerecord.errors.models.epp_domain.attributes.base.dispute_update_requires_registrant_change') ) assert_equal old_auth_code, @domain.auth_info assert @domain.disputed? @@ -67,7 +67,7 @@ def test_adds_epp_error_when_reserved_pw_is_missing_for_disputed_registrant_chan json, http_status: :bad_request, code: 2304, - message: 'Required parameter missing; reservedpw element required for dispute domains' + message: I18n.t('activerecord.errors.models.epp_domain.attributes.base.required_parameter_missing_disputed') ) refute_equal new_registrant.code, @domain.registrant.code assert @domain.disputed? @@ -83,7 +83,7 @@ def test_rejects_disputed_domain_update_with_valid_pw_but_no_registrant_change json, http_status: :bad_request, code: 2304, - message: 'Object status prohibits operation; disputed domain update requires registrant change' + message: I18n.t('activerecord.errors.models.epp_domain.attributes.base.dispute_update_requires_registrant_change') ) assert_equal old_auth_code, @domain.auth_info assert @domain.disputed? @@ -141,7 +141,7 @@ def test_adds_epp_error_when_reserved_pw_is_invalid_for_disputed_domain json, http_status: :bad_request, code: 2202, - message: 'Invalid authorization information; invalid reserved>pw value' + message: I18n.t('activerecord.errors.models.epp_domain.attributes.base.invalid_auth_information_disputed') ) refute_equal new_registrant.code, @domain.registrant.code assert @domain.disputed? @@ -161,7 +161,7 @@ def test_rejects_disputed_domain_registrant_and_transfer_code_change_without_res json, http_status: :bad_request, code: 2304, - message: 'Required parameter missing; reservedpw element required for dispute domains' + message: I18n.t('activerecord.errors.models.epp_domain.attributes.base.required_parameter_missing_disputed') ) refute_equal new_registrant.code, @domain.registrant.code assert_equal old_transfer_code, @domain.transfer_code @@ -194,10 +194,18 @@ def update_domain(domain_params) params: { domain: domain_params }.to_json @domain.reload - json = JSON.parse(response.body, symbolize_names: true) - assert_response :bad_request - assert_equal 2304, json[:code] - assert_equal 'Required parameter missing; reserved_pw element required for dispute domains', json[:message] + JSON.parse(response.body, symbolize_names: true) + end + + def assert_repp_success(json) + assert_response :ok + assert_equal 1000, json[:code] + end + + def assert_repp_error(json, http_status:, code:, message:) + assert_response http_status + assert_equal code, json[:code] + assert_equal message, json[:message] end def json_headers @@ -212,12 +220,4 @@ def create_dispute(password: '1234567890') expires_at: Time.zone.now + 5.days ) end - - put "/repp/v1/domains/#{@domain.name}", headers: @auth_headers, params: payload.to_json - @domain.reload - json = JSON.parse(response.body, symbolize_names: true) - assert_response :bad_request - assert_equal 2202, json[:code] - assert_equal 'Invalid authorization information; invalid reserved_pw value', json[:message] - end end From 8c47a36d2858dd8f61db62476e45bd648ec698c3 Mon Sep 17 00:00:00 2001 From: Sergei Tsoganov Date: Thu, 4 Jun 2026 11:44:24 +0300 Subject: [PATCH 04/11] Modified repp translations --- config/locales/repp.en.yml | 2 +- config/locales/repp.et.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/config/locales/repp.en.yml b/config/locales/repp.en.yml index b01521a790..0a9eadd8d9 100644 --- a/config/locales/repp.en.yml +++ b/config/locales/repp.en.yml @@ -2,7 +2,7 @@ en: repp: command_completed_successfully: Command completed successfully object_does_not_exist: Object does not exist - object_does_not_exist_with_name: 'Object does not exist: %{name}' + object_does_not_exist_with_name: "Object does not exist: %{name}" errors: invalid_authorization_information: Invalid authorization information object_not_eligible_for_transfer: Object is not eligible for transfer diff --git a/config/locales/repp.et.yml b/config/locales/repp.et.yml index 6c05c6324a..548fe54e24 100644 --- a/config/locales/repp.et.yml +++ b/config/locales/repp.et.yml @@ -1,8 +1,8 @@ et: repp: command_completed_successfully: Käsk edukalt täidetud - object_does_not_exist: Objekti ei ole olemas - object_does_not_exist_with_name: 'Objekti ei ole olemas: %{name}' + object_does_not_exist: Objekt puudub + object_does_not_exist_with_name: "Objekt puudub: %{name}" errors: invalid_authorization_information: Vigane autoriseerimisteave - object_not_eligible_for_transfer: Objekt ei ole ülekandmiseks sobilik + object_not_eligible_for_transfer: Objekt ei ole üleandmiseks sobilik From ed9f620d388743d431ed719043e8ce2520b36d24 Mon Sep 17 00:00:00 2001 From: Sergei Tsoganov Date: Mon, 8 Jun 2026 14:25:54 +0300 Subject: [PATCH 05/11] Updated eligible for sign in condition --- app/models/api_user.rb | 3 +-- test/models/api_user_test.rb | 5 +---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/app/models/api_user.rb b/app/models/api_user.rb index 1c1d970f5a..4d0cd152f8 100644 --- a/app/models/api_user.rb +++ b/app/models/api_user.rb @@ -40,7 +40,6 @@ def self.min_password_length # Must precede .validates scope :eligible_for_sign_in, lambda { where(active: true) - .where.not(verified_at: nil) } def identity_verified? @@ -48,7 +47,7 @@ def identity_verified? end def eligible_for_sign_in? - active? && identity_verified? + active? end def verification_pending? diff --git a/test/models/api_user_test.rb b/test/models/api_user_test.rb index 979be3b42e..e01a9e16e1 100644 --- a/test/models/api_user_test.rb +++ b/test/models/api_user_test.rb @@ -160,12 +160,9 @@ def test_linked_with_by_subject_only end def test_eligible_for_sign_in_requires_active_and_verified - @user.update_columns(active: true, verified_at: Time.zone.now, subject: nil) + @user.update_columns(active: true, subject: nil) assert @user.eligible_for_sign_in? - @user.update_columns(verified_at: nil) - assert_not @user.eligible_for_sign_in? - @user.update_columns(verified_at: Time.zone.now, active: false) assert_not @user.eligible_for_sign_in? From e00844a17baec0f3e71c393084f3995cba81a2d7 Mon Sep 17 00:00:00 2001 From: Sergei Tsoganov Date: Wed, 10 Jun 2026 10:00:34 +0300 Subject: [PATCH 06/11] Added contact verification endpoints --- .../identification_requests_controller.rb | 30 ++-- .../repp/v1/contacts_controller.rb | 33 +++- .../actions/contact_approve_verification.rb | 80 ++++++++++ .../actions/contact_reject_verification.rb | 26 ++++ app/interactions/actions/contact_verify.rb | 4 +- .../process_contact_identification_webhook.rb | 119 +++++++++++++++ app/mailers/registrar_mailer.rb | 8 + .../contact_verification_pending.html.erb | 57 +++++++ .../contact_verification_pending.text.erb | 45 ++++++ config/locales/contacts.en.yml | 3 + config/locales/contacts.et.yml | 3 + config/locales/mailers/registrar.en.yml | 4 + config/locales/mailers/registrar.et.yml | 2 + config/routes.rb | 2 + ..._add_contact_verification_review_fields.rb | 11 ++ db/structure.sql | 18 ++- lib/serializers/repp/contact.rb | 4 +- .../identification_requests_webhook_test.rb | 143 +++++++++++++++++- .../repp/v1/contacts/download_poi_test.rb | 15 ++ .../contact_approve_verification_test.rb | 34 +++++ ...ess_contact_identification_webhook_test.rb | 42 +++++ 21 files changed, 661 insertions(+), 22 deletions(-) create mode 100644 app/interactions/actions/contact_approve_verification.rb create mode 100644 app/interactions/actions/contact_reject_verification.rb create mode 100644 app/interactions/actions/process_contact_identification_webhook.rb create mode 100644 app/views/mailers/registrar_mailer/contact_verification_pending.html.erb create mode 100644 app/views/mailers/registrar_mailer/contact_verification_pending.text.erb create mode 100644 db/migrate/20260608120000_add_contact_verification_review_fields.rb create mode 100644 test/interactions/actions/contact_approve_verification_test.rb create mode 100644 test/interactions/actions/process_contact_identification_webhook_test.rb diff --git a/app/controllers/eeid/webhooks/identification_requests_controller.rb b/app/controllers/eeid/webhooks/identification_requests_controller.rb index 17c139d6e6..36a30eb776 100644 --- a/app/controllers/eeid/webhooks/identification_requests_controller.rb +++ b/app/controllers/eeid/webhooks/identification_requests_controller.rb @@ -92,18 +92,20 @@ def process_verification(entity) if entity.is_a?(ApiUser) process_api_user(entity) else - verify_contact(entity) + process_contact(entity) end end - def verify_contact(contact) - ref = permitted_params[:reference] - if contact&.ident_request_sent_at.present? - 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 + def process_contact(contact) + ident_service = Eeid::IdentificationService.new(contact.ident_type) + response = ident_service.get_identification_request(permitted_params[:identification_request_id]) + result = response[:result] || response['result'] || {} + + @contact_outcome = Actions::ProcessContactIdentificationWebhook.new( + contact, + identification_request_id: permitted_params[:identification_request_id], + result: result + ).call end def process_api_user(api_user) @@ -133,7 +135,15 @@ def inform_registrar(entity, poi) if entity.is_a?(ApiUser) inform_registrar_api_user(email, entity, poi) else - RegistrarMailer.contact_verified(email: email, contact: entity, poi: poi).deliver_now + inform_registrar_contact(email, entity, poi) + end + end + + def inform_registrar_contact(email, contact, poi) + if contact.verification_pending_at.present? + RegistrarMailer.contact_verification_pending(email: email, contact: contact, poi: poi).deliver_now + elsif contact.verified_at.present? + RegistrarMailer.contact_verified(email: email, contact: contact, poi: poi).deliver_now end end diff --git a/app/controllers/repp/v1/contacts_controller.rb b/app/controllers/repp/v1/contacts_controller.rb index da1e360a5c..acf098f33a 100644 --- a/app/controllers/repp/v1/contacts_controller.rb +++ b/app/controllers/repp/v1/contacts_controller.rb @@ -2,10 +2,11 @@ module Repp module V1 class ContactsController < BaseController # rubocop:disable Metrics/ClassLength - before_action :find_contact, only: %i[show update destroy verify download_poi] + before_action :find_contact, only: %i[show update destroy verify download_poi approve_verification reject_verification] skip_around_action :log_request, only: %i[search] - THROTTLED_ACTIONS = %i[index check search create show update destroy verify download_poi].freeze + THROTTLED_ACTIONS = %i[index check search create show update destroy verify download_poi + approve_verification reject_verification].freeze include Shunter::Integration::Throttle api :get, '/repp/v1/contacts' @@ -144,6 +145,34 @@ def download_poi handle_non_epp_errors(@contact, e.message) end + api :POST, '/repp/v1/contacts/approve_verification/:contact_code' + desc 'Manually approve pending contact identification' + def approve_verification + authorize! :verify, Epp::Contact + action = Actions::ContactApproveVerification.new(@contact) + + unless action.call + handle_non_epp_errors(@contact) + return + end + + render_success(data: { contact: { code: @contact.code } }) + end + + api :POST, '/repp/v1/contacts/reject_verification/:contact_code' + desc 'Reject pending contact identification' + def reject_verification + authorize! :verify, Epp::Contact + action = Actions::ContactRejectVerification.new(@contact) + + unless action.call + handle_non_epp_errors(@contact) + return + end + + render_success(data: { contact: { code: @contact.code } }) + end + private def index_params diff --git a/app/interactions/actions/contact_approve_verification.rb b/app/interactions/actions/contact_approve_verification.rb new file mode 100644 index 0000000000..23af850160 --- /dev/null +++ b/app/interactions/actions/contact_approve_verification.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +module Actions + # Registrar manually approves a pending Contact identification. + class ContactApproveVerification + SUB_PATTERN = /\A([A-Z]{2})([0-9A-Za-z]+)\z/ + + attr_reader :contact + + def initialize(contact) + @contact = contact + end + + def call + unless contact.verification_pending_at.present? + contact.errors.add(:base, :not_pending_verification) + return false + end + + snapshot = (contact.verification_snapshot || {}).with_indifferent_access + attrs = { + verified_at: Time.zone.now, + verification_pending_at: nil, + verification_snapshot: {} + } + + if contact.ident_type == Contact::BIRTHDAY + ident_attrs = birthday_attrs_from_snapshot(snapshot) + return false if ident_attrs.nil? + + attrs.merge!(ident_attrs) + else + subject = snapshot[:sub].to_s.strip.presence + if subject.blank? + contact.errors.add(:base, :missing_subject) + return false + end + + country_code, ident = parse_subject(subject) + if country_code.blank? || ident.blank? + contact.errors.add(:base, :missing_subject) + return false + end + + attrs[:ident_country_code] = country_code + attrs[:ident] = ident + end + + contact.update!(attrs) + true + end + + private + + def birthday_attrs_from_snapshot(snapshot) + birthdate = snapshot[:birthdate].presence || snapshot[:date_of_birth].presence + name = snapshot[:name].presence || + [snapshot[:given_name], snapshot[:family_name]].compact.join(' ').strip.presence + country_code = snapshot[:country].to_s.strip.upcase.presence + + if birthdate.blank? || name.blank? || country_code.blank? + contact.errors.add(:base, :missing_claims) + return nil + end + + { + ident: birthdate, + name: name, + ident_country_code: country_code + } + end + + def parse_subject(subject) + match = subject.match(SUB_PATTERN) + return [nil, nil] unless match + + [match[1], match[2]] + end + end +end diff --git a/app/interactions/actions/contact_reject_verification.rb b/app/interactions/actions/contact_reject_verification.rb new file mode 100644 index 0000000000..5529c4e6fa --- /dev/null +++ b/app/interactions/actions/contact_reject_verification.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Actions + # Clears pending Contact identification after registrar rejection. + class ContactRejectVerification + attr_reader :contact + + def initialize(contact) + @contact = contact + end + + def call + unless contact.verification_pending_at.present? + contact.errors.add(:base, :not_pending_verification) + return false + end + + contact.update!( + verification_pending_at: nil, + verification_id: nil, + verification_snapshot: {} + ) + true + end + end +end diff --git a/app/interactions/actions/contact_verify.rb b/app/interactions/actions/contact_verify.rb index e8c4babf78..9b8c9b8d74 100644 --- a/app/interactions/actions/contact_verify.rb +++ b/app/interactions/actions/contact_verify.rb @@ -63,7 +63,9 @@ def commit @contact.update( ident_request_sent_at: Time.zone.now, verified_at: nil, - verification_id: nil + verification_id: nil, + verification_pending_at: nil, + verification_snapshot: {} ) end end diff --git a/app/interactions/actions/process_contact_identification_webhook.rb b/app/interactions/actions/process_contact_identification_webhook.rb new file mode 100644 index 0000000000..78d8c32f79 --- /dev/null +++ b/app/interactions/actions/process_contact_identification_webhook.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +module Actions + # Applies eeID identification results to a Contact (auto-verify or pending review). + class ProcessContactIdentificationWebhook + attr_reader :contact, :outcome + + def initialize(contact, identification_request_id:, result:) + @contact = contact + @identification_request_id = identification_request_id + @result = (result || {}).with_indifferent_access + end + + def call + unless contact.ident_request_sent_at.present? + Rails.logger.error("Contact verification ignored: ident not requested for contact #{contact.id}") + @outcome = :ignored + return false + end + + if auto_verifiable? + apply_auto_verification! + @outcome = :auto_verified + else + apply_pending_review!(pending_reason) + @outcome = :pending_review + end + + true + end + + def pending_reason + @pending_reason ||= compute_pending_reason + end + + private + + def compute_pending_reason + if contact.ident_type == Contact::BIRTHDAY + return :missing_claims if birthday_claims_incomplete? + return :claim_mismatch if birthday_claim_mismatch? + else + subject = result_subject + return :missing_subject if subject.blank? + return :ident_mismatch if ident_mismatch?(subject) + end + + nil + end + + def auto_verifiable? + pending_reason.nil? + end + + def result_subject + @result[:sub].to_s.strip.presence + end + + def expected_subject + "#{contact.ident_country_code}#{contact.ident}" + end + + def ident_mismatch?(subject) + subject != expected_subject + end + + def birthday_claims_incomplete? + birthdate_from_result.blank? || name_from_result.blank? || country_from_result.blank? + end + + def birthday_claim_mismatch? + normalize(birthdate_from_result) != normalize(contact.ident) || + normalize(name_from_result) != normalize(contact.name) || + normalize(country_from_result) != normalize(contact.ident_country_code) + end + + def birthdate_from_result + @result[:birthdate].presence || @result[:date_of_birth].presence + end + + def name_from_result + @result[:name].presence || + [@result[:given_name], @result[:family_name]].compact.join(' ').strip.presence + end + + def country_from_result + @result[:country].to_s.strip.upcase.presence + end + + def normalize(value) + value.to_s.strip.upcase + end + + def apply_auto_verification! + contact.update!( + verified_at: Time.zone.now, + verification_id: @identification_request_id, + verification_pending_at: nil, + verification_snapshot: {} + ) + Rails.logger.info("Contact verified (auto): #{contact.id}") + end + + def apply_pending_review!(reason) + contact.update!( + verification_id: @identification_request_id, + verification_pending_at: Time.zone.now, + verification_snapshot: verification_snapshot, + verified_at: nil + ) + Rails.logger.info("Contact verification pending (#{reason}): #{contact.id}") + end + + def verification_snapshot + @result.slice(:sub, :given_name, :family_name, :name, :date_of_birth, :birthdate, :country, + :authentication_type).compact + end + end +end diff --git a/app/mailers/registrar_mailer.rb b/app/mailers/registrar_mailer.rb index 44ae4843e1..7e9a78e7d0 100644 --- a/app/mailers/registrar_mailer.rb +++ b/app/mailers/registrar_mailer.rb @@ -8,6 +8,14 @@ def contact_verified(email:, contact:, poi:) mail(to: email, subject: subject) end + def contact_verification_pending(email:, contact:, poi:) + @contact = contact + @verification_snapshot = contact.verification_snapshot + subject = default_i18n_subject(contact_code: contact.code) + attachments['proof_of_identity.pdf'] = poi if poi.present? + mail(to: email, subject: subject) + end + def api_user_verified(email:, api_user:, poi:) @api_user = api_user subject = default_i18n_subject(username: api_user.username) diff --git a/app/views/mailers/registrar_mailer/contact_verification_pending.html.erb b/app/views/mailers/registrar_mailer/contact_verification_pending.html.erb new file mode 100644 index 0000000000..c9563c07e6 --- /dev/null +++ b/app/views/mailers/registrar_mailer/contact_verification_pending.html.erb @@ -0,0 +1,57 @@ +Tere, +

+

+Anname teada, et järgmise kontakti isikutuvastus vajab registripidaja käsitsi kinnitust. +Automaatne kinnitamine ei olnud võimalik (nt isikukood puudus tulemuses või andmed ei ühti kontakti andmetega). +

+

Kontakti andmed:

+
    +
  • Kontakti kood: <%= @contact.code %>
  • +
  • Nimi: <%= @contact.name %>
  • +
+<% if @verification_snapshot.present? %> +

eeID tulemus:

+
    + <% @verification_snapshot.each do |key, value| %> + <% next if value.blank? %> +
  • <%= key %>: <%= value %>
  • + <% end %> +
+<% end %> +

+Palun kinnitage või lükake isikutuvastus tagasi registripidaja portaalis. +

+

+Parimate soovidega, +

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

+ +Hi, +

+

+We are writing to inform you that the identity verification for the following contact requires manual registrar confirmation. +Automatic verification was not possible (e.g. identity code missing in the result or data does not match the contact record). +

+

Contact details:

+
    +
  • Contact code: <%= @contact.code %>
  • +
  • Name: <%= @contact.name %>
  • +
+<% if @verification_snapshot.present? %> +

eeID result:

+
    + <% @verification_snapshot.each do |key, value| %> + <% next if value.blank? %> +
  • <%= key %>: <%= value %>
  • + <% end %> +
+<% end %> +

+Please confirm or reject the identity verification in the Registrar Portal. +

+

+Best regards, +

+<%= render 'mailers/shared/signatures/signature.en.html' %> diff --git a/app/views/mailers/registrar_mailer/contact_verification_pending.text.erb b/app/views/mailers/registrar_mailer/contact_verification_pending.text.erb new file mode 100644 index 0000000000..88d325ba85 --- /dev/null +++ b/app/views/mailers/registrar_mailer/contact_verification_pending.text.erb @@ -0,0 +1,45 @@ +Tere, + +Anname teada, et järgmise kontakti isikutuvastus vajab registripidaja käsitsi kinnitust. +Automaatne kinnitamine ei olnud võimalik (nt isikukood puudus tulemuses või andmed ei ühti kontakti andmetega). + +Kontakti kood: <%= @contact.code %> +Nimi: <%= @contact.name %> + +<% if @verification_snapshot.present? %> +eeID tulemus: +<% @verification_snapshot.each do |key, value| %> +<% next if value.blank? %> +<%= key %>: <%= value %> +<% end %> +<% end %> + +Palun kinnitage või lükake isikutuvastus tagasi registripidaja portaalis. + +Parimate soovidega, + +<%= render 'mailers/shared/signatures/signature.et.text' %> + +--- + +Hi, + +We are writing to inform you that the identity verification for the following contact requires manual registrar confirmation. +Automatic verification was not possible (e.g. identity code missing in the result or data does not match the contact record). + +Contact code: <%= @contact.code %> +Name: <%= @contact.name %> + +<% if @verification_snapshot.present? %> +eeID result: +<% @verification_snapshot.each do |key, value| %> +<% next if value.blank? %> +<%= key %>: <%= value %> +<% end %> +<% end %> + +Please confirm or reject the identity verification in the Registrar Portal. + +Best regards, + +<%= render 'mailers/shared/signatures/signature.en.text' %> diff --git a/config/locales/contacts.en.yml b/config/locales/contacts.en.yml index f8aa823bbb..6317326cbe 100644 --- a/config/locales/contacts.en.yml +++ b/config/locales/contacts.en.yml @@ -31,6 +31,9 @@ en: base: verification_exists: Contact already verified verification_error: Sending identification request failed + not_pending_verification: No pending verification to approve or reject + missing_subject: Identity subject is required to approve verification + missing_claims: Required identity claims are missing to approve verification code: blank: Required parameter missing - code invalid: Contact code is invalid diff --git a/config/locales/contacts.et.yml b/config/locales/contacts.et.yml index 6d7d07d167..b31dc93ab4 100644 --- a/config/locales/contacts.et.yml +++ b/config/locales/contacts.et.yml @@ -31,6 +31,9 @@ et: base: verification_exists: Kontakt on juba kinnitatud verification_error: Identifitseerimistaotluse saatmine ebaõnnestus + not_pending_verification: Kinnitamist ootavat identifitseerimist pole + missing_subject: Isiku identifikaator on kinnitamiseks kohustuslik + missing_claims: Kinnitamiseks vajalikud isikuandmed puuduvad code: blank: Kohustuslik parameeter puudub – kood invalid: Kontakti kood on vigane diff --git a/config/locales/mailers/registrar.en.yml b/config/locales/mailers/registrar.en.yml index ce9cb762f5..8ce5c86790 100644 --- a/config/locales/mailers/registrar.en.yml +++ b/config/locales/mailers/registrar.en.yml @@ -4,6 +4,10 @@ en: subject: >- Teade: Kontakti [%{contact_code}] kinnitamine edukalt lõpule viidud / Notification: Contact [%{contact_code}] verification successfully completed + contact_verification_pending: + subject: >- + Teade: Kontakti [%{contact_code}] isikutuvastus vajab registripidaja kinnitust + / Notification: Contact [%{contact_code}] identity verification needs registrar review api_user_verified: subject: >- Teade: API kasutaja [%{username}] isikutuvastus kinnitatud automaatselt diff --git a/config/locales/mailers/registrar.et.yml b/config/locales/mailers/registrar.et.yml index 8b0942455a..909738742d 100644 --- a/config/locales/mailers/registrar.et.yml +++ b/config/locales/mailers/registrar.et.yml @@ -1,5 +1,7 @@ et: registrar_mailer: + contact_verification_pending: + subject: 'Teade: Kontakti [%{contact_code}] isikutuvastus vajab registripidaja kinnitust' api_user_verified: subject: 'Teade: API kasutaja [%{username}] isikutuvastus kinnitatud automaatselt' api_user_verification_pending: diff --git a/config/routes.rb b/config/routes.rb index 0c226e330c..05af94cda3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -77,6 +77,8 @@ get 'search(/:id)', to: 'contacts#search' post 'verify/:id', to: 'contacts#verify' get 'download_poi/:id', to: 'contacts#download_poi' + post 'approve_verification/:id', to: 'contacts#approve_verification' + post 'reject_verification/:id', to: 'contacts#reject_verification' end end end diff --git a/db/migrate/20260608120000_add_contact_verification_review_fields.rb b/db/migrate/20260608120000_add_contact_verification_review_fields.rb new file mode 100644 index 0000000000..2e8a174626 --- /dev/null +++ b/db/migrate/20260608120000_add_contact_verification_review_fields.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddContactVerificationReviewFields < ActiveRecord::Migration[6.1] + disable_ddl_transaction! + + def change + add_column :contacts, :verification_pending_at, :datetime + add_column :contacts, :verification_snapshot, :jsonb, default: {} + add_index :contacts, :verification_pending_at, algorithm: :concurrently + end +end diff --git a/db/structure.sql b/db/structure.sql index a8c309c4e8..f9fcd64f8c 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -1,4 +1,4 @@ -\restrict UcsiEKfHzXBZIZHqjYwWEEWgCcCXbG1YFqBmhSq7V4X0gwin7b9h3ziY4zklaPe +\restrict WmlyozFAnc1c6zHWXudb7s2jRC1uKwHPlCMDikRGHsbNPX1TGBaq3KQ01YXVO8T -- Dumped from database version 13.4 (Debian 13.4-4.pgdg110+1) -- Dumped by pg_dump version 13.23 (Debian 13.23-1.pgdg11+1) @@ -738,7 +738,9 @@ CREATE TABLE public.contacts ( verification_id character varying, checked_company_at timestamp without time zone, company_register_status character varying, - system_disclosed_attributes character varying[] DEFAULT '{}'::character varying[] + system_disclosed_attributes character varying[] DEFAULT '{}'::character varying[], + verification_pending_at timestamp without time zone, + verification_snapshot jsonb DEFAULT '{}'::jsonb ); @@ -4447,6 +4449,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_verification_pending_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_contacts_on_verification_pending_at ON public.contacts USING btree (verification_pending_at); + + -- -- Name: index_contacts_on_verified_at; Type: INDEX; Schema: public; Owner: - -- @@ -5346,7 +5355,7 @@ ALTER TABLE ONLY public.users -- PostgreSQL database dump complete -- -\unrestrict UcsiEKfHzXBZIZHqjYwWEEWgCcCXbG1YFqBmhSq7V4X0gwin7b9h3ziY4zklaPe +\unrestrict WmlyozFAnc1c6zHWXudb7s2jRC1uKwHPlCMDikRGHsbNPX1TGBaq3KQ01YXVO8T SET search_path TO "$user", public; @@ -5853,6 +5862,7 @@ INSERT INTO "schema_migrations" (version) VALUES ('20260220111500'), ('20260406125446'), ('20260529120000'), -('20260601120000'); +('20260601120000'), +('20260608120000'); diff --git a/lib/serializers/repp/contact.rb b/lib/serializers/repp/contact.rb index a6f7fb2c92..00431d6ab1 100644 --- a/lib/serializers/repp/contact.rb +++ b/lib/serializers/repp/contact.rb @@ -19,7 +19,9 @@ def to_json(obj = contact) created_at: obj.created_at, auth_info: obj.auth_info, email: obj.email, statuses: statuses, disclosed_attributes: obj.disclosed_attributes, registrar: registrar, ident_request_sent_at: obj.ident_request_sent_at, - verified_at: obj.verified_at, verification_id: obj.verification_id } + verified_at: obj.verified_at, verification_id: obj.verification_id, + verification_pending_at: obj.verification_pending_at, + verification_snapshot: obj.verification_snapshot } 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 index 3ba4f30867..98374e96a4 100644 --- a/test/integration/eeid/identification_requests_webhook_test.rb +++ b/test/integration/eeid/identification_requests_webhook_test.rb @@ -16,9 +16,16 @@ class Eeid::IdentificationRequestsWebhookTest < ActionDispatch::IntegrationTest 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}) + pdf_content = File.binread(Rails.root.join('test/fixtures/files/legaldoc.pdf')) + stub_request(:get, %r{api/ident/v1/identification_requests/.+/proof_of_identity}) .to_return(status: 200, body: pdf_content, headers: { 'Content-Type' => 'application/pdf' }) + default_ident_body = { + id: '123', + status: 'completed', + result: { sub: 'US1234' } + }.to_json + stub_request(:get, %r{api/ident/v1/identification_requests/[^/]+$}) + .to_return(status: 200, body: default_ident_body, headers: { 'Content-Type' => 'application/json' }) adapter = ENV['shunter_default_adapter'].constantize.new adapter&.clear! @@ -26,6 +33,14 @@ class Eeid::IdentificationRequestsWebhookTest < ActionDispatch::IntegrationTest test 'should verify contact with valid signature and parameters' do @contact.update!(ident_request_sent_at: Time.zone.now - 1.day) + ident_body = { + id: '123', + status: 'completed', + result: { sub: 'US1234' } + }.to_json + stub_request(:get, %r{api/ident/v1/identification_requests/123$}) + .to_return(status: 200, body: ident_body, headers: { 'Content-Type' => 'application/json' }) + 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 @@ -58,7 +73,7 @@ class Eeid::IdentificationRequestsWebhookTest < ActionDispatch::IntegrationTest @contact.update!(ident_request_sent_at: Time.zone.now - 1.day) Eeid::Webhooks::IdentificationRequestsController.stub_any_instance( - :verify_contact, + :process_contact, proc { |_contact| 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 } @@ -70,7 +85,7 @@ class Eeid::IdentificationRequestsWebhookTest < ActionDispatch::IntegrationTest end test 'should handle error from ident response' do - stub_request(:get, %r{api/ident/v1/identification_requests}) + stub_request(:get, %r{api/ident/v1/identification_requests/.+/proof_of_identity}) .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) @@ -178,6 +193,107 @@ class Eeid::IdentificationRequestsWebhookTest < ActionDispatch::IntegrationTest assert_includes ActionMailer::Base.deliveries.last.subject, api_user.username end + test 'should pending review contact when sub mismatches contact ident' do + @contact.update!(ident_request_sent_at: Time.zone.now - 1.day) + request_id = '321' + payload = { identification_request_id: request_id, reference: @contact.code } + ident_body = { + id: request_id, + status: 'completed', + claims_matched: false, + result: { sub: 'US9999', given_name: 'John' } + }.to_json + stub_request(:get, %r{api/ident/v1/identification_requests/#{request_id}$}) + .to_return(status: 200, body: ident_body, headers: { 'Content-Type' => 'application/json' }) + + post '/eeid/webhooks/identification_requests', + params: payload, + as: :json, + headers: { 'X-HMAC-Signature' => hmac_signature_for(payload) } + + assert_response :ok + assert_equal({ 'status' => 'success' }, JSON.parse(response.body)) + + @contact.reload + assert_nil @contact.verified_at + assert @contact.verification_pending_at.present? + assert_equal request_id, @contact.verification_id + assert_equal 'US9999', @contact.verification_snapshot['sub'] + assert_equal 'John', @contact.verification_snapshot['given_name'] + + assert_notify_registrar_pending_review( + "Teade: Kontakti [#{@contact.code}] isikutuvastus vajab registripidaja kinnitust " \ + "/ Notification: Contact [#{@contact.code}] identity verification needs registrar review" + ) + end + + test 'contact verify to mismatch webhook ends in pending review with downloadable POI' do + user = users(:api_bestnames) + auth_headers = { 'Authorization' => repp_basic_auth(user) } + + stub_request(:post, %r{api/ident/v1/identification_requests}) + .with( + body: { + claims_required: [{ type: 'sub', value: 'US1234' }], + reference: @contact.code + } + ).to_return( + status: 200, + body: { id: '555', link: 'http://link' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + post "/repp/v1/contacts/verify/#{@contact.code}", headers: auth_headers + + assert_response :ok + @contact.reload + assert @contact.ident_request_sent_at.present? + assert_nil @contact.verified_at + assert_nil @contact.verification_pending_at + + request_id = '555' + payload = { identification_request_id: request_id, reference: @contact.code } + ident_body = { + id: request_id, + status: 'completed', + claims_matched: false, + result: { sub: 'US9999', given_name: 'John', family_name: 'Doe' } + }.to_json + stub_request(:get, %r{api/ident/v1/identification_requests/#{request_id}$}) + .to_return(status: 200, body: ident_body, headers: { 'Content-Type' => 'application/json' }) + + ActionMailer::Base.deliveries.clear + + post '/eeid/webhooks/identification_requests', + params: payload, + as: :json, + headers: { 'X-HMAC-Signature' => hmac_signature_for(payload) } + + assert_response :ok + assert_equal({ 'status' => 'success' }, JSON.parse(response.body)) + + @contact.reload + assert_nil @contact.verified_at + assert @contact.verification_pending_at.present? + assert_equal request_id, @contact.verification_id + assert_equal 'US9999', @contact.verification_snapshot['sub'] + + assert_notify_registrar_pending_review( + "Teade: Kontakti [#{@contact.code}] isikutuvastus vajab registripidaja kinnitust " \ + "/ Notification: Contact [#{@contact.code}] identity verification needs registrar review" + ) + + 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_#{request_id}.pdf\"; filename*=UTF-8''proof_of_identity_#{request_id}.pdf", + response.headers['Content-Disposition'] + ) + assert_not_empty response.body + end + test 'should pending review api user when subject conflicts with another user' do existing = users(:api_bestnames) existing.update!(subject: 'GBAB999999', registrar: users(:api_bestnames_epp).registrar) @@ -223,6 +339,15 @@ class Eeid::IdentificationRequestsWebhookTest < ActionDispatch::IntegrationTest private + def hmac_signature_for(payload) + OpenSSL::HMAC.hexdigest('SHA256', @secret, payload.to_json) + end + + def repp_basic_auth(user) + token = Base64.encode64("#{user.username}:#{user.plain_text_password}") + "Basic #{token}" + end + def assert_notify_registrar(subject) assert_emails 1 email = ActionMailer::Base.deliveries.last @@ -231,4 +356,14 @@ def assert_notify_registrar(subject) assert_equal 1, email.attachments.size assert_equal 'proof_of_identity.pdf', email.attachments.first.filename end + + def assert_notify_registrar_pending_review(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 + assert_not_empty email.attachments.first.body.raw_source + end end diff --git a/test/integration/repp/v1/contacts/download_poi_test.rb b/test/integration/repp/v1/contacts/download_poi_test.rb index 1d943dc5c6..3c91aba150 100644 --- a/test/integration/repp/v1/contacts/download_poi_test.rb +++ b/test/integration/repp/v1/contacts/download_poi_test.rb @@ -41,6 +41,21 @@ def test_downloads_poi_for_contact assert_not_empty response.body end + def test_downloads_poi_for_contact_pending_registrar_review + @contact.update!( + ident_request_sent_at: 1.day.ago, + verification_pending_at: Time.zone.now, + verification_id: '321', + verification_snapshot: { sub: 'US9999', given_name: 'John' } + ) + 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_321.pdf\"; filename*=UTF-8''proof_of_identity_321.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( diff --git a/test/interactions/actions/contact_approve_verification_test.rb b/test/interactions/actions/contact_approve_verification_test.rb new file mode 100644 index 0000000000..f903107382 --- /dev/null +++ b/test/interactions/actions/contact_approve_verification_test.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'test_helper' + +class Actions::ContactApproveVerificationTest < ActiveSupport::TestCase + setup do + @contact = contacts(:john) + @contact.update!( + ident_request_sent_at: 1.day.ago, + verification_pending_at: Time.zone.now, + verification_id: 'pending-1', + verification_snapshot: { + 'sub' => 'US9999' + } + ) + end + + test 'approve sets ident from verification snapshot' do + assert Actions::ContactApproveVerification.new(@contact).call + + @contact.reload + assert @contact.verified_at.present? + assert_nil @contact.verification_pending_at + assert_equal 'US', @contact.ident_country_code + assert_equal '9999', @contact.ident + end + + test 'approve fails when subject is missing' do + @contact.update!(verification_snapshot: { 'given_name' => 'Test' }) + + assert_not Actions::ContactApproveVerification.new(@contact).call + assert @contact.errors.added?(:base, :missing_subject) + end +end diff --git a/test/interactions/actions/process_contact_identification_webhook_test.rb b/test/interactions/actions/process_contact_identification_webhook_test.rb new file mode 100644 index 0000000000..ad757a86af --- /dev/null +++ b/test/interactions/actions/process_contact_identification_webhook_test.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'test_helper' + +class Actions::ProcessContactIdentificationWebhookTest < ActiveSupport::TestCase + setup do + @contact = contacts(:john) + @contact.update!(ident_request_sent_at: 1.day.ago) + end + + test 'auto verifies when sub matches contact ident' do + action = Actions::ProcessContactIdentificationWebhook.new( + @contact, + identification_request_id: '123', + result: { sub: 'US1234' } + ) + + assert action.call + assert_equal :auto_verified, action.outcome + + @contact.reload + assert @contact.verified_at.present? + assert_nil @contact.verification_pending_at + assert_equal '123', @contact.verification_id + end + + test 'pending review when sub mismatches contact ident' do + action = Actions::ProcessContactIdentificationWebhook.new( + @contact, + identification_request_id: '124', + result: { sub: 'US9999', given_name: 'John' } + ) + + assert action.call + assert_equal :pending_review, action.outcome + + @contact.reload + assert_nil @contact.verified_at + assert @contact.verification_pending_at.present? + assert_equal 'US9999', @contact.verification_snapshot['sub'] + end +end From ba07a1612822ce4226cc6069ad571819a590db11 Mon Sep 17 00:00:00 2001 From: Sergei Tsoganov Date: Wed, 10 Jun 2026 10:39:46 +0300 Subject: [PATCH 07/11] Reset ident_request_sent_at after decline --- .../actions/api_user_reject_verification.rb | 1 + .../actions/contact_reject_verification.rb | 1 + .../api_user_reject_verification_test.rb | 34 +++++++++++++++++++ .../contact_reject_verification_test.rb | 33 ++++++++++++++++++ 4 files changed, 69 insertions(+) create mode 100644 test/interactions/actions/api_user_reject_verification_test.rb create mode 100644 test/interactions/actions/contact_reject_verification_test.rb diff --git a/app/interactions/actions/api_user_reject_verification.rb b/app/interactions/actions/api_user_reject_verification.rb index dc3e62bdc2..a1e88cf23a 100644 --- a/app/interactions/actions/api_user_reject_verification.rb +++ b/app/interactions/actions/api_user_reject_verification.rb @@ -16,6 +16,7 @@ def call end api_user.update!( + ident_request_sent_at: nil, verification_pending_at: nil, verification_id: nil, verification_snapshot: {} diff --git a/app/interactions/actions/contact_reject_verification.rb b/app/interactions/actions/contact_reject_verification.rb index 5529c4e6fa..cb1da94224 100644 --- a/app/interactions/actions/contact_reject_verification.rb +++ b/app/interactions/actions/contact_reject_verification.rb @@ -16,6 +16,7 @@ def call end contact.update!( + ident_request_sent_at: nil, verification_pending_at: nil, verification_id: nil, verification_snapshot: {} diff --git a/test/interactions/actions/api_user_reject_verification_test.rb b/test/interactions/actions/api_user_reject_verification_test.rb new file mode 100644 index 0000000000..5fa9fd3149 --- /dev/null +++ b/test/interactions/actions/api_user_reject_verification_test.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'test_helper' + +class Actions::ApiUserRejectVerificationTest < ActiveSupport::TestCase + setup do + @api_user = users(:api_bestnames_epp) + @api_user.update!( + email: 'pending@example.test', + ident_request_sent_at: 1.day.ago, + verification_pending_at: Time.zone.now, + verification_id: 'pending-1', + verification_snapshot: { 'sub' => 'GBMANUAL123' } + ) + end + + test 'reject clears pending verification and ident request timestamp' do + assert Actions::ApiUserRejectVerification.new(@api_user).call + + @api_user.reload + assert_nil @api_user.ident_request_sent_at + assert_nil @api_user.verification_pending_at + assert_nil @api_user.verification_id + assert_equal({}, @api_user.verification_snapshot) + assert_nil @api_user.verified_at + end + + test 'reject fails when not pending verification' do + @api_user.update!(verification_pending_at: nil) + + assert_not Actions::ApiUserRejectVerification.new(@api_user).call + assert @api_user.errors.added?(:base, :not_pending_verification) + end +end diff --git a/test/interactions/actions/contact_reject_verification_test.rb b/test/interactions/actions/contact_reject_verification_test.rb new file mode 100644 index 0000000000..bd32fe870a --- /dev/null +++ b/test/interactions/actions/contact_reject_verification_test.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'test_helper' + +class Actions::ContactRejectVerificationTest < ActiveSupport::TestCase + setup do + @contact = contacts(:john) + @contact.update!( + ident_request_sent_at: 1.day.ago, + verification_pending_at: Time.zone.now, + verification_id: 'pending-1', + verification_snapshot: { 'sub' => 'US9999' } + ) + end + + test 'reject clears pending verification and ident request timestamp' do + assert Actions::ContactRejectVerification.new(@contact).call + + @contact.reload + assert_nil @contact.ident_request_sent_at + assert_nil @contact.verification_pending_at + assert_nil @contact.verification_id + assert_equal({}, @contact.verification_snapshot) + assert_nil @contact.verified_at + end + + test 'reject fails when not pending verification' do + @contact.update!(verification_pending_at: nil) + + assert_not Actions::ContactRejectVerification.new(@contact).call + assert @contact.errors.added?(:base, :not_pending_verification) + end +end From 38244ce75e15ba9888378a442d1bea1cba59ab99 Mon Sep 17 00:00:00 2001 From: Sergei Tsoganov Date: Wed, 10 Jun 2026 11:04:40 +0300 Subject: [PATCH 08/11] Corrected tests --- test/integration/repp/v1/registrar/auth/check_info_test.rb | 7 ++++--- test/models/api_user_test.rb | 6 +++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/test/integration/repp/v1/registrar/auth/check_info_test.rb b/test/integration/repp/v1/registrar/auth/check_info_test.rb index bf761a436d..4543800519 100644 --- a/test/integration/repp/v1/registrar/auth/check_info_test.rb +++ b/test/integration/repp/v1/registrar/auth/check_info_test.rb @@ -39,14 +39,15 @@ def test_invalid_user_login assert_equal json[:message], 'Invalid authorization information' end - def test_rejects_unverified_user_login_even_with_valid_password + def test_allows_unverified_user_login_with_valid_password @user.update_columns(verified_at: nil) get '/repp/v1/registrar/auth', headers: @auth_headers json = JSON.parse(response.body, symbolize_names: true) - assert_response :unauthorized - assert_equal 'Invalid authorization information', json[:message] + assert_response :ok + assert_equal 1000, json[:code] + assert_equal json[:data][:username], @user.username end def test_returns_error_response_if_throttled diff --git a/test/models/api_user_test.rb b/test/models/api_user_test.rb index e01a9e16e1..179e961d6c 100644 --- a/test/models/api_user_test.rb +++ b/test/models/api_user_test.rb @@ -159,14 +159,14 @@ def test_linked_with_by_subject_only assert_not @user.linked_with?(nil) end - def test_eligible_for_sign_in_requires_active_and_verified + def test_eligible_for_sign_in_requires_active @user.update_columns(active: true, subject: nil) assert @user.eligible_for_sign_in? - @user.update_columns(verified_at: Time.zone.now, active: false) + @user.update_columns(active: false) assert_not @user.eligible_for_sign_in? - @user.update_columns(active: true, subject: 'EE1234') + @user.update_columns(active: true, subject: 'EE1234', verified_at: nil) assert @user.eligible_for_sign_in? end From 967d30ae78d00d848a1619e2a6b20f9bdb7d9c61 Mon Sep 17 00:00:00 2001 From: Sergei Tsoganov Date: Thu, 11 Jun 2026 13:03:21 +0300 Subject: [PATCH 09/11] Prevent unverified users to login --- app/controllers/repp/v1/base_controller.rb | 15 +++++++++++++-- app/models/api_user.rb | 2 +- config/locales/registrar/authorization.en.yml | 2 ++ .../repp/v1/registrar/auth/check_info_test.rb | 9 +++++---- test/models/api_user_test.rb | 6 +++--- 5 files changed, 24 insertions(+), 10 deletions(-) diff --git a/app/controllers/repp/v1/base_controller.rb b/app/controllers/repp/v1/base_controller.rb index 04932b9afe..30aea3488a 100644 --- a/app/controllers/repp/v1/base_controller.rb +++ b/app/controllers/repp/v1/base_controller.rb @@ -89,11 +89,22 @@ def authenticate_user raise(ArgumentError) rescue NoMethodError, ArgumentError - @response = { code: 2202, message: 'Invalid authorization information', - data: { username: username, password: password, eligible_for_sign_in: user_eligible } } + @response = { + code: 2202, + message: authentication_failure_message, + data: { username: username, password: password, eligible_for_sign_in: user_eligible } + } render(json: @response, status: :unauthorized) end + def authentication_failure_message + if @current_user&.active? && !@current_user.identity_verified? + I18n.t('registrar.authorization.identity_not_verified') + else + I18n.t('registrar.authorization.invalid_authorization_information') + end + end + def check_api_ip_restriction return if webclient_request? return if @current_user.registrar.api_ip_white?(request.ip) diff --git a/app/models/api_user.rb b/app/models/api_user.rb index 4d0cd152f8..210733cb0d 100644 --- a/app/models/api_user.rb +++ b/app/models/api_user.rb @@ -47,7 +47,7 @@ def identity_verified? end def eligible_for_sign_in? - active? + active? && identity_verified? end def verification_pending? diff --git a/config/locales/registrar/authorization.en.yml b/config/locales/registrar/authorization.en.yml index 1e4395cb34..9fa6f24ebf 100644 --- a/config/locales/registrar/authorization.en.yml +++ b/config/locales/registrar/authorization.en.yml @@ -2,3 +2,5 @@ en: registrar: authorization: ip_not_allowed: Access denied from IP %{ip} + invalid_authorization_information: Invalid authorization information + identity_not_verified: Identity verification is required before signing in with a password. Sign in with eeID or complete the verification request first. diff --git a/test/integration/repp/v1/registrar/auth/check_info_test.rb b/test/integration/repp/v1/registrar/auth/check_info_test.rb index 4543800519..8f105c8421 100644 --- a/test/integration/repp/v1/registrar/auth/check_info_test.rb +++ b/test/integration/repp/v1/registrar/auth/check_info_test.rb @@ -39,15 +39,16 @@ def test_invalid_user_login assert_equal json[:message], 'Invalid authorization information' end - def test_allows_unverified_user_login_with_valid_password + def test_rejects_unverified_user_login_with_valid_password @user.update_columns(verified_at: nil) get '/repp/v1/registrar/auth', headers: @auth_headers json = JSON.parse(response.body, symbolize_names: true) - assert_response :ok - assert_equal 1000, json[:code] - assert_equal json[:data][:username], @user.username + assert_response :unauthorized + assert_equal 2202, json[:code] + assert_equal I18n.t('registrar.authorization.identity_not_verified'), json[:message] + assert_equal false, json[:data][:eligible_for_sign_in] end def test_returns_error_response_if_throttled diff --git a/test/models/api_user_test.rb b/test/models/api_user_test.rb index 179e961d6c..a424debec5 100644 --- a/test/models/api_user_test.rb +++ b/test/models/api_user_test.rb @@ -159,15 +159,15 @@ def test_linked_with_by_subject_only assert_not @user.linked_with?(nil) end - def test_eligible_for_sign_in_requires_active - @user.update_columns(active: true, subject: nil) + def test_eligible_for_sign_in_requires_active_and_verified + @user.update_columns(active: true, verified_at: Time.zone.now) assert @user.eligible_for_sign_in? @user.update_columns(active: false) assert_not @user.eligible_for_sign_in? @user.update_columns(active: true, subject: 'EE1234', verified_at: nil) - assert @user.eligible_for_sign_in? + assert_not @user.eligible_for_sign_in? end def test_subject_change_clears_verification_status_when_subject_previously_present From 90b65dbf079f3053ce186178916eb002d4de345c Mon Sep 17 00:00:00 2001 From: Sergei Tsoganov Date: Fri, 12 Jun 2026 15:33:00 +0300 Subject: [PATCH 10/11] Restrict unverified users to login and updated birthday contact verification --- .../actions/contact_approve_verification.rb | 5 +++ .../process_contact_identification_webhook.rb | 7 ++- app/models/api_user.rb | 2 +- config/locales/contacts.en.yml | 1 + config/locales/contacts.et.yml | 1 + .../repp/v1/registrar/auth/check_info_test.rb | 1 - .../contact_approve_verification_test.rb | 43 +++++++++++++++++++ ...ess_contact_identification_webhook_test.rb | 28 ++++++++++++ 8 files changed, 85 insertions(+), 3 deletions(-) diff --git a/app/interactions/actions/contact_approve_verification.rb b/app/interactions/actions/contact_approve_verification.rb index 23af850160..3c2c1eb274 100644 --- a/app/interactions/actions/contact_approve_verification.rb +++ b/app/interactions/actions/contact_approve_verification.rb @@ -53,6 +53,11 @@ def call private def birthday_attrs_from_snapshot(snapshot) + if snapshot[:id_number].to_s.strip.present? + contact.errors.add(:base, :id_number_requires_priv_contact) + return nil + end + birthdate = snapshot[:birthdate].presence || snapshot[:date_of_birth].presence name = snapshot[:name].presence || [snapshot[:given_name], snapshot[:family_name]].compact.join(' ').strip.presence diff --git a/app/interactions/actions/process_contact_identification_webhook.rb b/app/interactions/actions/process_contact_identification_webhook.rb index 78d8c32f79..4b810ca89f 100644 --- a/app/interactions/actions/process_contact_identification_webhook.rb +++ b/app/interactions/actions/process_contact_identification_webhook.rb @@ -37,6 +37,7 @@ def pending_reason def compute_pending_reason if contact.ident_type == Contact::BIRTHDAY + return :id_number_requires_priv if id_number_from_result.present? return :missing_claims if birthday_claims_incomplete? return :claim_mismatch if birthday_claim_mismatch? else @@ -87,6 +88,10 @@ def country_from_result @result[:country].to_s.strip.upcase.presence end + def id_number_from_result + @result[:id_number].to_s.strip.presence + end + def normalize(value) value.to_s.strip.upcase end @@ -113,7 +118,7 @@ def apply_pending_review!(reason) def verification_snapshot @result.slice(:sub, :given_name, :family_name, :name, :date_of_birth, :birthdate, :country, - :authentication_type).compact + :authentication_type, :id_number, :document_number).compact end end end diff --git a/app/models/api_user.rb b/app/models/api_user.rb index 210733cb0d..28561c72ae 100644 --- a/app/models/api_user.rb +++ b/app/models/api_user.rb @@ -39,7 +39,7 @@ def self.min_password_length # Must precede .validates after_commit :notify_registrar_subject_changed, on: :update scope :eligible_for_sign_in, lambda { - where(active: true) + where(active: true).where.not(verified_at: nil) } def identity_verified? diff --git a/config/locales/contacts.en.yml b/config/locales/contacts.en.yml index 6317326cbe..5f6519a498 100644 --- a/config/locales/contacts.en.yml +++ b/config/locales/contacts.en.yml @@ -34,6 +34,7 @@ en: not_pending_verification: No pending verification to approve or reject missing_subject: Identity subject is required to approve verification missing_claims: Required identity claims are missing to approve verification + id_number_requires_priv_contact: Identification returned a national ID number. Birthday contacts cannot be approved with an ID number — change the contact type to private (priv) and approve again. code: blank: Required parameter missing - code invalid: Contact code is invalid diff --git a/config/locales/contacts.et.yml b/config/locales/contacts.et.yml index b31dc93ab4..c3d3bad259 100644 --- a/config/locales/contacts.et.yml +++ b/config/locales/contacts.et.yml @@ -34,6 +34,7 @@ et: not_pending_verification: Kinnitamist ootavat identifitseerimist pole missing_subject: Isiku identifikaator on kinnitamiseks kohustuslik missing_claims: Kinnitamiseks vajalikud isikuandmed puuduvad + id_number_requires_priv_contact: Isikutuvastus tagastas isikukoodi. Sünnikuupäeva tüüpi kontakti ei saa isikukoodiga kinnitada — muutke kontakti tüübiks eraisik (priv) ja kinnitage uuesti. code: blank: Kohustuslik parameeter puudub – kood invalid: Kontakti kood on vigane diff --git a/test/integration/repp/v1/registrar/auth/check_info_test.rb b/test/integration/repp/v1/registrar/auth/check_info_test.rb index 8f105c8421..770174231d 100644 --- a/test/integration/repp/v1/registrar/auth/check_info_test.rb +++ b/test/integration/repp/v1/registrar/auth/check_info_test.rb @@ -46,7 +46,6 @@ def test_rejects_unverified_user_login_with_valid_password json = JSON.parse(response.body, symbolize_names: true) assert_response :unauthorized - assert_equal 2202, json[:code] assert_equal I18n.t('registrar.authorization.identity_not_verified'), json[:message] assert_equal false, json[:data][:eligible_for_sign_in] end diff --git a/test/interactions/actions/contact_approve_verification_test.rb b/test/interactions/actions/contact_approve_verification_test.rb index f903107382..29f59e7b83 100644 --- a/test/interactions/actions/contact_approve_verification_test.rb +++ b/test/interactions/actions/contact_approve_verification_test.rb @@ -31,4 +31,47 @@ class Actions::ContactApproveVerificationTest < ActiveSupport::TestCase assert_not Actions::ContactApproveVerification.new(@contact).call assert @contact.errors.added?(:base, :missing_subject) end + + test 'approve fails for birthday contact when snapshot contains id_number' do + @contact.update!( + ident_type: Contact::BIRTHDAY, + ident: '2010-07-05', + ident_country_code: 'EE', + verification_snapshot: { + 'date_of_birth' => '2010-07-05', + 'given_name' => 'Child', + 'family_name' => 'Example', + 'country' => 'EE', + 'id_number' => '30303039914' + } + ) + + assert_not Actions::ContactApproveVerification.new(@contact).call + assert @contact.errors.added?(:base, :id_number_requires_priv_contact) + assert_nil @contact.reload.verified_at + end + + test 'approve succeeds for birthday contact without id_number in snapshot' do + @contact.update!( + ident_type: Contact::BIRTHDAY, + ident: '2010-07-05', + ident_country_code: 'EE', + name: 'Child Example', + verification_snapshot: { + 'date_of_birth' => '2010-07-05', + 'given_name' => 'Child', + 'family_name' => 'Example', + 'country' => 'EE', + 'document_number' => 'AB123456' + } + ) + + assert Actions::ContactApproveVerification.new(@contact).call + + @contact.reload + assert @contact.verified_at.present? + assert_equal '2010-07-05', @contact.ident + assert_equal 'EE', @contact.ident_country_code + assert_equal 'Child Example', @contact.name + end end diff --git a/test/interactions/actions/process_contact_identification_webhook_test.rb b/test/interactions/actions/process_contact_identification_webhook_test.rb index ad757a86af..19f29496b1 100644 --- a/test/interactions/actions/process_contact_identification_webhook_test.rb +++ b/test/interactions/actions/process_contact_identification_webhook_test.rb @@ -39,4 +39,32 @@ class Actions::ProcessContactIdentificationWebhookTest < ActiveSupport::TestCase assert @contact.verification_pending_at.present? assert_equal 'US9999', @contact.verification_snapshot['sub'] end + + test 'pending review for birthday contact when result contains id_number' do + @contact.update!( + ident_type: Contact::BIRTHDAY, + ident: '2010-07-05', + ident_country_code: 'EE', + name: 'Child Example' + ) + + action = Actions::ProcessContactIdentificationWebhook.new( + @contact, + identification_request_id: '125', + result: { + date_of_birth: '2010-07-05', + given_name: 'Child', + family_name: 'Example', + country: 'EE', + id_number: '30303039914' + } + ) + + assert action.call + assert_equal :pending_review, action.outcome + + @contact.reload + assert @contact.verification_pending_at.present? + assert_equal '30303039914', @contact.verification_snapshot['id_number'] + end end From 8c4a063f155eda3c78a9b63cf03715a8a7e170ec Mon Sep 17 00:00:00 2001 From: Sergei Tsoganov Date: Tue, 16 Jun 2026 09:42:08 +0300 Subject: [PATCH 11/11] Updated translations --- config/locales/contacts.en.yml | 2 +- config/locales/contacts.et.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config/locales/contacts.en.yml b/config/locales/contacts.en.yml index 5f6519a498..489c3d5b40 100644 --- a/config/locales/contacts.en.yml +++ b/config/locales/contacts.en.yml @@ -34,7 +34,7 @@ en: not_pending_verification: No pending verification to approve or reject missing_subject: Identity subject is required to approve verification missing_claims: Required identity claims are missing to approve verification - id_number_requires_priv_contact: Identification returned a national ID number. Birthday contacts cannot be approved with an ID number — change the contact type to private (priv) and approve again. + id_number_requires_priv_contact: Identification returned a national ID number. Birthday contacts cannot be approved with an ID number code: blank: Required parameter missing - code invalid: Contact code is invalid diff --git a/config/locales/contacts.et.yml b/config/locales/contacts.et.yml index c3d3bad259..d3acbe9c79 100644 --- a/config/locales/contacts.et.yml +++ b/config/locales/contacts.et.yml @@ -34,7 +34,7 @@ et: not_pending_verification: Kinnitamist ootavat identifitseerimist pole missing_subject: Isiku identifikaator on kinnitamiseks kohustuslik missing_claims: Kinnitamiseks vajalikud isikuandmed puuduvad - id_number_requires_priv_contact: Isikutuvastus tagastas isikukoodi. Sünnikuupäeva tüüpi kontakti ei saa isikukoodiga kinnitada — muutke kontakti tüübiks eraisik (priv) ja kinnitage uuesti. + id_number_requires_priv_contact: Isikutuvastus tagastas isikukoodi. Sünnikuupäeva tüüpi kontakti ei saa isikukoodiga kinnitada code: blank: Kohustuslik parameeter puudub – kood invalid: Kontakti kood on vigane
<%= ApiUser.human_attribute_name :username %><%= ApiUser.human_attribute_name :active %><%= ApiUser.human_attribute_name :username %><%= t('.verification_status') %><%= ApiUser.human_attribute_name :active %>
<%= link_to api_user, admin_registrar_api_user_path(registrar, api_user) %> + <% if api_user.verified_at.present? %> + <%= t('.verified') %> + <% elsif api_user.verification_pending_at.present? %> + <%= t('.pending_review') %> + <% elsif api_user.ident_request_sent_at.present? %> + <%= t('.requested') %> + <% else %> + <%= t('.unverified') %> + <% end %> + <%= api_user.active %>