Add bulk replace to registrar area

#662
This commit is contained in:
Artur Beljajev 2018-03-19 00:55:35 +02:00
parent f7c2b25a66
commit 228078a84e
30 changed files with 474 additions and 66 deletions

View file

@ -59,5 +59,6 @@ module Repp
mount Repp::AccountV1 mount Repp::AccountV1
mount Repp::DomainTransfersV1 mount Repp::DomainTransfersV1
mount Repp::NameserversV1 mount Repp::NameserversV1
mount Repp::DomainContactsV1
end end
end end

View file

@ -0,0 +1,35 @@
module Repp
class DomainContactsV1 < Grape::API
version 'v1', using: :path
resource :domains do
resource :contacts do
patch '/' do
predecessor = current_user.registrar.contacts.find_by(code: params[:predecessor])
successor = current_user.registrar.contacts.find_by(code: params[:successor])
unless predecessor
error!({ error: { type: 'invalid_request_error',
param: 'predecessor',
message: "No such contact: #{params[:predecessor]}" } }, :bad_request)
end
unless successor
error!({ error: { type: 'invalid_request_error',
param: 'successor',
message: "No such contact: #{params[:successor]}" } }, :bad_request)
end
if predecessor == successor
error!({ error: { type: 'invalid_request_error',
message: 'Successor contact must be different from predecessor' } },
:bad_request)
end
affected_domains, skipped_domains = TechDomainContact.replace(predecessor, successor)
@response = { affected_domains: affected_domains, skipped_domains: skipped_domains }
end
end
end
end
end

View file

@ -0,0 +1,20 @@
class Registrar
class BulkChangeController < DeppController
helper_method :available_contacts
def new
authorize! :manage, :repp
render file: 'registrar/bulk_change/new', locals: { active_tab: default_tab }
end
private
def available_contacts
current_user.registrar.contacts.order(:name).pluck(:name, :code)
end
def default_tab
:technical_contact
end
end
end

View file

@ -1,9 +1,5 @@
class Registrar class Registrar
class RegistrarNameserversController < DeppController class NameserversController < BulkChangeController
def edit
authorize! :manage, :repp
end
def update def update
authorize! :manage, :repp authorize! :manage, :repp
@ -52,7 +48,7 @@ class Registrar
redirect_to registrar_domains_url redirect_to registrar_domains_url
else else
@api_errors = parsed_response[:errors] @api_errors = parsed_response[:errors]
render :edit render file: 'registrar/bulk_change/new', locals: { active_tab: :nameserver }
end end
end end
end end

View file

@ -0,0 +1,58 @@
class Registrar
class TechContactsController < BulkChangeController
def update
authorize! :manage, :repp
uri = URI.parse("#{ENV['repp_url']}domains/contacts")
request = Net::HTTP::Patch.new(uri)
request.set_form_data(predecessor: params[:predecessor], successor: params[:successor])
request.basic_auth(current_user.username, current_user.password)
if Rails.env.test?
response = Net::HTTP.start(uri.hostname, uri.port,
use_ssl: (uri.scheme == 'https'),
verify_mode: OpenSSL::SSL::VERIFY_NONE) do |http|
http.request(request)
end
elsif Rails.env.development?
client_cert = File.read(ENV['cert_path'])
client_key = File.read(ENV['key_path'])
response = Net::HTTP.start(uri.hostname, uri.port,
use_ssl: (uri.scheme == 'https'),
verify_mode: OpenSSL::SSL::VERIFY_NONE,
cert: OpenSSL::X509::Certificate.new(client_cert),
key: OpenSSL::PKey::RSA.new(client_key)) do |http|
http.request(request)
end
else
client_cert = File.read(ENV['cert_path'])
client_key = File.read(ENV['key_path'])
response = Net::HTTP.start(uri.hostname, uri.port,
use_ssl: (uri.scheme == 'https'),
cert: OpenSSL::X509::Certificate.new(client_cert),
key: OpenSSL::PKey::RSA.new(client_key)) do |http|
http.request(request)
end
end
parsed_response = JSON.parse(response.body, symbolize_names: true)
if response.code == '200'
notices = [t('.replaced')]
notices << "#{t('.affected_domains')}: #{parsed_response[:affected_domains].join(', ')}"
if parsed_response[:skipped_domains]
notices << "#{t('.skipped_domains')}: #{parsed_response[:skipped_domains].join(', ')}"
end
flash[:notice] = notices
redirect_to registrar_domains_url
else
@error = parsed_response[:error]
render file: 'registrar/bulk_change/new', locals: { active_tab: :technical_contact }
end
end
end
end

View file

@ -1,2 +1,23 @@
class TechDomainContact < DomainContact class TechDomainContact < DomainContact
# Audit log is needed, therefore no raw SQL
def self.replace(predecessor, successor)
affected_domains = []
skipped_domains = []
tech_contacts = where(contact: predecessor)
transaction do
tech_contacts.each do |tech_contact|
if tech_contact.domain.discarded?
skipped_domains << tech_contact.domain.name
next
end
tech_contact.contact = successor
tech_contact.save!
affected_domains << tech_contact.domain.name
end
end
return affected_domains.sort, skipped_domains.sort
end
end end

View file

@ -1,7 +1,11 @@
<% if flash[:notice] %> <% if flash[:notice] %>
<div class="alert alert-success alert-dismissible"> <div class="alert alert-success alert-dismissible">
<button class="close" data-dismiss="alert" type=button><span>&times;</span></button> <button class="close" data-dismiss="alert" type=button><span>&times;</span></button>
<p><%= flash[:notice] %></p> <% if flash[:notice].respond_to?(:join) %>
<p><%= flash[:notice].join('<br>').html_safe %></p>
<% else %>
<p><%= flash[:notice] %></p>
<% end %>
</div> </div>
<% end %> <% end %>

View file

@ -39,7 +39,7 @@
</div> </div>
</nav> </nav>
<div class="container"> <div class="container">
<%= render 'shared/flash' %> <%= render 'flash_messages' %>
<% if depp_controller? %> <% if depp_controller? %>
<%= render 'registrar/shared/epp_results' %> <%= render 'registrar/shared/epp_results' %>
<% end %> <% end %>

View file

@ -1,12 +1,13 @@
<%= form_tag registrar_update_registrar_nameserver_path, method: :put, class: 'form-horizontal' do %> <%= form_tag registrar_nameservers_path, method: :patch, class: 'form-horizontal' do %>
<%= render 'registrar/domain_transfers/form/api_errors' %>
<div class="form-group"> <div class="form-group">
<div class="col-md-2 control-label"> <div class="col-md-2 control-label">
<%= label_tag :old_hostname %> <%= label_tag :old_hostname %>
</div> </div>
<div class="col-md-5"> <div class="col-md-5">
<%= text_field_tag :old_hostname, params[:old_hostname], autofocus: true, <%= text_field_tag :old_hostname, params[:old_hostname], required: true,
required: true,
class: 'form-control' %> class: 'form-control' %>
</div> </div>
</div> </div>

View file

@ -0,0 +1,48 @@
<%= form_tag registrar_tech_contacts_path, method: :patch, class: 'form-horizontal' do %>
<% if @error %>
<div class="alert alert-danger">
<%= @error[:message] %>
</div>
<% end %>
<div class="form-group">
<div class="col-md-2 control-label">
<%= label_tag :predecessor %>
</div>
<div class="col-md-5">
<%= text_field_tag :predecessor, params[:predecessor],
list: :contacts,
required: true,
autofocus: true,
class: 'form-control' %>
</div>
</div>
<div class="form-group">
<div class="col-md-2 control-label">
<%= label_tag :successor %>
</div>
<div class="col-md-5">
<%= text_field_tag :successor, params[:successor],
list: :contacts,
required: true,
class: 'form-control' %>
</div>
</div>
<div class="form-group">
<div class="col-md-5 col-md-offset-2 text-right">
<button class="btn btn-warning">
<%= t '.submit_btn' %>
</button>
</div>
</div>
<% end %>
<datalist id="contacts">
<% available_contacts.each do |data| %>
<option value="<%= data.second %>"><%= data.first %></option>
<% end %>
</datalist>

View file

@ -0,0 +1,32 @@
<ol class="breadcrumb">
<li><%= link_to t('registrar.domains.index.header'), registrar_domains_path %></li>
</ol>
<div class="page-header">
<h1><%= t '.header' %></h1>
</div>
<div class="row">
<div class="col-md-10">
<ul class="nav nav-tabs">
<li class="<%= 'active' if active_tab == :technical_contact %>">
<a href="#technical_contact" data-toggle="tab"><%= t '.technical_contact' %></a>
</li>
<li class="<%= 'active' if active_tab == :nameserver %>">
<a href="#nameserver" data-toggle="tab"><%= t '.nameserver' %></a>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane<%= ' active' if active_tab == :technical_contact %>"
id="technical_contact">
<%= render 'tech_contact_form', available_contacts: available_contacts %>
</div>
<div class="tab-pane<%= ' active' if active_tab == :nameserver %>" id="nameserver">
<%= render 'nameserver_form' %>
</div>
</div>
</div>
</div>

View file

@ -1,13 +1,13 @@
<div class="page-header"> <div class="page-header">
<div class="row"> <div class="row">
<div class="col-sm-7"> <div class="col-sm-5">
<h1><%= t '.header' %></h1> <h1><%= t '.header' %></h1>
</div> </div>
<div class="col-sm-5 text-right"> <div class="col-sm-7 text-right">
<%= link_to t('.new_btn'), new_registrar_domain_path, class: 'btn btn-primary' %> <%= link_to t('.new_btn'), new_registrar_domain_path, class: 'btn btn-primary' %>
<%= link_to t('.transfer_btn'), new_registrar_domain_transfer_path, class: 'btn btn-default' %> <%= link_to t('.transfer_btn'), new_registrar_domain_transfer_path, class: 'btn btn-default' %>
<%= link_to t('.replace_nameserver_btn'), registrar_edit_registrar_nameserver_path, <%= link_to t('.bulk_change_btn'), new_registrar_bulk_change_path,
class: 'btn btn-default' %> class: 'btn btn-default' %>
</div> </div>
</div> </div>
@ -24,22 +24,22 @@
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover table-bordered table-condensed"> <table class="table table-hover table-bordered table-condensed">
<thead> <thead>
<tr> <tr>
<th class="col-xs-2"> <th class="col-xs-2">
<%= sort_link(@q, 'name') %> <%= sort_link(@q, 'name') %>
</th> </th>
<th class="col-xs-2"> <th class="col-xs-2">
<%= sort_link @q, 'registrant_name', Registrant.model_name.human %> <%= sort_link @q, 'registrant_name', Registrant.model_name.human %>
</th> </th>
<th class="col-xs-2"> <th class="col-xs-2">
<%= sort_link @q, 'valid_to', Domain.human_attribute_name(:expire_time) %> <%= sort_link @q, 'valid_to', Domain.human_attribute_name(:expire_time) %>
</th> </th>
<th class="col-xs-2"></th> <th class="col-xs-2"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<%= render @domains %> <%= render @domains %>
</tbody> </tbody>
</table> </table>
</div> </div>

View file

@ -1,11 +0,0 @@
<ol class="breadcrumb">
<li><%= link_to t('registrar.domains.index.header'), registrar_domains_path %></li>
</ol>
<div class="page-header">
<h1><%= t '.header' %></h1>
</div>
<%= render 'registrar/domain_transfers/form/api_errors' %>
<%= render 'form' %>

View file

@ -0,0 +1,14 @@
en:
registrar:
bulk_change:
new:
header: Bulk change
technical_contact: Technical contact
nameserver: Nameserver
tech_contact_form:
submit_btn: Replace technical contacts
nameserver_form:
ip_hint: One IP per line
replace_btn: Replace nameserver

View file

@ -5,7 +5,7 @@ en:
header: Domains header: Domains
new_btn: New domain new_btn: New domain
transfer_btn: Transfer transfer_btn: Transfer
replace_nameserver_btn: Replace nameserver bulk_change_btn: Bulk change
csv: csv:
domain_name: Domain domain_name: Domain
transfer_code: Transfer code transfer_code: Transfer code

View file

@ -0,0 +1,5 @@
en:
registrar:
nameservers:
update:
replaced: Nameserver have been successfully replaced

View file

@ -1,13 +0,0 @@
en:
registrar:
registrar_nameservers:
edit:
header: Replace nameserver
replace_btn: Replace
form:
ip_hint: One IP per line
replace_btn: Replace nameserver
update:
replaced: Nameserver have been successfully replaced

View file

@ -0,0 +1,7 @@
en:
registrar:
tech_contacts:
update:
replaced: Technical contacts have been successfully replaced.
affected_domains: Affected domains
skipped_domains: Skipped domains

View file

@ -62,9 +62,9 @@ Rails.application.routes.draw do
end end
end end
resources :domain_transfers, only: %i[new create] resources :domain_transfers, only: %i[new create]
get 'registrar/nameservers', to: 'registrar_nameservers#edit', as: :edit_registrar_nameserver resource :bulk_change, controller: :bulk_change, only: :new
put 'registrar/nameservers', to: 'registrar_nameservers#update', as: :update_registrar_nameserver resource :tech_contacts, only: :update
resource :nameservers, only: :update
resources :contacts, constraints: {:id => /[^\/]+(?=#{ ActionController::Renderers::RENDERERS.map{|e| "\\.#{e}\\z"}.join("|") })|[^\/]+/} do resources :contacts, constraints: {:id => /[^\/]+(?=#{ ActionController::Renderers::RENDERERS.map{|e| "\\.#{e}\\z"}.join("|") })|[^\/]+/} do
member do member do
get 'delete' get 'delete'

View file

@ -0,0 +1,19 @@
# Domain contacts
## PATCH https://repp.internet.ee/v1/domains/contacts
Replaces all domain contacts of the current registrar.
### Example request
```
$ curl https://repp.internet.ee/v1/domains/contacts \
-X PATCH \
-u username:password \
-d predecessor=foo \
-d successor=bar
```
### Example response
```
{
"affected_domains": ["example.com", "example.org"]
}
```

View file

@ -8,11 +8,26 @@ shop_william:
contact: william contact: william
type: TechDomainContact type: TechDomainContact
shop_acme_ltd:
domain: shop
contact: acme_ltd
type: TechDomainContact
airport_john: airport_john:
domain: airport domain: airport
contact: john contact: john
type: AdminDomainContact type: AdminDomainContact
airport_william_admin:
domain: airport
contact: william
type: AdminDomainContact
airport_william_tech:
domain: airport
contact: william
type: TechDomainContact
library_john: library_john:
domain: library domain: library
contact: john contact: john

View file

@ -18,6 +18,10 @@ airport_ns1:
hostname: ns1.bestnames.test hostname: ns1.bestnames.test
domain: airport domain: airport
airport_ns2:
hostname: ns2.bestnames.test
domain: airport
metro_ns1: metro_ns1:
hostname: ns1.bestnames.test hostname: ns1.bestnames.test
domain: metro domain: metro

View file

@ -0,0 +1,103 @@
require 'test_helper'
class APIDomainContactsTest < ActionDispatch::IntegrationTest
def test_replace_all_tech_contacts_of_the_current_registrar
patch '/repp/v1/domains/contacts', { predecessor: 'william-001', successor: 'john-001' },
{ 'HTTP_AUTHORIZATION' => http_auth_key }
assert_nil domains(:shop).tech_contacts.find_by(code: 'william-001')
assert domains(:shop).tech_contacts.find_by(code: 'john-001')
assert domains(:airport).tech_contacts.find_by(code: 'john-001')
end
def test_skip_discarded_domains
domains(:airport).update!(statuses: [DomainStatus::DELETE_CANDIDATE])
patch '/repp/v1/domains/contacts', { predecessor: 'william-001', successor: 'john-001' },
{ 'HTTP_AUTHORIZATION' => http_auth_key }
assert domains(:airport).tech_contacts.find_by(code: 'william-001')
end
def test_return_affected_domains_in_alphabetical_order
patch '/repp/v1/domains/contacts', { predecessor: 'william-001', successor: 'john-001' },
{ 'HTTP_AUTHORIZATION' => http_auth_key }
assert_response :ok
assert_equal ({ affected_domains: %w[airport.test shop.test],
skipped_domains: [] }),
JSON.parse(response.body, symbolize_names: true)
end
def test_return_skipped_domains_in_alphabetical_order
domains(:shop).update!(statuses: [DomainStatus::DELETE_CANDIDATE])
domains(:airport).update!(statuses: [DomainStatus::DELETE_CANDIDATE])
patch '/repp/v1/domains/contacts', { predecessor: 'william-001', successor: 'john-001' },
{ 'HTTP_AUTHORIZATION' => http_auth_key }
assert_response :ok
assert_equal %w[airport.test shop.test], JSON.parse(response.body,
symbolize_names: true)[:skipped_domains]
end
def test_keep_other_tech_contacts_intact
patch '/repp/v1/domains/contacts', { predecessor: 'william-001', successor: 'john-001' },
{ 'HTTP_AUTHORIZATION' => http_auth_key }
assert domains(:shop).tech_contacts.find_by(code: 'acme-ltd-001')
end
def test_keep_admin_contacts_intact
patch '/repp/v1/domains/contacts', { predecessor: 'william-001', successor: 'john-001' },
{ 'HTTP_AUTHORIZATION' => http_auth_key }
assert domains(:airport).admin_contacts.find_by(code: 'william-001')
end
def test_restrict_contacts_to_the_current_registrar
patch '/repp/v1/domains/contacts', { predecessor: 'jack-001', successor: 'william-002' },
{ 'HTTP_AUTHORIZATION' => http_auth_key }
assert_response :bad_request
assert_equal ({ error: { type: 'invalid_request_error',
param: 'predecessor',
message: 'No such contact: jack-001' } }),
JSON.parse(response.body, symbolize_names: true)
end
def test_non_existent_predecessor
patch '/repp/v1/domains/contacts', { predecessor: 'non-existent', successor: 'john-001' },
{ 'HTTP_AUTHORIZATION' => http_auth_key }
assert_response :bad_request
assert_equal ({ error: { type: 'invalid_request_error',
param: 'predecessor',
message: 'No such contact: non-existent' } }),
JSON.parse(response.body, symbolize_names: true)
end
def test_non_existent_successor
patch '/repp/v1/domains/contacts', { predecessor: 'william-001', successor: 'non-existent' },
{ 'HTTP_AUTHORIZATION' => http_auth_key }
assert_response :bad_request
assert_equal ({ error: { type: 'invalid_request_error',
param: 'successor',
message: 'No such contact: non-existent' } }),
JSON.parse(response.body, symbolize_names: true)
end
def test_disallow_self_replacement
patch '/repp/v1/domains/contacts', { predecessor: 'william-001', successor: 'william-001' },
{ 'HTTP_AUTHORIZATION' => http_auth_key }
assert_response :bad_request
assert_equal ({ error: { type: 'invalid_request_error',
message: 'Successor contact must be different from predecessor' } }),
JSON.parse(response.body, symbolize_names: true)
end
private
def http_auth_key
ActionController::HttpAuthentication::Basic.encode_credentials('test_bestnames', 'testtest')
end
end

View file

@ -53,7 +53,7 @@ class APIDomainTransfersTest < ActionDispatch::IntegrationTest
end end
def test_duplicates_registrant_admin_and_tech_contacts def test_duplicates_registrant_admin_and_tech_contacts
assert_difference -> { @new_registrar.contacts.size }, 2 do assert_difference -> { @new_registrar.contacts.size }, 3 do
post '/repp/v1/domain_transfers', request_params, { 'HTTP_AUTHORIZATION' => http_auth_key } post '/repp/v1/domain_transfers', request_params, { 'HTTP_AUTHORIZATION' => http_auth_key }
end end
end end

View file

@ -49,7 +49,7 @@ class EppDomainTransferRequestTest < ActionDispatch::IntegrationTest
end end
def test_duplicates_registrant_admin_and_tech_contacts def test_duplicates_registrant_admin_and_tech_contacts
assert_difference -> { @new_registrar.contacts.size }, 2 do assert_difference -> { @new_registrar.contacts.size }, 3 do
post '/epp/command/transfer', { frame: request_xml }, { 'HTTP_COOKIE' => 'session=api_goodnames' } post '/epp/command/transfer', { frame: request_xml }, { 'HTTP_COOKIE' => 'session=api_goodnames' }
end end
end end

View file

@ -1,8 +1,7 @@
require 'test_helper' require 'test_helper'
class RegistrarNameserverReplacementTest < ActionDispatch::IntegrationTest class RegistrarAreaNameserverBulkChangeTest < ActionDispatch::IntegrationTest
def setup setup do
WebMock.reset!
login_as users(:api_goodnames) login_as users(:api_goodnames)
end end
@ -21,7 +20,8 @@ class RegistrarNameserverReplacementTest < ActionDispatch::IntegrationTest
}] }.to_json, status: 200) }] }.to_json, status: 200)
visit registrar_domains_url visit registrar_domains_url
click_link 'Replace nameserver' click_link 'Bulk change'
click_link 'Nameserver'
fill_in 'Old hostname', with: 'ns1.bestnames.test' fill_in 'Old hostname', with: 'ns1.bestnames.test'
fill_in 'New hostname', with: 'new-ns.bestnames.test' fill_in 'New hostname', with: 'new-ns.bestnames.test'
@ -40,7 +40,8 @@ class RegistrarNameserverReplacementTest < ActionDispatch::IntegrationTest
headers: { 'Content-type' => 'application/json' }) headers: { 'Content-type' => 'application/json' })
visit registrar_domains_url visit registrar_domains_url
click_link 'Replace nameserver' click_link 'Bulk change'
click_link 'Nameserver'
fill_in 'Old hostname', with: 'old hostname' fill_in 'Old hostname', with: 'old hostname'
fill_in 'New hostname', with: 'new hostname' fill_in 'New hostname', with: 'new hostname'

View file

@ -0,0 +1,47 @@
require 'test_helper'
class RegistrarAreaTechContactBulkChangeTest < ActionDispatch::IntegrationTest
setup do
login_as users(:api_bestnames)
end
def test_replace_domain_contacts_of_current_registrar
request_stub = stub_request(:patch, /domains\/contacts/)
.with(body: { predecessor: 'william-001', successor: 'john-001' },
basic_auth: ['test_bestnames', 'testtest'])
.to_return(body: { affected_domains: %w[foo.test bar.test],
skipped_domains: %w[baz.test qux.test] }.to_json,
status: 200)
visit registrar_domains_url
click_link 'Bulk change'
fill_in 'Predecessor', with: 'william-001'
fill_in 'Successor', with: 'john-001'
click_on 'Replace technical contacts'
assert_requested request_stub
assert_current_path registrar_domains_path
assert_text 'Technical contacts have been successfully replaced'
assert_text 'Affected domains: foo.test, bar.test'
assert_text 'Skipped domains: baz.test, qux.test'
end
def test_fails_gracefully
stub_request(:patch, /domains\/contacts/)
.to_return(status: 400,
body: { error: { message: 'epic fail' } }.to_json,
headers: { 'Content-type' => 'application/json' })
visit registrar_domains_url
click_link 'Bulk change'
fill_in 'Predecessor', with: 'william-001'
fill_in 'Successor', with: 'john-001'
click_on 'Replace technical contacts'
assert_text 'epic fail'
assert_field 'Predecessor', with: 'william-001'
assert_field 'Successor', with: 'john-001'
end
end

View file

@ -1,8 +1,7 @@
require 'test_helper' require 'test_helper'
class RegistrarDomainTransfersTest < ActionDispatch::IntegrationTest class RegistrarDomainTransfersTest < ActionDispatch::IntegrationTest
def setup setup do
WebMock.reset!
login_as users(:api_goodnames) login_as users(:api_goodnames)
end end

View file

@ -20,7 +20,7 @@ class DomainTransferTest < ActiveSupport::TestCase
body = 'Transfer of domain shop.test has been approved.' \ body = 'Transfer of domain shop.test has been approved.' \
' It was associated with registrant john-001' \ ' It was associated with registrant john-001' \
' and contacts jane-001, william-001.' ' and contacts acme-ltd-001, jane-001, william-001.'
id = @domain_transfer.id id = @domain_transfer.id
class_name = @domain_transfer.class.name class_name = @domain_transfer.class.name

View file

@ -50,9 +50,11 @@ class NameserverTest < ActiveSupport::TestCase
end end
def test_hostnames def test_hostnames
assert_equal %w[ns1.bestnames.test assert_equal %w[
ns1.bestnames.test
ns2.bestnames.test ns2.bestnames.test
ns1.bestnames.test ns1.bestnames.test
ns2.bestnames.test
ns1.bestnames.test], Nameserver.hostnames ns1.bestnames.test], Nameserver.hostnames
end end