diff --git a/JOBS b/JOBS index 1cf6a8d..84bf522 100644 --- a/JOBS +++ b/JOBS @@ -1,48 +1,54 @@ # Jobs ## add_domain Add domain to bind as slave (master: webdns). It uses the `/usr/sbin/rndc addzone` interface. ## trigger_event Trigger an event for the specified domain through the WebDNS API. The state machine will take care of the rest once the event is received, it usually changes state and pushes more jobs to the queue. ## opendnssec_add Add a zone to opendnssec for signing. The zone is transfered from WebDNS and a signed zone file is created to `/var/lib/opendnssec/signed` (should be symlinked to '/var/cache/bind/webdns'). ## bind_convert_to_dnssed It checks that the signed zone file exists and triggers a 'rndc delzone' followed by 'rnd addzone' that serves the signed zone as a master (file "webdns/signed/%{zone}"). ## wait_for_ready_to_push_ds Wait for KSK to become ready so we can publish the DS records to the parent authority. ## publish_ds -Pushes the DS records to the parent depending on the parent authority. +Pushes the DS records to the parent depending on the parent authority. Also +drops DS when a DSSSEC domain is removed (on full-remove). ## wait_for_active Wait for the KSK to become active. The KSK is marked active by the ds-monitor script. ds-monitor checks if the DS records are visible using the local recursor. +## wait_until + +Wait until a specific timestamp is reached, used to pause other jobs in the +queue, like domain removal. + ## remove_domain Remove a zone from bind using rndc delzone. ## opendnssec_remove Remove a zone from ods uning 'ods-ksmutil zone delete'. diff --git a/app/helpers/domains_helper.rb b/app/helpers/domains_helper.rb index 992400f..0bcde66 100644 --- a/app/helpers/domains_helper.rb +++ b/app/helpers/domains_helper.rb @@ -1,38 +1,39 @@ module DomainsHelper # Human names for domain states def human_state(state) human = case state.to_sym when :initial then 'Initial' when :pending_install then 'Becoming public' when :pending_signing then 'Signing zone' when :wait_for_ready then 'Waiting for KSK to become ready' when :pending_ds then 'Publishing DS records' when :pending_ds_rollover then 'Performing KSK rollover' + when :pending_ds_removal then 'Removing DS records' when :pending_plain then 'Removing dnssec' when :pending_remove then 'Preparing removal' when :operational then 'Operational' when :destroy then 'Ready to be destroyed' else state end prog = Domain.dnssec_progress(state) return human if prog.nil? "#{human} (#{prog})" end # Most of the time the parent zone will be easily computed def guess_parent_zone(name) name.split('.', 2).last || '' end def dnssec_policy_human(policy) info = policy.info.map { |name, value| [name, seconds_to_human(value)].join(': ') } "#{policy.name}: (#{info.join(' | ')})" end end diff --git a/app/models/domain.rb b/app/models/domain.rb index 65f4103..0edddca 100644 --- a/app/models/domain.rb +++ b/app/models/domain.rb @@ -1,304 +1,309 @@ class Domain < ActiveRecord::Base class NotAChild < StandardError; end self.inheritance_column = :nx # List all supported domain types. def self.domain_types [ 'NATIVE', 'MASTER', 'SLAVE', ] end # List domain types that can be created. def self.allowed_domain_types domain_types - WebDNS.settings[:prohibit_domain_types] end # List parent authorities def self.dnssec_parent_authorities WebDNS.settings[:dnssec_parent_authorities].keys.map(&:to_s) end # Fire event after transaction commmit # Changing state inside a hook messes things up, # this trick handles that attr_accessor :fire_event belongs_to :group has_many :jobs has_many :records # BUG in bump_serial_trigger has_one :soa, -> { unscope(where: :type).where(type: 'soa') }, class_name: SOA belongs_to :dnssec_policy validates :group_id, presence: true validates :name, uniqueness: true, presence: true validates :type, presence: true, inclusion: { in: domain_types } validates :master, presence: true, ipv4: true, if: :slave? validates :dnssec, inclusion: { in: [false] }, unless: :dnssec_elegible? validates :dnssec_parent_authority, inclusion: { in: dnssec_parent_authorities }, if: :dnssec? validates :dnssec_parent, hostname: true, if: :dnssec? validates :dnssec_policy_id, presence: true, if: :dnssec? after_create :generate_soa after_create :generate_ns after_create :install before_save :check_convert before_save :check_dnssec_parent_authority, if: :dnssec? after_commit :after_commit_event attr_writer :serial_strategy def self.dnssec_progress(current_state) progress = [ :pending_signing, # 1/3 :wait_for_ready, # 2/3 :pending_ds] # 3/3 idx = progress.index(current_state.to_sym) return if idx.nil? [idx+1, progress.size].join('/') end state_machine initial: :initial do after_transition(any => :pending_install) { |domain, _t| Job.add_domain(domain) } after_transition(any => :pending_remove) { |domain, _t| Job.shutdown_domain(domain) } + after_transition(any => :pending_ds_removal) { |domain, _t| Job.dnssec_drop_ds(domain) } after_transition(any => :pending_signing) { |domain, _t| Job.dnssec_sign(domain) } after_transition(any => :wait_for_ready) { |domain, _t| Job.wait_for_ready(domain) } after_transition(any => :pending_ds) { |domain, t| Job.dnssec_push_ds(domain, *t.args) } after_transition(any => :pending_ds_rollover) { |domain, t| Job.dnssec_rollover_ds(domain, *t.args) } after_transition(any => :pending_plain) { |domain, _t| Job.convert_to_plain(domain) } after_transition(any => :destroy) { |domain, _t| domain.destroy } # User events event :install do transition initial: :pending_install end event :dnssec_sign do transition operational: :pending_signing end event :signed do transition pending_signing: :wait_for_ready end event :push_ds do transition wait_for_ready: :pending_ds, operational: :pending_ds_rollover end event :plain_convert do transition operational: :pending_plain end event :remove do - transition operational: :pending_remove + transition [:operational, :pending_ds_removal] => :pending_remove + end + + event :full_remove do + transition operational: :pending_ds_removal end # Machine events event :installed do transition pending_install: :operational end event :converted do transition [:pending_ds, :pending_plain] => :operational end event :complete_rollover do transition pending_ds_rollover: :operational end event :cleaned_up do transition pending_remove: :destroy end event :ksk_rollover_detected do transition operational: :ksk_rollover end end # Returns true if this domain is elegigble for DNSSEC def dnssec_elegible? return false if slave? true end # Returns the zone serial if a SOA record exists def serial return if !soa soa.serial end # Get the zone's serial strategy. # # Returns one of the supported serial strategies. def serial_strategy @serial_strategy ||= WebDNS.settings[:serial_strategy] end # Returns true if this a reverse zone. def reverse? name.end_with?('.in-addr.arpa') || name.end_with?('.ip6.arpa') end # Returns true if this a ENUM zone. def enum? name.end_with?('.e164.arpa') end # Returns true if this is a slave zone. def slave? type == 'SLAVE' end # Compute subnet for reverse records def subnet return if not reverse? if name.end_with?('.in-addr.arpa') subnet_v4 elsif name.end_with?('.ip6.arpa') subnet_v6 end end def self.replace_ds(parent, child, records) parent = find_by_name!(parent) fail NotAChild if not child.end_with?(parent.name) existing = parent.records.where(name: child, type: 'DS') recs = records.map { |rec| DS.new(domain: parent, name: child, content: rec) } ActiveRecord::Base.transaction do existing.destroy_all recs.map(&:save!) end end # Apply bulk to operations to the zones # # 1) Deletions # 2) Changes # 3) Additions def bulk(opts) deletes = opts[:deletes] || [] changes = opts[:changes] || {} additions = opts[:additions] || {} errors = Hash.new { |h, k| h[k] = {} } ActiveRecord::Base.transaction do # Deletes to_delete = records.where(id: deletes).index_by(&:id) deletes.each { |rec_id| if rec = to_delete[Integer(rec_id)] rec.destroy next end errors[:deletes][rec_id] = 'Deleted record not found' } # Changes to_change = records.where(id: changes.keys).index_by(&:id) changes.each {|rec_id, changes| binding if rec = to_change[Integer(rec_id)] errors[:changes][rec_id] = rec.errors.full_messages.join(', ') if !rec.update(changes) next end errors[:changes][rec_id] = 'Changed record not found' } # Additions additions.each { |inc, attrs| rec = records.new(attrs) errors[:additions][inc] = rec.errors.full_messages.join(', ') if !rec.save } raise ActiveRecord::Rollback if errors.any? end errors end private def subnet_v4 # get ip octets (remove .in-addr.arpa) octets = name.split('.')[0...-2].reverse return if octets.any? { |_| false } mask = 8 * octets.size octets += [0, 0, 0, 0] ip = IPAddr.new octets[0, 4].join('.') [ip, mask].join('/') end def subnet_v6 nibbles = name.split('.')[0...-2].reverse return if nibbles.any? { |_| false } mask = 4 * nibbles.size nibbles += [0] * 32 ip = IPAddr.new nibbles[0, 32].in_groups_of(4).map(&:join).join(':') [ip, mask].join('/') end # Hooks def generate_soa soa_record = SOA.new(domain: self) soa_record.save! end def generate_ns return if slave? return if WebDNS.settings[:default_ns].empty? WebDNS.settings[:default_ns].each { |ns| Record.find_or_create_by!(domain: self, type: 'NS', name: '', content: ns) } end def check_convert return if !dnssec_changed? event = dnssec ? :dnssec_sign : :plain_convert if state_events.include?(event) self.fire_event = event # Schedule event for after commit return true end errors.add(:dnssec, 'You cannot modify dnssec settings in this state!') false end def check_dnssec_parent_authority cfg = WebDNS.settings[:dnssec_parent_authorities][dnssec_parent_authority.to_sym] return if !cfg[:valid] return true if cfg[:valid].call(dnssec_parent) errors.add(:dnssec_parent_authority, 'Parent zone is not accepted for the selected parent authority!') false end def after_commit_event return if !fire_event fire_state_event(fire_event) self.fire_event = nil end end diff --git a/app/models/job.rb b/app/models/job.rb index 723e18a..5aa81cd 100644 --- a/app/models/job.rb +++ b/app/models/job.rb @@ -1,131 +1,145 @@ class Job < ActiveRecord::Base belongs_to :domain scope :pending, -> { where(status: 0) } scope :failed, -> { where(status: 2) } scope :completed, -> { where(status: [1, 2]) } def failed? status == 2 end def done? status == 1 end def pending? status == 0 end def arguments JSON.parse(args) end def zone return domain.name if domain arguments['zone'] end def run_event! args = arguments raise 'Not an event!' unless args['event'] Domain .find_by_name(args['zone']) .fire_state_event(args['event']) self.status = 1 self.save! end class << self def add_domain(domain) ActiveRecord::Base.transaction do jobs_for_domain(domain, :add_domain) trigger_event(domain, :installed) end end def shutdown_domain(domain) ActiveRecord::Base.transaction do job_for_domain(domain, :remove_domain) job_for_domain(domain, :opendnssec_remove) if domain.dnssec? trigger_event(domain, :cleaned_up) end end def dnssec_sign(domain) ActiveRecord::Base.transaction do job_for_domain(domain, :opendnssec_add, policy: domain.dnssec_policy.name) job_for_domain(domain, :bind_convert_to_dnssec) trigger_event(domain, :signed) end end def wait_for_ready(domain) jobs_for_domain(domain, :wait_for_ready_to_push_ds) end def dnssec_push_ds(domain, dss) opts = Hash[:dnssec_parent, domain.dnssec_parent, :dnssec_parent_authority, domain.dnssec_parent_authority, :dss, dss] keytag = dss.map { |ds| ds.split.first }.first # Both records should have the same keytag ActiveRecord::Base.transaction do job_for_domain(domain, :publish_ds, opts) job_for_domain(domain, :wait_for_active, keytag: keytag) trigger_event(domain, :converted) end end def dnssec_rollover_ds(domain, dss) opts = Hash[:dnssec_parent, domain.dnssec_parent, :dnssec_parent_authority, domain.dnssec_parent_authority, :dss, dss] keytag = dss.map { |ds| ds.split.first }.first # Both records should have the same keytag ActiveRecord::Base.transaction do job_for_domain(domain, :publish_ds, opts) job_for_domain(domain, :wait_for_active, keytag: keytag) trigger_event(domain, :complete_rollover) end end + def dnssec_drop_ds(domain) + opts = Hash[:dnssec_parent, domain.dnssec_parent, + :dnssec_parent_authority, domain.dnssec_parent_authority, + :dss, []] + + ActiveRecord::Base.transaction do + job_for_domain(domain, :publish_ds, opts) + # Wait for the change to propagate + job_for_domain(domain, :wait_until, until: Time.now.to_i + WebDNS.settings[:dnssec_ds_removal_sleep]) + + trigger_event(domain, :remove) + end + end + def convert_to_plain(domain) ActiveRecord::Base.transaction do jobs_for_domain(domain, :remove_domain, :add_domain, :opendnssec_remove) trigger_event(domain, :converted) end end private def trigger_event(domain, event) job_for_domain(domain, :trigger_event, event: event) end def jobs_for_domain(domain, *job_names) job_names.each { |job_name| job_for_domain(domain, job_name) } end def job_for_domain(domain, job_name, args = {}) args = { zone: domain.name }.merge!(args) create!(domain: domain, job_type: job_name, args: args.to_json) end end end diff --git a/config/initializers/00_settings.rb b/config/initializers/00_settings.rb index 6bfc783..57b321d 100644 --- a/config/initializers/00_settings.rb +++ b/config/initializers/00_settings.rb @@ -1,47 +1,48 @@ WebDNS.settings[:soa_defaults] = { primary_ns: 'ns1.example.com', contact: 'domainmaster@example.com', serial: 1, refresh: 10_800, retry: 3600, expire: 604_800, nx: 3600 } WebDNS.settings[:default_ns] = [ 'ns1.example.com', 'ns2.example.com' ] WebDNS.settings[:dnssec] = true WebDNS.settings[:dnssec_parent_authorities] = { webdns: { valid: -> (parent) { Domain.find_by_name(parent) } # Check if parent is self-hosted }, papaki: { valid: -> (parent) { parent.split('.').size == 1 } # TLDs } } +WebDNS.settings[:dnssec_ds_removal_sleep] = 14400 * 2 # Testing helper WebDNS.settings[:dnssec_parent_authorities].merge!( test_authority: { valid: -> (parent) { true } } ) if Rails.env.test? WebDNS.settings[:serial_strategy] = Strategies::Date WebDNS.settings[:prohibit_records_types] = [] WebDNS.settings[:prohibit_domain_types] = ['NATIVE'] WebDNS.settings[:contact_mail] = 'webdns@example.com' WebDNS.settings[:mail_from] = 'webdns@example.com' WebDNS.settings[:admin_group] = 'admin' WebDNS.settings[:saml] = false WebDNS.settings[:saml_required_entitlement] = 'webdns' WebDNS.settings[:saml_login_text] = 'Login with SAML' # Allow local overrides local_settings = File.expand_path('../../local_settings.rb', __FILE__) require_relative local_settings if File.exist?(local_settings) diff --git a/dnsworker/lib/dnsworker/worker.rb b/dnsworker/lib/dnsworker/worker.rb index 98d3aba..483029a 100755 --- a/dnsworker/lib/dnsworker/worker.rb +++ b/dnsworker/lib/dnsworker/worker.rb @@ -1,109 +1,113 @@ require 'json' require 'net/http' require 'uri' require 'pp' require 'rack/utils' require 'dnsworker' require 'dnsworker/base_worker' require 'dnsworker/pushers/base' require 'dnsworker/pushers/papaki' require 'dnsworker/pushers/webdns' class DNSWorker::Worker include DNSWorker::BaseWorker Pushers = Hash[ :papaki, DNSWorker::Pushers::Papaki, :webdns, DNSWorker::Pushers::Webdns, ] def initialize(cfg) @cfg = cfg super(cfg['mysql']) end def add_domain(params) params[:master] = cfg['hidden_master'] cmd(cfg['bind_add'] % params) end def remove_domain(params) cmd(cfg['bind_del'] % params) end def opendnssec_add(params) cmd(cfg['ods_add'] % params) end def opendnssec_remove(params) cmd(cfg['ods_del'] % params) end def bind_convert_to_dnssec(params) fail Retry if !File.exist? File.join(cfg['zone_root'], 'signed', params[:zone]) # Remove zone and re-add it as a master zone remove_domain(params) cmd(cfg['bind_add_dnssec'] % params) end # The zone is signed, waiting for the ksk to become ready def wait_for_ready_to_push_ds(params) out, _err = cmd(cfg['ready_to_push_ds'] % params) fail Retry unless out['ds-seen'] end def publish_ds(params) pub_cls = Pushers[params[:dnssec_parent_authority].to_sym] fail JobFailed unless pub_cls pub = pub_cls.new(cfg) fail JobFailed unless pub.replace_ds(params[:dnssec_parent], params[:zone], params[:dss]) end def wait_for_active(params) keytag = params[:keytag] out, _err = cmd(cfg['key_activated'] % params) key_lines = out.each_line.select { |line| line.start_with?(params[:zone]) } # Check if the key is activated return if key_lines.any? {|kl| # example # KSK active 2016-12-12 18:41:33 (retire) 2048 8 b70042f966e5f01deb2e988607ad67ba SoftHSM 60076 kl.strip! _domain, _type, status, _rest = kl.split(/\s+/, 4) status == 'active' and _rest.end_with?(keytag) } fail Retry end + def wait_until(params) + fail Retry if Time.now.to_i < params[:until] + end + def trigger_event(params) query = Rack::Utils.build_query(domain: params[:zone], event: params[:event]) uri = URI(cfg.values_at('webdns_base', 'update_state').join % { query: query }) Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http| resp = http.request Net::HTTP::Put.new(uri.request_uri) fail JobFailed if resp.code != '200' ok = JSON.parse(resp.body)['ok'] fail JobFailed if !ok end end private def cmdline(jtype, jargs) if jargs send(jtype, jargs) else send(jtype) end end end diff --git a/test/models/domain_test.rb b/test/models/domain_test.rb index 7b6433a..2e6255b 100644 --- a/test/models/domain_test.rb +++ b/test/models/domain_test.rb @@ -1,238 +1,305 @@ require 'test_helper' class DomainTest < ActiveSupport::TestCase def setup @domain = build(:domain) end test 'automatic SOA creation' do @domain.save! @domain.reload assert_not_nil @domain.soa end test 'increment serial on new record' do @domain.save! soa = @domain.soa assert_serial_update soa do www = A.new(name: 'www', domain: @domain, content: '1.2.3.4') www.save! end end test 'increment serial on record update' do @domain.save! www = A.new(name: 'www', domain: @domain, content: '1.2.3.4') www.save! soa = @domain.soa.reload assert_serial_update soa do www.content = '1.2.3.5' www.save! end end test 'automatic NS creation' do @domain.save! @domain.reload assert_equal WebDNS.settings[:default_ns].sort, @domain.records.where(type: 'NS').pluck(:content).sort end test 'increment serial on record destroy' do @domain.save! www = A.new(name: 'www', domain: @domain, content: '1.2.3.4') www.save! soa = @domain.soa.reload assert_serial_update soa do www.destroy! end end class SlaveDomainTest < ActiveSupport::TestCase def setup @domain = build(:slave) end test 'saves' do @domain.save assert_empty @domain.errors end test 'automatic SOA creation' do @domain.save! @domain.reload assert_not_nil @domain.soa assert_equal 1, @domain.soa.serial end test 'validates master' do @domain.master = 'not-an-ip' @domain.save assert_not_empty @domain.errors['master'] end test 'no records are allowed for users' do @domain.save! rec = build(:a, domain_id: @domain.id) assert_not rec.valid? assert_not_empty rec.errors[:type] end end class StatesDomainTest < ActiveSupport::TestCase def setup @domain = build(:domain) @policy = create(:dnssec_policy) end test 'domain lifetime' do assert_equal 'initial', @domain.state # Create assert_jobs do @domain.save! # user triggered assert_equal 'pending_install', @domain.state end @domain.installed # job triggered assert_equal 'operational', @domain.state # Convert to dnssec (sign) assert_jobs do @domain.dnssec = true @domain.dnssec_policy = @policy @domain.dnssec_parent = @domain.name.split('.', 2).last @domain.dnssec_parent_authority = 'test_authority' @domain.save! # After commit is not triggered in tests, # so we have to trigger it manually @domain.send(:after_commit_event) assert_equal 'pending_signing', @domain.state end assert_jobs do assert @domain.signed # job triggered assert_equal 'wait_for_ready', @domain.state end # Convert to dnssec (publish ds) assert_jobs do assert @domain.push_ds(['dss1', 'dss2']) # triggered by schedule-ds script assert_equal 'pending_ds', @domain.state end assert @domain.converted # job triggered assert_equal 'operational', @domain.state # KSK rollover assert_jobs do assert @domain.push_ds(['dss3', 'dss4']) # triggered by schedule-ds script assert_equal 'pending_ds_rollover', @domain.state end assert @domain.complete_rollover # job triggered assert_equal 'operational', @domain.state # Convert to plain assert_jobs do assert @domain.plain_convert # user triggered assert_equal 'pending_plain', @domain.state end assert @domain.converted # job triggered assert_equal 'operational', @domain.state # Remove assert_jobs do assert @domain.remove # user triggered assert_equal 'pending_remove', @domain.state end assert @domain.cleaned_up # job triggered assert_equal 'destroy', @domain.state end + + test 'domain lifetime #full-destroy' do + assert_equal 'initial', @domain.state + + # Create + assert_jobs do + @domain.save! # user triggered + assert_equal 'pending_install', @domain.state + end + @domain.installed # job triggered + assert_equal 'operational', @domain.state + + # Convert to dnssec (sign) + assert_jobs do + @domain.dnssec = true + @domain.dnssec_policy = @policy + @domain.dnssec_parent = @domain.name.split('.', 2).last + @domain.dnssec_parent_authority = 'test_authority' + @domain.save! + + # After commit is not triggered in tests, + # so we have to trigger it manually + @domain.send(:after_commit_event) + + assert_equal 'pending_signing', @domain.state + end + + assert_jobs do + assert @domain.signed # job triggered + assert_equal 'wait_for_ready', @domain.state + end + + # Convert to dnssec (publish ds) + assert_jobs do + assert @domain.push_ds(['dss1', 'dss2']) # triggered by schedule-ds script + assert_equal 'pending_ds', @domain.state + end + assert @domain.converted # job triggered + assert_equal 'operational', @domain.state + + # KSK rollover + assert_jobs do + assert @domain.push_ds(['dss3', 'dss4']) # triggered by schedule-ds script + assert_equal 'pending_ds_rollover', @domain.state + end + assert @domain.complete_rollover # job triggered + assert_equal 'operational', @domain.state + + # Full Remove (Drops DS records) + assert_jobs do + assert @domain.full_remove # user triggered + assert_equal 'pending_ds_removal', @domain.state + end + + assert_jobs do + assert @domain.remove # job triggered + assert_equal 'pending_remove', @domain.state + end + assert @domain.cleaned_up # job triggered + assert_equal 'destroy', @domain.state + end end class DsDomainTest < ActiveSupport::TestCase def setup @domain = create(:domain) @ds = [ '31406 8 1 189968811e6eba862dd6c209f75623d8d9ed9142', '31406 8 2 f78cf3344f72137235098ecbbd08947c2c9001c7f6a085a17f518b5d8f6b916d', ] @child = "dnssec.#{@domain.name}" @extra = DS.create(domain: @domain, name: @child, content: 'other') end test 'add ds records' do Domain.replace_ds(@domain.name, @child, @ds) @extra.save! # Should be deleted assert_equal @ds.size, DS.where(name: "dnssec.#{@domain.name}").count @ds.each { |ds| assert_equal 1, DS.where(name: "dnssec.#{@domain.name}", content: ds).count } end + test 'remove ds records' do + Domain.replace_ds(@domain.name, @child, []) + + assert_equal 0, DS.where(name: "dnssec.#{@domain.name}").count + end + test 'check if child is a valid subdomain' do assert_raise Domain::NotAChild do Domain.replace_ds(@domain.name, 'dnssec.example.net', @ds) end end end class BulkTest < ActiveSupport::TestCase def setup @domain = create(:domain) @a = create(:a, domain: @domain) @aaaa = create(:aaaa, domain: @domain) @new = build(:mx, domain: @domain) end def valid_changes @valid_changes ||= begin {}.tap { |c| c[:deletes] = [@a.id] c[:changes] = { @aaaa.id => { content: '::42' }} c[:additions] = { 1 => @new.as_bulky_json } } end end def invalid_changes @invalid_changes ||= begin {}.tap { |c| c[:deletes] = [Record.maximum(:id) + 1] c[:changes] = { @aaaa.id => { content: '1.2.3.4' }} c[:additions] = { 1 => @new.as_bulky_json.update(prio: -1) } } end end test 'apply changes not' do err = @domain.bulk invalid_changes assert_not_empty err assert_includes err[:deletes][Record.maximum(:id) + 1], 'record not found' assert_includes err[:changes][@aaaa.id], 'not a valid IPv6' assert_includes err[:additions][1], 'not a valid DNS priority' end test 'apply changes' do err = @domain.bulk valid_changes @domain.reload @aaaa.reload assert_empty err assert_empty @domain.records.where(id: @a.id) assert_equal '::42', @aaaa.content assert_equal 1, @domain.records.where(type: :mx).count end end end