diff --git a/app/models/ability.rb b/app/models/ability.rb index baa26e4cb..caca24524 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -12,7 +12,7 @@ class Ability @user.roles&.each { |role| send(role) } when 'ApiUser' @user.roles&.each { |role| send(role) } - when 'RegistrantUser' + when 'RegistrantUser' static_registrant end diff --git a/app/models/action.rb b/app/models/action.rb index ac5ee7f72..12435f3f0 100644 --- a/app/models/action.rb +++ b/app/models/action.rb @@ -2,18 +2,37 @@ class Action < ApplicationRecord has_paper_trail versions: { class_name: 'Version::ActionVersion' } belongs_to :user - belongs_to :contact + belongs_to :contact, optional: true + has_many :subactions, class_name: 'Action', foreign_key: 'bulk_action_id', dependent: :destroy + belongs_to :bulk_action, class_name: 'Action', optional: true validates :operation, inclusion: { in: proc { |action| action.class.valid_operations } } class << self def valid_operations - %w[update] + %w[update bulk_update] end end def notification_key - raise 'Action object is missing' unless contact + raise 'Action object is missing' unless bulk_action? || contact + "contact_#{operation}".to_sym end + + def bulk_action? + !!subactions.exists? + end + + def to_non_available_contact_codes + return [] unless bulk_action? + + subactions.map do |a| + { + code: a.contact&.code, + avail: 0, + reason: 'in use', + } + end + end end diff --git a/app/models/bulk_action.rb b/app/models/bulk_action.rb new file mode 100644 index 000000000..1a8cad771 --- /dev/null +++ b/app/models/bulk_action.rb @@ -0,0 +1,2 @@ +class BulkAction < Action +end \ No newline at end of file diff --git a/app/models/epp/contact.rb b/app/models/epp/contact.rb index 614be201b..35691d789 100644 --- a/app/models/epp/contact.rb +++ b/app/models/epp/contact.rb @@ -47,7 +47,7 @@ class Epp::Contact < Contact codes = codes.map { |c| c.include?(':') ? c : "#{reg}:#{c}" } res = [] - codes.map { |c| c.include?(':') ? c : "#{reg}:#{c}" }.map { |c| c.strip.upcase }.each do |x| + codes.map { |c| c.strip.upcase }.each do |x| c = find_by_epp_code(x) res << (c ? { code: c.code, avail: 0, reason: 'in use' } : { code: x, avail: 1 }) end diff --git a/app/models/notification.rb b/app/models/notification.rb index 07e824367..c9af66c56 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -2,7 +2,7 @@ class Notification < ApplicationRecord include Versions # version/notification_version.rb belongs_to :registrar - belongs_to :action + belongs_to :action, optional: true scope :unread, -> { where(read: false) } diff --git a/app/models/registrant_user.rb b/app/models/registrant_user.rb index 80b8ecab9..a1f6993af 100644 --- a/app/models/registrant_user.rb +++ b/app/models/registrant_user.rb @@ -112,13 +112,17 @@ class RegistrantUser < User end def update_related_contacts - contacts = Contact.where(ident: ident, ident_country_code: country.alpha2) + grouped_contacts = Contact.where(ident: ident, ident_country_code: country.alpha2) .where('UPPER(name) != UPPER(?)', username) - - contacts.each do |contact| - contact.update(name: username) - action = actions.create!(contact: contact, operation: :update) - contact.registrar.notify(action) + .includes(:registrar).group_by { |c| c.registrar } + grouped_contacts.each do |registrar, contacts| + bulk_action, action = actions.create!(operation: :bulk_update) if contacts.size > 1 + contacts.each do |c| + if c.update(name: username) + action = actions.create!(contact: c, operation: :update, bulk_action_id: bulk_action&.id) + end + end + registrar.notify(bulk_action || action) end end diff --git a/app/models/registrar.rb b/app/models/registrar.rb index 8517bd6fe..d7ba62306 100644 --- a/app/models/registrar.rb +++ b/app/models/registrar.rb @@ -218,8 +218,15 @@ class Registrar < ApplicationRecord end def notify(action) - text = I18n.t("notifications.texts.#{action.notification_key}", contact: action.contact.code) - notifications.create!(text: text) + text = I18n.t("notifications.texts.#{action.notification_key}", contact: action.contact&.code, + count: action.subactions&.count) + if action.bulk_action? + notifications.create!(text: text, action_id: action.id, + attached_obj_type: 'BulkAction', + attached_obj_id: action.id) + else + notifications.create!(text: text) + end end def e_invoice_iban diff --git a/app/views/epp/contacts/check.xml.builder b/app/views/epp/contacts/check.xml.builder index 6b4ea4cc7..f3b1f555a 100644 --- a/app/views/epp/contacts/check.xml.builder +++ b/app/views/epp/contacts/check.xml.builder @@ -5,15 +5,7 @@ xml.epp_head do end xml.resData do - xml.tag!('contact:chkData', 'xmlns:contact' => - Xsd::Schema.filename(for_prefix: 'contact-ee', for_version: '1.1')) do - @results.each do |result| - xml.tag!('contact:cd') do - xml.tag! "contact:id", result[:code], avail: result[:avail] - xml.tag!('contact:reason', result[:reason]) unless result[:avail] == 1 - end - end - end + xml << render('epp/contacts/partials/check', builder: xml, results: @results) end render('epp/shared/trID', builder: xml) diff --git a/app/views/epp/contacts/partials/_check.xml.builder b/app/views/epp/contacts/partials/_check.xml.builder new file mode 100644 index 000000000..70bb1f4e3 --- /dev/null +++ b/app/views/epp/contacts/partials/_check.xml.builder @@ -0,0 +1,9 @@ +builder.tag!('contact:chkData', 'xmlns:contact' => + Xsd::Schema.filename(for_prefix: 'contact-ee', for_version: '1.1')) do + results.each do |result| + builder.tag!('contact:cd') do + builder.tag! 'contact:id', result[:code], avail: result[:avail] + # builder.tag!('contact:reason', result[:reason]) unless result[:avail] == 1 + end + end +end diff --git a/app/views/epp/poll/_action.xml.builder b/app/views/epp/poll/_action.xml.builder index dc0adb4e4..838ae9aaf 100644 --- a/app/views/epp/poll/_action.xml.builder +++ b/app/views/epp/poll/_action.xml.builder @@ -1,9 +1,12 @@ builder.extension do builder.tag!('changePoll:changeData', - 'xmlns:changePoll' => Xsd::Schema.filename(for_prefix: 'changePoll')) do + 'xmlns:changePoll' => Xsd::Schema.filename(for_prefix: 'changePoll', for_version: '1.0')) do builder.tag!('changePoll:operation', action.operation) builder.tag!('changePoll:date', action.created_at.utc.xmlschema) builder.tag!('changePoll:svTRID', action.id) builder.tag!('changePoll:who', action.user) + if action.bulk_action? + builder.tag!('changePoll:reason', 'Auto-update according to official data') + end end end diff --git a/app/views/epp/poll/poll_req.xml.builder b/app/views/epp/poll/poll_req.xml.builder index a1f12fd65..f6688fb48 100644 --- a/app/views/epp/poll/poll_req.xml.builder +++ b/app/views/epp/poll/poll_req.xml.builder @@ -9,13 +9,24 @@ xml.epp_head do xml.msg @notification.text end - if @notification.attached_obj_type == 'DomainTransfer' && @object - xml.resData do - xml << render('epp/domains/partials/transfer', builder: xml, dt: @object) + if @object + case @notification.attached_obj_type + when 'DomainTransfer' + xml.resData do + xml << render('epp/domains/partials/transfer', builder: xml, dt: @object) + end + when 'BulkAction' + xml.resData do + xml << render( + 'epp/contacts/partials/check', + builder: xml, + results: @object.to_non_available_contact_codes + ) + end end end - if @notification.action&.contact || @notification.registry_lock? + if @notification.action || @notification.registry_lock? if @notification.registry_lock? state = @notification.text.include?('unlocked') ? 'unlock' : 'lock' xml.extension do diff --git a/config/locales/notifications.en.yml b/config/locales/notifications.en.yml index b5c1dfd47..3bd65ea7e 100644 --- a/config/locales/notifications.en.yml +++ b/config/locales/notifications.en.yml @@ -6,6 +6,7 @@ en: It was associated with registrant %{old_registrant_code} and contacts %{old_contacts_codes}. contact_update: Contact %{contact} has been updated by registrant + contact_bulk_update: '%{count} contacts have been updated by registrant' csync: CSYNC DNSSEC %{action} for domain %{domain} registrar_locked: Domain %{domain_name} has been locked by registrant registrar_unlocked: Domain %{domain_name} has been unlocked by registrant diff --git a/db/migrate/20220316140727_add_bulk_actions.rb b/db/migrate/20220316140727_add_bulk_actions.rb new file mode 100644 index 000000000..1eae94220 --- /dev/null +++ b/db/migrate/20220316140727_add_bulk_actions.rb @@ -0,0 +1,5 @@ +class AddBulkActions < ActiveRecord::Migration[6.1] + def change + add_column :actions, :bulk_action_id, :integer, default: nil + end +end diff --git a/db/structure.sql b/db/structure.sql index 0c8420f43..f55203bb3 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -304,7 +304,8 @@ CREATE TABLE public.actions ( user_id integer, operation character varying NOT NULL, created_at timestamp without time zone, - contact_id integer + contact_id integer, + bulk_action_id integer ); @@ -5400,10 +5401,14 @@ INSERT INTO "schema_migrations" (version) VALUES ('20211126085139'), ('20211231113934'), ('20220106123143'), +<<<<<<< HEAD ('20220113201642'), ('20220113220809'), ('20220124105717'), ('20220216113112'), ('20220228093211'); +======= +('20220316140727'); +>>>>>>> f98598620 (Moved notifications about automatic contact name update to bulk change poll message) diff --git a/test/fixtures/actions.yml b/test/fixtures/actions.yml index 46736e0a1..b802679ba 100644 --- a/test/fixtures/actions.yml +++ b/test/fixtures/actions.yml @@ -2,4 +2,24 @@ contact_update: operation: update contact: john created_at: <%= Time.zone.parse('2010-07-05').to_s(:db) %> - user: registrant \ No newline at end of file + user: registrant + +contacts_update_bulk_action: + operation: bulk_update + user: registrant + +contact_update_subaction_one: + operation: update + contact: william + created_at: <%= Time.zone.parse('2010-07-05').to_s(:db) %> + user: registrant + bulk_action: contacts_update_bulk_action + +contact_update_subaction_two: + operation: update + contact: jane + created_at: <%= Time.zone.parse('2010-07-05').to_s(:db) %> + user: registrant + bulk_action: contacts_update_bulk_action + + diff --git a/test/integration/epp/contact/check/base_test.rb b/test/integration/epp/contact/check/base_test.rb index f1b9f4d16..6ad027fc6 100644 --- a/test/integration/epp/contact/check/base_test.rb +++ b/test/integration/epp/contact/check/base_test.rb @@ -76,7 +76,7 @@ class EppContactCheckBaseTest < EppTestCase response_xml = Nokogiri::XML(response.body) assert_correct_against_schema response_xml assert_equal '0', response_xml.at_xpath('//contact:id', contact: xml_schema)['avail'] - assert_equal 'in use', response_xml.at_xpath('//contact:reason', contact: xml_schema).text + # assert_equal 'in use', response_xml.at_xpath('//contact:reason', contact: xml_schema).text end def test_multiple_contacts @@ -127,7 +127,7 @@ class EppContactCheckBaseTest < EppTestCase assert_correct_against_schema response_xml assert_epp_response :completed_successfully assert_equal "#{@contact.registrar.code}:JOHN-001".upcase, response_xml.at_xpath('//contact:id', contact: xml_schema).text - assert_equal 'in use', response_xml.at_xpath('//contact:reason', contact: xml_schema).text + # assert_equal 'in use', response_xml.at_xpath('//contact:reason', contact: xml_schema).text end def test_check_contact_without_prefix @@ -154,7 +154,7 @@ class EppContactCheckBaseTest < EppTestCase assert_correct_against_schema response_xml assert_epp_response :completed_successfully assert_equal "#{@contact.registrar.code}:JOHN-001".upcase, response_xml.at_xpath('//contact:id', contact: xml_schema).text - assert_equal 'in use', response_xml.at_xpath('//contact:reason', contact: xml_schema).text + # assert_equal 'in use', response_xml.at_xpath('//contact:reason', contact: xml_schema).text end private diff --git a/test/integration/epp/poll_test.rb b/test/integration/epp/poll_test.rb index 5cdb7e524..29c24af26 100644 --- a/test/integration/epp/poll_test.rb +++ b/test/integration/epp/poll_test.rb @@ -7,16 +7,8 @@ class EppPollTest < EppTestCase # Deliberately does not conform to RFC5730, which requires the first notification to be returned def test_return_latest_notification_when_queue_is_not_empty - request_xml = <<-XML - - - - - - - XML - post epp_poll_path, params: { frame: request_xml }, - headers: { 'HTTP_COOKIE' => 'session=api_bestnames' } + post epp_poll_path, params: { frame: request_req_xml }, + headers: { 'HTTP_COOKIE' => 'session=api_bestnames' } xml_doc = Nokogiri::XML(response.body) assert_epp_response :completed_successfully_ack_to_dequeue @@ -30,17 +22,9 @@ class EppPollTest < EppTestCase version = Version::DomainVersion.last @notification.update(attached_obj_type: 'DomainVersion', attached_obj_id: version.id) - request_xml = <<-XML - - - - - - - XML assert_nothing_raised do - post epp_poll_path, params: { frame: request_xml }, - headers: { 'HTTP_COOKIE' => 'session=api_bestnames' } + post epp_poll_path, params: { frame: request_req_xml }, + headers: { 'HTTP_COOKIE' => 'session=api_bestnames' } end xml_doc = Nokogiri::XML(response.body) @@ -54,19 +38,11 @@ class EppPollTest < EppTestCase def test_return_action_data_when_present @notification.update!(action: actions(:contact_update)) - request_xml = <<-XML - - - - - - - XML - post epp_poll_path, params: { frame: request_xml }, - headers: { 'HTTP_COOKIE' => 'session=api_bestnames' } + post epp_poll_path, params: { frame: request_req_xml }, + headers: { 'HTTP_COOKIE' => 'session=api_bestnames' } xml_doc = Nokogiri::XML(response.body) - namespace = Xsd::Schema.filename(for_prefix: 'changePoll') + namespace = Xsd::Schema.filename(for_prefix: 'changePoll', for_version: '1.0') assert_equal 'update', xml_doc.xpath('//changePoll:operation', 'changePoll' => namespace).text assert_equal Time.zone.parse('2010-07-05').utc.xmlschema, xml_doc.xpath('//changePoll:date', 'changePoll' => namespace).text @@ -76,19 +52,35 @@ class EppPollTest < EppTestCase 'changePoll' => namespace).text end + def test_return_notifcation_with_bulk_action_data + bulk_action = actions(:contacts_update_bulk_action) + @notification.update!(action: bulk_action, + attached_obj_id: bulk_action.id, + attached_obj_type: 'BulkAction') + + post epp_poll_path, params: { frame: request_req_xml }, + headers: { 'HTTP_COOKIE' => 'session=api_bestnames' } + + xml_doc = Nokogiri::XML(response.body) + namespace = Xsd::Schema.filename(for_prefix: 'changePoll', for_version: '1.0') + + assert_equal 2, xml_doc.xpath('//contact:cd', contact: xml_schema).size + assert_epp_response :completed_successfully_ack_to_dequeue + assert_equal 'bulk_update', xml_doc.xpath('//changePoll:operation', + 'changePoll' => namespace).text + assert_equal @notification.action.id.to_s, xml_doc.xpath('//changePoll:svTRID', + 'changePoll' => namespace).text + assert_equal 'Registrant User', xml_doc.xpath('//changePoll:who', + 'changePoll' => namespace).text + assert_equal 'Auto-update according to official data', + xml_doc.xpath('//changePoll:reason', 'changePoll' => namespace).text + end + def test_no_notifications registrars(:bestnames).notifications.delete_all(:delete_all) - request_xml = <<-XML - - - - - - - XML - post epp_poll_path, params: { frame: request_xml }, - headers: { 'HTTP_COOKIE' => 'session=api_bestnames' } + post epp_poll_path, params: { frame: request_req_xml }, + headers: { 'HTTP_COOKIE' => 'session=api_bestnames' } assert_epp_response :completed_successfully_no_messages end @@ -106,7 +98,7 @@ class EppPollTest < EppTestCase XML post epp_poll_path, params: { frame: request_xml }, - headers: { 'HTTP_COOKIE' => 'session=api_bestnames' } + headers: { 'HTTP_COOKIE' => 'session=api_bestnames' } notification.reload xml_doc = Nokogiri::XML(response.body) @@ -128,7 +120,7 @@ class EppPollTest < EppTestCase XML post epp_poll_path, params: { frame: request_xml }, - headers: { 'HTTP_COOKIE' => 'session=api_bestnames' } + headers: { 'HTTP_COOKIE' => 'session=api_bestnames' } notification.reload assert notification.unread? @@ -145,13 +137,22 @@ class EppPollTest < EppTestCase XML post epp_poll_path, params: { frame: request_xml }, - headers: { 'HTTP_COOKIE' => 'session=api_bestnames' } + headers: { 'HTTP_COOKIE' => 'session=api_bestnames' } assert_epp_response :object_does_not_exist end def test_anonymous_user_cannot_access - request_xml = <<-XML + post '/epp/command/poll', params: { frame: request_req_xml }, + headers: { 'HTTP_COOKIE' => 'session=non-existent' } + + assert_epp_response :authorization_error + end + + private + + def request_req_xml + <<-XML @@ -159,10 +160,9 @@ class EppPollTest < EppTestCase XML + end - post '/epp/command/poll', params: { frame: request_xml }, - headers: { 'HTTP_COOKIE' => 'session=non-existent' } - - assert_epp_response :authorization_error + def xml_schema + Xsd::Schema.filename(for_prefix: 'contact-ee', for_version: '1.1') end end diff --git a/test/models/registrant_user/registrant_user_creation_test.rb b/test/models/registrant_user/registrant_user_creation_test.rb index 9fff4ca02..f183c94dc 100644 --- a/test/models/registrant_user/registrant_user_creation_test.rb +++ b/test/models/registrant_user/registrant_user_creation_test.rb @@ -7,34 +7,40 @@ class RegistrantUserCreationTest < ActiveSupport::TestCase first_name: 'JOHN', last_name: 'SMITH' } - - RegistrantUser.find_or_create_by_api_data(user_data) + assert_difference 'RegistrantUser.count' do + RegistrantUser.find_or_create_by_api_data(user_data) + end user = User.find_by(registrant_ident: 'EE-37710100070') assert_equal('JOHN SMITH', user.username) end - def test_find_or_create_by_api_data_creates_a_user_with_original_name + def test_find_or_create_by_api_data_updates_a_user_with_existing_ident user_data = { - ident: '37710100070', + ident: '1234', + country_code: 'US', first_name: 'John', - last_name: 'Smith' + last_name: 'Smith', } + assert_no_difference 'RegistrantUser.count' do + RegistrantUser.find_or_create_by_api_data(user_data) + end - RegistrantUser.find_or_create_by_api_data(user_data) - - user = User.find_by(registrant_ident: 'EE-37710100070') + user = User.find_by(registrant_ident: 'US-1234') assert_equal('John Smith', user.username) end - def test_updates_related_contacts_name_if_differs_from_e_identity - contact = contacts(:john) - contact.update(ident: '39708290276', ident_country_code: 'EE') + def test_updates_related_contacts_name_if_different_from_e_identity + registrars = [registrars(:bestnames), registrars(:goodnames)] + contacts = [contacts(:john), contacts(:william), contacts(:identical_to_william)] + contacts.each do |c| + c.update(ident: '39708290276', ident_country_code: 'EE') + end user_data = { ident: '39708290276', first_name: 'John', - last_name: 'Doe' + last_name: 'Doe', } RegistrantUser.find_or_create_by_api_data(user_data) @@ -42,7 +48,28 @@ class RegistrantUserCreationTest < ActiveSupport::TestCase user = User.find_by(registrant_ident: 'EE-39708290276') assert_equal('John Doe', user.username) - contact.reload - assert_equal user.username, contact.name + contacts.each do |c| + c.reload + assert_equal user.username, c.name + assert user.actions.find_by(operation: :update, contact_id: c.id) + end + + bulk_action = BulkAction.find_by(user_id: user.id, operation: :bulk_update) + assert_equal 2, bulk_action.subactions.size + + registrars.each do |r| + notification = r.notifications.unread.order('created_at DESC').take + if r == registrars(:bestnames) + assert_equal '2 contacts have been updated by registrant', notification.text + assert_equal 'BulkAction', notification.attached_obj_type + assert_equal bulk_action.id, notification.attached_obj_id + assert_equal bulk_action.id, notification.action_id + else + assert_equal 'Contact william-002 has been updated by registrant', notification.text + refute notification.action_id + refute notification.attached_obj_id + refute notification.attached_obj_type + end + end end end