diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 3efd037..7f63099 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -1,51 +1,55 @@ /* * This is a manifest file that'll be compiled into application.css, which will include all the files * listed below. * * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, * or vendor/assets/stylesheets of plugins, if any, can be referenced here using a relative path. * * You're free to add application-wide styles to this file and they'll appear at the bottom of the * compiled file so the styles you add here take precedence over styles defined in any styles * defined in the other CSS/SCSS files in this directory. It is generally better to create a new * file per style scope. * *= require bootstrap.min *= require dataTables.bootstrap.min *= require bootstrap-editable *= require_tree . *= require_self */ /* Make sure navbar does not overlay body */ body { padding-top: 70px; } .highlight , .highlight>td { background-color: #b8e0b8 !important; } /* DataTable resets bootstrap's margin-bottom */ .datatable-wrapper { margin-bottom: 20px; } /* Reset bootstrap's help cursor on control links */ table a abbr[title] { cursor: pointer; } .tab-pane table { margin-top: 20px; } #inline-record-form #record_ttl { width: 80px; } #inline-record-form #record_prio { width: 80px; } #inline-record-form #record_content { width: 300px; } + +#domains span.glyphicon-volume-up { + color: red; +} diff --git a/app/controllers/domains_controller.rb b/app/controllers/domains_controller.rb index 5c9903d..35f001f 100644 --- a/app/controllers/domains_controller.rb +++ b/app/controllers/domains_controller.rb @@ -1,104 +1,107 @@ +require 'set' + class DomainsController < ApplicationController before_action :authenticate_user! before_action :domain, only: [:show, :edit, :edit_dnssec, :update, :destroy, :full_destroy] before_action :group, only: [:show, :edit, :edit_dnssec, :update, :destroy, :full_destroy] helper_method :edit_group_scope # GET /domains def index @domains = show_domain_scope.includes(:group, :soa).all + @optouts = Set.new current_user.subscriptions.pluck(:domain_id) end # GET /domains/1 def show @record = Record.new(domain_id: @domain.id) end # GET /domains/new def new @domain = Domain.new(new_domain_params) end # GET /domains/1/edit def edit end # GET /domains/1/edit_dnssec def edit_dnssec end # POST /domains def create @domain = Domain.new(domain_params) if @domain.save notify_domain(@domain, :create) redirect_to @domain, notice: "#{@domain.name} was successfully created." else render :new end end # PATCH/PUT /domains/1 def update if @domain.update(domain_params) notify_domain(@domain, :update) redirect_to @domain, notice: "#{@domain.name} was successfully updated." else if domain_params[:dnssec] # DNSSEC form render :edit_dnssec else render :edit end end end # DELETE /domains/1 def destroy if @domain.remove notify_domain(@domain, :destroy) redirect_to domains_url, notice: "#{@domain.name} is scheduled for removal." else redirect_to domains_url, alert: "#{@domain.name} cannot be deleted! (state '#{@domain.state}')" end end # DELETE /domains/1/full_destroy def full_destroy if @domain.full_remove notify_domain(@domain, :destroy) redirect_to domains_url, notice: "#{@domain.name} is scheduled for full removal. DS records will be dropped from the parent zone before proceeding" else redirect_to domains_url, alert: "#{@domain.name} cannot be deleted! (state '#{@domain.state}')" end end private def group domain.group end def new_domain_params params.permit(:group_id) end def domain_params params.require(:domain).tap { |d| # Make sure group id is permitted (belongs to edit_group_scope) d[:group_id] = edit_group_scope.find_by_id(d[:group_id]).try(:id) # Sometimes domain name might contain whitespace, make sure we remove # them. Note that we use a regex to handle unicode whitespace characters as well. d[:name] = d[:name].gsub(/\p{Space}/, '') if d[:name] }.permit(:name, :type, :master, :group_id, :dnssec, :dnssec_parent, :dnssec_parent_authority, :dnssec_policy_id) end def notify_domain(*args) notification.notify_domain(current_user, *args) if WebDNS.settings[:notifications] end end diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 0d0c3eb..d22f844 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -1,53 +1,54 @@ class GroupsController < ApplicationController before_action :authenticate_user! before_action :group, only: [:show, :create_member, :destroy_member, :search_member] before_action :user, only: [:destroy_member] # GET /groups/1 def show @domains = @group.domains + @optouts = Set.new current_user.subscriptions.where(domain_id: @domains).pluck(:domain_id) end # POST /groups/1/members/ def create_member @user = User.find_by_email(params[:email]) if !@user redirect_to group_path(@group, anchor: 'tab-members'), alert: "User '#{params[:email]}' not found!" return end membership = @group.memberships.find_or_create_by!(user_id: @user.id) redirect_to group_path(@group, anchor: 'tab-members'), notice: "#{membership.user.email} is now a member of #{@group.name}" end # DELETE /groups/1/member/1 def destroy_member membership = @group.memberships.find_by!(user_id: user.id) membership.destroy! redirect_to @group, notice: "#{membership.user.email} was successfully removed from #{@group.name}" end def search_member results = [] if params[:q].present? uids = group.users.pluck(:id) results = User .where('email like ?', "#{params[:q]}%") .where.not(id: uids) # Exclude group members .limit(10) end render json: results.map { |r| Hash[:id, r.id, :email, r.email] } end private def user @user ||= User.find(params[:user_id]) end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index ef79c33..5ae40dd 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1,31 +1,64 @@ class UsersController < ApplicationController before_action :authenticate_user! - before_action :user, only: [:token, :generate_token] + before_action :user, only: [:mute, :unmute, :mute_all, :unmute_all, :token, :generate_token] # GET /users/1/token def token end # POST /users/1/generate_token def generate_token @user.token = SecureRandom.hex(10) @user.save! redirect_to token_user_path(@user) end + # PUT /users/1/unsubscribe/2 + def mute + domain = show_domain_scope.find(params[:domain_id]) + @user.subscriptions.find_or_create_by!(domain: domain) + + redirect_to domains_url, notice: "Successfully unsubscribed from #{domain.name} notifications!" + end + + # PUT /users/1/subscribe/2 + def unmute + domain = show_domain_scope.find(params[:domain_id]) + # Drop all opt-outs + @user.subscriptions.where(domain: domain).delete_all + + redirect_to domains_url, notice: "Successfully subscribed to #{domain.name} notifications!" + end + + # PUT /users/1/domains/mute + def mute_all + @user.update_column(:notifications, false) + @user.mute_all_domains + + redirect_to domains_url, notice: "Successfully unsubscribed from all domain notifications!" + end + + # PUT /users/1/domains/mute + def unmute_all + @user.update_column(:notifications, true) + @user.subscriptions.delete_all + + redirect_to domains_url, notice: "Successfully unsubscribed from all domain notifications!" + end + private def user - @user ||= User.find(params[:id]) + @user ||= User.find(params[:user_id] || params[:id]) # Guard access to other user tokens if current_user.id != @user.id && !admin? redirect_to(root_path, alert: 'You need admin rights for that!') end @user end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index b48b410..7a7b4d0 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,57 +1,65 @@ module ApplicationHelper TIME_PERIODS = { 1.second => 'second', 1.minute => 'minute', 1.hour => 'hour', 1.day => 'day', 1.week => 'week', 1.month => 'month', 1.year.to_i => 'year', } def can_edit?(object) return true unless object.respond_to?(:editable?) by = admin? ? :admin : :user object.editable?(by) end def seconds_to_human(seconds) acc = {} remaining = seconds TIME_PERIODS.to_a.reverse_each do |p, human| period_count, remaining = remaining.divmod(p) acc[human] = period_count if not period_count.zero? end acc.map { |singular, count| human = count < 2 ? singular : "#{singular}s" "#{count} #{human}" }.join(', ') end def link_to_edit(*args, &block) link_to(abbr_glyph(:pencil, 'Edit'), *args, &block) end def link_to_destroy(*args, &block) link_to(abbr_glyph(:remove, 'Remove'), *args, &block) end def link_to_enable(*args, &block) link_to(abbr_glyph(:'eye-close', 'Enable'), *args, &block) end def link_to_disable(*args, &block) link_to(abbr_glyph(:'eye-open', 'Disable'), *args, &block) end + def link_to_mute(*args, &block) + link_to(abbr_glyph(:'volume-off', 'Disable notifications'), *args, &block) + end + + def link_to_unmute(*args, &block) + link_to(abbr_glyph(:'volume-up', 'Renable notifications'), *args, &block) + end + def glyph(icon) content_tag(:span, '', class: "glyphicon glyphicon-#{icon}") end def abbr_glyph(icon, title) content_tag(:abbr, glyph(icon), title: title) end end diff --git a/app/models/domain.rb b/app/models/domain.rb index 25e4c52..56e3fe5 100644 --- a/app/models/domain.rb +++ b/app/models/domain.rb @@ -1,392 +1,401 @@ 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 :opt_outs, class_name: 'Subscription', dependent: :delete_all + 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 :generate_subscriptions 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 def to_api Hash[ :name, name, :slave, slave?, :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 generate_subscriptions + group.users.where(notifications: false).each { |u| + opt_outs.create(user: u) + } + 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/subscription.rb b/app/models/subscription.rb new file mode 100644 index 0000000..476954d --- /dev/null +++ b/app/models/subscription.rb @@ -0,0 +1,11 @@ +class Subscription < ActiveRecord::Base + belongs_to :domain + belongs_to :user + + validates_presence_of :domain + validates_presence_of :user + validates_uniqueness_of :domain_id, scope: :user_id + + # opt-out only + validates :disabled, inclusion: { in: [true] }, presence: true +end diff --git a/app/models/user.rb b/app/models/user.rb index b9f1cbe..66f7508 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,30 +1,48 @@ class User < ActiveRecord::Base devise :database_authenticatable, :registerable, :recoverable, :rememberable, :trackable, :validatable has_many :memberships has_many :groups, through: :memberships + has_many :subscriptions, dependent: :delete_all + scope :orphans, -> { includes(:memberships).where(:memberships => { user_id: nil }) } # Check if the user can change his password # # Remote users are not able to change their password def can_change_password? !identifier? end def to_api Hash[ :id, id, :email, email ].with_indifferent_access end def self.find_for_database_authentication(conditions) # Override devise method for database auth # We only want to auth local user via the database. find_first_by_auth_conditions(conditions, identifier: '') end + + def mute_all_domains + ActiveRecord::Base.transaction do + domain_ids = Domain.where(group: groups).pluck(:id) + domain_ids.each { |did| + + sub = self.subscriptions.create(domain_id: did) + if !sub.valid? + # Allow only domain_id (uniqueness) errors + raise x.errors.full_messages.join(', ') if sub.errors.size > 1 + raise x.errors.full_messages.join(', ') if !sub.errors[:domain_id] + end + + } + end + end end diff --git a/app/views/domains/index.html.erb b/app/views/domains/index.html.erb index a95799f..22beec0 100644 --- a/app/views/domains/index.html.erb +++ b/app/views/domains/index.html.erb @@ -1,56 +1,61 @@ <% if current_user.memberships.empty? %>

Wellcome to WebDNS!

In order to manage domains you have to be a member of a group.

You can either contact an admin to create a new group for you, or ask another user for an invite to an existing group.

<% end %>
<% @domains.group_by(&:group).each do |group, domains| %> <% domains.each do |domain| %> <% end %> <% end %>
Domain Serial Group State Slave DNSSEC Controls
<%= link_to domain.name, domain %> <%= domain.serial %> <%= link_to group.name, group_path(group) %> <%= human_state(domain.state) %> <%= domain.slave? ? domain.master : '-' %> <%= domain.dnssec? ? 'secure' : '-' %> <%= link_to_edit edit_domain_path(domain) %> + <% if @optouts.include? domain.id %> + <%= link_to_unmute user_domain_unmute_path(current_user, domain), method: :put %> + <% else %> + <%= link_to_mute user_domain_mute_path(current_user, domain), method: :put %> + <% end %> <%= link_to_destroy domain, method: :delete, data: { confirm: 'Are you sure?' } if domain.can_remove? %> <%= link_to_full_destroy full_destroy_domain_path(domain), method: :delete, data: { confirm: 'Are you sure?' } if domain.can_remove? && domain.dnssec? %>

<% if current_user.memberships.any? %> <%= link_to 'Add Domain', new_domain_path, class: 'btn btn-primary' %> <% else %> <%= link_to 'Add Domain', new_domain_path, class: 'btn btn-primary disabled' %> <% end %>

diff --git a/app/views/groups/show.html.erb b/app/views/groups/show.html.erb index 7874355..d81de70 100644 --- a/app/views/groups/show.html.erb +++ b/app/views/groups/show.html.erb @@ -1,87 +1,92 @@ <% content_for :more_breadcrumbs do %>
  • <%= link_to_edit edit_admin_group_path(@group) %> <%= link_to_destroy admin_group_path(@group), method: :delete, data: { confirm: 'Are you sure?' } %>
  • <% end if admin? %>
    <% @domains.group_by(&:group).each do |group, domains| %> <% domains.each do |domain| %> <% end %> <% end %>
    Domain Serial Group State Slave DNSSEC Controls
    <%= link_to domain.name, domain %> <%= domain.serial %> <%= link_to group.name, group_path(group) %> <%= human_state(domain.state) %> <%= domain.slave? ? domain.master : '-' %> <%= domain.dnssec? ? 'secure' : '-' %> <%= link_to_edit edit_domain_path(domain) %> + <% if @optouts.include? domain.id %> + <%= link_to_unmute user_domain_unmute_path(current_user, domain), method: :put %> + <% else %> + <%= link_to_mute user_domain_mute_path(current_user, domain), method: :put %> + <% end %> <%= link_to_destroy domain, method: :delete, data: { confirm: 'Are you sure?' } if domain.can_remove? %> <%= link_to_full_destroy full_destroy_domain_path(domain), method: :delete, data: { confirm: 'Are you sure?' } if domain.can_remove? && domain.dnssec? %>

    <% if current_user.memberships.any? %> <%= link_to 'Add Domain', new_domain_path(group_id: @group.id), class: 'btn btn-primary' %> <% else %> <%= link_to 'Add Domain', new_domain_path(group_id: @group.id), class: 'btn btn-primary disabled' %> <% end %>

    <% @group.memberships.includes(:user).each do |membership| %> <% end %>
    Member Controls
    <%= membership.user.email %><%= " (you)" if current_user == membership.user %> <%= link_to_destroy destroy_member_group_path(@group, membership.user_id), method: :delete %>

    <%= bootstrap_form_tag(url: create_member_group_path(@group), layout: :inline) do |f| %> <%= f.text_field :email, prepend: 'Add Member', hide_label: true, id: 'js-search-member', data: { group: @group.id } %> <%= f.submit 'Add', class: 'btn btn-primary' %> <% end %>

    diff --git a/app/views/shared/_nav.html.erb b/app/views/shared/_nav.html.erb index e05d35d..22fc114 100644 --- a/app/views/shared/_nav.html.erb +++ b/app/views/shared/_nav.html.erb @@ -1,57 +1,64 @@ diff --git a/config/routes.rb b/config/routes.rb index cb736ad..b85202c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,82 +1,88 @@ 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 + resources :domains, only: [] do + put :mute, to: 'users#mute' + put :unmute, to: 'users#unmute' + put :mute, to: 'users#mute_all', on: :collection + put :unmute, to: 'users#unmute_all', on: :collection + end 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 } } get '/type/:category', to: 'jobs#index', on: :collection, constraints: proc { |req| ['completed', 'pending'].include?(req.params[:category]) } 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: /[^\/]+/} get :domains, to: 'api#domains' 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' get 'help/api', to: 'help#api' end diff --git a/db/migrate/20170305083712_create_subscriptions.rb b/db/migrate/20170305083712_create_subscriptions.rb new file mode 100644 index 0000000..a9474af --- /dev/null +++ b/db/migrate/20170305083712_create_subscriptions.rb @@ -0,0 +1,11 @@ +class CreateSubscriptions < ActiveRecord::Migration + def change + create_table :subscriptions do |t| + t.references :domain, index: true, null: false + t.references :user, index: true, null: false + t.boolean :disabled, default: true, null: false + + t.timestamps + end + end +end diff --git a/db/migrate/20170308060441_add_notifications_to_user.rb b/db/migrate/20170308060441_add_notifications_to_user.rb new file mode 100644 index 0000000..554df66 --- /dev/null +++ b/db/migrate/20170308060441_add_notifications_to_user.rb @@ -0,0 +1,5 @@ +class AddNotificationsToUser < ActiveRecord::Migration + def change + add_column :users, :notifications, :boolean, default: true, null: false + end +end diff --git a/db/structure.sql b/db/structure.sql index 1613156..073b154 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -1,318 +1,343 @@ -- MySQL dump 10.15 Distrib 10.0.20-MariaDB, for debian-linux-gnu (x86_64) -- -- Host: localhost Database: webns -- ------------------------------------------------------ -- Server version 10.0.20-MariaDB-3 /*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; /*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; /*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; /*!40101 SET NAMES utf8 */; /*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; /*!40103 SET TIME_ZONE='+00:00' */; /*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; /*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; /*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; /*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; -- -- Table structure for table `comments` -- DROP TABLE IF EXISTS `comments`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `comments` ( `id` int(11) NOT NULL AUTO_INCREMENT, `domain_id` int(11) NOT NULL, `name` varchar(255) NOT NULL, `type` varchar(10) NOT NULL, `modified_at` int(11) NOT NULL, `account` varchar(40) NOT NULL, `comment` mediumtext NOT NULL, PRIMARY KEY (`id`), KEY `comments_domain_id_idx` (`domain_id`), KEY `comments_name_type_idx` (`name`,`type`), KEY `comments_order_idx` (`domain_id`,`modified_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; /*!40101 SET character_set_client = @saved_cs_client */; -- -- Table structure for table `cryptokeys` -- DROP TABLE IF EXISTS `cryptokeys`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `cryptokeys` ( `id` int(11) NOT NULL AUTO_INCREMENT, `domain_id` int(11) NOT NULL, `flags` int(11) NOT NULL, `active` tinyint(1) DEFAULT NULL, `content` text, PRIMARY KEY (`id`), KEY `domainidindex` (`domain_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; /*!40101 SET character_set_client = @saved_cs_client */; -- -- Table structure for table `dnssec_policies` -- DROP TABLE IF EXISTS `dnssec_policies`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `dnssec_policies` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(255) DEFAULT NULL, `active` tinyint(1) DEFAULT NULL, `policy` text, `created_at` datetime NOT NULL, `updated_at` datetime NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; /*!40101 SET character_set_client = @saved_cs_client */; -- -- Table structure for table `domainmetadata` -- DROP TABLE IF EXISTS `domainmetadata`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `domainmetadata` ( `id` int(11) NOT NULL AUTO_INCREMENT, `domain_id` int(11) NOT NULL, `kind` varchar(32) DEFAULT NULL, `content` text, PRIMARY KEY (`id`), KEY `domainmetadata_idx` (`domain_id`,`kind`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; /*!40101 SET character_set_client = @saved_cs_client */; -- -- Table structure for table `domains` -- DROP TABLE IF EXISTS `domains`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `domains` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(255) NOT NULL, `master` varchar(128) DEFAULT NULL, `last_check` int(11) DEFAULT NULL, `type` varchar(6) NOT NULL, `notified_serial` int(11) DEFAULT NULL, `account` varchar(40) DEFAULT NULL, `group_id` int(11) DEFAULT NULL, `created_at` datetime NOT NULL, `updated_at` datetime NOT NULL, `state` varchar(255) NOT NULL DEFAULT 'initial', `dnssec` tinyint(1) NOT NULL DEFAULT '0', `dnssec_parent` varchar(255) NOT NULL DEFAULT '', `dnssec_parent_authority` varchar(255) NOT NULL DEFAULT '', `dnssec_policy_id` int(11) DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `name_index` (`name`), KEY `index_domains_on_group_id` (`group_id`) ) ENGINE=InnoDB AUTO_INCREMENT=37 DEFAULT CHARSET=utf8; /*!40101 SET character_set_client = @saved_cs_client */; -- -- Table structure for table `groups` -- DROP TABLE IF EXISTS `groups`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `groups` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(255) DEFAULT NULL, `disabled` tinyint(1) DEFAULT '0', `created_at` datetime DEFAULT NULL, `updated_at` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `index_groups_on_name` (`name`) ) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8; /*!40101 SET character_set_client = @saved_cs_client */; -- -- Table structure for table `jobs` -- DROP TABLE IF EXISTS `jobs`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `jobs` ( `id` int(11) NOT NULL AUTO_INCREMENT, `job_type` varchar(255) NOT NULL, `domain_id` int(11) DEFAULT NULL, `args` varchar(255) NOT NULL, `status` int(11) NOT NULL DEFAULT '0', `retries` int(11) NOT NULL DEFAULT '0', `created_at` datetime DEFAULT NULL, `updated_at` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `index_jobs_on_domain_id` (`domain_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; /*!40101 SET character_set_client = @saved_cs_client */; -- -- Table structure for table `memberships` -- DROP TABLE IF EXISTS `memberships`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `memberships` ( `id` int(11) NOT NULL AUTO_INCREMENT, `group_id` int(11) DEFAULT NULL, `user_id` int(11) DEFAULT NULL, `created_at` datetime DEFAULT NULL, `updated_at` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `index_memberships_on_group_id` (`group_id`), KEY `index_memberships_on_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; /*!40101 SET character_set_client = @saved_cs_client */; -- -- Table structure for table `records` -- DROP TABLE IF EXISTS `records`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `records` ( `id` int(11) NOT NULL AUTO_INCREMENT, `domain_id` int(11) DEFAULT NULL, `name` varchar(255) DEFAULT NULL, `type` varchar(10) DEFAULT NULL, `content` mediumtext, `ttl` int(11) DEFAULT NULL, `prio` int(11) DEFAULT NULL, `change_date` int(11) DEFAULT NULL, `disabled` tinyint(1) DEFAULT '0', `ordername` varchar(255) CHARACTER SET utf8 COLLATE utf8_bin DEFAULT NULL, `auth` tinyint(1) DEFAULT '1', `created_at` datetime NOT NULL, `updated_at` datetime NOT NULL, PRIMARY KEY (`id`), KEY `nametype_index` (`name`,`type`), KEY `domain_id` (`domain_id`), KEY `recordorder` (`domain_id`,`ordername`), CONSTRAINT `records_ibfk_1` FOREIGN KEY (`domain_id`) REFERENCES `domains` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB AUTO_INCREMENT=32 DEFAULT CHARSET=utf8; /*!40101 SET character_set_client = @saved_cs_client */; -- -- Table structure for table `schema_migrations` -- DROP TABLE IF EXISTS `schema_migrations`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `schema_migrations` ( `version` varchar(255) NOT NULL, UNIQUE KEY `unique_schema_migrations` (`version`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; /*!40101 SET character_set_client = @saved_cs_client */; -- +-- Table structure for table `subscriptions` +-- + +DROP TABLE IF EXISTS `subscriptions`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `subscriptions` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `domain_id` int(11) NOT NULL, + `user_id` int(11) NOT NULL, + `disabled` tinyint(1) NOT NULL DEFAULT '1', + `created_at` datetime DEFAULT NULL, + `updated_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `index_subscriptions_on_domain_id` (`domain_id`), + KEY `index_subscriptions_on_user_id` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- -- Table structure for table `supermasters` -- DROP TABLE IF EXISTS `supermasters`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `supermasters` ( `ip` varchar(64) NOT NULL, `nameserver` varchar(255) NOT NULL, `account` varchar(40) NOT NULL, PRIMARY KEY (`ip`,`nameserver`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; /*!40101 SET character_set_client = @saved_cs_client */; -- -- Table structure for table `tsigkeys` -- DROP TABLE IF EXISTS `tsigkeys`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `tsigkeys` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(255) DEFAULT NULL, `algorithm` varchar(50) DEFAULT NULL, `secret` varchar(255) DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `namealgoindex` (`name`,`algorithm`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; /*!40101 SET character_set_client = @saved_cs_client */; -- -- Table structure for table `users` -- DROP TABLE IF EXISTS `users`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `users` ( `id` int(11) NOT NULL AUTO_INCREMENT, `email` varchar(255) NOT NULL DEFAULT '', `encrypted_password` varchar(255) NOT NULL DEFAULT '', `reset_password_token` varchar(255) DEFAULT NULL, `reset_password_sent_at` datetime DEFAULT NULL, `remember_created_at` datetime DEFAULT NULL, `sign_in_count` int(11) NOT NULL DEFAULT '0', `current_sign_in_at` datetime DEFAULT NULL, `last_sign_in_at` datetime DEFAULT NULL, `current_sign_in_ip` varchar(255) DEFAULT NULL, `last_sign_in_ip` varchar(255) DEFAULT NULL, `created_at` datetime NOT NULL, `updated_at` datetime NOT NULL, `identifier` varchar(255) DEFAULT '', `token` varchar(255) DEFAULT NULL, + `notifications` tinyint(1) NOT NULL DEFAULT '1', PRIMARY KEY (`id`), UNIQUE KEY `index_users_on_email` (`email`), UNIQUE KEY `index_users_on_reset_password_token` (`reset_password_token`), UNIQUE KEY `index_users_on_token` (`token`), KEY `index_users_on_identifier` (`identifier`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; /*!40101 SET character_set_client = @saved_cs_client */; /*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; /*!40101 SET SQL_MODE=@OLD_SQL_MODE */; /*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; /*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; /*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; /*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; /*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; -- Dump completed on 2015-11-08 12:57:51 INSERT INTO schema_migrations (version) VALUES ('20151028123326'); INSERT INTO schema_migrations (version) VALUES ('20151028123327'); INSERT INTO schema_migrations (version) VALUES ('20151031184819'); INSERT INTO schema_migrations (version) VALUES ('20151107182656'); INSERT INTO schema_migrations (version) VALUES ('20151108093333'); INSERT INTO schema_migrations (version) VALUES ('20151108105701'); INSERT INTO schema_migrations (version) VALUES ('20151207054417'); INSERT INTO schema_migrations (version) VALUES ('20151207194729'); INSERT INTO schema_migrations (version) VALUES ('20151213102322'); INSERT INTO schema_migrations (version) VALUES ('20160206083933'); INSERT INTO schema_migrations (version) VALUES ('20160214155026'); INSERT INTO schema_migrations (version) VALUES ('20160403094641'); +INSERT INTO schema_migrations (version) VALUES ('20170305083712'); + +INSERT INTO schema_migrations (version) VALUES ('20170308060441'); + diff --git a/lib/notification.rb b/lib/notification.rb index 1793940..77618b6 100644 --- a/lib/notification.rb +++ b/lib/notification.rb @@ -1,139 +1,142 @@ require 'singleton' class Notification include Singleton # Send out a notification about bulk record operations. def notify_record_bulk(user, domain, ops) ActiveSupport::Notifications.instrument( 'webdns.record.bulk', user: user, domain: domain, ops: ops) end # Send out a notification about notable record changes. def notify_record(user, record, context) ActiveSupport::Notifications.instrument( 'webdns.record', user: user, context: context, object: record) end # Send out a notification about notable domain changes. def notify_domain(user, domain, context) ActiveSupport::Notifications.instrument( 'webdns.domain', user: user, context: context, object: domain) end # Subscribe to domain/record notifications. def hook hook_record hook_record_bulk hook_domain end private def hook_record ActiveSupport::Notifications .subscribe 'webdns.record' do |_name, _started, _finished, _unique_id, data| handle_record(data) end end def hook_record_bulk ActiveSupport::Notifications .subscribe 'webdns.record.bulk' do |_name, _started, _finished, _unique_id, data| handle_record_bulk(data) end end def hook_domain ActiveSupport::Notifications .subscribe 'webdns.domain' do |_name, _started, _finished, _unique_id, data| handle_domain(data) end end def handle_record(data) record, context, user = data.values_at(:object, :context, :user) domain = record.domain changes = filter_changes(record) return if changes.empty? && context == :update - others = domain.group.users.pluck(:email) + opt_outs = domain.opt_outs.pluck(:user_id) + others = domain.group.users.where.not(id: opt_outs).pluck(:email) return if others.empty? admin_action = !user.groups.exists?(domain.group_id) NotificationMailer.notify_record( record: record, context: context, user: user, admin: admin_action, others: others, changes: changes ).deliver end def handle_record_bulk(data) ops, domain, user = data.values_at(:ops, :domain, :user) operations = [] operations += ops[:deletes].map { |rec| [:destroy, rec, nil] } operations += ops[:changes].map { |rec| [:update, rec, filter_changes(rec)] } operations += ops[:additions].map { |rec| [:create, rec, nil] } - - others = domain.group.users.pluck(:email) + + opt_outs = domain.opt_outs.pluck(:user_id) + others = domain.group.users.where.not(id: opt_outs).pluck(:email) return if others.empty? admin_action = !user.groups.exists?(domain.group_id) NotificationMailer.notify_record_bulk( user: user, admin: admin_action, others: others, domain: domain, operations: operations, ).deliver end def handle_domain(data) domain, context, user = data.values_at(:object, :context, :user) changes = filter_changes(domain) return if changes.empty? && context == :update - others = domain.group.users.pluck(:email) + opt_outs = domain.opt_outs.pluck(:user_id) + others = domain.group.users.where.not(id: opt_outs).pluck(:email) return if others.empty? admin_action = !user.groups.exists?(domain.group_id) NotificationMailer.notify_domain( domain: domain, context: context, user: user, admin: admin_action, others: others, changes: changes ).deliver end private def filter_changes(record) changes = record.previous_changes # Nobody is interested in those changes.delete('updated_at') changes.delete('created_at') changes end end diff --git a/test/factories/domain.rb b/test/factories/domain.rb index 6f74dd5..0da1225 100644 --- a/test/factories/domain.rb +++ b/test/factories/domain.rb @@ -1,29 +1,36 @@ FactoryGirl.define do sequence(:domain) { |n| "example#{n}.com" } factory :domain do group name { generate(:domain) } serial_strategy Strategies::Date type 'NATIVE' end factory :slave, parent: :domain do type 'SLAVE' master '1.2.3.4' end factory :date_domain, class: Domain do group name { generate(:domain) } serial_strategy Strategies::Date type 'NATIVE' end factory :v4_arpa_domain, parent: :domain do name '2.0.192.in-addr.arpa' end factory :v6_arpa_domain, parent: :domain do name '8.b.d.0.1.0.0.2.ip6.arpa' end + + factory :domain_with_subscriptions, parent: :domain do + association :group, factory: :group_with_users + after(:create) do |domain| + Subscription.create(domain: domain, user:domain.group.users.first) + end + end end diff --git a/test/mailers/notification_mailer_test.rb b/test/mailers/notification_mailer_test.rb index 5805c3c..96ab23d 100644 --- a/test/mailers/notification_mailer_test.rb +++ b/test/mailers/notification_mailer_test.rb @@ -1,175 +1,189 @@ require 'test_helper' class NotificationMailerTest < ActionMailer::TestCase class DomainNotificationMailerTest < ActionMailer::TestCase def setup @notification = Notification.instance @group = create(:group_with_users) @domain = create(:domain, group: @group) @record = build(:a, name: 'a', domain: @domain) end + test 'skip users with opt-out notifications' do + @record.save! + + # Opt out + author = @group.users.first + Subscription.create!(user: author, domain: @domain) + + @notification.notify_domain(@group.users.first, @domain, :create) + + assert_not ActionMailer::Base.deliveries.empty? + mail = ActionMailer::Base.deliveries.last + assert_equal @group.users.pluck(:email) - [author.email], mail.to + end + test 'domain add' do @record.save! @notification.notify_domain(@group.users.first, @domain, :create) assert_not ActionMailer::Base.deliveries.empty? mail = ActionMailer::Base.deliveries.last assert_equal mail.to, @group.users.pluck(:email) assert_includes mail.subject, 'Created' assert_includes mail.body.to_s, "Domain: #{@domain.name}" assert_includes mail.body.to_s, "By: #{@group.users.first.email}" end test 'domain edit' do @record.save! @domain.type = 'SLAVE' @domain.master = '1.2.3.4' @domain.save! @notification.notify_domain(@group.users.first, @domain, :update) assert_not ActionMailer::Base.deliveries.empty? mail = ActionMailer::Base.deliveries.last assert_equal mail.to, @group.users.pluck(:email) assert_includes mail.subject, 'Modified' assert_includes mail.body.to_s, "Domain: #{@domain.name}" assert_includes mail.body.to_s, "By: #{@group.users.first.email}" assert_includes mail.body.to_s, 'type from NATIVE' assert_includes mail.body.to_s, 'type to SLAVE' assert_includes mail.body.to_s, 'master from (empty)' assert_includes mail.body.to_s, 'master to 1.2.3.4' end test 'domain destroy' do @record.save! @domain.destroy! @notification.notify_domain(@group.users.first, @domain, :destroy) assert_not ActionMailer::Base.deliveries.empty? mail = ActionMailer::Base.deliveries.last assert_equal mail.to, @group.users.pluck(:email) assert_includes mail.subject, 'Deleted' assert_includes mail.body.to_s, "Domain: #{@domain.name}" assert_includes mail.body.to_s, "By: #{@group.users.first.email}" end end class DomainNotificationMailerTest < ActionMailer::TestCase test 'record add' do @record.save! @notification.notify_record(@group.users.first, @record, :create) assert_not ActionMailer::Base.deliveries.empty? mail = ActionMailer::Base.deliveries.last assert_equal mail.to, @group.users.pluck(:email) assert_includes mail.subject, 'Created' assert_includes mail.body.to_s, "Record: #{@record.name}" assert_includes mail.body.to_s, "Domain: #{@domain.name}" assert_includes mail.body.to_s, "State: #{@record.to_dns}" assert_includes mail.body.to_s, "By: #{@group.users.first.email}" end test 'record edit' do @record.save! prev_content = @record.content @record.content = '1.1.1.1' @record.save! @notification.notify_record(@group.users.first, @record, :update) assert_not ActionMailer::Base.deliveries.empty? mail = ActionMailer::Base.deliveries.last assert_equal mail.to.sort, @group.users.pluck(:email) assert_includes mail.subject, 'Modified' assert_includes mail.body.to_s, "Record: #{@record.name}" assert_includes mail.body.to_s, "Domain: #{@domain.name}" assert_includes mail.body.to_s, "State: #{@record.to_dns}" assert_includes mail.body.to_s, "By: #{@group.users.first.email}" assert_includes mail.body.to_s, "content from #{prev_content}" assert_includes mail.body.to_s, 'content to 1.1.1.1' end test 'soa edit' do @record = @domain.soa prev_content = @record.content @record.nx = 10 @record.save! @notification.notify_record(@group.users.first, @record, :update) assert_not ActionMailer::Base.deliveries.empty? mail = ActionMailer::Base.deliveries.last assert_equal mail.to, @group.users.pluck(:email) assert_includes mail.subject, 'Modified' assert_includes mail.body.to_s, "Record: #{@record.name}" assert_includes mail.body.to_s, "Domain: #{@domain.name}" assert_includes mail.body.to_s, "State: #{@record.to_dns}" assert_includes mail.body.to_s, "By: #{@group.users.first.email}" assert_includes mail.body.to_s, "content from #{prev_content}" assert_includes mail.body.to_s, "content to #{@record.content}" assert_includes mail.body.to_s, ' 10' end test 'record destroy' do @record.save! @record.destroy! @notification.notify_record(@group.users.first, @record, :destroy) assert_not ActionMailer::Base.deliveries.empty? mail = ActionMailer::Base.deliveries.last assert_equal mail.to, @group.users.pluck(:email) assert_includes mail.subject, 'Deleted' assert_includes mail.body.to_s, "Record: #{@record.name}" assert_includes mail.body.to_s, "Domain: #{@domain.name}" assert_includes mail.body.to_s, "By: #{@group.users.first.email}" end test 'bulk operations' do a = create(:a, domain: @domain) aaaa = create(:aaaa, domain: @domain) new = build(:mx, domain: @domain) changes = {}.tap { |c| c[:deletes] = [a.id] c[:changes] = { aaaa.id => { content: '::42' }} c[:additions] = { 1 => new.as_bulky_json } } ops, err = @domain.bulk(changes) assert_empty err @notification.notify_record_bulk(@group.users.first, @domain, ops) assert_not ActionMailer::Base.deliveries.empty? mail = ActionMailer::Base.deliveries.last assert_equal mail.to, @group.users.pluck(:email) assert_includes mail.subject, 'Bulk' assert_includes mail.body.to_s, "Domain: #{@domain.name}" assert_includes mail.body.to_s, "By: #{@group.users.first.email}" assert_includes mail.body.to_s, "Action: destroy" assert_includes mail.body.to_s, "Action: update" assert_includes mail.body.to_s, "Action: create" end end end diff --git a/test/models/subscription_test.rb b/test/models/subscription_test.rb new file mode 100644 index 0000000..080ce30 --- /dev/null +++ b/test/models/subscription_test.rb @@ -0,0 +1,48 @@ +require 'test_helper' + +class SubscriptionTest < ActiveSupport::TestCase + + test 'single subscription for a domain' do + domain = create(:domain_with_subscriptions) + assert_equal 1, domain.opt_outs.count + + subscription = domain.opt_outs.first + assert_equal true, subscription.disabled + + user = subscription.user + user.reload + + assert_equal domain, user.subscriptions.first.domain + end + + test 'mute new domains on users with persistent opt-out setting' do + g = create(:group_with_users) + + u = g.users.first + u.notifications = false + u.save! + + d = create(:domain, group: g) + + # Opt out subscription should be set + assert_equal 1, u.subscriptions.where(domain: d).count + end + + test 'mute all domains for a user' do + d1 = create(:domain_with_subscriptions) + d2 = create(:domain_with_subscriptions) + user = create(:user) + + # Add user to the groups + d1.group.users << user + d2.group.users << user + + # Opt out from notifications + user.mute_all_domains + # Ensure this is re-entrant + user.mute_all_domains + + # Assert 2 opt-out domains + assert_equal 2, user.subscriptions.count + end +end