Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,5 @@ CLAUDE.md
AGENT.md
own
AGENT.md

/docs/
.repowise
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
10.08.2026
* Registrars can now cancel a pending registrant change https://github.com/internetee/registry/issues/2939

23.07.2026
* Operations with pending status now return result code 1001 in REPP https://github.com/internetee/registry/issues/2940
* Fixed case sensitivity issue for REPP requests https://github.com/internetee/registry/issues/2943
Expand Down
36 changes: 36 additions & 0 deletions app/controllers/epp/domains_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ class DomainsController < BaseController
THROTTLED_ACTIONS = %i[info create check renew update transfer delete].freeze
include Shunter::Integration::Throttle

# Everything Deserializers::Xml::DomainUpdate can emit besides the registrant itself.
# :domain and :registrar_id are always present, :legal_document may be mandatory for
# the registrar even when cancelling.
UPDATE_KEYS_BESIDES_REGISTRANT = %i[contacts nameservers dns_keys statuses transfer_code
reserved_pw].freeze

def info
authorize! :info, @domain

Expand Down Expand Up @@ -47,6 +53,9 @@ def update
registrar_id = current_user.registrar.id
update_params = ::Deserializers::Xml::DomainUpdate.new(params[:parsed_frame],
registrar_id).call

return cancel_pending_update if cancels_pending_update?(update_params)

action = Actions::DomainUpdate.new(@domain, update_params, false)
unless action.call
handle_errors(@domain)
Expand Down Expand Up @@ -134,6 +143,33 @@ def transfer

private

# EPP has no command to manipulate pending operations (RFC 3731 covers transfer only),
# so a domain:update requesting the registrant the domain already has is treated as a
# request to cancel the pending registrant change. Same idea as domain:renew cancelling
# pendingDelete in Epp::Domain#renew.
def cancels_pending_update?(update_params)
return false unless @domain.pending_update?
return false if update_params[:registrant].blank?

requested = Registrant.find_by(code: update_params[:registrant][:code])
return false unless requested&.id == @domain.registrant_id

(update_params.keys & UPDATE_KEYS_BESIDES_REGISTRANT).empty?
end

def cancel_pending_update
result = ::Domains::CancelPendingUpdate.run(domain: @domain,
initiator: current_user.username)
unless result.valid?
@domain.add_epp_error('2304', 'status', DomainStatus::PENDING_UPDATE,
result.errors.full_messages.join(', '))
handle_errors(@domain)
return
end

render_epp_response('/epp/domains/success')
end

def validate_info
@prefix = 'info > info >'
requires('name')
Expand Down
29 changes: 29 additions & 0 deletions app/controllers/repp/v1/domains/pending_updates_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
module Repp
module V1
module Domains
class PendingUpdatesController < BaseController
before_action :set_domain

THROTTLED_ACTIONS = %i[destroy].freeze
include Shunter::Integration::Throttle

api :DELETE, '/repp/v1/domains/:domain_name/pending_update'
param :domain_name, String, desc: 'Domain name'
desc 'Cancel a pending registrant change of a specific domain'
def destroy
authorize!(:update, @domain)

result = ::Domains::CancelPendingUpdate.run(domain: @domain,
initiator: current_user.username)
unless result.valid?
@domain.add_epp_error('2304', 'status', DomainStatus::PENDING_UPDATE,
result.errors.full_messages.join(', '))
return handle_errors(@domain)
end

render_success(data: { domain: { name: @domain.name } })
end
end
end
end
end
52 changes: 52 additions & 0 deletions app/interactions/domains/cancel_pending_update.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
module Domains
class CancelPendingUpdate < ActiveInteraction::Base
object :domain,
class: Domain,
description: 'Domain with a pending registrant change'
string :initiator,
default: nil

validate :domain_has_pending_update

def execute
::PaperTrail.request.whodunnit = "interaction - #{self.class.name} - cancelled by"\
" #{initiator}"

ActiveRecord::Base.transaction do
notify_registrants
clean_pendings!
end

UpdateWhoisRecordJob.perform_later(domain.name, 'domain')
end

private

def domain_has_pending_update
return if domain&.pending_update?

errors.add(:domain, I18n.t(:object_status_prohibits_operation))
end

# Both parties already got a confirmation link that is about to become invalid,
# so they are notified before the verification data is wiped.
def notify_registrants
RegistrantChangeMailer.cancelled(domain: domain,
registrar: domain.registrar,
registrant: domain.registrant,
send_to: [domain.new_registrant_email,
domain.registrant.email]).deliver_later
end

def clean_pendings!
domain.is_admin = true
# Has to happen before save, otherwise before_update reinstates pendingUpdate
domain.registrant_verification_token = nil
domain.registrant_verification_asked_at = nil
domain.pending_json = {}
domain.statuses.delete(DomainStatus::PENDING_UPDATE)
domain.status_notes[DomainStatus::PENDING_UPDATE] = ''
domain.save!
end
end
end
4 changes: 4 additions & 0 deletions app/interactions/domains/update_confirm/process_action.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ module Domains
module UpdateConfirm
class ProcessAction < Base
def execute
# The registrant decision arrives asynchronously, so the pending update may already
# be gone by now - cancelled by the registrar or cleaned up by the expiry cron.
return unless domain.pending_update?

::PaperTrail.request.whodunnit = "interaction - #{self.class.name} - #{action} by"\
" #{initiator}"

Expand Down
9 changes: 9 additions & 0 deletions app/mailers/registrant_change_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ def rejected(domain:, registrar:, registrant:)
mail(to: domain.new_registrant_email, subject: subject)
end

def cancelled(domain:, registrar:, registrant:, send_to:)
@domain = DomainPresenter.new(domain: domain, view: view_context)
@registrar = RegistrarPresenter.new(registrar: registrar, view: view_context)
@registrant = RegistrantPresenter.new(registrant: registrant, view: view_context)

subject = default_i18n_subject(domain_name: domain.name)
mail(to: send_to, subject: subject)
end

def expired(domain:, registrar:, registrant:, send_to:)
@domain = DomainPresenter.new(domain: domain, view: view_context)
@registrar = RegistrarPresenter.new(registrar: registrar, view: view_context)
Expand Down
18 changes: 18 additions & 0 deletions app/views/mailers/registrant_change_mailer/cancelled.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
Tere
<br><br>
Registripidaja tühistas domeeni <%= @domain.name %> registreerija vahetuse taotluse. Varem saadetud kinnituslink ei kehti enam.
<br><br>
Küsimuste korral palun võtke ühendust oma registripidajaga:

<%= render 'mailers/shared/registrar/registrar.et.html', registrar: @registrar %>
<%= render 'mailers/shared/signatures/signature.et.html' %>
<hr>
<br><br>
Hi,
<br><br>
The registrar has cancelled the registrant change request for the domain <%= @domain.name %>. The confirmation link sent earlier is no longer valid.
<br><br>
Please contact your registrar if you have any questions:

<%= render 'mailers/shared/registrar/registrar.en.html', registrar: @registrar %>
<%= render 'mailers/shared/signatures/signature.en.html' %>
20 changes: 20 additions & 0 deletions app/views/mailers/registrant_change_mailer/cancelled.text.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
Tere

Registripidaja tühistas domeeni <%= @domain.name %> registreerija vahetuse taotluse. Varem saadetud kinnituslink ei kehti enam.

Küsimuste korral palun võtke ühendust oma registripidajaga:

<%= render 'mailers/shared/registrar/registrar.et.text', registrar: @registrar %>
<%= render 'mailers/shared/signatures/signature.et.text' %>

--------------------------------------

Hi,

The registrar has cancelled the registrant change request for the domain <%= @domain.name %>. The confirmation link sent earlier is no longer valid.

Please contact your registrar if you have any questions:

<%= render 'mailers/shared/registrar/registrar.en.text', registrar: @registrar %>

<%= render 'mailers/shared/signatures/signature.en.text' %>
6 changes: 5 additions & 1 deletion config/locales/mailers/registrant_change.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,8 @@ en:
expired:
subject: >-
Domeeni %{domain_name} registreerija vahetuse taotlus on tühistatud
/ %{domain_name} registrant change cancelled
/ %{domain_name} registrant change cancelled
cancelled:
subject: >-
Registripidaja tühistas domeeni %{domain_name} registreerija vahetuse taotluse
/ %{domain_name} registrant change request was cancelled by the registrar
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@
resources :renew, only: %i[create], constraints: { id: /.*/ }, controller: 'domains/renews'
resources :transfer, only: %i[create], constraints: { id: /.*/ }, controller: 'domains/transfers'
resources :statuses, only: %i[update destroy], constraints: { id: /.*/ }, controller: 'domains/statuses'
resource :pending_update, only: %i[destroy], controller: 'domains/pending_updates'
match 'dnssec', to: 'domains/dnssec#destroy', via: 'delete', defaults: { id: nil }
match 'contacts', to: 'domains/contacts#destroy', via: 'delete', defaults: { id: nil }
collection do
Expand Down
129 changes: 129 additions & 0 deletions test/integration/epp/domain/update/cancel_pending_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
require 'test_helper'

class EppDomainUpdateCancelPendingTest < EppTestCase
include ActionMailer::TestHelper
include ActiveJob::TestHelper

setup do
@domain = domains(:shop)
@original_registrant_change_verification =
Setting.request_confirmation_on_registrant_change_enabled
Setting.request_confirmation_on_registrant_change_enabled = true
ActionMailer::Base.deliveries.clear

adapter = ENV['shunter_default_adapter'].constantize.new
adapter&.clear!
end

teardown do
Setting.request_confirmation_on_registrant_change_enabled =
@original_registrant_change_verification
end

def test_cancels_pending_update_when_current_registrant_is_requested_again
request_registrant_change
old_registrant = @domain.registrant

post_domain_update(old_registrant)

assert_epp_response :completed_successfully
assert_equal old_registrant, @domain.registrant
assert_not_includes @domain.statuses, DomainStatus::PENDING_UPDATE
assert_empty @domain.pending_json
assert_not @domain.registrant_verification_asked?
end

def test_notifies_both_registrants_when_pending_update_is_cancelled
request_registrant_change
new_registrant_email = @domain.new_registrant_email
registrant_email = @domain.registrant.email
ActionMailer::Base.deliveries.clear

perform_enqueued_jobs { post_domain_update(@domain.registrant) }

email = ActionMailer::Base.deliveries.last
assert_includes email.to, new_registrant_email
assert_includes email.to, registrant_email
end

def test_rejects_update_of_pending_domain_when_another_registrant_is_requested
request_registrant_change
old_registrant = @domain.registrant

post_domain_update(contacts(:jack))

assert_epp_response :object_status_prohibits_operation
assert_equal old_registrant, @domain.registrant
assert_includes @domain.statuses, DomainStatus::PENDING_UPDATE
end

def test_does_not_cancel_pending_update_when_other_changes_are_requested
request_registrant_change
old_transfer_code = @domain.transfer_code

post_domain_update(@domain.registrant, transfer_code: 'new-transfer-code')

assert_epp_response :object_status_prohibits_operation
assert_equal old_transfer_code, @domain.transfer_code
assert_includes @domain.statuses, DomainStatus::PENDING_UPDATE
end

def test_keeps_regular_update_intact_when_domain_has_no_pending_update
assert_not_includes @domain.statuses, DomainStatus::PENDING_UPDATE

post_domain_update(@domain.registrant)

assert_epp_response :completed_successfully
assert_not_includes @domain.statuses, DomainStatus::PENDING_UPDATE
end

private

def request_registrant_change
new_registrant = contacts(:william)
assert_not_equal new_registrant, @domain.registrant

post_domain_update(new_registrant)

assert_epp_response :completed_successfully_action_pending
assert_includes @domain.statuses, DomainStatus::PENDING_UPDATE
end

def post_domain_update(registrant, transfer_code: nil)
post epp_update_path,
params: { frame: registrant_change_xml(registrant, transfer_code: transfer_code) },
headers: { 'HTTP_COOKIE' => 'session=api_bestnames' }

# assert_epp_response memoizes the parsed response, reset it between requests
@epp_response = nil
@domain.reload
end

def registrant_change_xml(registrant, transfer_code: nil)
auth_info = if transfer_code
"<domain:authInfo><domain:pw>#{transfer_code}</domain:pw></domain:authInfo>"
end

<<-XML
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<epp xmlns="#{Xsd::Schema.filename(for_prefix: 'epp-ee', for_version: '1.0')}">
<command>
<update>
<domain:update xmlns:domain="#{Xsd::Schema.filename(for_prefix: 'domain-ee', for_version: '1.2')}">
<domain:name>#{@domain.name}</domain:name>
<domain:chg>
<domain:registrant verified="no">#{registrant.code}</domain:registrant>
#{auth_info}
</domain:chg>
</domain:update>
</update>
<extension>
<eis:extdata xmlns:eis="#{Xsd::Schema.filename(for_prefix: 'eis', for_version: '1.0')}">
<eis:legalDocument type="pdf">#{'test' * 2000}</eis:legalDocument>
</eis:extdata>
</extension>
</command>
</epp>
XML
end
end
Loading
Loading