Page MenuHomeGRNET

No OneTemporary

File Metadata

Created
Tue, Sep 29, 4:41 PM
diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb
index 2d5bf65..b578d15 100644
--- a/app/controllers/api_controller.rb
+++ b/app/controllers/api_controller.rb
@@ -1,27 +1,82 @@
class ApiController < ApplicationController
+ rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
+ rescue_from ActionController::ParameterMissing, with: :parameter_missing
+
# This a private trusted API
skip_before_action :verify_authenticity_token
before_action :authenticate_token
+ before_action :domain, only: [:list, :bulk]
# GET /ping
def ping
render json: { ok: true, response: :pong }
end
# GET /whoami
def whoami
render json: { ok: true, response: current_user.to_api }
end
+ # GET domain/<name>/list
+ def list
+ records = Record.smart_order(@domain.records).map(&:to_api)
+ render json: { ok: true, response: records }
+ end
+
+ # POST domain/<name>/list
+ def bulk
+ api_params = params.require(:api).permit!
+ ops, err, bulk_ops = domain.api_bulk(api_params)
+
+ if err.empty?
+ notify_record_bulk(domain, bulk_ops)
+
+ render json: { ok: true,
+ response: {
+ operations: ops
+ }
+ }
+ else
+ render json: { ok: false,
+ errors: err,
+ response: {
+ operations: ops
+ }
+ }
+ end
+ end
+
private
def authenticate_token
if user = User.find_by_token(params.require(:token))
warden.set_user(user, store: false)
else
head(403)
end
end
+ def domain
+ if params[:domain] =~ /^[0-9]+$/
+ params[:domain_id] = params[:domain]
+ else
+ params[:domain_id] = Domain.find_by_name!(params[:domain]).id
+ end
+
+ super
+ end
+
+ def record_not_found
+ render json: { ok: false, error: :record_not_found }
+ end
+
+ def parameter_missing
+ render json: { ok: false, error: :parameter_missing }
+ end
+
+ def notify_record_bulk(*args)
+ notification.notify_record_bulk(current_user, *args) if WebDNS.settings[:notifications]
+ end
+
end
diff --git a/app/models/domain.rb b/app/models/domain.rb
index edad604..2894121 100644
--- a/app/models/domain.rb
+++ b/app/models/domain.rb
@@ -1,321 +1,384 @@
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_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
def to_export
Hash[
:id, id,
:name, name,
:group, group.name,
].with_indifferent_access
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)
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 api bulk to operations to the zone
+ #
+ # 1) Deletions
+ # 2) Upserts
+ # 3) Additions
+ def api_bulk(opts)
+ api_deletes = opts[:deletes] || []
+ api_upserts = opts[:upserts] || []
+ api_additions = opts[:additions] || []
+ api_delete_errors = {}
+
+ deletes = []
+ additions = {}
+
+ api_deletes.each { |del|
+ rec = records.find_by(del)
+ # Fail-fast if record doesn't exist
+ if rec.nil?
+ return [{}, { deletes: { del: 'record not found'}}]
+ end
+
+ deletes << rec.id
+ }
+
+ # We delete records matching the same name & type
+ api_upserts.each { |ups|
+ query = ups.slice(:name, :type)
+ existing = records.where(query).to_a
+
+ # Skip upsert if we are trying to save the same record
+ next if existing.one? && ups.all? { |k, v| existing.first.to_api[k] == v }
+
+ deletes += existing.map(&:id)
+ api_additions << ups
+ }
+
+ api_additions.each { |add|
+ additions[add] = add
+ }
+
+ ops, errors = bulk(deletes: deletes, additions: additions)
+
+ # Serialize the response for API
+ api_ops = {}
+ api_errors = {}
+
+ # ops
+ ops.each { |op, recs| api_ops[op] = recs.map(&:to_api) }
+
+ # errors
+ if errors.any?
+ errors.each { |op, err|
+ api_errors[op] = err.map { |rec, err|
+ { operation: rec, error: err }
+ }
+ }
+ end
+
+ # This is a bit ugly, we return an ops hash with the original bulk
+ # responses so we can feed it to record notification.
+ [api_ops, api_errors, ops]
+ 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] = {} }
operations = 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
operations[:deletes] << rec
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|
if rec = to_change[Integer(rec_id)]
operations[:changes] << rec
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)
operations[:additions] << rec
errors[:additions][inc] = rec.errors.full_messages.join(', ') if !rec.save
}
raise ActiveRecord::Rollback if errors.any?
end
[operations, 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/record.rb b/app/models/record.rb
index c29fa47..228e4f9 100644
--- a/app/models/record.rb
+++ b/app/models/record.rb
@@ -1,311 +1,322 @@
require 'ipaddr'
require_dependency 'drop_privileges_validator'
class Record < ActiveRecord::Base
belongs_to :domain
# Powerdns inserts empty records on slave zones,
# we want to hide them
#
# http://mailman.powerdns.com/pipermail/pdns-users/2013-December/010389.html
default_scope { where.not(type: nil) }
# List all supported DNS RR types.
def self.record_types
[
'A', 'AAAA', 'CNAME',
'MX',
'TXT', 'SPF', 'SRV', 'SSHFP',
'SOA', 'NS',
'PTR', 'NAPTR',
'DS'
]
end
# List types usually used in forward zones.
def self.forward_records
record_types - ['SOA', 'PTR']
end
# List types usually used in reverse zones.
def self.reverse_records
['PTR', 'CNAME', 'TXT', 'NS', 'NAPTR', 'DS']
end
# List types usually used in enum zones.
def self.enum_records
['NAPTR', 'CNAME', 'TXT', 'NS', 'DS']
end
# List types that can be touched by a simple user.
def self.allowed_record_types
record_types - WebDNS.settings[:prohibit_records_types]
end
validates :name, presence: true
validates :type, inclusion: { in: record_types }
# http://mark.lindsey.name/2009/03/never-use-dns-ttl-of-zero-0.html
validates_numericality_of :ttl,
allow_nil: true, # Default pdns TTL
only_integer: true,
greater_than: 0,
less_than_or_equal_to: 2_147_483_647
# Don't allow the following actions on drop privileges mode
validate :no_touching_for_slave_zones, if: -> { domain.slave? }
validates_drop_privileges :type,
message: 'You cannot touch that record!',
unless: -> { Record.allowed_record_types.include?(type) }
validates_drop_privileges :name,
message: 'You cannot touch top level NS records!',
if: -> { type == 'NS' && domain_record? }
before_validation :guess_reverse_name
before_validation :set_name
after_save :update_zone_serial
after_destroy :update_zone_serial
before_create :generate_classless_delegations, unless: -> { domain.slave? }
before_destroy :delete_classless_delegations, unless: -> { domain.slave? }
# Smart sort a list of records.
#
# Order by:
# * Top level records
# * Record name
# * SOA
# * NS
# * Friendly type
# * Priority
# * Content
#
# records - The list of records to order.
#
# Returns the list sorted.
def self.smart_order(records)
records.sort_by { |r|
[
r.domain_record? ? 0 : 1, # Zone records
r.classless_delegated? ? 1 : 0,
r.name,
r.type == 'SOA' ? 0 : 1,
r.type == 'NS' ? 0 : 1,
record_types.index(r.type), # Friendly type
r.prio,
r.content
]
}
end
def self.search(query)
wild_search = "%#{query}%" # !index_friendly
where('name like :q or content like :q', q: wild_search)
end
# Get the a short name for the record (without the zone suffix).
#
# Returns a string.
def short
return '' if name == domain.name
return '' if name.blank?
File.basename(name, ".#{domain.name}")
end
# Returns true if this is a zone record.
def domain_record?
name.blank? || name == domain.name
end
# Find out if the record is edittable.
#
# by - Editable by :user or :admin.
#
# Returns true if the record is editable.
def editable?(by = :user)
return false if domain.slave?
return false if classless_delegated?
case by
when :user
return false unless Record.allowed_record_types.include?(type)
return false if type == 'NS' && domain_record?
end
true
end
# Find out this record type supports priorities.
#
# We set this to false by default, record types that support priorities.
# shoule override this.
#
# Returns true this record type support priorities.
def supports_prio?
false
end
# Make sure rails generates record specific urls for all record types.
#
# Overrides default rails STI behavior.
def self.model_name
return super if self == Record
Record.model_name
end
# Generate the usual admin friendly DNS record line.
#
# Returns a string.
def to_dns
[name, ttl, 'IN', type, supports_prio? ? prio : nil, content].compact.join(' ')
end
# Generate a shorter version of the DNS record line.
#
# Returns a string.
def to_short_dns
[name, 'IN', type].join(' ')
end
+ def to_api
+ Hash[
+ :name, name,
+ :content, content,
+ :type, type,
+ :ttl, ttl,
+ :prio, prio,
+ :disabled, disabled
+ ].with_indifferent_access
+ end
+
def classless_delegated?
return false if not type == 'CNAME'
return false if not domain.name.end_with?('.in-addr.arpa')
network, mask = parse_delegation(content)
return false if network.nil?
octet = name.split('.').first.to_i
return true if octet >= network
return true if octet <= network + 2 ^ (32 - mask) - 1 # max
false
end
def classless_delegation?
return true if classless_delegation
false
end
def as_bulky_json
Hash[
id: id,
name: name,
type: type,
ttl: ttl,
prio: prio,
content: content,
disabled: disabled
]
end
private
# Validations
def no_touching_for_slave_zones
# Allow automatic SOA creation for slave zones
# powerdns needs a valid serial to compare it with master
return if type == 'SOA' && validation_context == :create
errors.add(:type, 'This is a slave zone!')
end
# Hooks
def guess_reverse_name
return if not type == 'PTR'
return if not domain.reverse?
return if name.blank?
reverse = IPAddr.new(name).reverse
self.name = reverse if reverse.end_with?(domain.name)
rescue IPAddr::InvalidAddressError # rubycop:disable HandleExceptions
end
# Powerdns expects full domain names
def set_name
self.name = domain.name if name.blank?
self.name = "#{name}.#{domain.name}" if not name.end_with?(domain.name)
end
def remove_terminating_dot
self.content = content.gsub(/\.+\Z/, '')
end
def update_zone_serial
# SOA records handle serial themselves
return true if type == 'SOA'
return true if !domain
domain.soa.bump_serial!
end
def classless_delegation
return if not type == 'NS'
return if not domain.name.end_with?('.in-addr.arpa')
network, mask = parse_delegation(name)
return if network.nil?
range = IPAddr.new("0.0.0.#{network}/#{mask}").to_range
return if !range.first.to_s.end_with?(".#{network}")
range.map { |ip|
octet = ip.to_s.split('.').last
"#{octet}.#{domain.name}"
}
end
def parse_delegation(value)
first, _rest = value.split('.', 2)
first.gsub!('-', '/')
return if !first['/']
network, mask = first.split('/', 2).map { |i| Integer(i).abs }
return if [network, mask].join('/') != first
return if mask <= 24
return if mask > 31
return if network > 255
[network, mask]
rescue ArgumentError # Not an integer
end
def delete_classless_delegations
rnames = classless_delegation
return unless rnames
# Check if we have another NS for the same delegation
return if domain.records.where(type: 'NS', name: name)
.where.not(id: id).exists?
# Delete all CNAMEs
domain.records.where(name: rnames,
type: 'CNAME',
content: name).delete_all
end
def generate_classless_delegations
rnames = classless_delegation
return unless rnames
# Make sure no record exists for a delegated domain
if domain.records.where(name: rnames)
.where.not(content: name).exists?
errors.add(:name, 'Records already exist for the delegated octets!')
return false
end
rnames.each { |rname|
CNAME.find_or_create_by!(
domain: domain,
name: rname,
content: name
)
}
end
end
diff --git a/config/routes.rb b/config/routes.rb
index 0ecff57..80b5e24 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1,75 +1,77 @@
Rails.application.routes.draw do
# Override devise user removal
devise_scope :users do
delete :users, to: redirect('/')
end
devise_for :users
get '/auth/saml', to: 'auth#saml'
root to: redirect('/domains')
resources :users, only: [] do
get :token, to: 'users#token', on: :member
post :generate_token, to: 'users#generate_token', on: :member
end
resources :groups, only: [:show] do
get :search_member,
to: 'groups#search_member', on: :member
post :members,
to: 'groups#create_member', as: :create_member, on: :member
delete 'member/:user_id',
to: 'groups#destroy_member', as: :destroy_member, on: :member
end
resources :domains do
get :edit_dnssec, to: 'domains#edit_dnssec', on: :member
delete :full_destroy, to: 'domains#full_destroy', on: :member
resources :records, except: [:index, :show] do
# Reuse records#update instead of introducing new controller actions
#
# rubocop:disable Style/AlignHash
put :disable, to: 'records#update', on: :member,
defaults: { record: { disabled: true } }
put :enable, to: 'records#update', on: :member,
defaults: { record: { disabled: false } }
put :editable, to: 'records#editable', on: :collection
post :valid, to: 'records#valid', on: :collection
post :bulk, to: 'records#bulk', on: :collection
# rubocop:enable Style/AlignHash
end
end
get '/records/search', to: 'records#search'
# Admin
namespace :admin do
root to: redirect('/admin/groups')
resources :groups, except: [:show]
resources :jobs, only: [:index, :destroy] do
put :done, to: 'jobs#update', on: :member,
defaults: { job: { status: 1 } }
put :pending, to: 'jobs#update', on: :member,
defaults: { job: { status: 0 } }
end
resources :users, only: [:destroy] do
get :orphans, to: 'users#orphans', on: :collection
put :update_groups, to: 'users#update_groups', on: :collection
end
end
# API
scope '/api' do
get :ping, to: 'api#ping'
get :whoami, to: 'api#whoami'
+ get '/domain/:domain/list', to: 'api#list', constraints: { domain: /[^\/]+/}
+ post '/domain/:domain/bulk', to: 'api#bulk', constraints: { domain: /[^\/]+/}
end if WebDNS.settings[:api]
# Private
put 'private/replace_ds', to: 'private#replace_ds'
put 'private/trigger_event', to: 'private#trigger_event'
get 'private/zones', to: 'private#zones'
end
diff --git a/test/models/domain_test.rb b/test/models/domain_test.rb
index b91ea82..fce86b1 100644
--- a/test/models/domain_test.rb
+++ b/test/models/domain_test.rb
@@ -1,308 +1,414 @@
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 ds-schedule 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 ds-schedule 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 ds-schedule 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 ds-schedule 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
ops, 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
ops, 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
assert_equal 1, ops[:additions].size
assert_equal 1, ops[:changes].size
assert_equal 1, ops[:deletes].size
end
end
+
+ class ApiBulkTest < ActiveSupport::TestCase
+ def setup
+ @domain = create(:domain)
+ @a = create(:a, domain: @domain)
+ @aaaa = create(:aaaa, domain: @domain, content: '::42')
+ @new = build(:mx, domain: @domain)
+ @upsert_txt = build(:txt, domain: @domain)
+
+ end
+
+ def valid_changes
+ @valid_changes ||= begin
+ {}.tap { |c|
+ c[:deletes] = [@a.to_api]
+ c[:additions] = [@new.to_api]
+ c[:upserts] = [@upsert_txt.to_api]
+ }
+ end
+ end
+
+ test 'apply changes' do
+ ops, err = @domain.api_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
+ assert_equal 2, ops[:additions].size # upsert is accounted as in addition
+ assert_equal 1, ops[:deletes].size
+
+ end
+
+ test 'additions is invalid' do
+ changes = {
+ additions: [ @new.to_api.update(prio: -1) ]
+ }
+
+ ops, err = @domain.api_bulk changes
+
+ assert_not_empty err
+ assert_includes err[:additions].first[:error], 'not a valid DNS priority'
+ end
+
+
+ test 'delete not exists' do
+ changes = Hash[
+ :deletes, [{name: 'nx', type: 'TXT', content: 'not-exists'}]
+ ]
+
+ ops, err = @domain.api_bulk changes
+
+ assert_empty ops
+ assert_equal 1, err[:deletes].size
+ end
+
+ test 'upsert does not exist (single record)' do
+ a1 = create(:a, domain: @domain, name: 'rr', content: '127.0.0.1')
+
+ rr_name = "rr.#{@domain.name}"
+ changes = Hash[
+ :upserts, [{name: rr_name, type: 'A', content: '127.0.0.3'}]
+ ]
+
+ ops, err = @domain.api_bulk changes
+
+ assert_empty err
+ assert_equal 1, @domain.records.where(name: rr_name, type: 'A').count
+ assert_equal '127.0.0.3', @domain.records.find_by(name: rr_name, type: 'A').content
+ end
+
+ test 'upsert does not exist (multiple records)' do
+ a1 = create(:a, domain: @domain, name: 'rr', content: '127.0.0.1')
+ a2 = create(:a, domain: @domain, name: 'rr', content: '127.0.0.2')
+
+ rr_name = "rr.#{@domain.name}"
+ changes = Hash[
+ :upserts, [{name: rr_name, type: 'A', content: '127.0.0.3'}]
+ ]
+
+ ops, err = @domain.api_bulk changes
+
+ assert_empty err
+ assert_equal 1, @domain.records.where(name: rr_name, type: 'A').count
+ assert_equal '127.0.0.3', @domain.records.find_by(name: rr_name, type: 'A').content
+ end
+
+ test 'upsert exists' do
+ a1 = create(:a, domain: @domain, name: 'rr', content: '127.0.0.1')
+
+ rr_name = "rr.#{@domain.name}"
+ changes = Hash[
+ :upserts, [{name: rr_name, type: 'A', content: '127.0.0.1'}]
+ ]
+
+ ops, err = @domain.api_bulk changes
+
+ assert_empty err
+ assert_empty ops # upsert is a noop
+ assert_equal 1, @domain.records.where(name: rr_name, type: 'A').count
+ assert_equal '127.0.0.1', @domain.records.find_by(name: rr_name, type: 'A').content
+ end
+ end
end

Event Timeline